forgedock 1.5.0 → 1.5.1
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/forgedock.mjs
CHANGED
|
@@ -1513,6 +1513,51 @@ function resolveGlobalNpmForgedockDir() {
|
|
|
1513
1513
|
}
|
|
1514
1514
|
}
|
|
1515
1515
|
|
|
1516
|
+
/**
|
|
1517
|
+
* Compare two already-`resolve()`d absolute paths for equality or containment
|
|
1518
|
+
* (child equals parent, or sits strictly under it). On case-insensitive
|
|
1519
|
+
* filesystems (win32/darwin) the same physical directory can resolve with
|
|
1520
|
+
* differing letter casing (drive letter, path segments), so both operands are
|
|
1521
|
+
* lowercased before comparing there; on Linux the comparison stays
|
|
1522
|
+
* case-sensitive byte-for-byte (forge#2668). On the case-insensitive branch
|
|
1523
|
+
* both operands are also Unicode-normalized to NFC: macOS APFS/HFS+ enumerate
|
|
1524
|
+
* filenames NFD-normalized while CLI args/env vars typically arrive NFC, and
|
|
1525
|
+
* `resolve()` preserves the input form — so without normalization two paths
|
|
1526
|
+
* for the same physical directory containing accented characters can compare
|
|
1527
|
+
* unequal (forge#2674). NFC is idempotent for pure-ASCII and pre-composed
|
|
1528
|
+
* paths, so ASCII behavior is unchanged; win32 normalizes harmlessly. The
|
|
1529
|
+
* `+ sep` boundary keeps `/foo-bar` from matching parent `/foo`. Callers pass
|
|
1530
|
+
* paths that are already resolved — this helper deliberately does not
|
|
1531
|
+
* re-resolve.
|
|
1532
|
+
*
|
|
1533
|
+
* Known limitation: `caseInsensitive` is gated on `process.platform`, an
|
|
1534
|
+
* OS-level constant, not on the actual case-sensitivity of the volume
|
|
1535
|
+
* FORGE_HOME/the npm global root live on. This is the correct assumption for
|
|
1536
|
+
* the default filesystem format on each OS (NTFS on win32, APFS/HFS+ on
|
|
1537
|
+
* darwin), but a volume explicitly formatted case-sensitive (e.g. opt-in
|
|
1538
|
+
* case-sensitive APFS) would still be case-folded here, and two distinct
|
|
1539
|
+
* directories differing only by case could be treated as equal. This is an
|
|
1540
|
+
* accepted approximation, not a probe of real volume semantics — both call
|
|
1541
|
+
* sites (`isGlobalNpmInstall`, `findSeparateGlobalInstall`) already fail
|
|
1542
|
+
* closed / stay advisory-only, so a false match here degrades to their
|
|
1543
|
+
* pre-existing "detection inconclusive" behavior rather than causing any
|
|
1544
|
+
* destructive or incorrect action (forge#2675). Probing the actual volume
|
|
1545
|
+
* (e.g. an `existsSync` case-twist check) is deliberately not done — the
|
|
1546
|
+
* added complexity and syscall aren't warranted for a purely advisory path.
|
|
1547
|
+
*
|
|
1548
|
+
* @param {string} child - resolved absolute path being tested
|
|
1549
|
+
* @param {string} parent - resolved absolute path of the candidate ancestor
|
|
1550
|
+
* @returns {boolean}
|
|
1551
|
+
*/
|
|
1552
|
+
function samePathOrUnder(child, parent) {
|
|
1553
|
+
const caseInsensitive =
|
|
1554
|
+
process.platform === "win32" || process.platform === "darwin";
|
|
1555
|
+
const fold = (p) => (caseInsensitive ? p.normalize("NFC").toLowerCase() : p);
|
|
1556
|
+
const a = fold(child);
|
|
1557
|
+
const b = fold(parent);
|
|
1558
|
+
return a === b || a.startsWith(b + sep);
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1516
1561
|
/**
|
|
1517
1562
|
* Detect whether FORGE_HOME resolves inside npm's global install tree, i.e.
|
|
1518
1563
|
* this process is running as a globally-installed `forgedock` package (not
|
|
@@ -1533,7 +1578,7 @@ function isGlobalNpmInstall() {
|
|
|
1533
1578
|
const globalPkgDir = resolveGlobalNpmForgedockDir();
|
|
1534
1579
|
if (!globalPkgDir) return false;
|
|
1535
1580
|
const home = resolve(FORGE_HOME);
|
|
1536
|
-
return home
|
|
1581
|
+
return samePathOrUnder(home, globalPkgDir);
|
|
1537
1582
|
}
|
|
1538
1583
|
|
|
1539
1584
|
/**
|
|
@@ -1558,7 +1603,7 @@ function findSeparateGlobalInstall() {
|
|
|
1558
1603
|
const globalPkgDir = resolveGlobalNpmForgedockDir();
|
|
1559
1604
|
if (!globalPkgDir) return null;
|
|
1560
1605
|
const home = resolve(FORGE_HOME);
|
|
1561
|
-
if (home
|
|
1606
|
+
if (samePathOrUnder(home, globalPkgDir)) {
|
|
1562
1607
|
// Current FORGE_HOME already IS the global install — nothing separate.
|
|
1563
1608
|
return null;
|
|
1564
1609
|
}
|
|
@@ -1804,9 +1849,10 @@ async function update() {
|
|
|
1804
1849
|
// dirty tree. HEAD is still never moved in either case.
|
|
1805
1850
|
const cwdResolved = resolve(process.cwd());
|
|
1806
1851
|
const forgeHomeResolved = resolve(FORGE_HOME);
|
|
1807
|
-
const isSourceRepoSelf =
|
|
1808
|
-
cwdResolved
|
|
1809
|
-
|
|
1852
|
+
const isSourceRepoSelf = samePathOrUnder(
|
|
1853
|
+
cwdResolved,
|
|
1854
|
+
forgeHomeResolved,
|
|
1855
|
+
);
|
|
1810
1856
|
|
|
1811
1857
|
if (isSourceRepoSelf) {
|
|
1812
1858
|
console.log(
|
package/bin/journey.mjs
CHANGED
|
@@ -1060,6 +1060,73 @@ export async function isSymlinkTraversable(target) {
|
|
|
1060
1060
|
}
|
|
1061
1061
|
}
|
|
1062
1062
|
|
|
1063
|
+
/**
|
|
1064
|
+
* Atomically install `target` as a symlink to `file`, verifying the result is
|
|
1065
|
+
* readable by *this* process. Single source of truth for the correctness
|
|
1066
|
+
* invariants that previously lived as near-verbatim copies at every symlink
|
|
1067
|
+
* call site (forge#2667):
|
|
1068
|
+
*
|
|
1069
|
+
* - The symlink is first created at a tmp sibling suffixed with this
|
|
1070
|
+
* process's PID, then rename()d onto target. symlink() is exclusive-create,
|
|
1071
|
+
* so two concurrent forge() invocations installing the same path would
|
|
1072
|
+
* collide with an uncaught EEXIST on a shared literal path — the PID
|
|
1073
|
+
* suffix guarantees they never share one (forge#2542, forge#2599,
|
|
1074
|
+
* forge#2600, forge#2631). rename() atomically replaces any existing
|
|
1075
|
+
* target, including on Windows.
|
|
1076
|
+
* - The tmp sibling is unlinked on every failure path so a rename failure
|
|
1077
|
+
* (or any rethrow) never orphans it (forge#2612).
|
|
1078
|
+
* - The link is read back via isSymlinkTraversable() on the PID-scoped tmp
|
|
1079
|
+
* path *before* the rename: fs.symlink() can succeed while producing a link
|
|
1080
|
+
* this runtime cannot resolve (MSYS symlink + native Windows "untrusted
|
|
1081
|
+
* mount point" — forge#2620). Verifying the tmp sibling — which no other
|
|
1082
|
+
* process ever touches — instead of the shared target *after* the rename
|
|
1083
|
+
* closes a TOCTOU window: a post-rename readback could describe (and the
|
|
1084
|
+
* old cleanup unlink could remove) a concurrent installer's freshly-renamed
|
|
1085
|
+
* link, not ours (forge#2679). An unreadable link is discarded as tmp and
|
|
1086
|
+
* never renamed onto target, so target is left untouched.
|
|
1087
|
+
*
|
|
1088
|
+
* Callers keep their own `wantSymlink` guard and site-specific bookkeeping
|
|
1089
|
+
* (counters, manifest mutation, copy fall-through) around the result.
|
|
1090
|
+
*
|
|
1091
|
+
* @param {string} file - Symlink target (the source file to link to).
|
|
1092
|
+
* @param {string} target - Path where the symlink is installed.
|
|
1093
|
+
* @returns {Promise<"linked"|"unreadable"|"denied">}
|
|
1094
|
+
* "linked" — symlink atomically installed at target and traversable.
|
|
1095
|
+
* "unreadable" — symlink was created but not readable back (forge#2620);
|
|
1096
|
+
* the dead link was discarded as tmp and the rename never
|
|
1097
|
+
* happened, so any existing target is left intact (forge#2679).
|
|
1098
|
+
* Caller should fall back to a copy.
|
|
1099
|
+
* "denied" — EPERM/EACCES from symlink/rename (no symlink installed;
|
|
1100
|
+
* e.g. Windows without Developer Mode). Caller should fall
|
|
1101
|
+
* back to a copy.
|
|
1102
|
+
* Any other error (EEXIST on the tmp path, ENOSPC, rename failures, …) is
|
|
1103
|
+
* rethrown after tmp cleanup.
|
|
1104
|
+
*/
|
|
1105
|
+
export async function atomicSymlinkInstall(file, target) {
|
|
1106
|
+
const tmpTarget = target + "." + process.pid + ".tmp";
|
|
1107
|
+
try {
|
|
1108
|
+
await symlink(file, tmpTarget);
|
|
1109
|
+
// Read back the PID-scoped tmp sibling *before* renaming onto target. No
|
|
1110
|
+
// other process touches tmpTarget, so the verdict cannot describe a
|
|
1111
|
+
// concurrent installer's link, and an unreadable link is discarded here
|
|
1112
|
+
// without ever unlinking target (forge#2679, forge#2620).
|
|
1113
|
+
if (!(await isSymlinkTraversable(tmpTarget))) {
|
|
1114
|
+
await unlink(tmpTarget).catch(() => {});
|
|
1115
|
+
return "unreadable";
|
|
1116
|
+
}
|
|
1117
|
+
try {
|
|
1118
|
+
await rename(tmpTarget, target);
|
|
1119
|
+
} catch (renameErr) {
|
|
1120
|
+
await unlink(tmpTarget).catch(() => {});
|
|
1121
|
+
throw renameErr;
|
|
1122
|
+
}
|
|
1123
|
+
return "linked";
|
|
1124
|
+
} catch (linkErr) {
|
|
1125
|
+
if (!isLinkPermissionError(linkErr)) throw linkErr;
|
|
1126
|
+
return "denied";
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1063
1130
|
/**
|
|
1064
1131
|
* Allowlist of universal pipeline-agent scripts installed to
|
|
1065
1132
|
* ~/.claude/scripts/ by forge() (linkPipelineScripts()) and cleaned up by
|
|
@@ -1679,27 +1746,7 @@ async function linkPipelineScripts(ctx) {
|
|
|
1679
1746
|
|
|
1680
1747
|
let linked = false;
|
|
1681
1748
|
if (wantSymlink) {
|
|
1682
|
-
|
|
1683
|
-
// concurrent forge() invocation linking the same brand-new pipeline
|
|
1684
|
-
// script never collides on the same literal target path (which
|
|
1685
|
-
// raises an uncaught EEXIST — isLinkPermissionError() only treats
|
|
1686
|
-
// EPERM/EACCES as recoverable). Mirrors the fresh-install symlink
|
|
1687
|
-
// branch in forge() (forge#2631) and the relink/upgrade branches
|
|
1688
|
-
// (forge#2542, forge#2600, forge#2599).
|
|
1689
|
-
const tmpTarget = target + "." + process.pid + ".tmp";
|
|
1690
|
-
try {
|
|
1691
|
-
await symlink(file, tmpTarget);
|
|
1692
|
-
try {
|
|
1693
|
-
await rename(tmpTarget, target);
|
|
1694
|
-
} catch (renameErr) {
|
|
1695
|
-
await unlink(tmpTarget).catch(() => {});
|
|
1696
|
-
throw renameErr;
|
|
1697
|
-
}
|
|
1698
|
-
linked = await isSymlinkTraversable(target);
|
|
1699
|
-
if (!linked) await unlink(target).catch(() => {});
|
|
1700
|
-
} catch (linkErr) {
|
|
1701
|
-
if (!isLinkPermissionError(linkErr)) throw linkErr;
|
|
1702
|
-
}
|
|
1749
|
+
linked = (await atomicSymlinkInstall(file, target)) === "linked";
|
|
1703
1750
|
}
|
|
1704
1751
|
if (!linked) {
|
|
1705
1752
|
await copyFile(file, target);
|
|
@@ -1835,6 +1882,364 @@ export function detectCrossEnvInstall(ctx, envInfo, deps = {}) {
|
|
|
1835
1882
|
}
|
|
1836
1883
|
}
|
|
1837
1884
|
|
|
1885
|
+
/**
|
|
1886
|
+
* Final manifest save for forge(): locked re-read → replay this run's
|
|
1887
|
+
* `manifestOps` → write. Returns true on success, false on any failure —
|
|
1888
|
+
* never throws, because manifest housekeeping must never abort the caller's
|
|
1889
|
+
* receipt or the hook installation.
|
|
1890
|
+
*/
|
|
1891
|
+
async function saveManifestWithMerge(manifestPath, manifestOps) {
|
|
1892
|
+
try {
|
|
1893
|
+
// Re-read-and-merge (forge#2614): another concurrent forge() invocation
|
|
1894
|
+
// may have saved its own manifest.files adds/deletes since this run's
|
|
1895
|
+
// initial load above. Re-reading the on-disk manifest right before this
|
|
1896
|
+
// run's own save and replaying only this run's own ops (manifestOps)
|
|
1897
|
+
// onto that fresh copy — rather than blindly overwriting with the
|
|
1898
|
+
// run-start in-memory snapshot — means this run's save can no longer
|
|
1899
|
+
// silently drop the other run's changes. loadCopiedManifest() never
|
|
1900
|
+
// throws (missing/corrupt on-disk manifest resolves to an empty one),
|
|
1901
|
+
// so this stays within the surrounding never-abort contract.
|
|
1902
|
+
//
|
|
1903
|
+
// Lock the re-read→merge→write sequence itself (forge#2637): #2614
|
|
1904
|
+
// narrowed the race from "whole run duration" to this critical section,
|
|
1905
|
+
// but left it unprotected — a third concurrent forge() could still land
|
|
1906
|
+
// its own save inside the gap between this run's re-read and its own
|
|
1907
|
+
// write-rename completing. acquireManifestLock() is best-effort: on
|
|
1908
|
+
// failure to acquire (contention exhausted retries, or an unexpected
|
|
1909
|
+
// error) it returns null and this run proceeds unlocked, same as
|
|
1910
|
+
// before — the lock narrows the window further, it does not change the
|
|
1911
|
+
// never-abort contract.
|
|
1912
|
+
const lockHandle = await acquireManifestLock(manifestPath);
|
|
1913
|
+
try {
|
|
1914
|
+
const mergedManifest = await loadCopiedManifest(manifestPath);
|
|
1915
|
+
for (const { rel, op } of manifestOps) {
|
|
1916
|
+
if (op === "add") {
|
|
1917
|
+
mergedManifest.files[rel] = true;
|
|
1918
|
+
} else {
|
|
1919
|
+
delete mergedManifest.files[rel];
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
await saveCopiedManifest(manifestPath, mergedManifest);
|
|
1923
|
+
} finally {
|
|
1924
|
+
await releaseManifestLock(manifestPath, lockHandle);
|
|
1925
|
+
}
|
|
1926
|
+
return true;
|
|
1927
|
+
} catch {
|
|
1928
|
+
return false;
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
/**
|
|
1933
|
+
* Per-file branch for forge(): a manifest-tracked regular file — a copy we
|
|
1934
|
+
* made on a previous run, ours to manage. First try upgrading it to a
|
|
1935
|
+
* symlink (Developer Mode enabled since), preserving atomicSymlinkInstall()'s
|
|
1936
|
+
* tri-state "linked"/"unreadable"/"denied" contract (forge#2620 / ADR #2667)
|
|
1937
|
+
* exactly; on "denied", fall through to content-compare + copy.
|
|
1938
|
+
* Returns counter deltas for forge() to fold in.
|
|
1939
|
+
*/
|
|
1940
|
+
async function upgradeManagedCopy(file, target, rel, { wantSymlink, recordDelete }) {
|
|
1941
|
+
let upgraded = false;
|
|
1942
|
+
let restoredAsCopy = false;
|
|
1943
|
+
if (wantSymlink) {
|
|
1944
|
+
const result = await atomicSymlinkInstall(file, target);
|
|
1945
|
+
if (result === "linked") {
|
|
1946
|
+
upgraded = true;
|
|
1947
|
+
recordDelete(rel);
|
|
1948
|
+
} else if (result === "unreadable") {
|
|
1949
|
+
// Symlink was created but is not readable back (forge#2620) — the helper
|
|
1950
|
+
// discarded it as tmp and left the existing copy at target untouched
|
|
1951
|
+
// (forge#2679). Overwrite it with a fresh copy so the command stays
|
|
1952
|
+
// readable. Keep the manifest entry (still copy-managed, not
|
|
1953
|
+
// symlink-managed) and do NOT fall through to the generic
|
|
1954
|
+
// copy-comparison below — the copy is already done.
|
|
1955
|
+
await copyFile(file, target);
|
|
1956
|
+
restoredAsCopy = true;
|
|
1957
|
+
}
|
|
1958
|
+
// "denied" — no symlink installed; fall through to content-compare.
|
|
1959
|
+
}
|
|
1960
|
+
if (upgraded || restoredAsCopy) {
|
|
1961
|
+
return { updatedDelta: 1, skippedDelta: 0 };
|
|
1962
|
+
}
|
|
1963
|
+
const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
|
|
1964
|
+
if (src.equals(dst)) {
|
|
1965
|
+
return { updatedDelta: 0, skippedDelta: 1 };
|
|
1966
|
+
}
|
|
1967
|
+
await copyFile(file, target);
|
|
1968
|
+
return { updatedDelta: 1, skippedDelta: 0 };
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
/**
|
|
1972
|
+
* Per-file branch for forge(): a regular file not in the manifest — a copy
|
|
1973
|
+
* from a pre-manifest ForgeDock version, or a stale copy that was never
|
|
1974
|
+
* adopted. `rel` here is always enumerated from `files` (ForgeDock's own
|
|
1975
|
+
* commandsDir listing) — every path this branch touches is a
|
|
1976
|
+
* ForgeDock-managed command file, never an arbitrary user file, so there is
|
|
1977
|
+
* no "user-customized" case to protect. Content-compare: if it matches
|
|
1978
|
+
* source, adopt it into the manifest silently (it was ours all along). If it
|
|
1979
|
+
* differs, it's stale — overwrite it and adopt it into the manifest too,
|
|
1980
|
+
* mirroring the manifest-tracked branch, so future runs take the
|
|
1981
|
+
* manifest-aware fast path.
|
|
1982
|
+
*
|
|
1983
|
+
* Never throws. Returns an effects object forge() folds into its counters:
|
|
1984
|
+
* `handled: false` means every repair attempt failed and forge() should
|
|
1985
|
+
* print the per-file WARNING (terminal/progress-bar state stays in forge()).
|
|
1986
|
+
* `backedUpDelta` is mutated in place as backup/rollback progresses so a
|
|
1987
|
+
* mid-sequence failure still reports any backup that remains on disk
|
|
1988
|
+
* (forge#2559).
|
|
1989
|
+
*/
|
|
1990
|
+
async function adoptOrRepairUnmanagedCopy(file, target, rel, { recordCopy }) {
|
|
1991
|
+
const effects = { handled: false, updatedDelta: 0, skippedDelta: 0, backedUpDelta: 0 };
|
|
1992
|
+
try {
|
|
1993
|
+
const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
|
|
1994
|
+
if (src.equals(dst)) {
|
|
1995
|
+
recordCopy(rel);
|
|
1996
|
+
effects.skippedDelta++;
|
|
1997
|
+
effects.handled = true;
|
|
1998
|
+
} else {
|
|
1999
|
+
// Write to a .tmp sibling and atomically rename onto target — never
|
|
2000
|
+
// open target itself for a direct overwrite. copyFile() is not
|
|
2001
|
+
// atomic: a mid-write failure (AV lock / ENOSPC / process kill)
|
|
2002
|
+
// would otherwise leave target truncated/corrupted rather than
|
|
2003
|
+
// simply stale, which the WARNING in forge() cannot distinguish. This
|
|
2004
|
+
// mirrors the .tmp+rename swap already used for symlink installs
|
|
2005
|
+
// (lines ~1573-1579, ~1601-1607).
|
|
2006
|
+
//
|
|
2007
|
+
// Unlike those symlink-based branches — where symlink() is itself
|
|
2008
|
+
// exclusive-create and therefore naturally races out a concurrent
|
|
2009
|
+
// writer — copyFile() has no such semantics: two concurrent
|
|
2010
|
+
// forge() invocations (e.g. an overlapping `doctor --fix` run, or
|
|
2011
|
+
// a file watcher) targeting the same file would both write
|
|
2012
|
+
// through the same shared `target + ".tmp"` path and could
|
|
2013
|
+
// interleave or clobber each other before either rename() runs.
|
|
2014
|
+
// Suffix the tmp sibling with this process's PID so concurrent
|
|
2015
|
+
// invocations never share a path (forge#2542).
|
|
2016
|
+
const tmpTarget = target + "." + process.pid + ".tmp";
|
|
2017
|
+
try {
|
|
2018
|
+
await copyFile(file, tmpTarget);
|
|
2019
|
+
} catch (copyErr) {
|
|
2020
|
+
// A copyFile failure (disk full mid-copy, AV lock, permission
|
|
2021
|
+
// error) can still leave a partially-written .tmp sibling on
|
|
2022
|
+
// disk even though the write itself threw. The sibling
|
|
2023
|
+
// rename() failure just below already cleans up tmpTarget
|
|
2024
|
+
// on its own failure path (renameErr) — mirror that here so a
|
|
2025
|
+
// copyFile failure doesn't orphan the .tmp file instead
|
|
2026
|
+
// (forge#2540). Best-effort: never let the cleanup itself mask
|
|
2027
|
+
// or replace the original copyErr being rethrown.
|
|
2028
|
+
await unlink(tmpTarget).catch(() => {});
|
|
2029
|
+
throw copyErr;
|
|
2030
|
+
}
|
|
2031
|
+
// Preserve the pre-existing content before it's replaced, mirroring
|
|
2032
|
+
// half of the confirm+backup convention used by the forge.yaml
|
|
2033
|
+
// overwrite path (bin/forgedock.mjs ~990-1021 / journey.mjs
|
|
2034
|
+
// review(), both via backupExisting()). A confirmation prompt is
|
|
2035
|
+
// deliberately NOT added: this branch also runs non-interactively
|
|
2036
|
+
// inside `doctor --fix`'s automated repair (bin/forgedock.mjs
|
|
2037
|
+
// runInstallRepairOnce() -> forge(ctx())), so pausing per-file for
|
|
2038
|
+
// input would break that flow. Placed here — after the .tmp write
|
|
2039
|
+
// has already succeeded, before the final rename — rather than
|
|
2040
|
+
// before copyFile (as the forge.yaml path does), so a copyFile
|
|
2041
|
+
// failure never backs up (and thereby displaces) a target that's
|
|
2042
|
+
// about to remain untouched (see the .tmp-write-failure test,
|
|
2043
|
+
// forge#2498). backupExisting()'s rename is itself atomic, so this
|
|
2044
|
+
// ordering carries none of the non-atomic-write risk that forge#1396
|
|
2045
|
+
// flagged for the old backup-then-writeFileSync forge.yaml path.
|
|
2046
|
+
//
|
|
2047
|
+
// Note: backupExisting() below is a *synchronous* rename
|
|
2048
|
+
// (target -> target+".bak"); the tmpTarget -> target rename just
|
|
2049
|
+
// after it is a separate, async filesystem operation. Between
|
|
2050
|
+
// the two, target genuinely does not exist on disk for a
|
|
2051
|
+
// sub-millisecond window. Only a hard process kill (not any
|
|
2052
|
+
// catchable JS error) can land exactly there — any catchable
|
|
2053
|
+
// failure of the second rename is already handled by the
|
|
2054
|
+
// rollback block below — and recovery in that rare case is via
|
|
2055
|
+
// the surviving target+".bak" on the next forge() run (forge#2558).
|
|
2056
|
+
const backup = backupExisting(target);
|
|
2057
|
+
if (backup) effects.backedUpDelta++;
|
|
2058
|
+
try {
|
|
2059
|
+
await rename(tmpTarget, target);
|
|
2060
|
+
} catch (renameErr) {
|
|
2061
|
+
await unlink(tmpTarget).catch(() => {});
|
|
2062
|
+
if (backup) {
|
|
2063
|
+
// The final rename failed AFTER backupExisting() already moved
|
|
2064
|
+
// the original file to target+".bak" — target no longer exists
|
|
2065
|
+
// on disk at this point. Without this rollback the user's file
|
|
2066
|
+
// would simply vanish (present only as .bak) while the WARNING
|
|
2067
|
+
// in forge() implies it merely "could not be repaired". Restore
|
|
2068
|
+
// the prior content so a failed repair is a no-op, matching the
|
|
2069
|
+
// pre-backup invariant that a rename failure never removes the
|
|
2070
|
+
// existing file (see the .tmp-write-failure test, forge#2498).
|
|
2071
|
+
// WIRE:PROVEN — manual: reasoned, not exercised by an automated
|
|
2072
|
+
// test. This branch requires rename(tmp, target) to fail on the
|
|
2073
|
+
// exact call immediately after backupExisting()'s own rename of
|
|
2074
|
+
// the same target succeeded — an OS-level race with no portable
|
|
2075
|
+
// way to force deterministically (the sibling renameErr catches
|
|
2076
|
+
// for the symlink-relink paths above, lines ~1576/~1604, are the
|
|
2077
|
+
// same kind of defensive OS-failure branch and are likewise
|
|
2078
|
+
// untested for the same reason). Verified by code inspection:
|
|
2079
|
+
// rename() is fs/promises' rename. The rollback rename's own
|
|
2080
|
+
// outcome is captured explicitly rather than assumed — if it
|
|
2081
|
+
// also fails (e.g. .bak itself is locked), target+".bak" is
|
|
2082
|
+
// left in place as the only remaining copy of the prior
|
|
2083
|
+
// content, so backedUpDelta must NOT be decremented in that
|
|
2084
|
+
// case; it correctly reflects "a backup file still exists on
|
|
2085
|
+
// disk" (forge#2559). Only on confirmed rollback success does
|
|
2086
|
+
// the decrement keep the counter consistent with the restored
|
|
2087
|
+
// on-disk state before renameErr is rethrown to the outer catch.
|
|
2088
|
+
let rollbackOk = true;
|
|
2089
|
+
await rename(target + ".bak", target).catch(() => { rollbackOk = false; });
|
|
2090
|
+
if (rollbackOk) effects.backedUpDelta--;
|
|
2091
|
+
}
|
|
2092
|
+
throw renameErr;
|
|
2093
|
+
}
|
|
2094
|
+
recordCopy(rel);
|
|
2095
|
+
effects.updatedDelta++;
|
|
2096
|
+
effects.handled = true;
|
|
2097
|
+
}
|
|
2098
|
+
} catch { /* readFile/copyFile/rename failure — fall through to warning */ }
|
|
2099
|
+
return effects;
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
/**
|
|
2103
|
+
* Per-file branch for forge(): target does not exist yet (ENOENT from
|
|
2104
|
+
* lstat) — fresh install. Symlink-first, copy-fallback; a successful link
|
|
2105
|
+
* also drops any stale manifest record for the path.
|
|
2106
|
+
* Returns counter deltas for forge() to fold in.
|
|
2107
|
+
*/
|
|
2108
|
+
async function installFreshCommandFile(file, target, rel, { wantSymlink, manifest, recordCopy, recordDelete }) {
|
|
2109
|
+
let linked = false;
|
|
2110
|
+
if (wantSymlink) {
|
|
2111
|
+
linked = (await atomicSymlinkInstall(file, target)) === "linked";
|
|
2112
|
+
if (linked && manifest.files[rel]) {
|
|
2113
|
+
// Symlinks work now and the old copy is gone — drop the stale record.
|
|
2114
|
+
recordDelete(rel);
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
if (!linked) {
|
|
2118
|
+
// Windows without Developer Mode/admin, or symlink unreadable — copy.
|
|
2119
|
+
await copyFile(file, target);
|
|
2120
|
+
recordCopy(rel);
|
|
2121
|
+
return { installedDelta: 0, copiedDelta: 1 };
|
|
2122
|
+
}
|
|
2123
|
+
return { installedDelta: 1, copiedDelta: 0 };
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
/**
|
|
2127
|
+
* Act II receipt: render forge()'s summary lines, advisories, and hook
|
|
2128
|
+
* statuses. Pure reporting — mechanical move of the forge() tail; reads
|
|
2129
|
+
* `results` and writes to ctx.stdout, mutates nothing.
|
|
2130
|
+
*/
|
|
2131
|
+
function renderForgeReceipt(ctx, results) {
|
|
2132
|
+
const { stdout: w } = ctx;
|
|
2133
|
+
const {
|
|
2134
|
+
installed, updated, skipped, copied, backedUp, pruned, total,
|
|
2135
|
+
staleExtensionlessPruned, scriptsResult, manifestSaveFailed,
|
|
2136
|
+
hookStatus, settingsPath, preToolUseStatus, subagentStopEnforceStatus,
|
|
2137
|
+
} = results;
|
|
2138
|
+
|
|
2139
|
+
const glyph = (ok) => (ctx.mode === "none" ? (ok ? "✔" : "!") : `\x1b[38;2;255;179;71m${ok ? "✔" : "!"}\x1b[0m`);
|
|
2140
|
+
const headlineVerb = copied > 0 ? "installed" : "linked";
|
|
2141
|
+
const backupNote = backedUp > 0 ? `, ${backedUp} backed up` : "";
|
|
2142
|
+
const headlineDetail = copied > 0
|
|
2143
|
+
? `(new ${installed}, copied ${copied}, updated ${updated}${backupNote}, unchanged ${skipped})`
|
|
2144
|
+
: `(new ${installed}, updated ${updated}${backupNote}, unchanged ${skipped})`;
|
|
2145
|
+
w.write(` ${glyph(true)} ${total} slash commands ${headlineVerb} ${dimLine(ctx, headlineDetail)}\n`);
|
|
2146
|
+
if (pruned > 0) {
|
|
2147
|
+
w.write(` ${glyph(true)} ${pruned} orphaned symlink${pruned === 1 ? "" : "s"} removed ${dimLine(ctx, "(commands deleted or renamed since last install)")}\n`);
|
|
2148
|
+
}
|
|
2149
|
+
if (staleExtensionlessPruned > 0) {
|
|
2150
|
+
w.write(` ${glyph(true)} ${staleExtensionlessPruned} stale extensionless entr${staleExtensionlessPruned === 1 ? "y" : "ies"} removed ${dimLine(ctx, "(leftover from prior npx forgedock runs)")}\n`);
|
|
2151
|
+
}
|
|
2152
|
+
if (copied > 0) {
|
|
2153
|
+
w.write(` ${glyph(false)} ${copied} copied (not linked) ${dimLine(ctx, "— enable Windows Developer Mode for live-updating links")}\n`);
|
|
2154
|
+
}
|
|
2155
|
+
if (scriptsResult.total > 0) {
|
|
2156
|
+
const scriptsChanged = scriptsResult.installed + scriptsResult.updated + scriptsResult.copied;
|
|
2157
|
+
const scriptsDetail = scriptsChanged > 0
|
|
2158
|
+
? `(new ${scriptsResult.installed}, updated ${scriptsResult.updated}, copied ${scriptsResult.copied}, unchanged ${scriptsResult.skipped})`
|
|
2159
|
+
: `(unchanged ${scriptsResult.skipped})`;
|
|
2160
|
+
w.write(` ${glyph(true)} ${scriptsResult.total} pipeline scripts linked into ~/.claude/scripts ${dimLine(ctx, scriptsDetail)}\n`);
|
|
2161
|
+
}
|
|
2162
|
+
if (manifestSaveFailed) {
|
|
2163
|
+
w.write(" " + dimLine(ctx, "manifest not saved — re-runs may warn about copied files") + "\n");
|
|
2164
|
+
}
|
|
2165
|
+
if (hookStatus === "skipped-malformed") {
|
|
2166
|
+
w.write(` ${glyph(false)} SessionStart hook NOT registered — ${settingsPath} is not valid JSON\n`);
|
|
2167
|
+
w.write(fixCard([`Fix the JSON in ${settingsPath}, then re-run: npx forgedock install`], ctx.mode) + "\n");
|
|
2168
|
+
} else {
|
|
2169
|
+
w.write(` ${glyph(true)} SessionStart hook ${hookStatus === "already" ? "active" : "registered"} ${dimLine(ctx, settingsPath)}\n`);
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
// Persisted-home / ephemeral-cache advisory (forge#1895, extended #1943):
|
|
2173
|
+
// runJourney() calls persistHome(ctx) right before forge() and stashes its
|
|
2174
|
+
// result on ctx.persistHomeResult; when persistHome() actually ran (i.e.
|
|
2175
|
+
// wasn't skipped), ctx.forgeHome above has ALREADY been reassigned to the
|
|
2176
|
+
// stable ~/.forge/ copy, so report that outcome instead of the raw
|
|
2177
|
+
// ephemeral-cache warning. Direct forge() callers that never set
|
|
2178
|
+
// ctx.persistHomeResult (e.g. relinkAndHint() in bin/forgedock.mjs) fall
|
|
2179
|
+
// through to the original ephemeral-cache-only check unchanged.
|
|
2180
|
+
const persistResult = ctx.persistHomeResult;
|
|
2181
|
+
if (persistResult && !persistResult.skipped) {
|
|
2182
|
+
if (persistResult.migrated) {
|
|
2183
|
+
w.write(` ${glyph(true)} toolset migrated to persisted home ${dimLine(ctx, persistResult.forgeHome)}\n`);
|
|
2184
|
+
} else {
|
|
2185
|
+
w.write(` ${glyph(true)} persisted home already current ${dimLine(ctx, `v${persistResult.version || "unknown"} at ${persistResult.forgeHome}`)}\n`);
|
|
2186
|
+
}
|
|
2187
|
+
} else if (persistResult && persistResult.skipped && !isEphemeralCachePath(ctx.forgeHome)) {
|
|
2188
|
+
// Skipped for a reason OTHER than "still ephemeral" — today that's always
|
|
2189
|
+
// the git-working-tree exemption (Acceptance Criteria #5). Informational,
|
|
2190
|
+
// not a warning: this is the expected, correct state for a git-clone install.
|
|
2191
|
+
w.write(` ${dimLine(ctx, `persisted home skipped — ${persistResult.reason || "not applicable"}`)}\n`);
|
|
2192
|
+
} else if (hookStatus !== "skipped-malformed" && isEphemeralCachePath(ctx.forgeHome)) {
|
|
2193
|
+
// Either persistHome() was never run (no ctx.persistHomeResult), or it
|
|
2194
|
+
// ran but failed and left ctx.forgeHome pointing at the original
|
|
2195
|
+
// ephemeral source (fail-open — see persistHome()'s error branch).
|
|
2196
|
+
w.write(` ${glyph(false)} FORGE_HOME resolves inside an ephemeral cache directory ${dimLine(ctx, ctx.forgeHome)}\n`);
|
|
2197
|
+
w.write(fixCard([
|
|
2198
|
+
"This path (npx/pnpm dlx/yarn dlx cache) can be pruned by npm/npx/pnpm/yarn",
|
|
2199
|
+
"or OS temp cleanup. If that happens, the SessionStart hook silently stops",
|
|
2200
|
+
"injecting ForgeDock context — no error is shown.",
|
|
2201
|
+
"Mitigation: npm install -g forgedock (or re-run npx forgedock periodically).",
|
|
2202
|
+
], ctx.mode, "warning") + "\n");
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
// Cross-environment install advisory (forge#1893): warn when this repo is
|
|
2206
|
+
// reachable from both WSL and native Windows and BOTH sides already have a
|
|
2207
|
+
// ForgeDock install. Each environment's install is fully independent (see
|
|
2208
|
+
// #1589 — installs are always global, never per-repo), so running from
|
|
2209
|
+
// both silently leaves forge.yaml/hook paths pointing at whichever side ran
|
|
2210
|
+
// most recently, clobbering the other's config with no error surfaced.
|
|
2211
|
+
// Advisory only — never fails the install.
|
|
2212
|
+
const envInfo = detectEnvironment({ platform: ctx.platform, env: ctx.env, release: ctx.release });
|
|
2213
|
+
const crossEnv = detectCrossEnvInstall(ctx, envInfo);
|
|
2214
|
+
if (crossEnv.conflict) {
|
|
2215
|
+
const otherLabel = crossEnv.direction === "windows" ? "a native Windows" : "a WSL";
|
|
2216
|
+
w.write(` ${glyph(false)} ${otherLabel} ForgeDock install detected for this repo ${dimLine(ctx, crossEnv.otherPath)}\n`);
|
|
2217
|
+
w.write(fixCard([
|
|
2218
|
+
`This repo is reachable from both WSL and native Windows, and ${otherLabel}`,
|
|
2219
|
+
"install already exists. Each side's install is independent — running",
|
|
2220
|
+
"forgedock from both can leave forge.yaml and hook paths pointing at",
|
|
2221
|
+
"whichever environment ran last, silently overwriting the other's config.",
|
|
2222
|
+
"Mitigation: pick one environment for this repo, or sync forge.yaml manually.",
|
|
2223
|
+
], ctx.mode, "warning") + "\n");
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
// Report enforcement hook status.
|
|
2227
|
+
if (preToolUseStatus !== null) {
|
|
2228
|
+
w.write(` ${glyph(true)} PreToolUse enforcement hook ${preToolUseStatus === "already" ? "active" : "registered"} ${dimLine(ctx, "(branch/label enforcement)")}\n`);
|
|
2229
|
+
} else {
|
|
2230
|
+
w.write(" " + dimLine(ctx, "PreToolUse hook skipped — requires Claude Code v2.1.163+") + "\n");
|
|
2231
|
+
}
|
|
2232
|
+
// "skipped-malformed" and "absent" are intentionally silent here:
|
|
2233
|
+
// - skipped-malformed: installSessionStartHook() above reads the same
|
|
2234
|
+
// settingsPath and already prints the malformed-JSON fix-card, so a
|
|
2235
|
+
// second message would just repeat the same signal.
|
|
2236
|
+
// - absent: the expected steady state (nothing to remove) — this
|
|
2237
|
+
// function only reports state *changes*, not no-ops.
|
|
2238
|
+
if (subagentStopEnforceStatus === "removed") {
|
|
2239
|
+
w.write(` ${glyph(true)} SubagentStop enforcement hook removed ${dimLine(ctx, "(non-functional — see forge#1527)")}\n`);
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
|
|
1838
2243
|
/**
|
|
1839
2244
|
* Act II: link commands into ~/.claude/commands with a molten progress line,
|
|
1840
2245
|
* then register the SessionStart hook.
|
|
@@ -1889,6 +2294,14 @@ export async function forge(ctx) {
|
|
|
1889
2294
|
}
|
|
1890
2295
|
manifestOps.push({ rel, op: "add" });
|
|
1891
2296
|
};
|
|
2297
|
+
// Mirror of recordCopy for removals — keeps manifest.files, manifestChanged,
|
|
2298
|
+
// and the replayable manifestOps diff (forge#2614) mutating as one unit so
|
|
2299
|
+
// the in-memory manifest and the final merge-save replay never diverge.
|
|
2300
|
+
const recordDelete = (rel) => {
|
|
2301
|
+
delete manifest.files[rel];
|
|
2302
|
+
manifestChanged = true;
|
|
2303
|
+
manifestOps.push({ rel, op: "delete" });
|
|
2304
|
+
};
|
|
1892
2305
|
|
|
1893
2306
|
for (let i = 0; i < files.length; i++) {
|
|
1894
2307
|
const file = files[i];
|
|
@@ -1905,40 +2318,15 @@ export async function forge(ctx) {
|
|
|
1905
2318
|
} else {
|
|
1906
2319
|
let relinked = false;
|
|
1907
2320
|
if (wantSymlink) {
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
// on the same literal path (which raises an uncaught EEXIST —
|
|
1911
|
-
// isLinkPermissionError() only treats EPERM/EACCES as
|
|
1912
|
-
// recoverable). Mirrors the copyFile branch and
|
|
1913
|
-
// saveCopiedManifest fixes already applied in this file
|
|
1914
|
-
// (forge#2542, forge#2599).
|
|
1915
|
-
const tmpTarget = target + "." + process.pid + ".tmp";
|
|
1916
|
-
try {
|
|
1917
|
-
await symlink(file, tmpTarget);
|
|
1918
|
-
try {
|
|
1919
|
-
await rename(tmpTarget, target);
|
|
1920
|
-
} catch (renameErr) {
|
|
1921
|
-
await unlink(tmpTarget).catch(() => {});
|
|
1922
|
-
throw renameErr;
|
|
1923
|
-
}
|
|
1924
|
-
relinked = await isSymlinkTraversable(target);
|
|
1925
|
-
if (relinked) {
|
|
1926
|
-
updated++;
|
|
1927
|
-
} else {
|
|
1928
|
-
// Symlink was created but is not readable back (e.g. MSYS
|
|
1929
|
-
// symlink hit by native Windows "untrusted mount point" —
|
|
1930
|
-
// forge#2620). Remove it and fall through to the copy branch.
|
|
1931
|
-
await unlink(target).catch(() => {});
|
|
1932
|
-
}
|
|
1933
|
-
} catch (linkErr) {
|
|
1934
|
-
if (!isLinkPermissionError(linkErr)) throw linkErr;
|
|
1935
|
-
}
|
|
2321
|
+
relinked = (await atomicSymlinkInstall(file, target)) === "linked";
|
|
2322
|
+
if (relinked) updated++;
|
|
1936
2323
|
}
|
|
1937
2324
|
if (!relinked) {
|
|
1938
2325
|
// Can't (or shouldn't) re-link — replace the managed link with a copy.
|
|
1939
2326
|
// unlink first: copyFile onto a symlink writes THROUGH the link.
|
|
1940
|
-
// (
|
|
1941
|
-
//
|
|
2327
|
+
// (On "unreadable" the rename never happened so the old link is
|
|
2328
|
+
// still here; on "denied" likewise. ENOENT tolerated defensively
|
|
2329
|
+
// in case the entry vanished out from under us — forge#2679.)
|
|
1942
2330
|
await unlink(target).catch((err) => {
|
|
1943
2331
|
if (err.code !== "ENOENT") throw err;
|
|
1944
2332
|
});
|
|
@@ -1948,174 +2336,17 @@ export async function forge(ctx) {
|
|
|
1948
2336
|
}
|
|
1949
2337
|
}
|
|
1950
2338
|
} else if (manifest.files[rel]) {
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
let restoredAsCopy = false;
|
|
1955
|
-
if (wantSymlink) {
|
|
1956
|
-
// Same per-process-unique tmp suffix as the relink branch above —
|
|
1957
|
-
// sibling branch, identical shared-literal-path EEXIST risk
|
|
1958
|
-
// (forge#2542, forge#2599).
|
|
1959
|
-
const tmpTarget = target + "." + process.pid + ".tmp";
|
|
1960
|
-
try {
|
|
1961
|
-
await symlink(file, tmpTarget);
|
|
1962
|
-
try {
|
|
1963
|
-
await rename(tmpTarget, target); // rename replaces existing files on Windows
|
|
1964
|
-
} catch (renameErr) {
|
|
1965
|
-
await unlink(tmpTarget).catch(() => {});
|
|
1966
|
-
throw renameErr;
|
|
1967
|
-
}
|
|
1968
|
-
upgraded = await isSymlinkTraversable(target);
|
|
1969
|
-
if (upgraded) {
|
|
1970
|
-
updated++;
|
|
1971
|
-
delete manifest.files[rel];
|
|
1972
|
-
manifestChanged = true;
|
|
1973
|
-
manifestOps.push({ rel, op: "delete" });
|
|
1974
|
-
} else {
|
|
1975
|
-
// Symlink was created but is not readable back (e.g. MSYS
|
|
1976
|
-
// symlink hit by native Windows "untrusted mount point" —
|
|
1977
|
-
// forge#2620). Restore a real copy so the command stays
|
|
1978
|
-
// readable. Keep the manifest entry (still copy-managed, not
|
|
1979
|
-
// symlink-managed) and do NOT fall through to the generic
|
|
1980
|
-
// copy-comparison below — the copy is already done.
|
|
1981
|
-
await unlink(target).catch(() => {});
|
|
1982
|
-
await copyFile(file, target);
|
|
1983
|
-
updated++;
|
|
1984
|
-
restoredAsCopy = true;
|
|
1985
|
-
}
|
|
1986
|
-
} catch (linkErr) {
|
|
1987
|
-
if (!isLinkPermissionError(linkErr)) throw linkErr;
|
|
1988
|
-
}
|
|
1989
|
-
}
|
|
1990
|
-
if (!upgraded && !restoredAsCopy) {
|
|
1991
|
-
const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
|
|
1992
|
-
if (src.equals(dst)) {
|
|
1993
|
-
skipped++;
|
|
1994
|
-
} else {
|
|
1995
|
-
await copyFile(file, target);
|
|
1996
|
-
updated++;
|
|
1997
|
-
}
|
|
1998
|
-
}
|
|
2339
|
+
const eff = await upgradeManagedCopy(file, target, rel, { wantSymlink, recordDelete });
|
|
2340
|
+
updated += eff.updatedDelta;
|
|
2341
|
+
skipped += eff.skippedDelta;
|
|
1999
2342
|
} else {
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
// ours all along). If it differs, it's stale — overwrite it and
|
|
2008
|
-
// adopt it into the manifest too, mirroring the manifest-tracked
|
|
2009
|
-
// branch above, so future runs take the manifest-aware fast path.
|
|
2010
|
-
let handled = false;
|
|
2011
|
-
try {
|
|
2012
|
-
const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
|
|
2013
|
-
if (src.equals(dst)) {
|
|
2014
|
-
recordCopy(rel);
|
|
2015
|
-
skipped++;
|
|
2016
|
-
handled = true;
|
|
2017
|
-
} else {
|
|
2018
|
-
// Write to a .tmp sibling and atomically rename onto target — never
|
|
2019
|
-
// open target itself for a direct overwrite. copyFile() is not
|
|
2020
|
-
// atomic: a mid-write failure (AV lock / ENOSPC / process kill)
|
|
2021
|
-
// would otherwise leave target truncated/corrupted rather than
|
|
2022
|
-
// simply stale, which the WARNING below cannot distinguish. This
|
|
2023
|
-
// mirrors the .tmp+rename swap already used for symlink installs
|
|
2024
|
-
// above (lines ~1573-1579, ~1601-1607).
|
|
2025
|
-
//
|
|
2026
|
-
// Unlike those symlink-based branches — where symlink() is itself
|
|
2027
|
-
// exclusive-create and therefore naturally races out a concurrent
|
|
2028
|
-
// writer — copyFile() has no such semantics: two concurrent
|
|
2029
|
-
// forge() invocations (e.g. an overlapping `doctor --fix` run, or
|
|
2030
|
-
// a file watcher) targeting the same file would both write
|
|
2031
|
-
// through the same shared `target + ".tmp"` path and could
|
|
2032
|
-
// interleave or clobber each other before either rename() runs.
|
|
2033
|
-
// Suffix the tmp sibling with this process's PID so concurrent
|
|
2034
|
-
// invocations never share a path (forge#2542).
|
|
2035
|
-
const tmpTarget = target + "." + process.pid + ".tmp";
|
|
2036
|
-
try {
|
|
2037
|
-
await copyFile(file, tmpTarget);
|
|
2038
|
-
} catch (copyErr) {
|
|
2039
|
-
// A copyFile failure (disk full mid-copy, AV lock, permission
|
|
2040
|
-
// error) can still leave a partially-written .tmp sibling on
|
|
2041
|
-
// disk even though the write itself threw. The sibling
|
|
2042
|
-
// rename() failure just below already cleans up tmpTarget
|
|
2043
|
-
// on its own failure path (renameErr) — mirror that here so a
|
|
2044
|
-
// copyFile failure doesn't orphan the .tmp file instead
|
|
2045
|
-
// (forge#2540). Best-effort: never let the cleanup itself mask
|
|
2046
|
-
// or replace the original copyErr being rethrown.
|
|
2047
|
-
await unlink(tmpTarget).catch(() => {});
|
|
2048
|
-
throw copyErr;
|
|
2049
|
-
}
|
|
2050
|
-
// Preserve the pre-existing content before it's replaced, mirroring
|
|
2051
|
-
// half of the confirm+backup convention used by the forge.yaml
|
|
2052
|
-
// overwrite path (bin/forgedock.mjs ~990-1021 / journey.mjs
|
|
2053
|
-
// review(), both via backupExisting()). A confirmation prompt is
|
|
2054
|
-
// deliberately NOT added: this branch also runs non-interactively
|
|
2055
|
-
// inside `doctor --fix`'s automated repair (bin/forgedock.mjs
|
|
2056
|
-
// runInstallRepairOnce() -> forge(ctx())), so pausing per-file for
|
|
2057
|
-
// input would break that flow. Placed here — after the .tmp write
|
|
2058
|
-
// has already succeeded, before the final rename — rather than
|
|
2059
|
-
// before copyFile (as the forge.yaml path does), so a copyFile
|
|
2060
|
-
// failure never backs up (and thereby displaces) a target that's
|
|
2061
|
-
// about to remain untouched (see the .tmp-write-failure test,
|
|
2062
|
-
// forge#2498). backupExisting()'s rename is itself atomic, so this
|
|
2063
|
-
// ordering carries none of the non-atomic-write risk that forge#1396
|
|
2064
|
-
// flagged for the old backup-then-writeFileSync forge.yaml path.
|
|
2065
|
-
//
|
|
2066
|
-
// Note: backupExisting() below is a *synchronous* rename
|
|
2067
|
-
// (target -> target+".bak"); the tmpTarget -> target rename just
|
|
2068
|
-
// after it is a separate, async filesystem operation. Between
|
|
2069
|
-
// the two, target genuinely does not exist on disk for a
|
|
2070
|
-
// sub-millisecond window. Only a hard process kill (not any
|
|
2071
|
-
// catchable JS error) can land exactly there — any catchable
|
|
2072
|
-
// failure of the second rename is already handled by the
|
|
2073
|
-
// rollback block below — and recovery in that rare case is via
|
|
2074
|
-
// the surviving target+".bak" on the next forge() run (forge#2558).
|
|
2075
|
-
const backup = backupExisting(target);
|
|
2076
|
-
if (backup) backedUp++;
|
|
2077
|
-
try {
|
|
2078
|
-
await rename(tmpTarget, target);
|
|
2079
|
-
} catch (renameErr) {
|
|
2080
|
-
await unlink(tmpTarget).catch(() => {});
|
|
2081
|
-
if (backup) {
|
|
2082
|
-
// The final rename failed AFTER backupExisting() already moved
|
|
2083
|
-
// the original file to target+".bak" — target no longer exists
|
|
2084
|
-
// on disk at this point. Without this rollback the user's file
|
|
2085
|
-
// would simply vanish (present only as .bak) while the WARNING
|
|
2086
|
-
// below implies it merely "could not be repaired". Restore the
|
|
2087
|
-
// prior content so a failed repair is a no-op, matching the
|
|
2088
|
-
// pre-backup invariant that a rename failure never removes the
|
|
2089
|
-
// existing file (see the .tmp-write-failure test, forge#2498).
|
|
2090
|
-
// WIRE:PROVEN — manual: reasoned, not exercised by an automated
|
|
2091
|
-
// test. This branch requires rename(tmp, target) to fail on the
|
|
2092
|
-
// exact call immediately after backupExisting()'s own rename of
|
|
2093
|
-
// the same target succeeded — an OS-level race with no portable
|
|
2094
|
-
// way to force deterministically (the sibling renameErr catches
|
|
2095
|
-
// for the symlink-relink paths above, lines ~1576/~1604, are the
|
|
2096
|
-
// same kind of defensive OS-failure branch and are likewise
|
|
2097
|
-
// untested for the same reason). Verified by code inspection:
|
|
2098
|
-
// rename() is fs/promises' rename. The rollback rename's own
|
|
2099
|
-
// outcome is captured explicitly rather than assumed — if it
|
|
2100
|
-
// also fails (e.g. .bak itself is locked), target+".bak" is
|
|
2101
|
-
// left in place as the only remaining copy of the prior
|
|
2102
|
-
// content, so backedUp must NOT be decremented in that case;
|
|
2103
|
-
// it correctly reflects "a backup file still exists on disk"
|
|
2104
|
-
// (forge#2559). Only on confirmed rollback success does
|
|
2105
|
-
// backedUp-- keep the counter consistent with the restored
|
|
2106
|
-
// on-disk state before renameErr is rethrown to the outer catch.
|
|
2107
|
-
let rollbackOk = true;
|
|
2108
|
-
await rename(target + ".bak", target).catch(() => { rollbackOk = false; });
|
|
2109
|
-
if (rollbackOk) backedUp--;
|
|
2110
|
-
}
|
|
2111
|
-
throw renameErr;
|
|
2112
|
-
}
|
|
2113
|
-
recordCopy(rel);
|
|
2114
|
-
updated++;
|
|
2115
|
-
handled = true;
|
|
2116
|
-
}
|
|
2117
|
-
} catch { /* readFile/copyFile/rename failure — fall through to warning */ }
|
|
2118
|
-
if (!handled) {
|
|
2343
|
+
const eff = await adoptOrRepairUnmanagedCopy(file, target, rel, { recordCopy });
|
|
2344
|
+
updated += eff.updatedDelta;
|
|
2345
|
+
skipped += eff.skippedDelta;
|
|
2346
|
+
backedUp += eff.backedUpDelta;
|
|
2347
|
+
if (!eff.handled) {
|
|
2348
|
+
// Terminal/progress-bar state stays here — helpers never touch
|
|
2349
|
+
// barShown or write to the stream.
|
|
2119
2350
|
if (barShown) { w.write("\x1b[1A\x1b[2K"); barShown = false; }
|
|
2120
2351
|
w.write(` WARNING: ${rel} could not be repaired — remove it manually to let ForgeDock manage it\n`);
|
|
2121
2352
|
skipped++;
|
|
@@ -2123,48 +2354,9 @@ export async function forge(ctx) {
|
|
|
2123
2354
|
}
|
|
2124
2355
|
} catch (err) {
|
|
2125
2356
|
if (err.code !== "ENOENT") throw err;
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
// concurrent forge() invocation installing the same brand-new
|
|
2130
|
-
// command never collides on the same literal target path (which
|
|
2131
|
-
// raises an uncaught EEXIST — isLinkPermissionError() only treats
|
|
2132
|
-
// EPERM/EACCES as recoverable). Mirrors the relink and copy-upgrade
|
|
2133
|
-
// branches above (forge#2542, forge#2600, forge#2599).
|
|
2134
|
-
const tmpTarget = target + "." + process.pid + ".tmp";
|
|
2135
|
-
try {
|
|
2136
|
-
await symlink(file, tmpTarget);
|
|
2137
|
-
try {
|
|
2138
|
-
await rename(tmpTarget, target);
|
|
2139
|
-
} catch (renameErr) {
|
|
2140
|
-
await unlink(tmpTarget).catch(() => {});
|
|
2141
|
-
throw renameErr;
|
|
2142
|
-
}
|
|
2143
|
-
linked = await isSymlinkTraversable(target);
|
|
2144
|
-
if (linked) {
|
|
2145
|
-
installed++;
|
|
2146
|
-
if (manifest.files[rel]) {
|
|
2147
|
-
// Symlinks work now and the old copy is gone — drop the stale record.
|
|
2148
|
-
delete manifest.files[rel];
|
|
2149
|
-
manifestChanged = true;
|
|
2150
|
-
manifestOps.push({ rel, op: "delete" });
|
|
2151
|
-
}
|
|
2152
|
-
} else {
|
|
2153
|
-
// Symlink created but not readable back (e.g. MSYS symlink hit by
|
|
2154
|
-
// native Windows "untrusted mount point" — forge#2620). Remove it
|
|
2155
|
-
// and fall through to the copy path below.
|
|
2156
|
-
await unlink(target).catch(() => {});
|
|
2157
|
-
}
|
|
2158
|
-
} catch (linkErr) {
|
|
2159
|
-
if (!isLinkPermissionError(linkErr)) throw linkErr;
|
|
2160
|
-
}
|
|
2161
|
-
}
|
|
2162
|
-
if (!linked) {
|
|
2163
|
-
// Windows without Developer Mode/admin, or symlink unreadable — copy.
|
|
2164
|
-
await copyFile(file, target);
|
|
2165
|
-
copied++;
|
|
2166
|
-
recordCopy(rel);
|
|
2167
|
-
}
|
|
2357
|
+
const eff = await installFreshCommandFile(file, target, rel, { wantSymlink, manifest, recordCopy, recordDelete });
|
|
2358
|
+
installed += eff.installedDelta;
|
|
2359
|
+
copied += eff.copiedDelta;
|
|
2168
2360
|
}
|
|
2169
2361
|
|
|
2170
2362
|
if (ctx.motion) {
|
|
@@ -2204,149 +2396,18 @@ export async function forge(ctx) {
|
|
|
2204
2396
|
const { status: subagentStopEnforceStatus } = removeSubagentStopEnforceHook(settingsPath);
|
|
2205
2397
|
|
|
2206
2398
|
// Housekeeping — must never abort the receipt or the hook.
|
|
2399
|
+
// saveManifestWithMerge() owns the locked re-read → replay-manifestOps →
|
|
2400
|
+
// save sequence (forge#2614/forge#2637) and never throws.
|
|
2207
2401
|
let manifestSaveFailed = false;
|
|
2208
2402
|
if (manifestChanged) {
|
|
2209
|
-
|
|
2210
|
-
// Re-read-and-merge (forge#2614): another concurrent forge() invocation
|
|
2211
|
-
// may have saved its own manifest.files adds/deletes since this run's
|
|
2212
|
-
// initial load above. Re-reading the on-disk manifest right before this
|
|
2213
|
-
// run's own save and replaying only this run's own ops (manifestOps)
|
|
2214
|
-
// onto that fresh copy — rather than blindly overwriting with the
|
|
2215
|
-
// run-start in-memory snapshot — means this run's save can no longer
|
|
2216
|
-
// silently drop the other run's changes. loadCopiedManifest() never
|
|
2217
|
-
// throws (missing/corrupt on-disk manifest resolves to an empty one),
|
|
2218
|
-
// so this stays within the surrounding never-abort contract.
|
|
2219
|
-
//
|
|
2220
|
-
// Lock the re-read→merge→write sequence itself (forge#2637): #2614
|
|
2221
|
-
// narrowed the race from "whole run duration" to this critical section,
|
|
2222
|
-
// but left it unprotected — a third concurrent forge() could still land
|
|
2223
|
-
// its own save inside the gap between this run's re-read and its own
|
|
2224
|
-
// write-rename completing. acquireManifestLock() is best-effort: on
|
|
2225
|
-
// failure to acquire (contention exhausted retries, or an unexpected
|
|
2226
|
-
// error) it returns null and this run proceeds unlocked, same as
|
|
2227
|
-
// before — the lock narrows the window further, it does not change the
|
|
2228
|
-
// never-abort contract.
|
|
2229
|
-
const lockHandle = await acquireManifestLock(manifestPath);
|
|
2230
|
-
try {
|
|
2231
|
-
const mergedManifest = await loadCopiedManifest(manifestPath);
|
|
2232
|
-
for (const { rel, op } of manifestOps) {
|
|
2233
|
-
if (op === "add") {
|
|
2234
|
-
mergedManifest.files[rel] = true;
|
|
2235
|
-
} else {
|
|
2236
|
-
delete mergedManifest.files[rel];
|
|
2237
|
-
}
|
|
2238
|
-
}
|
|
2239
|
-
await saveCopiedManifest(manifestPath, mergedManifest);
|
|
2240
|
-
} finally {
|
|
2241
|
-
await releaseManifestLock(manifestPath, lockHandle);
|
|
2242
|
-
}
|
|
2243
|
-
} catch {
|
|
2244
|
-
manifestSaveFailed = true;
|
|
2245
|
-
}
|
|
2246
|
-
}
|
|
2247
|
-
|
|
2248
|
-
const glyph = (ok) => (ctx.mode === "none" ? (ok ? "✔" : "!") : `\x1b[38;2;255;179;71m${ok ? "✔" : "!"}\x1b[0m`);
|
|
2249
|
-
const headlineVerb = copied > 0 ? "installed" : "linked";
|
|
2250
|
-
const backupNote = backedUp > 0 ? `, ${backedUp} backed up` : "";
|
|
2251
|
-
const headlineDetail = copied > 0
|
|
2252
|
-
? `(new ${installed}, copied ${copied}, updated ${updated}${backupNote}, unchanged ${skipped})`
|
|
2253
|
-
: `(new ${installed}, updated ${updated}${backupNote}, unchanged ${skipped})`;
|
|
2254
|
-
w.write(` ${glyph(true)} ${files.length} slash commands ${headlineVerb} ${dimLine(ctx, headlineDetail)}\n`);
|
|
2255
|
-
if (pruned > 0) {
|
|
2256
|
-
w.write(` ${glyph(true)} ${pruned} orphaned symlink${pruned === 1 ? "" : "s"} removed ${dimLine(ctx, "(commands deleted or renamed since last install)")}\n`);
|
|
2257
|
-
}
|
|
2258
|
-
if (staleExtensionlessPruned > 0) {
|
|
2259
|
-
w.write(` ${glyph(true)} ${staleExtensionlessPruned} stale extensionless entr${staleExtensionlessPruned === 1 ? "y" : "ies"} removed ${dimLine(ctx, "(leftover from prior npx forgedock runs)")}\n`);
|
|
2260
|
-
}
|
|
2261
|
-
if (copied > 0) {
|
|
2262
|
-
w.write(` ${glyph(false)} ${copied} copied (not linked) ${dimLine(ctx, "— enable Windows Developer Mode for live-updating links")}\n`);
|
|
2263
|
-
}
|
|
2264
|
-
if (scriptsResult.total > 0) {
|
|
2265
|
-
const scriptsChanged = scriptsResult.installed + scriptsResult.updated + scriptsResult.copied;
|
|
2266
|
-
const scriptsDetail = scriptsChanged > 0
|
|
2267
|
-
? `(new ${scriptsResult.installed}, updated ${scriptsResult.updated}, copied ${scriptsResult.copied}, unchanged ${scriptsResult.skipped})`
|
|
2268
|
-
: `(unchanged ${scriptsResult.skipped})`;
|
|
2269
|
-
w.write(` ${glyph(true)} ${scriptsResult.total} pipeline scripts linked into ~/.claude/scripts ${dimLine(ctx, scriptsDetail)}\n`);
|
|
2270
|
-
}
|
|
2271
|
-
if (manifestSaveFailed) {
|
|
2272
|
-
w.write(" " + dimLine(ctx, "manifest not saved — re-runs may warn about copied files") + "\n");
|
|
2273
|
-
}
|
|
2274
|
-
if (hookStatus === "skipped-malformed") {
|
|
2275
|
-
w.write(` ${glyph(false)} SessionStart hook NOT registered — ${settingsPath} is not valid JSON\n`);
|
|
2276
|
-
w.write(fixCard([`Fix the JSON in ${settingsPath}, then re-run: npx forgedock install`], ctx.mode) + "\n");
|
|
2277
|
-
} else {
|
|
2278
|
-
w.write(` ${glyph(true)} SessionStart hook ${hookStatus === "already" ? "active" : "registered"} ${dimLine(ctx, settingsPath)}\n`);
|
|
2279
|
-
}
|
|
2280
|
-
|
|
2281
|
-
// Persisted-home / ephemeral-cache advisory (forge#1895, extended #1943):
|
|
2282
|
-
// runJourney() calls persistHome(ctx) right before forge() and stashes its
|
|
2283
|
-
// result on ctx.persistHomeResult; when persistHome() actually ran (i.e.
|
|
2284
|
-
// wasn't skipped), ctx.forgeHome above has ALREADY been reassigned to the
|
|
2285
|
-
// stable ~/.forge/ copy, so report that outcome instead of the raw
|
|
2286
|
-
// ephemeral-cache warning. Direct forge() callers that never set
|
|
2287
|
-
// ctx.persistHomeResult (e.g. relinkAndHint() in bin/forgedock.mjs) fall
|
|
2288
|
-
// through to the original ephemeral-cache-only check unchanged.
|
|
2289
|
-
const persistResult = ctx.persistHomeResult;
|
|
2290
|
-
if (persistResult && !persistResult.skipped) {
|
|
2291
|
-
if (persistResult.migrated) {
|
|
2292
|
-
w.write(` ${glyph(true)} toolset migrated to persisted home ${dimLine(ctx, persistResult.forgeHome)}\n`);
|
|
2293
|
-
} else {
|
|
2294
|
-
w.write(` ${glyph(true)} persisted home already current ${dimLine(ctx, `v${persistResult.version || "unknown"} at ${persistResult.forgeHome}`)}\n`);
|
|
2295
|
-
}
|
|
2296
|
-
} else if (persistResult && persistResult.skipped && !isEphemeralCachePath(ctx.forgeHome)) {
|
|
2297
|
-
// Skipped for a reason OTHER than "still ephemeral" — today that's always
|
|
2298
|
-
// the git-working-tree exemption (Acceptance Criteria #5). Informational,
|
|
2299
|
-
// not a warning: this is the expected, correct state for a git-clone install.
|
|
2300
|
-
w.write(` ${dimLine(ctx, `persisted home skipped — ${persistResult.reason || "not applicable"}`)}\n`);
|
|
2301
|
-
} else if (hookStatus !== "skipped-malformed" && isEphemeralCachePath(ctx.forgeHome)) {
|
|
2302
|
-
// Either persistHome() was never run (no ctx.persistHomeResult), or it
|
|
2303
|
-
// ran but failed and left ctx.forgeHome pointing at the original
|
|
2304
|
-
// ephemeral source (fail-open — see persistHome()'s error branch).
|
|
2305
|
-
w.write(` ${glyph(false)} FORGE_HOME resolves inside an ephemeral cache directory ${dimLine(ctx, ctx.forgeHome)}\n`);
|
|
2306
|
-
w.write(fixCard([
|
|
2307
|
-
"This path (npx/pnpm dlx/yarn dlx cache) can be pruned by npm/npx/pnpm/yarn",
|
|
2308
|
-
"or OS temp cleanup. If that happens, the SessionStart hook silently stops",
|
|
2309
|
-
"injecting ForgeDock context — no error is shown.",
|
|
2310
|
-
"Mitigation: npm install -g forgedock (or re-run npx forgedock periodically).",
|
|
2311
|
-
], ctx.mode, "warning") + "\n");
|
|
2312
|
-
}
|
|
2313
|
-
|
|
2314
|
-
// Cross-environment install advisory (forge#1893): warn when this repo is
|
|
2315
|
-
// reachable from both WSL and native Windows and BOTH sides already have a
|
|
2316
|
-
// ForgeDock install. Each environment's install is fully independent (see
|
|
2317
|
-
// #1589 — installs are always global, never per-repo), so running from
|
|
2318
|
-
// both silently leaves forge.yaml/hook paths pointing at whichever side ran
|
|
2319
|
-
// most recently, clobbering the other's config with no error surfaced.
|
|
2320
|
-
// Advisory only — never fails the install.
|
|
2321
|
-
const envInfo = detectEnvironment({ platform: ctx.platform, env: ctx.env, release: ctx.release });
|
|
2322
|
-
const crossEnv = detectCrossEnvInstall(ctx, envInfo);
|
|
2323
|
-
if (crossEnv.conflict) {
|
|
2324
|
-
const otherLabel = crossEnv.direction === "windows" ? "a native Windows" : "a WSL";
|
|
2325
|
-
w.write(` ${glyph(false)} ${otherLabel} ForgeDock install detected for this repo ${dimLine(ctx, crossEnv.otherPath)}\n`);
|
|
2326
|
-
w.write(fixCard([
|
|
2327
|
-
`This repo is reachable from both WSL and native Windows, and ${otherLabel}`,
|
|
2328
|
-
"install already exists. Each side's install is independent — running",
|
|
2329
|
-
"forgedock from both can leave forge.yaml and hook paths pointing at",
|
|
2330
|
-
"whichever environment ran last, silently overwriting the other's config.",
|
|
2331
|
-
"Mitigation: pick one environment for this repo, or sync forge.yaml manually.",
|
|
2332
|
-
], ctx.mode, "warning") + "\n");
|
|
2403
|
+
manifestSaveFailed = !(await saveManifestWithMerge(manifestPath, manifestOps));
|
|
2333
2404
|
}
|
|
2334
2405
|
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
}
|
|
2341
|
-
// "skipped-malformed" and "absent" are intentionally silent here:
|
|
2342
|
-
// - skipped-malformed: installSessionStartHook() above reads the same
|
|
2343
|
-
// settingsPath and already prints the malformed-JSON fix-card, so a
|
|
2344
|
-
// second message would just repeat the same signal.
|
|
2345
|
-
// - absent: the expected steady state (nothing to remove) — this
|
|
2346
|
-
// function only reports state *changes*, not no-ops.
|
|
2347
|
-
if (subagentStopEnforceStatus === "removed") {
|
|
2348
|
-
w.write(` ${glyph(true)} SubagentStop enforcement hook removed ${dimLine(ctx, "(non-functional — see forge#1527)")}\n`);
|
|
2349
|
-
}
|
|
2406
|
+
renderForgeReceipt(ctx, {
|
|
2407
|
+
installed, updated, skipped, copied, backedUp, pruned, total: files.length,
|
|
2408
|
+
staleExtensionlessPruned, scriptsResult, manifestSaveFailed,
|
|
2409
|
+
hookStatus, settingsPath, preToolUseStatus, subagentStopEnforceStatus,
|
|
2410
|
+
});
|
|
2350
2411
|
|
|
2351
2412
|
return { installed, updated, skipped, copied, backedUp, pruned, total: files.length, hookStatus, preToolUseStatus, subagentStopEnforceStatus, scriptsResult };
|
|
2352
2413
|
}
|
|
@@ -29,7 +29,7 @@ COMPLEXITY_BAND=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
|
|
|
29
29
|
COMPLEXITY_BAND="${COMPLEXITY_BAND:-STANDARD}"
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
**If COMPLEXITY_BAND: TRIVIAL** → skip all phases (A0 through A5), post
|
|
32
|
+
**If COMPLEXITY_BAND: TRIVIAL** → skip all phases (A0 through A5), **post the minimal skip marker comment described in "Skip Marker" under Skip Conditions below** (reason: `TRIVIAL complexity band`), then return an empty plan to caller immediately. Do not read any files. This is not an error — trivial single-file changes have no cross-path consistency risk. The skip marker (ending in `<!-- FORGE:ARCHITECT:COMPLETE -->`) is what makes a legitimate skip observable to the headless engine's marker-only gate, so the issue advances to build instead of being stranded at `needs-human`. <!-- Added: forge#679; skip-marker on skip: forge#2689 -->
|
|
33
33
|
|
|
34
34
|
**If COMPLEXITY_BAND: STANDARD or COMPLEX** → proceed to Phase A0 below.
|
|
35
35
|
|
|
@@ -668,14 +668,39 @@ gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:ARCHITECT -->
|
|
|
668
668
|
|
|
669
669
|
## Skip Conditions
|
|
670
670
|
|
|
671
|
-
Skip
|
|
671
|
+
Skip the planning work (Phases A0–A5) and return an empty plan to caller — **but always post the Skip Marker below first** — if:
|
|
672
672
|
- **COMPLEXITY_BAND: TRIVIAL** — checked via FORGE:FAST_PATH comment at entry (see guard above) <!-- Primary skip path: forge#679 -->
|
|
673
673
|
- Issue creates only **new files** with no callers to find (e.g. a new command file with no existing integration point yet)
|
|
674
674
|
- Issue is a 1-file config or docs edit with no code logic
|
|
675
675
|
- Issue title starts with "docs:" or "chore:"
|
|
676
676
|
- `{AFFECTED_FILES}` is empty
|
|
677
677
|
|
|
678
|
-
When skipped, the builder proceeds with investigation report + context briefing only.
|
|
678
|
+
When skipped, the builder proceeds with investigation report + context briefing only (see Integration Point below — the builder falls back gracefully on an empty plan).
|
|
679
|
+
|
|
680
|
+
### Skip Marker (MANDATORY on every skip — do NOT post nothing) <!-- forge#2689 -->
|
|
681
|
+
|
|
682
|
+
**Do not post nothing.** The headless engine gate (`bin/engine/phases.mjs` → `architect.detectOutcome`) treats a **missing** `FORGE:ARCHITECT:COMPLETE` sentinel as a phase **failure**, retries to `maxAttempts` (3), then strands the issue at `needs-human`. It reads GitHub comment markers only — it cannot see this subcommand's in-process "empty plan" return value. So a skip that posts no marker is indistinguishable from a crashed architect run and deterministically strands **every** `chore:`/`docs:`/trivial issue.
|
|
683
|
+
|
|
684
|
+
On **every** skip branch above, post exactly this minimal comment (substitute the concrete `{SKIP_REASON}`, e.g. `chore:/docs: title`, `TRIVIAL complexity band`, `new files only — no callers`, or `AFFECTED_FILES empty`) **before** returning the empty plan:
|
|
685
|
+
|
|
686
|
+
```bash
|
|
687
|
+
# The skip marker is mandatory and un-gated by design — posting it IS the point
|
|
688
|
+
# (a missing marker strands the issue at needs-human; see above). Same report-only
|
|
689
|
+
# risk class as the unguarded plan post in "Output Format" above, so it carries the
|
|
690
|
+
# same allowlist rather than a DRY_RUN guard.
|
|
691
|
+
SKIP_BODY=$(cat <<SKIP_EOF
|
|
692
|
+
<!-- FORGE:ARCHITECT -->
|
|
693
|
+
## Architecture Plan — Skipped
|
|
694
|
+
|
|
695
|
+
**Skipped**: {SKIP_REASON}. No cross-path consistency risk — the builder proceeds with the investigation report + context briefing.
|
|
696
|
+
|
|
697
|
+
<!-- FORGE:ARCHITECT:COMPLETE -->
|
|
698
|
+
SKIP_EOF
|
|
699
|
+
)
|
|
700
|
+
gh issue comment {NUMBER} {GH_FLAG} --body "$SKIP_BODY" # <!-- allowlist:check-command-side-effects -->
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
The empty-plan-with-marker changes no downstream behavior — the builder already falls back to the investigation report + contract when the plan is empty (see Integration Point below). The marker only makes the legitimate skip **observable** to the marker-only engine gate. This intentionally does **not** weaken crash detection: a genuinely interrupted architect run still posts no marker at all, so `detectOutcome` still fails-and-escalates it correctly.
|
|
679
704
|
|
|
680
705
|
---
|
|
681
706
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forgedock",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "Autonomous dev pipeline for Claude Code. GitHub as institutional memory — every issue, PR, and annotation survives context resets and makes the next agent smarter.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"forgedock": "bin/forgedock.mjs"
|
|
@@ -48,7 +48,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, rea
|
|
|
48
48
|
import { execSync, spawnSync } from 'child_process';
|
|
49
49
|
import { homedir } from 'os';
|
|
50
50
|
import { dirname, join, resolve } from 'path';
|
|
51
|
-
import { fileURLToPath } from 'url';
|
|
51
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
52
52
|
|
|
53
53
|
const __filename = fileURLToPath(import.meta.url);
|
|
54
54
|
const __dirname = dirname(__filename);
|
|
@@ -309,7 +309,11 @@ async function getParser() {
|
|
|
309
309
|
const parsePath = join(repoRoot, 'packages', 'protocol', 'src', 'parse.js');
|
|
310
310
|
|
|
311
311
|
try {
|
|
312
|
-
|
|
312
|
+
// On win32 a bare absolute path (C:\...) makes Node parse the drive letter as a
|
|
313
|
+
// URL scheme (ERR_UNSUPPORTED_ESM_URL_SCHEME). Convert to a file:// URL first —
|
|
314
|
+
// valid on POSIX too, and matches the import(pathToFileURL(...).href) idiom used
|
|
315
|
+
// at check-admission-parity.mjs / validate-annotation-node.mjs / check-phase-registry-drift.mjs.
|
|
316
|
+
const mod = await import(pathToFileURL(parsePath).href);
|
|
313
317
|
_parseAnnotation = mod.parse;
|
|
314
318
|
return _parseAnnotation;
|
|
315
319
|
} catch (e) {
|
|
@@ -126,11 +126,15 @@ fi
|
|
|
126
126
|
# Portable (non-PCRE) extraction — grep -oP is not supported by Git Bash's
|
|
127
127
|
# grep build on Windows, so use a bash regex + BASH_REMATCH instead of a
|
|
128
128
|
# lookbehind (same convention as scripts/code-index.sh).
|
|
129
|
+
# The leading (^|[^a-z]) group anchors the alternation to a word start so a
|
|
130
|
+
# close-verb embedded in a larger word (e.g. "encloses #1234") cannot match;
|
|
131
|
+
# the body is already lowercased, so [^a-z] is the correct complement class.
|
|
132
|
+
# The extra group shifts the issue-number capture to BASH_REMATCH[3].
|
|
129
133
|
PR_BODY=$(echo "$PR_JSON" | jq -r '.body // empty')
|
|
130
134
|
SOURCE_ISSUE=""
|
|
131
135
|
PR_BODY_LOWER=$(echo "$PR_BODY" | tr '[:upper:]' '[:lower:]')
|
|
132
|
-
if [[ "$PR_BODY_LOWER" =~ (closes|fixes|resolves)[[:space:]]*#([0-9]+) ]]; then
|
|
133
|
-
SOURCE_ISSUE="${BASH_REMATCH[
|
|
136
|
+
if [[ "$PR_BODY_LOWER" =~ (^|[^a-z])(closes|fixes|resolves)[[:space:]]*#([0-9]+) ]]; then
|
|
137
|
+
SOURCE_ISSUE="${BASH_REMATCH[3]}"
|
|
134
138
|
fi
|
|
135
139
|
|
|
136
140
|
if [ -n "$SOURCE_ISSUE" ]; then
|
|
@@ -167,12 +171,18 @@ if [ -n "$MILESTONE_BRANCH" ]; then
|
|
|
167
171
|
ALL_MILESTONES=$(gh api "repos/${MILESTONES_REPO}/milestones" 2>/dev/null || echo "")
|
|
168
172
|
|
|
169
173
|
if [ -n "$ALL_MILESTONES" ] && [ "$ALL_MILESTONES" != "[]" ]; then
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
174
|
+
# Single jq invocation: shared `def slugify` replaces the duplicated
|
|
175
|
+
# downcase/gsub slug transform that previously appeared once per tier
|
|
176
|
+
# (exact-match, then substring-fallback). Exact match is tried first
|
|
177
|
+
# (array 1), substring match second (array 2); `first` picks the winner
|
|
178
|
+
# from the concatenation, preserving the exact-match-wins-over-substring
|
|
179
|
+
# ordering and the regex semantics of `test($slug)` exactly as before.
|
|
180
|
+
# <!-- Fixes: forge#2670 -->
|
|
181
|
+
MATCH=$(echo "$ALL_MILESTONES" | jq -r --arg slug "$BRANCH_SLUG" '
|
|
182
|
+
def slugify: ascii_downcase | gsub("[^a-z0-9]+"; "-");
|
|
183
|
+
([.[] | select((.title | slugify) == $slug)]
|
|
184
|
+
+ [.[] | select((.title | slugify) | test($slug))])
|
|
185
|
+
| (first.title // empty)')
|
|
176
186
|
if [ -n "$MATCH" ]; then
|
|
177
187
|
echo "$MATCH"
|
|
178
188
|
exit 0
|