diff --git a/.codex b/.codex new file mode 100644 index 000000000..e69de29bb diff --git a/.github/workflows/build-native.yml b/.github/workflows/build-native.yml index 57efa21a2..2fd7d5563 100644 --- a/.github/workflows/build-native.yml +++ b/.github/workflows/build-native.yml @@ -77,8 +77,8 @@ jobs: if: runner.os != 'Windows' run: | mkdir -p artifacts - cp native/target/${{ matrix.target }}/release/libgsd_engine.dylib artifacts/gsd_engine.node 2>/dev/null || \ - cp native/target/${{ matrix.target }}/release/libgsd_engine.so artifacts/gsd_engine.node 2>/dev/null || \ + cp native/target/${{ matrix.target }}/release/libforge_engine.dylib artifacts/forge_engine.node 2>/dev/null || \ + cp native/target/${{ matrix.target }}/release/libforge_engine.so artifacts/forge_engine.node 2>/dev/null || \ { echo "::error::No library found for ${{ matrix.platform }}"; exit 1; } ls -la artifacts/ @@ -86,13 +86,13 @@ jobs: if: runner.os == 'Windows' run: | mkdir artifacts - copy native\target\${{ matrix.target }}\release\gsd_engine.dll artifacts\gsd_engine.node + copy native\target\${{ matrix.target }}\release\forge_engine.dll artifacts\forge_engine.node - name: Upload artifact uses: actions/upload-artifact@v4 with: name: native-${{ matrix.platform }} - path: artifacts/gsd_engine.node + path: artifacts/forge_engine.node if-no-files-found: error publish: @@ -118,7 +118,7 @@ jobs: - name: Copy binaries to platform packages run: | for platform in darwin-arm64 darwin-x64 linux-x64-gnu linux-arm64-gnu win32-x64-msvc; do - cp "artifacts/native-${platform}/gsd_engine.node" "native/npm/${platform}/gsd_engine.node" + cp "artifacts/native-${platform}/forge_engine.node" "native/npm/${platform}/forge_engine.node" echo "Copied binary for ${platform}" ls -la "native/npm/${platform}/" done diff --git a/flake.lock b/flake.lock new file mode 100644 index 000000000..8da6f7b09 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1776067740, + "narHash": "sha256-B35lpsqnSZwn1Lmz06BpwF7atPgFmUgw1l8KAV3zpVQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "7e495b747b51f95ae15e74377c5ce1fe69c1765f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix index ad5e05c52..66536d804 100644 --- a/flake.nix +++ b/flake.nix @@ -18,8 +18,13 @@ packages = with pkgs; [ bash bun + cargo + clippy git nodejs_24 + rust-analyzer + rustc + rustfmt ]; shellHook = '' @@ -28,7 +33,9 @@ echo "gsd-2 runtime shell" echo " bun : $(command -v bun)" + echo " cargo: $(command -v cargo)" echo " node: $(command -v node)" + echo " rustc: $(command -v rustc)" ''; }; }); diff --git a/native/crates/ast/Cargo.toml b/native/crates/ast/Cargo.toml index 91647fb32..d0fc47d7a 100644 --- a/native/crates/ast/Cargo.toml +++ b/native/crates/ast/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "gsd-ast" +name = "forge-ast" version.workspace = true edition.workspace = true license.workspace = true diff --git a/native/crates/ast/src/ast.rs b/native/crates/ast/src/ast.rs index 8559af9f3..a648294cc 100644 --- a/native/crates/ast/src/ast.rs +++ b/native/crates/ast/src/ast.rs @@ -383,7 +383,7 @@ mod tests { impl Drop for TempTree { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.root); } } fn make_temp_tree() -> TempTree { let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); - let root = std::env::temp_dir().join(format!("gsd-ast-test-{unique}")); + let root = std::env::temp_dir().join(format!("forge-ast-test-{unique}")); fs::create_dir_all(root.join("nested")).unwrap(); fs::write(root.join("a.ts"), "const a = 1;\n").unwrap(); fs::write(root.join("nested").join("b.ts"), "const b = 2;\n").unwrap(); @@ -421,7 +421,7 @@ mod tests { #[test] fn rejects_mixed_replace_lang() { let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); - let root = std::env::temp_dir().join(format!("gsd-ast-mixed-{unique}")); + let root = std::env::temp_dir().join(format!("forge-ast-mixed-{unique}")); fs::create_dir_all(&root).unwrap(); fs::write(root.join("a.ts"), "const a = 1;\n").unwrap(); fs::write(root.join("b.rs"), "fn main() {}\n").unwrap(); diff --git a/native/crates/engine/Cargo.toml b/native/crates/engine/Cargo.toml index 20b39e349..6038b9e12 100644 --- a/native/crates/engine/Cargo.toml +++ b/native/crates/engine/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "gsd-engine" +name = "forge-engine" version.workspace = true edition.workspace = true license.workspace = true @@ -13,8 +13,8 @@ test = false doctest = false [dependencies] -gsd-ast = { path = "../ast" } -gsd-grep = { path = "../grep" } +forge-ast = { path = "../ast" } +forge-grep = { path = "../grep" } arboard = "3" dashmap = "6" globset = "0.4" diff --git a/native/crates/engine/src/ast.rs b/native/crates/engine/src/ast.rs index 78570d939..e711cff59 100644 --- a/native/crates/engine/src/ast.rs +++ b/native/crates/engine/src/ast.rs @@ -1,2 +1,2 @@ -//! Forces the linker to include gsd_ast napi registrations. -use gsd_ast as _; +//! Forces the linker to include forge_ast napi registrations. +use forge_ast as _; diff --git a/native/crates/engine/src/grep.rs b/native/crates/engine/src/grep.rs index cb4713c90..7cb28e290 100644 --- a/native/crates/engine/src/grep.rs +++ b/native/crates/engine/src/grep.rs @@ -1,13 +1,13 @@ //! N-API bindings for the grep module. //! -//! Wraps `gsd_grep` functions and exposes them as JS-callable N-API exports. +//! Wraps `forge_grep` functions and exposes them as JS-callable N-API exports. use napi::bindgen_prelude::*; use napi_derive::napi; use crate::task; -// ── N-API types (mirroring gsd_grep types for the JS boundary) ──────── +// ── N-API types (mirroring forge_grep types for the JS boundary) ──────── #[napi(object)] pub struct NapiContextLine { @@ -105,14 +105,14 @@ fn clamp_u32(value: u64) -> u32 { value.min(u32::MAX as u64) as u32 } -fn convert_context_line(cl: gsd_grep::ContextLine) -> NapiContextLine { +fn convert_context_line(cl: forge_grep::ContextLine) -> NapiContextLine { NapiContextLine { line_number: clamp_u32(cl.line_number), line: cl.line, } } -fn convert_search_match(m: gsd_grep::SearchMatch) -> NapiSearchMatch { +fn convert_search_match(m: forge_grep::SearchMatch) -> NapiSearchMatch { NapiSearchMatch { line_number: clamp_u32(m.line_number), line: m.line, @@ -130,7 +130,7 @@ fn convert_search_match(m: gsd_grep::SearchMatch) -> NapiSearchMatch { } } -fn convert_file_match(m: gsd_grep::FileMatch) -> NapiGrepMatch { +fn convert_file_match(m: forge_grep::FileMatch) -> NapiGrepMatch { NapiGrepMatch { path: m.path, line_number: clamp_u32(m.line_number), @@ -157,7 +157,7 @@ fn convert_file_match(m: gsd_grep::FileMatch) -> NapiGrepMatch { /// and optional context lines. #[napi(js_name = "search")] pub fn search(content: Buffer, options: NapiSearchOptions) -> Result { - let opts = gsd_grep::SearchOptions { + let opts = forge_grep::SearchOptions { pattern: options.pattern, ignore_case: options.ignore_case.unwrap_or(false), multiline: options.multiline.unwrap_or(false), @@ -167,7 +167,7 @@ pub fn search(content: Buffer, options: NapiSearchOptions) -> Result Ok(NapiSearchResult { matches: result .matches @@ -188,7 +188,7 @@ pub fn search(content: Buffer, options: NapiSearchOptions) -> Result task::Async { task::blocking("grep", (), move |_ct| { - let opts = gsd_grep::GrepOptions { + let opts = forge_grep::GrepOptions { pattern: options.pattern, path: options.path, glob: options.glob, @@ -202,7 +202,7 @@ pub fn grep(options: NapiGrepOptions) -> task::Async { max_columns: options.max_columns.map(|v| v as usize), }; - match gsd_grep::search_path(&opts) { + match forge_grep::search_path(&opts) { Ok(result) => Ok(NapiGrepResult { matches: result.matches.into_iter().map(convert_file_match).collect(), total_matches: clamp_u32(result.total_matches), diff --git a/native/crates/grep/Cargo.toml b/native/crates/grep/Cargo.toml index d77fb6faf..f7c422a2a 100644 --- a/native/crates/grep/Cargo.toml +++ b/native/crates/grep/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "gsd-grep" +name = "forge-grep" version.workspace = true edition.workspace = true license.workspace = true diff --git a/native/npm/darwin-arm64/package.json b/native/npm/darwin-arm64/package.json index 7d77c1d5d..f45d588fc 100644 --- a/native/npm/darwin-arm64/package.json +++ b/native/npm/darwin-arm64/package.json @@ -8,9 +8,9 @@ "cpu": [ "arm64" ], - "main": "gsd_engine.node", + "main": "forge_engine.node", "files": [ - "gsd_engine.node" + "forge_engine.node" ], "license": "MIT", "repository": { diff --git a/native/npm/darwin-x64/package.json b/native/npm/darwin-x64/package.json index c0223f376..35c57d060 100644 --- a/native/npm/darwin-x64/package.json +++ b/native/npm/darwin-x64/package.json @@ -8,9 +8,9 @@ "cpu": [ "x64" ], - "main": "gsd_engine.node", + "main": "forge_engine.node", "files": [ - "gsd_engine.node" + "forge_engine.node" ], "license": "MIT", "repository": { diff --git a/native/npm/linux-arm64-gnu/package.json b/native/npm/linux-arm64-gnu/package.json index 88cb0d0f2..bb5ba5131 100644 --- a/native/npm/linux-arm64-gnu/package.json +++ b/native/npm/linux-arm64-gnu/package.json @@ -8,9 +8,9 @@ "cpu": [ "arm64" ], - "main": "gsd_engine.node", + "main": "forge_engine.node", "files": [ - "gsd_engine.node" + "forge_engine.node" ], "license": "MIT", "repository": { diff --git a/native/npm/linux-x64-gnu/package.json b/native/npm/linux-x64-gnu/package.json index 7f988b372..0d3d11d48 100644 --- a/native/npm/linux-x64-gnu/package.json +++ b/native/npm/linux-x64-gnu/package.json @@ -8,9 +8,9 @@ "cpu": [ "x64" ], - "main": "gsd_engine.node", + "main": "forge_engine.node", "files": [ - "gsd_engine.node" + "forge_engine.node" ], "license": "MIT", "repository": { diff --git a/native/npm/win32-x64-msvc/package.json b/native/npm/win32-x64-msvc/package.json index 6954da721..fe4387e66 100644 --- a/native/npm/win32-x64-msvc/package.json +++ b/native/npm/win32-x64-msvc/package.json @@ -8,9 +8,9 @@ "cpu": [ "x64" ], - "main": "gsd_engine.node", + "main": "forge_engine.node", "files": [ - "gsd_engine.node" + "forge_engine.node" ], "license": "MIT", "repository": { diff --git a/native/scripts/build.js b/native/scripts/build.js index 65d270a94..b6697fff2 100644 --- a/native/scripts/build.js +++ b/native/scripts/build.js @@ -27,7 +27,7 @@ const profile = isDev ? "debug" : "release"; const cargoArgs = ["build"]; if (!isDev) cargoArgs.push("--release"); -console.log(`Building gsd-engine (${profile})...`); +console.log(`Building forge-engine (${profile})...`); try { execSync(`cargo ${cargoArgs.join(" ")}`, { @@ -52,9 +52,9 @@ const targetDir = path.join(cargoTargetRoot, profile); const platformTag = `${process.platform}-${process.arch}`; const libraryNames = { - darwin: "libgsd_engine.dylib", - linux: "libgsd_engine.so", - win32: "gsd_engine.dll", + darwin: "libforge_engine.dylib", + linux: "libforge_engine.so", + win32: "forge_engine.dll", }; const libName = libraryNames[process.platform]; @@ -72,8 +72,8 @@ if (!fs.existsSync(sourcePath)) { fs.mkdirSync(addonDir, { recursive: true }); const destFilename = isDev - ? "gsd_engine.dev.node" - : `gsd_engine.${platformTag}.node`; + ? "forge_engine.dev.node" + : `forge_engine.${platformTag}.node`; const destPath = path.join(addonDir, destFilename); fs.copyFileSync(sourcePath, destPath); diff --git a/packages/native/src/__tests__/clipboard.test.mjs b/packages/native/src/__tests__/clipboard.test.mjs index 407b6bdc9..08b1154de 100644 --- a/packages/native/src/__tests__/clipboard.test.mjs +++ b/packages/native/src/__tests__/clipboard.test.mjs @@ -10,8 +10,8 @@ const require = createRequire(import.meta.url); const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); const platformTag = `${process.platform}-${process.arch}`; const candidates = [ - path.join(addonDir, `gsd_engine.${platformTag}.node`), - path.join(addonDir, "gsd_engine.dev.node"), + path.join(addonDir, `forge_engine.${platformTag}.node`), + path.join(addonDir, "forge_engine.dev.node"), ]; let native; diff --git a/packages/native/src/__tests__/diff.test.mjs b/packages/native/src/__tests__/diff.test.mjs index a590f467f..3b20391b7 100644 --- a/packages/native/src/__tests__/diff.test.mjs +++ b/packages/native/src/__tests__/diff.test.mjs @@ -19,8 +19,8 @@ const addonDir = path.resolve( ); const platformTag = `${process.platform}-${process.arch}`; const candidates = [ - path.join(addonDir, `gsd_engine.${platformTag}.node`), - path.join(addonDir, "gsd_engine.dev.node"), + path.join(addonDir, `forge_engine.${platformTag}.node`), + path.join(addonDir, "forge_engine.dev.node"), ]; let native; diff --git a/packages/native/src/__tests__/fd.test.mjs b/packages/native/src/__tests__/fd.test.mjs index a2ed59e17..b91af2a14 100644 --- a/packages/native/src/__tests__/fd.test.mjs +++ b/packages/native/src/__tests__/fd.test.mjs @@ -13,8 +13,8 @@ const require = createRequire(import.meta.url); const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); const platformTag = `${process.platform}-${process.arch}`; const candidates = [ - path.join(addonDir, `gsd_engine.${platformTag}.node`), - path.join(addonDir, "gsd_engine.dev.node"), + path.join(addonDir, `forge_engine.${platformTag}.node`), + path.join(addonDir, "forge_engine.dev.node"), ]; let native; diff --git a/packages/native/src/__tests__/glob.test.mjs b/packages/native/src/__tests__/glob.test.mjs index a9784cf60..64719a225 100644 --- a/packages/native/src/__tests__/glob.test.mjs +++ b/packages/native/src/__tests__/glob.test.mjs @@ -21,8 +21,8 @@ const addonDir = path.resolve( ); const platformTag = `${process.platform}-${process.arch}`; const candidates = [ - path.join(addonDir, `gsd_engine.${platformTag}.node`), - path.join(addonDir, "gsd_engine.dev.node"), + path.join(addonDir, `forge_engine.${platformTag}.node`), + path.join(addonDir, "forge_engine.dev.node"), ]; let native; diff --git a/packages/native/src/__tests__/grep.test.mjs b/packages/native/src/__tests__/grep.test.mjs index 0f3c200f7..ff8a2a828 100644 --- a/packages/native/src/__tests__/grep.test.mjs +++ b/packages/native/src/__tests__/grep.test.mjs @@ -13,8 +13,8 @@ const require = createRequire(import.meta.url); const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); const platformTag = `${process.platform}-${process.arch}`; const candidates = [ - path.join(addonDir, `gsd_engine.${platformTag}.node`), - path.join(addonDir, "gsd_engine.dev.node"), + path.join(addonDir, `forge_engine.${platformTag}.node`), + path.join(addonDir, "forge_engine.dev.node"), ]; let native; diff --git a/packages/native/src/__tests__/highlight.test.mjs b/packages/native/src/__tests__/highlight.test.mjs index 286821b22..f6cda9c28 100644 --- a/packages/native/src/__tests__/highlight.test.mjs +++ b/packages/native/src/__tests__/highlight.test.mjs @@ -11,8 +11,8 @@ const require = createRequire(import.meta.url); const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); const platformTag = `${process.platform}-${process.arch}`; const candidates = [ - path.join(addonDir, `gsd_engine.${platformTag}.node`), - path.join(addonDir, "gsd_engine.dev.node"), + path.join(addonDir, `forge_engine.${platformTag}.node`), + path.join(addonDir, "forge_engine.dev.node"), ]; let native; diff --git a/packages/native/src/__tests__/html.test.mjs b/packages/native/src/__tests__/html.test.mjs index 7a9424a79..4f1d70330 100644 --- a/packages/native/src/__tests__/html.test.mjs +++ b/packages/native/src/__tests__/html.test.mjs @@ -10,8 +10,8 @@ const require = createRequire(import.meta.url); const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); const platformTag = `${process.platform}-${process.arch}`; const candidates = [ - path.join(addonDir, `gsd_engine.${platformTag}.node`), - path.join(addonDir, "gsd_engine.dev.node"), + path.join(addonDir, `forge_engine.${platformTag}.node`), + path.join(addonDir, "forge_engine.dev.node"), ]; let native; diff --git a/packages/native/src/__tests__/image.test.mjs b/packages/native/src/__tests__/image.test.mjs index b560507b4..45da73403 100644 --- a/packages/native/src/__tests__/image.test.mjs +++ b/packages/native/src/__tests__/image.test.mjs @@ -11,8 +11,8 @@ const require = createRequire(import.meta.url); const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); const platformTag = `${process.platform}-${process.arch}`; const candidates = [ - path.join(addonDir, `gsd_engine.${platformTag}.node`), - path.join(addonDir, "gsd_engine.dev.node"), + path.join(addonDir, `forge_engine.${platformTag}.node`), + path.join(addonDir, "forge_engine.dev.node"), ]; let native; diff --git a/packages/native/src/__tests__/json-parse.test.mjs b/packages/native/src/__tests__/json-parse.test.mjs index 19c16e75f..ed05ef8de 100644 --- a/packages/native/src/__tests__/json-parse.test.mjs +++ b/packages/native/src/__tests__/json-parse.test.mjs @@ -10,8 +10,8 @@ const require = createRequire(import.meta.url); const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); const platformTag = `${process.platform}-${process.arch}`; const candidates = [ - path.join(addonDir, `gsd_engine.${platformTag}.node`), - path.join(addonDir, "gsd_engine.dev.node"), + path.join(addonDir, `forge_engine.${platformTag}.node`), + path.join(addonDir, "forge_engine.dev.node"), ]; let native; diff --git a/packages/native/src/__tests__/ps.test.mjs b/packages/native/src/__tests__/ps.test.mjs index 0730f8d00..4ab57e237 100644 --- a/packages/native/src/__tests__/ps.test.mjs +++ b/packages/native/src/__tests__/ps.test.mjs @@ -12,8 +12,8 @@ const require = createRequire(import.meta.url); const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); const platformTag = `${process.platform}-${process.arch}`; const candidates = [ - path.join(addonDir, `gsd_engine.${platformTag}.node`), - path.join(addonDir, "gsd_engine.dev.node"), + path.join(addonDir, `forge_engine.${platformTag}.node`), + path.join(addonDir, "forge_engine.dev.node"), ]; let native; diff --git a/packages/native/src/__tests__/text.test.mjs b/packages/native/src/__tests__/text.test.mjs index c9f635bc0..130d094bc 100644 --- a/packages/native/src/__tests__/text.test.mjs +++ b/packages/native/src/__tests__/text.test.mjs @@ -19,8 +19,8 @@ const addonDir = path.resolve( ); const platformTag = `${process.platform}-${process.arch}`; const candidates = [ - path.join(addonDir, `gsd_engine.${platformTag}.node`), - path.join(addonDir, "gsd_engine.dev.node"), + path.join(addonDir, `forge_engine.${platformTag}.node`), + path.join(addonDir, "forge_engine.dev.node"), ]; let native; diff --git a/packages/native/src/__tests__/truncate.test.mjs b/packages/native/src/__tests__/truncate.test.mjs index 07a79463e..e33c16d71 100644 --- a/packages/native/src/__tests__/truncate.test.mjs +++ b/packages/native/src/__tests__/truncate.test.mjs @@ -10,8 +10,8 @@ const require = createRequire(import.meta.url); const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); const platformTag = `${process.platform}-${process.arch}`; const candidates = [ - path.join(addonDir, `gsd_engine.${platformTag}.node`), - path.join(addonDir, "gsd_engine.dev.node"), + path.join(addonDir, `forge_engine.${platformTag}.node`), + path.join(addonDir, "forge_engine.dev.node"), ]; let native; diff --git a/packages/native/src/__tests__/ttsr.test.mjs b/packages/native/src/__tests__/ttsr.test.mjs index 7a1e29085..912ee442c 100644 --- a/packages/native/src/__tests__/ttsr.test.mjs +++ b/packages/native/src/__tests__/ttsr.test.mjs @@ -11,8 +11,8 @@ const require = createRequire(import.meta.url); const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); const platformTag = `${process.platform}-${process.arch}`; const candidates = [ - path.join(addonDir, `gsd_engine.${platformTag}.node`), - path.join(addonDir, "gsd_engine.dev.node"), + path.join(addonDir, `forge_engine.${platformTag}.node`), + path.join(addonDir, "forge_engine.dev.node"), ]; let native; diff --git a/packages/native/src/native.ts b/packages/native/src/native.ts index b22a17a3f..a2b0083f9 100644 --- a/packages/native/src/native.ts +++ b/packages/native/src/native.ts @@ -4,8 +4,8 @@ * Locates and loads the compiled Rust N-API addon (`.node` file). * Resolution order: * 1. @singularity-forge/engine-{platform} npm optional dependency (production install) - * 2. native/addon/gsd_engine.{platform}.node (local release build) - * 3. native/addon/gsd_engine.dev.node (local debug build) + * 2. native/addon/forge_engine.{platform}.node (local release build) + * 3. native/addon/forge_engine.dev.node (local debug build) */ import * as path from "node:path"; @@ -44,8 +44,8 @@ function loadNative(): Record { } } - // 2. Try local release build (native/addon/gsd_engine.{platform}.node) - const releasePath = path.join(addonDir, `gsd_engine.${platformTag}.node`); + // 2. Try local release build (native/addon/forge_engine.{platform}.node) + const releasePath = path.join(addonDir, `forge_engine.${platformTag}.node`); try { _loadedSuccessfully = true; return _require(releasePath) as Record; } catch (err) { @@ -53,8 +53,8 @@ function loadNative(): Record { errors.push(`${releasePath}: ${message}`); } - // 3. Try local dev build (native/addon/gsd_engine.dev.node) - const devPath = path.join(addonDir, "gsd_engine.dev.node"); + // 3. Try local dev build (native/addon/forge_engine.dev.node) + const devPath = path.join(addonDir, "forge_engine.dev.node"); try { _loadedSuccessfully = true; return _require(devPath) as Record; } catch (err) { @@ -70,7 +70,7 @@ function loadNative(): Record { // entire import chain at startup (#1223). Consumers with JS fallbacks // (parseRoadmap, parsePlan, fuzzyFind, etc.) catch these and degrade gracefully. process.stderr.write( - `[gsd] Native addon not available for ${platformTag}. Falling back to JS implementations (slower).\n` + + `[forge] Native addon not available for ${platformTag}. Falling back to JS implementations (slower).\n` + ` Supported native platforms: ${supportedPlatforms.join(", ")}\n`, ); return new Proxy({} as Record, { diff --git a/scripts/build-web-if-stale.cjs b/scripts/build-web-if-stale.cjs index bd9e828d4..10bf5fa4e 100644 --- a/scripts/build-web-if-stale.cjs +++ b/scripts/build-web-if-stale.cjs @@ -19,7 +19,7 @@ const { join, resolve } = require('node:path') // Skip on Windows — Next.js webpack build hits EPERM scanning system dirs if (process.platform === 'win32') { - console.log('[gsd] Web build skipped on Windows.') + console.log('[forge] Web build skipped on Windows.') process.exit(0) } @@ -83,7 +83,7 @@ function ensureWebBuildDependencies() { return } - console.log('[gsd] Web build dependencies are missing or incomplete — running npm --prefix web ci...') + console.log('[forge] Web build dependencies are missing or incomplete — running npm --prefix web ci...') execSync('npm --prefix web ci', { cwd: root, stdio: 'inherit' }) } @@ -91,20 +91,20 @@ const sourceMtime = Math.max(newestMtime(webRoot), newestMtime(srcRoot)) const builtMtime = sentinelMtime() if (builtMtime > 0 && builtMtime >= sourceMtime) { - console.log('[gsd] Web build is up-to-date, skipping rebuild.') + console.log('[forge] Web build is up-to-date, skipping rebuild.') process.exit(0) } if (builtMtime === 0) { - console.log('[gsd] No staged web build found — building now...') + console.log('[forge] No staged web build found — building now...') } else { - console.log('[gsd] Web/src source has changed since last build — rebuilding...') + console.log('[forge] Web/src source has changed since last build — rebuilding...') } try { ensureWebBuildDependencies() execSync('npm run build:web-host', { cwd: root, stdio: 'inherit' }) } catch (err) { - console.error('[gsd] Web build failed:', err.message) + console.error('[forge] Web build failed:', err.message) process.exit(1) } diff --git a/scripts/dev-cli.js b/scripts/dev-cli.js index fd4ec0a0c..fdeb26791 100644 --- a/scripts/dev-cli.js +++ b/scripts/dev-cli.js @@ -20,7 +20,7 @@ const child = spawn( ) child.on('error', (error) => { - console.error(`[gsd] Failed to launch local dev CLI: ${error instanceof Error ? error.message : String(error)}`) + console.error(`[forge] Failed to launch local dev CLI: ${error instanceof Error ? error.message : String(error)}`) process.exit(1) }) diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 2e4b39776..95a0336e6 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -36,7 +36,7 @@ function run(cmd) { } function logWarn(message) { - process.stderr.write(`[gsd] postinstall: ${message}\n`) + process.stderr.write(`[forge] postinstall: ${message}\n`) } function resolveAssetName() { diff --git a/scripts/stage-web-standalone.cjs b/scripts/stage-web-standalone.cjs index 85800473b..8f87799ae 100644 --- a/scripts/stage-web-standalone.cjs +++ b/scripts/stage-web-standalone.cjs @@ -43,7 +43,7 @@ function overlayNodePty(targetRoot) { } if (!existsSync(standaloneAppRoot)) { - console.error('[gsd] Web standalone build not found at web/.next/standalone/web. Run `npm --prefix web run build` first.') + console.error('[forge] Web standalone build not found at web/.next/standalone/web. Run `npm --prefix web run build` first.') process.exit(1) } @@ -67,7 +67,7 @@ if (existsSync(publicRoot)) { const hydratedTargets = overlayNodePty(distStandaloneRoot) -console.log(`[gsd] Staged web standalone host at ${distStandaloneRoot}`) +console.log(`[forge] Staged web standalone host at ${distStandaloneRoot}`) if (hydratedTargets.length > 0) { - console.log(`[gsd] Hydrated node-pty native assets in ${hydratedTargets.length} location(s).`) + console.log(`[forge] Hydrated node-pty native assets in ${hydratedTargets.length} location(s).`) } diff --git a/src/cli-web-branch.ts b/src/cli-web-branch.ts index a4afe765b..ab950f187 100644 --- a/src/cli-web-branch.ts +++ b/src/cli-web-branch.ts @@ -122,7 +122,7 @@ export function migrateLegacyFlatSessions(baseSessionsDir: string, projectSessio function emitWebModeFailure(stderr: WritableLike, status: WebModeLaunchStatus): void { if (status.ok) return - stderr.write(`[gsd] Web mode launch failed: ${status.failureReason}\n`) + stderr.write(`[forge] Web mode launch failed: ${status.failureReason}\n`) } /** @@ -249,7 +249,7 @@ export async function runWebCliBranch( currentCwd = resolve(defaultCwd, webPath) const checkExists = existsSync if (!checkExists(currentCwd)) { - stderr.write(`[gsd] Project path does not exist: ${currentCwd}\n`) + stderr.write(`[forge] Project path does not exist: ${currentCwd}\n`) return { handled: true, exitCode: 1, @@ -270,7 +270,7 @@ export async function runWebCliBranch( launchInputs: { cwd: currentCwd, projectSessionsDir: '', agentDir: deps.agentDir ?? defaultAgentDir }, } } - stderr.write(`[gsd] Using project path: ${currentCwd}\n`) + stderr.write(`[forge] Using project path: ${currentCwd}\n`) } else { currentCwd = defaultCwd } diff --git a/src/cli.ts b/src/cli.ts index 846280a07..4d34274d3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -681,7 +681,7 @@ if (!cliFlags.worktree && !isPrintMode) { // which handles non-interactive output gracefully. // --------------------------------------------------------------------------- if (cliFlags.messages[0] === 'auto' && !process.stdout.isTTY) { - process.stderr.write('[gsd] stdout is not a terminal — running auto-mode in headless mode.\n') + process.stderr.write('[forge] stdout is not a terminal — running auto-mode in headless mode.\n') await runHeadlessFromAuto(cliFlags.messages.slice(1)) } diff --git a/src/headless-ui.ts b/src/headless-ui.ts index 16a904d77..0e25cd4e8 100644 --- a/src/headless-ui.ts +++ b/src/headless-ui.ts @@ -303,8 +303,8 @@ export function formatProgress(event: Record, ctx: ProgressCont // Bold important notifications const isImportant = /^(committed:|verification gate:|milestone|blocked:)/i.test(msg) return isImportant - ? `${c.bold}[gsd] ${msg}${c.reset}` - : `[gsd] ${msg}` + ? `${c.bold}[forge] ${msg}${c.reset}` + : `[forge] ${msg}` } if (method === 'setStatus') { diff --git a/src/mcp-server.ts b/src/mcp-server.ts index ee24b547e..1dbfd8b10 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -134,5 +134,5 @@ export async function startMcpServer(options: { // Connect to stdin/stdout transport const transport = new StdioServerTransport() await server.connect(transport) - process.stderr.write(`[gsd] MCP server started (v${version})\n`) + process.stderr.write(`[forge] MCP server started (v${version})\n`) } diff --git a/src/onboarding.ts b/src/onboarding.ts index 7e4086b26..262cd0546 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -107,7 +107,7 @@ async function loadClack(): Promise { try { return await import('@clack/prompts') } catch { - throw new Error('[gsd] @clack/prompts not found — onboarding wizard requires this dependency') + throw new Error('[forge] @clack/prompts not found — onboarding wizard requires this dependency') } } @@ -220,7 +220,7 @@ export async function runOnboarding(authStorage: AuthStorage): Promise { ;[p, pc] = await Promise.all([loadClack(), loadPico()]) } catch (err) { // If clack isn't available, fall back silently — don't block boot - process.stderr.write(`[gsd] Onboarding wizard unavailable: ${err instanceof Error ? err.message : String(err)}\n`) + process.stderr.write(`[forge] Onboarding wizard unavailable: ${err instanceof Error ? err.message : String(err)}\n`) return } diff --git a/src/pi-migration.ts b/src/pi-migration.ts index 704fd69b5..d5571783d 100644 --- a/src/pi-migration.ts +++ b/src/pi-migration.ts @@ -50,7 +50,7 @@ export function migratePiCredentials(authStorage: AuthStorage): boolean { authStorage.set(providerId, credential) const isLlm = LLM_PROVIDER_IDS.includes(providerId) if (isLlm) migratedLlm = true - process.stderr.write(`[gsd] Migrated ${isLlm ? 'LLM provider' : 'credential'}: ${providerId} (from Pi)\n`) + process.stderr.write(`[forge] Migrated ${isLlm ? 'LLM provider' : 'credential'}: ${providerId} (from Pi)\n`) } return migratedLlm diff --git a/src/resource-loader.ts b/src/resource-loader.ts index b36c18c72..870d1cf20 100644 --- a/src/resource-loader.ts +++ b/src/resource-loader.ts @@ -352,7 +352,7 @@ function reconcileSymlink(link: string, target: string): void { try { symlinkSync(target, link, 'junction') } catch (err) { - console.error(`[gsd] WARN: Failed to symlink ${link} → ${target}: ${err instanceof Error ? err.message : err}`) + console.error(`[forge] WARN: Failed to symlink ${link} → ${target}: ${err instanceof Error ? err.message : err}`) } } @@ -397,7 +397,7 @@ function reconcileMergedNodeModules( try { symlinkSync(join(hoisted, entry.name), join(agentNodeModules, entry.name), 'junction'); linkedCount++ } catch { /* skip individual */ } } } catch (err) { - console.error(`[gsd] WARN: Failed to read hoisted node_modules at ${hoisted}: ${err instanceof Error ? err.message : err}`) + console.error(`[forge] WARN: Failed to read hoisted node_modules at ${hoisted}: ${err instanceof Error ? err.message : err}`) } // Overlay internal node_modules entries that weren't hoisted. @@ -412,7 +412,7 @@ function reconcileMergedNodeModules( try { symlinkSync(join(internal, entry.name), link, 'junction'); linkedCount++ } catch { /* skip individual */ } } } catch (err) { - console.error(`[gsd] WARN: Failed to read internal node_modules at ${internal}: ${err instanceof Error ? err.message : err}`) + console.error(`[forge] WARN: Failed to read internal node_modules at ${internal}: ${err instanceof Error ? err.message : err}`) } // Only stamp marker if we actually linked something — avoids caching a broken state diff --git a/src/resources/extensions/gsd/activity-log.ts b/src/resources/extensions/gsd/activity-log.ts index f9155d967..889b92758 100644 --- a/src/resources/extensions/gsd/activity-log.ts +++ b/src/resources/extensions/gsd/activity-log.ts @@ -17,7 +17,7 @@ const SEQ_PREFIX_RE = /^(\d+)-/; import type { ExtensionContext } from "@sf-run/pi-coding-agent"; import { gsdRoot } from "./paths.js"; import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js"; -import { isUnifiedAuditEnabled } from "./uok/audit-toggle.js"; +import { isAuditEnvelopeEnabled } from "./uok/audit-toggle.js"; interface ActivityLogState { nextSeq: number; @@ -135,7 +135,7 @@ export function saveActivityLog( state.nextSeq += 1; state.lastSnapshotKeyByUnit.set(unitKey, key); - if (isUnifiedAuditEnabled()) { + if (isAuditEnvelopeEnabled()) { emitUokAuditEvent( basePath, buildAuditEnvelope({ diff --git a/src/resources/extensions/gsd/auto-start.ts b/src/resources/extensions/gsd/auto-start.ts index 696709359..4ec3b3932 100644 --- a/src/resources/extensions/gsd/auto-start.ts +++ b/src/resources/extensions/gsd/auto-start.ts @@ -489,8 +489,8 @@ export async function bootstrapAutoSession( // Route to the interactive discussion handler instead of falling through to // auto-mode, which would immediately stop with "needs discussion". if (hasSurvivorBranch && state.phase === "needs-discussion") { - const { showSmartEntry } = await import("./guided-flow.js"); - await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); + const { showWorkflowEntry } = await import("./guided-flow.js"); + await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode }); invalidateAllCaches(); const postState = await deriveState(base); @@ -547,8 +547,8 @@ export async function bootstrapAutoSession( return releaseLockAndReturn(); } - const { showSmartEntry } = await import("./guided-flow.js"); - await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); + const { showWorkflowEntry } = await import("./guided-flow.js"); + await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode }); invalidateAllCaches(); const postState = await deriveState(base); @@ -589,8 +589,8 @@ export async function bootstrapAutoSession( const contextFile = resolveMilestoneFile(base, mid, "CONTEXT"); const hasContext = !!(contextFile && (await loadFile(contextFile))); if (!hasContext) { - const { showSmartEntry } = await import("./guided-flow.js"); - await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); + const { showWorkflowEntry } = await import("./guided-flow.js"); + await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode }); invalidateAllCaches(); const postState = await deriveState(base); @@ -608,8 +608,8 @@ export async function bootstrapAutoSession( // Active milestone has CONTEXT-DRAFT but no full context — needs discussion if (state.phase === "needs-discussion") { - const { showSmartEntry } = await import("./guided-flow.js"); - await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); + const { showWorkflowEntry } = await import("./guided-flow.js"); + await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode }); invalidateAllCaches(); const postState = await deriveState(base); @@ -630,8 +630,8 @@ export async function bootstrapAutoSession( // Unreachable safety check if (!state.activeMilestone) { - const { showSmartEntry } = await import("./guided-flow.js"); - await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); + const { showWorkflowEntry } = await import("./guided-flow.js"); + await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode }); return releaseLockAndReturn(); } diff --git a/src/resources/extensions/gsd/auto/phases.ts b/src/resources/extensions/gsd/auto/phases.ts index d093edd10..5dafb1293 100644 --- a/src/resources/extensions/gsd/auto/phases.ts +++ b/src/resources/extensions/gsd/auto/phases.ts @@ -48,7 +48,7 @@ import { withTimeout, FINALIZE_PRE_TIMEOUT_MS, FINALIZE_POST_TIMEOUT_MS } from " import { getEligibleSlices } from "../slice-parallel-eligibility.js"; import { startSliceParallel } from "../slice-parallel-orchestrator.js"; import { isDbAvailable, getMilestoneSlices } from "../gsd-db.js"; -import { ensurePlanV2Graph } from "../uok/plan-v2.js"; +import { ensurePlanV2Graph as ensurePlanningFlowGraph } from "../uok/plan-v2.js"; import { resolveUokFlags } from "../uok/flags.js"; import { UokGateRunner } from "../uok/gate-runner.js"; import { resetEvidence } from "../safety/evidence-collector.js"; @@ -84,15 +84,15 @@ export function _resolveDispatchGuardBasePath( return s.originalBasePath || s.basePath; } -const PLAN_V2_GATE_PHASES: ReadonlySet = new Set([ +const PLANNING_FLOW_GATE_PHASES: ReadonlySet = new Set([ "executing", "summarizing", "validating-milestone", "completing-milestone", ]); -function shouldRunPlanV2Gate(phase: Phase): boolean { - return PLAN_V2_GATE_PHASES.has(phase); +function shouldRunPlanningFlowGate(phase: Phase): boolean { + return PLANNING_FLOW_GATE_PHASES.has(phase); } function shouldSkipArtifactVerification(unitType: string): boolean { @@ -404,29 +404,30 @@ export async function runPreDispatch( // Derive state let state = await deps.deriveState(s.basePath); - if (prefs?.uok?.plan_v2?.enabled && shouldRunPlanV2Gate(state.phase)) { - const compiled = ensurePlanV2Graph(s.basePath, state); + const planningFlowEnabled = prefs?.uok?.planning_flow?.enabled === true || prefs?.uok?.plan_v2?.enabled === true; + if (planningFlowEnabled && shouldRunPlanningFlowGate(state.phase)) { + const compiled = ensurePlanningFlowGraph(s.basePath, state); if (!compiled.ok) { - const reason = compiled.reason ?? "Plan v2 compilation failed"; + const reason = compiled.reason ?? "Planning flow compilation failed"; await runPreDispatchGate({ - gateId: "plan-v2-gate", + gateId: "planning-flow-gate", gateType: "policy", outcome: "manual-attention", failureClass: "manual-attention", - rationale: "plan v2 compile gate failed", + rationale: "planning flow compile gate failed", findings: reason, milestoneId: state.activeMilestone?.id ?? undefined, }); ctx.ui.notify(`Plan gate failed-closed: ${reason}`, "error"); await deps.pauseAuto(ctx, pi); - return { action: "break", reason: "plan-v2-gate-failed" }; + return { action: "break", reason: "planning-flow-gate-failed" }; } await runPreDispatchGate({ - gateId: "plan-v2-gate", + gateId: "planning-flow-gate", gateType: "policy", outcome: "pass", failureClass: "none", - rationale: "plan v2 compile gate passed", + rationale: "planning flow compile gate passed", milestoneId: state.activeMilestone?.id ?? undefined, }); } diff --git a/src/resources/extensions/gsd/bootstrap/crash-log.ts b/src/resources/extensions/gsd/bootstrap/crash-log.ts index 61278f173..da87c3b61 100644 --- a/src/resources/extensions/gsd/bootstrap/crash-log.ts +++ b/src/resources/extensions/gsd/bootstrap/crash-log.ts @@ -21,7 +21,7 @@ export function writeCrashLog(err: Error, source: string): void { const ts = new Date().toISOString().replace(/[:.]/g, "-"); const logPath = join(crashDir, `${ts}.log`); const lines = [ - `[gsd] ${source}: ${err.message}`, + `[forge] ${source}: ${err.message}`, `timestamp: ${new Date().toISOString()}`, `pid: ${process.pid}`, err.stack ?? "(no stack trace available)", diff --git a/src/resources/extensions/gsd/bootstrap/register-extension.ts b/src/resources/extensions/gsd/bootstrap/register-extension.ts index fad1dfc15..4108f7b20 100644 --- a/src/resources/extensions/gsd/bootstrap/register-extension.ts +++ b/src/resources/extensions/gsd/bootstrap/register-extension.ts @@ -22,11 +22,11 @@ export function handleRecoverableExtensionProcessError(err: Error): boolean { if ((err as NodeJS.ErrnoException).code === "ENOENT") { const syscall = (err as NodeJS.ErrnoException).syscall; if (syscall?.startsWith("spawn")) { - process.stderr.write(`[gsd] spawn ENOENT: ${(err as any).path ?? "unknown"} — command not found\n`); + process.stderr.write(`[forge] spawn ENOENT: ${(err as any).path ?? "unknown"} — command not found\n`); return true; } if (syscall === "uv_cwd") { - process.stderr.write(`[gsd] ENOENT (${syscall}): ${err.message}\n`); + process.stderr.write(`[forge] ENOENT (${syscall}): ${err.message}\n`); return true; } } diff --git a/src/resources/extensions/gsd/bootstrap/register-hooks.ts b/src/resources/extensions/gsd/bootstrap/register-hooks.ts index 2284bc2d9..d3c00923a 100644 --- a/src/resources/extensions/gsd/bootstrap/register-hooks.ts +++ b/src/resources/extensions/gsd/bootstrap/register-hooks.ts @@ -44,7 +44,7 @@ export function registerHooks(pi: ExtensionAPI): void { const sid = ctx.sessionManager?.getSessionId?.() ?? ""; const sfile = ctx.sessionManager?.getSessionFile?.() ?? ""; if (sid) { - process.stderr.write(`[gsd] session ${sid.slice(0, 8)} · ${sfile}\n`); + process.stderr.write(`[forge] session ${sid.slice(0, 8)} · ${sfile}\n`); } } catch { /* non-fatal */ diff --git a/src/resources/extensions/gsd/cache.ts b/src/resources/extensions/gsd/cache.ts index 93eb05215..456a66ad9 100644 --- a/src/resources/extensions/gsd/cache.ts +++ b/src/resources/extensions/gsd/cache.ts @@ -1,4 +1,4 @@ -// GSD Extension — Unified Cache Invalidation +// GSD Extension — Cache Invalidation // // Three module-scoped caches exist across the GSD extension: // 1. State cache (state.ts) — memoized deriveState() result diff --git a/src/resources/extensions/gsd/commands/handlers/workflow.ts b/src/resources/extensions/gsd/commands/handlers/workflow.ts index 595dcf8a6..fd603d08d 100644 --- a/src/resources/extensions/gsd/commands/handlers/workflow.ts +++ b/src/resources/extensions/gsd/commands/handlers/workflow.ts @@ -267,8 +267,8 @@ export async function handleWorkflowCommand(trimmed: string, ctx: ExtensionComma try { unlinkSync(headlessContextPath); } catch { /* non-fatal */ } await showHeadlessMilestoneCreation(ctx, pi, basePath, seedContext); } else { - const { showSmartEntry } = await import("../../guided-flow.js"); - await showSmartEntry(ctx, pi, basePath); + const { showWorkflowEntry } = await import("../../guided-flow.js"); + await showWorkflowEntry(ctx, pi, basePath); } return true; } diff --git a/src/resources/extensions/gsd/docs/preferences-reference.md b/src/resources/extensions/gsd/docs/preferences-reference.md index da2897669..d8e142a79 100644 --- a/src/resources/extensions/gsd/docs/preferences-reference.md +++ b/src/resources/extensions/gsd/docs/preferences-reference.md @@ -193,7 +193,7 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `hooks`: boolean — enable routing hooks. Default: `true`. - `capability_routing`: boolean — enable capability-profile scoring for model selection within a tier. Requires `enabled: true`. Default: `false`. -- `uok`: Unified Orchestration Kernel controls. Keys: +- `uok`: orchestration kernel controls. Keys: - `enabled`: boolean — enable kernel wrappers and contract observers. Default: `true`. - `legacy_fallback.enabled`: boolean — emergency release fallback that forces legacy orchestration behavior even when `uok.enabled` is `true`. Default: `false`. - Runtime override: set `GSD_UOK_FORCE_LEGACY=1` (or `GSD_UOK_LEGACY_FALLBACK=1`) to force legacy behavior for the current process. @@ -203,8 +203,8 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea - `gitops.enabled`: boolean — persist turn-level git transaction records. - `gitops.turn_action`: `"commit"` | `"snapshot"` | `"status-only"` — turn transaction mode. - `gitops.turn_push`: boolean — whether turn transactions should include push intent metadata. - - `audit_unified.enabled`: boolean — dual-write unified audit envelope events. - - `plan_v2.enabled`: boolean — enable bounded clarify/research/draft/compile planning flow. + - `audit_envelope.enabled`: boolean — dual-write audit envelope events. + - `planning_flow.enabled`: boolean — enable bounded clarify/research/draft/compile planning flow. - `context_management`: configures context hygiene for auto-mode sessions. Keys: - `observation_masking`: boolean — mask old tool results to reduce context bloat. Default: `true`. diff --git a/src/resources/extensions/gsd/error-classifier.ts b/src/resources/extensions/gsd/error-classifier.ts index f302418ea..33fad43eb 100644 --- a/src/resources/extensions/gsd/error-classifier.ts +++ b/src/resources/extensions/gsd/error-classifier.ts @@ -1,5 +1,5 @@ /** - * Unified error classifier for provider/network/server errors. + * Error classifier for provider/network/server failures. * * Consolidates patterns from: * - isTransientNetworkError() in preferences-models.ts diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 1da70b65d..4fc4495cd 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -1,8 +1,9 @@ /** - * GSD Guided Flow — Smart Entry Wizard + * GSD Guided Flow — Workflow Entry Wizard * - * One function: showSmartEntry(). Reads state from disk, shows a contextual - * wizard via showNextAction(), and dispatches through GSD-WORKFLOW.md. + * Primary entrypoints: `showWorkflowEntry()` and the legacy `showSmartEntry()` + * export. Reads state from disk, shows a contextual wizard via + * `showNextAction()`, and dispatches through GSD-WORKFLOW.md. * No execution state, no hooks, no tools — the LLM does the rest. */ @@ -38,7 +39,7 @@ import { isInheritedRepo } from "./repo-identity.js"; import { ensureGitignore, ensurePreferences, untrackRuntimeFiles } from "./gitignore.js"; import { loadEffectiveGSDPreferences } from "./preferences.js"; import { resolveUokFlags } from "./uok/flags.js"; -import { ensurePlanV2Graph } from "./uok/plan-v2.js"; +import { ensurePlanV2Graph as ensurePlanningFlowGraph } from "./uok/plan-v2.js"; import { detectProjectState } from "./detection.js"; import { showProjectInit, offerMigration } from "./init-wizard.js"; import { validateDirectory } from "./validate-directory.js"; @@ -86,24 +87,24 @@ function nextMilestoneIdReserved(existingIds: string[], uniqueEnabled: boolean): return id; } -function needsPlanV2Gate(state: GSDState): boolean { +function needsPlanningFlowGate(state: GSDState): boolean { return state.phase === "executing" || state.phase === "summarizing" || state.phase === "validating-milestone" || state.phase === "completing-milestone"; } -function runPlanV2Gate( +function runPlanningFlowGate( ctx: ExtensionContext, basePath: string, state: GSDState, ): boolean { const prefs = loadEffectiveGSDPreferences()?.preferences; const uokFlags = resolveUokFlags(prefs); - if (!uokFlags.planV2 || !needsPlanV2Gate(state)) return true; - const compiled = ensurePlanV2Graph(basePath, state); + if (!uokFlags.planningFlow || !needsPlanningFlowGate(state)) return true; + const compiled = ensurePlanningFlowGraph(basePath, state); if (!compiled.ok) { - const reason = compiled.reason ?? "plan-v2 compilation failed"; + const reason = compiled.reason ?? "planning-flow compilation failed"; ctx.ui.notify( `Plan gate failed-closed: ${reason}. Complete plan/discuss artifacts before execution.`, "error", @@ -1079,16 +1080,16 @@ async function dispatchDiscussForMilestone( await dispatchWorkflow(pi, prompt, "gsd-discuss", ctx, "discuss-milestone"); } -// ─── Smart Entry Point ──────────────────────────────────────────────────────── +// ─── Workflow Entry Point ───────────────────────────────────────────────────── /** - * The one wizard. Reads state, shows contextual options, dispatches into the workflow doc. + * The workflow entry wizard. Reads state, shows contextual options, and dispatches into the workflow doc. */ /** * Self-heal: scan runtime records and clear stale ones left behind when * auto-mode crashed mid-unit. auto.ts has its own selfHealRuntimeRecords() * but guided-flow (manual /gsd mode) never called it — meaning stale records - * persisted until the next /gsd auto run. This ensures the wizard always + * persisted until the next /gsd auto run. This ensures the workflow entry * starts from a clean state regardless of how the previous session ended. */ function selfHealRuntimeRecords(basePath: string, ctx: ExtensionContext): { cleared: number } { @@ -1224,7 +1225,7 @@ async function handleMilestoneActions( return false; } -export async function showSmartEntry( +export async function showWorkflowEntry( ctx: ExtensionCommandContext, pi: ExtensionAPI, basePath: string, @@ -1350,7 +1351,7 @@ export async function showSmartEntry( logWarning("guided", `STATE.md rebuild failed: ${(err as Error).message}`); } - if (!runPlanV2Gate(ctx, basePath, state)) return; + if (!runPlanningFlowGate(ctx, basePath, state)) return; if (!state.activeMilestone?.id) { // Guard: if a discuss session is already in flight, don't re-inject the prompt. @@ -1642,7 +1643,7 @@ export async function showSmartEntry( }); if (confirmed) { discardMilestone(basePath, milestoneId); - return showSmartEntry(ctx, pi, basePath, options); + return showWorkflowEntry(ctx, pi, basePath, options); } } } else { @@ -1680,7 +1681,7 @@ export async function showSmartEntry( await fireStatusViaCommand(ctx); } else if (choice === "milestone_actions") { const acted = await handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneTitle, options); - if (acted) return showSmartEntry(ctx, pi, basePath, options); + if (acted) return showWorkflowEntry(ctx, pi, basePath, options); } } return; @@ -1780,7 +1781,7 @@ export async function showSmartEntry( await fireStatusViaCommand(ctx); } else if (choice === "milestone_actions") { const acted = await handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneTitle, options); - if (acted) return showSmartEntry(ctx, pi, basePath, options); + if (acted) return showWorkflowEntry(ctx, pi, basePath, options); } return; } @@ -1835,7 +1836,7 @@ export async function showSmartEntry( await fireStatusViaCommand(ctx); } else if (choice === "milestone_actions") { const acted = await handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneTitle, options); - if (acted) return showSmartEntry(ctx, pi, basePath, options); + if (acted) return showWorkflowEntry(ctx, pi, basePath, options); } return; } @@ -1926,7 +1927,7 @@ export async function showSmartEntry( await fireStatusViaCommand(ctx); } else if (choice === "milestone_actions") { const acted = await handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneTitle, options); - if (acted) return showSmartEntry(ctx, pi, basePath, options); + if (acted) return showWorkflowEntry(ctx, pi, basePath, options); } return; } @@ -1935,3 +1936,5 @@ export async function showSmartEntry( const { fireStatusViaCommand } = await import("./commands.js"); await fireStatusViaCommand(ctx); } + +export const showSmartEntry = showWorkflowEntry; diff --git a/src/resources/extensions/gsd/init-wizard.ts b/src/resources/extensions/gsd/init-wizard.ts index feafb2d86..66c2091cf 100644 --- a/src/resources/extensions/gsd/init-wizard.ts +++ b/src/resources/extensions/gsd/init-wizard.ts @@ -261,7 +261,7 @@ export async function showProjectInit( } // Write initial STATE.md so it exists before the first /gsd invocation. - // The explicit /gsd init path (ops.ts) returns without entering showSmartEntry(), + // The explicit /gsd init path (ops.ts) returns without entering showWorkflowEntry(), // which would otherwise generate STATE.md at guided-flow.ts:1358. try { const { deriveState } = await import("./state.js"); diff --git a/src/resources/extensions/gsd/journal.ts b/src/resources/extensions/gsd/journal.ts index 3d635b5ce..3a334e015 100644 --- a/src/resources/extensions/gsd/journal.ts +++ b/src/resources/extensions/gsd/journal.ts @@ -16,7 +16,7 @@ import { appendFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { gsdRoot } from "./paths.js"; import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js"; -import { isUnifiedAuditEnabled } from "./uok/audit-toggle.js"; +import { isAuditEnvelopeEnabled } from "./uok/audit-toggle.js"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -93,7 +93,7 @@ export function emitJournalEvent(basePath: string, entry: JournalEntry): void { // Silent failure — journal must never break auto-mode } - if (!isUnifiedAuditEnabled()) return; + if (!isAuditEnvelopeEnabled()) return; try { const causedBy = entry.causedBy ? `${entry.causedBy.flowId}:${entry.causedBy.seq}` diff --git a/src/resources/extensions/gsd/metrics.ts b/src/resources/extensions/gsd/metrics.ts index 42edda37a..1320ebbff 100644 --- a/src/resources/extensions/gsd/metrics.ts +++ b/src/resources/extensions/gsd/metrics.ts @@ -20,7 +20,7 @@ import { getAndClearSkills } from "./skill-telemetry.js"; import { loadJsonFile, loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js"; import { parseUnitId } from "./unit-id.js"; import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js"; -import { isUnifiedAuditEnabled } from "./uok/audit-toggle.js"; +import { isAuditEnvelopeEnabled } from "./uok/audit-toggle.js"; import { getDatabase } from "./gsd-db.js"; // Re-export from shared — import directly from format-utils to avoid pulling @@ -296,7 +296,7 @@ export function snapshotUnitMetrics( // Background outcome recording for Bayesian learning recordUnitOutcome(unit).catch(() => { /* fire-and-forget */ }); - if (isUnifiedAuditEnabled()) { + if (isAuditEnvelopeEnabled()) { emitUokAuditEvent( basePath, buildAuditEnvelope({ diff --git a/src/resources/extensions/gsd/preferences-types.ts b/src/resources/extensions/gsd/preferences-types.ts index a9802def2..bd6ea25e9 100644 --- a/src/resources/extensions/gsd/preferences-types.ts +++ b/src/resources/extensions/gsd/preferences-types.ts @@ -231,9 +231,17 @@ export interface UokPreferences { turn_action?: UokTurnActionMode; turn_push?: boolean; }; + audit_envelope?: { + enabled?: boolean; + }; + /** @deprecated Use `audit_envelope` instead. */ audit_unified?: { enabled?: boolean; }; + planning_flow?: { + enabled?: boolean; + }; + /** @deprecated Use `planning_flow` instead. */ plan_v2?: { enabled?: boolean; }; @@ -289,7 +297,7 @@ export interface GSDPreferences { post_unit_hooks?: PostUnitHookConfig[]; pre_dispatch_hooks?: PreDispatchHookConfig[]; dynamic_routing?: DynamicRoutingConfig; - /** Unified Orchestration Kernel controls (all flags default off). */ + /** Orchestration kernel controls (all flags default off). */ uok?: UokPreferences; /** Per-model capability overrides. Deep-merged with built-in profiles for capability-aware routing (ADR-004). */ modelOverrides?: Record }>; diff --git a/src/resources/extensions/gsd/preferences-validation.ts b/src/resources/extensions/gsd/preferences-validation.ts index 619b54bf1..09b39859b 100644 --- a/src/resources/extensions/gsd/preferences-validation.ts +++ b/src/resources/extensions/gsd/preferences-validation.ts @@ -186,8 +186,10 @@ export function validatePreferences(preferences: GSDPreferences): { } const parseEnabledBlock = ( - key: "legacy_fallback" | "gates" | "model_policy" | "execution_graph" | "audit_unified" | "plan_v2", + key: "legacy_fallback" | "gates" | "model_policy" | "execution_graph" | "audit_envelope" | "audit_unified" | "planning_flow" | "plan_v2", + targetKey?: "legacy_fallback" | "gates" | "model_policy" | "execution_graph" | "audit_envelope" | "planning_flow", ): void => { + const normalizedTargetKey = targetKey ?? (key === "plan_v2" ? "planning_flow" : key); const value = raw[key]; if (value === undefined) return; if (typeof value !== "object" || value === null) { @@ -205,7 +207,7 @@ export function validatePreferences(preferences: GSDPreferences): { warnings.push(`unknown uok.${key} key "${unk}" — ignored`); } if (Object.keys(parsed).length > 0) { - valid[key] = parsed; + valid[normalizedTargetKey] = parsed; } }; @@ -213,8 +215,16 @@ export function validatePreferences(preferences: GSDPreferences): { parseEnabledBlock("gates"); parseEnabledBlock("model_policy"); parseEnabledBlock("execution_graph"); - parseEnabledBlock("audit_unified"); - parseEnabledBlock("plan_v2"); + parseEnabledBlock("audit_envelope"); + if (raw.audit_unified !== undefined && raw.audit_envelope === undefined) { + warnings.push("uok.audit_unified is deprecated; use uok.audit_envelope"); + parseEnabledBlock("audit_unified", "audit_envelope"); + } + parseEnabledBlock("planning_flow"); + if (raw.plan_v2 !== undefined && raw.planning_flow === undefined) { + warnings.push("uok.plan_v2 is deprecated; use uok.planning_flow"); + parseEnabledBlock("plan_v2", "planning_flow"); + } if (raw.gitops !== undefined) { if (typeof raw.gitops !== "object" || raw.gitops === null) { @@ -257,7 +267,9 @@ export function validatePreferences(preferences: GSDPreferences): { "model_policy", "execution_graph", "gitops", + "audit_envelope", "audit_unified", + "planning_flow", "plan_v2", ]); for (const key of Object.keys(raw)) { diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index b5dd6e840..0a0443bbf 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -400,11 +400,27 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr gitops: (base.uok?.gitops || override.uok?.gitops) ? { ...(base.uok?.gitops ?? {}), ...(override.uok?.gitops ?? {}) } : undefined, - audit_unified: (base.uok?.audit_unified || override.uok?.audit_unified) - ? { ...(base.uok?.audit_unified ?? {}), ...(override.uok?.audit_unified ?? {}) } + audit_envelope: ( + base.uok?.audit_envelope + || base.uok?.audit_unified + || override.uok?.audit_envelope + || override.uok?.audit_unified + ) + ? { + ...(base.uok?.audit_envelope ?? base.uok?.audit_unified ?? {}), + ...(override.uok?.audit_envelope ?? override.uok?.audit_unified ?? {}), + } : undefined, - plan_v2: (base.uok?.plan_v2 || override.uok?.plan_v2) - ? { ...(base.uok?.plan_v2 ?? {}), ...(override.uok?.plan_v2 ?? {}) } + planning_flow: ( + base.uok?.planning_flow + || base.uok?.plan_v2 + || override.uok?.planning_flow + || override.uok?.plan_v2 + ) + ? { + ...(base.uok?.planning_flow ?? base.uok?.plan_v2 ?? {}), + ...(override.uok?.planning_flow ?? override.uok?.plan_v2 ?? {}), + } : undefined, } : undefined, diff --git a/src/resources/extensions/gsd/rule-registry.ts b/src/resources/extensions/gsd/rule-registry.ts index 7a697257a..446d578d9 100644 --- a/src/resources/extensions/gsd/rule-registry.ts +++ b/src/resources/extensions/gsd/rule-registry.ts @@ -1,13 +1,13 @@ -// GSD Extension — Unified Rule Registry +// GSD Extension — Rule Registry // -// Holds all dispatch rules and hooks as a flat list of UnifiedRule objects. +// Holds all dispatch rules and hooks as a flat list of RegistryRule objects. // Provides evaluation methods for each phase (dispatch, post-unit, pre-dispatch) // and encapsulates mutable hook state as instance fields. // // A module-level singleton accessor allows existing code to migrate incrementally. import { logWarning } from "./workflow-logger.js"; -import type { UnifiedRule, RulePhase } from "./rule-types.js"; +import type { RegistryRule, RulePhase } from "./rule-types.js"; import type { DispatchAction, DispatchContext, DispatchRule } from "./auto-dispatch.js"; import type { PostUnitHookConfig, @@ -39,10 +39,10 @@ export function resolveHookArtifactPath(basePath: string, unitId: string, artifa // ─── Dispatch Rule Conversion ────────────────────────────────────────────── /** - * Convert an array of DispatchRule objects to UnifiedRule[] format. + * Convert an array of DispatchRule objects to RegistryRule[] format. * Preserves exact array order — dispatch is order-dependent (first-match-wins). */ -export function convertDispatchRules(rules: DispatchRule[]): UnifiedRule[] { +export function convertDispatchRules(rules: DispatchRule[]): RegistryRule[] { return rules.map((rule) => ({ name: rule.name, when: "dispatch" as const, @@ -59,7 +59,7 @@ const HOOK_STATE_FILE = "hook-state.json"; export class RuleRegistry { /** Static dispatch rules provided at construction time. */ - private readonly dispatchRules: UnifiedRule[]; + private readonly dispatchRules: RegistryRule[]; // ── Mutable hook state (encapsulated, not module-level) ────────────── @@ -73,7 +73,7 @@ export class RuleRegistry { retryPending: boolean = false; retryTrigger: { unitType: string; unitId: string; retryArtifact: string } | null = null; - constructor(dispatchRules: UnifiedRule[]) { + constructor(dispatchRules: RegistryRule[]) { this.dispatchRules = dispatchRules; } @@ -83,8 +83,8 @@ export class RuleRegistry { * Returns all rules: static dispatch rules + dynamically loaded hook rules. * Hook rules are loaded fresh from preferences on each call (not cached). */ - listRules(): UnifiedRule[] { - const rules: UnifiedRule[] = [...this.dispatchRules]; + listRules(): RegistryRule[] { + const rules: RegistryRule[] = [...this.dispatchRules]; // Convert post-unit hooks to unified rules const postHooks = resolvePostUnitHooks(); @@ -575,7 +575,7 @@ export function setRegistry(r: RuleRegistry): void { } /** Create and set the singleton registry with the given dispatch rules. */ -export function initRegistry(dispatchRules: UnifiedRule[]): RuleRegistry { +export function initRegistry(dispatchRules: RegistryRule[]): RuleRegistry { const registry = new RuleRegistry(dispatchRules); setRegistry(registry); return registry; diff --git a/src/resources/extensions/gsd/rule-types.ts b/src/resources/extensions/gsd/rule-types.ts index 37478053c..d5a961998 100644 --- a/src/resources/extensions/gsd/rule-types.ts +++ b/src/resources/extensions/gsd/rule-types.ts @@ -1,6 +1,6 @@ -// GSD Extension — Unified Rule Type Definitions +// GSD Extension — Rule Type Definitions // -// Every dispatch rule and hook is expressed as a `UnifiedRule` with a +// Every dispatch rule and hook is expressed as a `RegistryRule` with a // consistent when/where/then shape. This file defines the type system; // the `RuleRegistry` class in rule-registry.ts holds instances at runtime. @@ -36,13 +36,13 @@ export interface RuleLifecycle { idempotency_key?: string; } -// ─── Unified Rule ─────────────────────────────────────────────────────────── +// ─── Registry Rule ─────────────────────────────────────────────────────────── /** * A single entry in the rule registry. Dispatch rules, post-unit hooks, * and pre-dispatch hooks all share this shape. */ -export interface UnifiedRule { +export interface RegistryRule { /** Stable human-readable identifier (existing names preserved per D005). */ name: string; /** Which phase/event this rule responds to. */ diff --git a/src/resources/extensions/gsd/session-lock.ts b/src/resources/extensions/gsd/session-lock.ts index 3fe5b73d1..701f6c3d2 100644 --- a/src/resources/extensions/gsd/session-lock.ts +++ b/src/resources/extensions/gsd/session-lock.ts @@ -222,14 +222,14 @@ function createLockCompromisedHandler(lockFilePath: string): () => void { const elapsed = Date.now() - _lockAcquiredAt; if (elapsed < 1_800_000) { process.stderr.write( - `[gsd] Lock heartbeat caught up after ${Math.round(elapsed / 1000)}s — long LLM call, no action needed.\n`, + `[forge] Lock heartbeat caught up after ${Math.round(elapsed / 1000)}s — long LLM call, no action needed.\n`, ); return; } const existing = readExistingLockDataWithRetry(lockFilePath); if (existing && existing.pid === process.pid) { process.stderr.write( - `[gsd] Lock heartbeat mismatch after ${Math.round(elapsed / 1000)}s — lock file still owned by PID ${process.pid}, treating as false positive.\n`, + `[forge] Lock heartbeat mismatch after ${Math.round(elapsed / 1000)}s — lock file still owned by PID ${process.pid}, treating as false positive.\n`, ); return; } @@ -463,7 +463,7 @@ export function getSessionLockStatus(basePath: string): SessionLockStatus { const result = acquireSessionLock(basePath); if (result.acquired) { process.stderr.write( - `[gsd] Lock recovered after onCompromised — lock file PID matched, re-acquired.\n`, + `[forge] Lock recovered after onCompromised — lock file PID matched, re-acquired.\n`, ); return { valid: true, recovered: true }; } diff --git a/src/resources/extensions/gsd/templates/PREFERENCES.md b/src/resources/extensions/gsd/templates/PREFERENCES.md index 9117bf24a..adf1fd0d0 100644 --- a/src/resources/extensions/gsd/templates/PREFERENCES.md +++ b/src/resources/extensions/gsd/templates/PREFERENCES.md @@ -53,9 +53,9 @@ uok: enabled: false turn_action: status-only turn_push: false - audit_unified: + audit_envelope: enabled: false - plan_v2: + planning_flow: enabled: false auto_visualize: auto_report: diff --git a/src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts b/src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts index db312680f..28224bc9b 100644 --- a/src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts +++ b/src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts @@ -11,8 +11,8 @@ test("bootstrapAutoSession snapshots ctx.model before guided-flow entry (#2829)" const snapshotIdx = source.indexOf("const startModelSnapshot = manualSessionOverride"); assert.ok(snapshotIdx > -1, "auto-start.ts should snapshot model at bootstrap start"); - const firstDiscussIdx = source.indexOf('await showSmartEntry(ctx, pi, base, { step: requestedStepMode });'); - assert.ok(firstDiscussIdx > -1, "auto-start.ts should route through showSmartEntry during guided flow"); + const firstDiscussIdx = source.indexOf('await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode });'); + assert.ok(firstDiscussIdx > -1, "auto-start.ts should route through showWorkflowEntry during guided flow"); assert.ok( snapshotIdx < firstDiscussIdx, diff --git a/src/resources/extensions/gsd/tests/auto-start-needs-discussion.test.ts b/src/resources/extensions/gsd/tests/auto-start-needs-discussion.test.ts index a14c5a539..7535b3c16 100644 --- a/src/resources/extensions/gsd/tests/auto-start-needs-discussion.test.ts +++ b/src/resources/extensions/gsd/tests/auto-start-needs-discussion.test.ts @@ -6,7 +6,7 @@ * * 1. The survivor branch check included needs-discussion, so a branch * created by a prior failed bootstrap caused hasSurvivorBranch = true, - * skipping all showSmartEntry calls. + * skipping all showWorkflowEntry calls. * * 2. No needs-discussion handler existed in the !hasSurvivorBranch block, * so the phase fell through to auto-mode which immediately stopped @@ -118,12 +118,12 @@ describe("auto-start-needs-discussion (#1726)", () => { const source = readAutoStartSource(); // After the pre-planning handler, there should be a needs-discussion handler - // that calls showSmartEntry + // that calls showWorkflowEntry const needsDiscussionHandler = source.match( - /if\s*\(state\.phase\s*===\s*"needs-discussion"\)\s*\{[^}]*showSmartEntry/s, + /if\s*\(state\.phase\s*===\s*"needs-discussion"\)\s*\{[^}]*showWorkflowEntry/s, ); assert.ok(!!needsDiscussionHandler, - "needs-discussion handler calling showSmartEntry must exist in !hasSurvivorBranch block"); + "needs-discussion handler calling showWorkflowEntry must exist in !hasSurvivorBranch block"); }); test("4. needs-discussion handler has abort path", () => { @@ -133,7 +133,7 @@ describe("auto-start-needs-discussion (#1726)", () => { // if discussion didn't promote the draft assert.ok( source.includes('postState.phase !== "needs-discussion"'), - "needs-discussion handler must check if phase advanced after showSmartEntry", + "needs-discussion handler must check if phase advanced after showWorkflowEntry", ); assert.ok( source.includes("milestone draft was not promoted"), @@ -157,12 +157,12 @@ describe("auto-start-needs-discussion (#1726)", () => { } }); - test("6. No infinite loop: needs-discussion always routes to showSmartEntry", () => { + test("6. No infinite loop: needs-discussion always routes to showWorkflowEntry", () => { const source = readAutoStartSource(); // Verify needs-discussion does NOT appear in auto-dispatch trigger conditions // within auto-start.ts. The only place needs-discussion should appear is in - // the showSmartEntry routing block. + // the showWorkflowEntry routing block. const survivorSection = source.match( /\/\/ Milestone branch recovery.*?let hasSurvivorBranch = false;[\s\S]*?if\s*\([^)]*state\.phase[^)]*\)\s*\{/, ); @@ -187,16 +187,16 @@ describe("auto-start-needs-discussion (#1726)", () => { } }); - test("7. Survivor branch + needs-discussion routes to showSmartEntry", () => { + test("7. Survivor branch + needs-discussion routes to showWorkflowEntry", () => { const source = readAutoStartSource(); // When hasSurvivorBranch is true AND phase is needs-discussion, the code - // must route to showSmartEntry instead of falling through to auto-mode. + // must route to showWorkflowEntry instead of falling through to auto-mode. const survivorNeedsDiscussion = source.match( - /if\s*\(hasSurvivorBranch\s*&&\s*state\.phase\s*===\s*"needs-discussion"\)\s*\{[^}]*showSmartEntry/s, + /if\s*\(hasSurvivorBranch\s*&&\s*state\.phase\s*===\s*"needs-discussion"\)\s*\{[^}]*showWorkflowEntry/s, ); assert.ok(!!survivorNeedsDiscussion, - "hasSurvivorBranch && needs-discussion must route to showSmartEntry"); + "hasSurvivorBranch && needs-discussion must route to showWorkflowEntry"); // Verify the handler checks if the discussion succeeded const handlerBlock = source.match( diff --git a/src/resources/extensions/gsd/tests/crash-handler-secondary.test.ts b/src/resources/extensions/gsd/tests/crash-handler-secondary.test.ts index bb981f16f..a5f58c14b 100644 --- a/src/resources/extensions/gsd/tests/crash-handler-secondary.test.ts +++ b/src/resources/extensions/gsd/tests/crash-handler-secondary.test.ts @@ -76,7 +76,7 @@ describe('register-extension crash handler secondary fixes (#3348)', () => { // The original #3696 fix replaced "throw err" with a log-and-continue. // The secondary fix replaces that with writeCrashLog + process.exit(1). assert.ok( - !registerExtSrc.includes('process.stderr.write(`[gsd] uncaught extension error (non-fatal)'), + !registerExtSrc.includes('process.stderr.write(`[forge] uncaught extension error (non-fatal)'), '_gsdEpipeGuard should NOT log errors as non-fatal and continue', ); assert.match( diff --git a/src/resources/extensions/gsd/tests/rule-registry.test.ts b/src/resources/extensions/gsd/tests/rule-registry.test.ts index b10455d5c..26892b3c0 100644 --- a/src/resources/extensions/gsd/tests/rule-registry.test.ts +++ b/src/resources/extensions/gsd/tests/rule-registry.test.ts @@ -1,6 +1,6 @@ // GSD Extension — Rule Registry Tests // -// Tests the RuleRegistry class, UnifiedRule types, singleton accessors, +// Tests the RuleRegistry class, RegistryRule types, singleton accessors, // and evaluation methods using mock rules. import assert from 'node:assert/strict'; @@ -14,14 +14,14 @@ import { convertDispatchRules, getOrCreateRegistry, } from "../rule-registry.ts"; -import type { UnifiedRule } from "../rule-types.ts"; +import type { RegistryRule } from "../rule-types.ts"; import type { DispatchAction, DispatchContext } from "../auto-dispatch.ts"; import { DISPATCH_RULES, getDispatchRuleNames } from "../auto-dispatch.ts"; import type { GSDState } from "../types.ts"; // ─── Mock Rule Factories ────────────────────────────────────────────────── -function mockDispatchRule(name: string, matchPhase: string): UnifiedRule { +function mockDispatchRule(name: string, matchPhase: string): RegistryRule { return { name, when: "dispatch", @@ -69,7 +69,7 @@ describe("RuleRegistry", () => { }); test("construct with dispatch rules, listRules returns them", () => { - const rules: UnifiedRule[] = [ + const rules: RegistryRule[] = [ mockDispatchRule("rule-a", "planning"), mockDispatchRule("rule-b", "executing"), mockDispatchRule("rule-c", "complete"), @@ -86,7 +86,7 @@ describe("RuleRegistry", () => { }); test("listRules returns correct fields on each rule", () => { - const rules: UnifiedRule[] = [ + const rules: RegistryRule[] = [ mockDispatchRule("check-fields", "planning"), ]; const registry = new RuleRegistry(rules); @@ -102,7 +102,7 @@ describe("RuleRegistry", () => { }); test("evaluateDispatch returns first matching rule", async () => { - const rules: UnifiedRule[] = [ + const rules: RegistryRule[] = [ mockDispatchRule("rule-planning", "planning"), mockDispatchRule("rule-executing", "executing"), mockDispatchRule("rule-complete", "complete"), @@ -119,7 +119,7 @@ describe("RuleRegistry", () => { }); test("evaluateDispatch returns stop when no rule matches", async () => { - const rules: UnifiedRule[] = [ + const rules: RegistryRule[] = [ mockDispatchRule("only-planning", "planning"), ]; const registry = new RuleRegistry(rules); @@ -133,7 +133,7 @@ describe("RuleRegistry", () => { }); test("evaluateDispatch works with async where predicate", async () => { - const asyncRule: UnifiedRule = { + const asyncRule: RegistryRule = { name: "async-rule", when: "dispatch", evaluation: "first-match", @@ -227,7 +227,7 @@ describe("RuleRegistry", () => { test("evaluateDispatch respects rule order (first match wins)", async () => { // Both rules match "planning" but rule-first should win - const ruleFirst: UnifiedRule = { + const ruleFirst: RegistryRule = { name: "rule-first", when: "dispatch", evaluation: "first-match", @@ -239,7 +239,7 @@ describe("RuleRegistry", () => { }, then: () => {}, }; - const ruleSecond: UnifiedRule = { + const ruleSecond: RegistryRule = { name: "rule-second", when: "dispatch", evaluation: "first-match", @@ -264,7 +264,7 @@ describe("RuleRegistry", () => { // ── Dispatch rule conversion tests ───────────────────────────────── - test("convertDispatchRules produces correct count of UnifiedRule objects", () => { + test("convertDispatchRules produces correct count of RegistryRule objects", () => { const converted = convertDispatchRules(DISPATCH_RULES); assert.deepStrictEqual(converted.length, DISPATCH_RULES.length, `convertDispatchRules produces ${DISPATCH_RULES.length} rules`); }); @@ -386,7 +386,7 @@ describe("RuleRegistry", () => { // ── matchedRule provenance (S02 journal support) ─────────────────── test("evaluateDispatch result includes matchedRule on dispatch match", async () => { - const rules: UnifiedRule[] = [ + const rules: RegistryRule[] = [ mockDispatchRule("my-planning-rule", "planning"), ]; const registry = new RuleRegistry(rules); @@ -398,7 +398,7 @@ describe("RuleRegistry", () => { }); test("evaluateDispatch result includes matchedRule '' on fallback stop", async () => { - const rules: UnifiedRule[] = [ + const rules: RegistryRule[] = [ mockDispatchRule("only-planning", "planning"), ]; const registry = new RuleRegistry(rules); diff --git a/src/resources/extensions/gsd/tests/smart-entry-draft.test.ts b/src/resources/extensions/gsd/tests/smart-entry-draft.test.ts index 978dba4a2..2a0e9565f 100644 --- a/src/resources/extensions/gsd/tests/smart-entry-draft.test.ts +++ b/src/resources/extensions/gsd/tests/smart-entry-draft.test.ts @@ -76,7 +76,7 @@ const guidedFlowSource = readFileSync( assert( guidedFlowSource.includes('state.phase === "needs-discussion"'), - "guided-flow.ts should have 'needs-discussion' phase check in showSmartEntry", + "guided-flow.ts should have 'needs-discussion' phase check in showWorkflowEntry", ); // Check the branch has draft-aware menu options diff --git a/src/resources/extensions/gsd/tests/survivor-branch-complete.test.ts b/src/resources/extensions/gsd/tests/survivor-branch-complete.test.ts index 0d6fe66a4..833451f25 100644 --- a/src/resources/extensions/gsd/tests/survivor-branch-complete.test.ts +++ b/src/resources/extensions/gsd/tests/survivor-branch-complete.test.ts @@ -76,13 +76,13 @@ const { assertTrue, assertEq, report } = createTestContext(); // Simulate the decision logic after the fix: // if (hasSurvivorBranch && state.phase === "complete") -> finalize // if (hasSurvivorBranch && state.phase === "needs-discussion") -> discuss - // if (!hasSurvivorBranch && state.phase === "complete") -> showSmartEntry + // if (!hasSurvivorBranch && state.phase === "complete") -> showWorkflowEntry const scenarios = [ { hasSurvivorBranch: true, phase: "complete", expected: "finalize" }, { hasSurvivorBranch: true, phase: "needs-discussion", expected: "discuss" }, { hasSurvivorBranch: true, phase: "pre-planning", expected: "continue" }, - { hasSurvivorBranch: false, phase: "complete", expected: "showSmartEntry" }, + { hasSurvivorBranch: false, phase: "complete", expected: "showWorkflowEntry" }, ]; for (const { hasSurvivorBranch, phase, expected } of scenarios) { @@ -92,7 +92,7 @@ const { assertTrue, assertEq, report } = createTestContext(); } else if (hasSurvivorBranch && phase === "needs-discussion") { result = "discuss"; } else if (!hasSurvivorBranch && (!phase || phase === "complete")) { - result = "showSmartEntry"; + result = "showWorkflowEntry"; } else { result = "continue"; } diff --git a/src/resources/extensions/gsd/tests/uok-audit-unified.test.ts b/src/resources/extensions/gsd/tests/uok-audit-unified.test.ts index 884b88115..417b56c1c 100644 --- a/src/resources/extensions/gsd/tests/uok-audit-unified.test.ts +++ b/src/resources/extensions/gsd/tests/uok-audit-unified.test.ts @@ -7,7 +7,7 @@ import { emitJournalEvent } from "../journal.ts"; import { saveActivityLog } from "../activity-log.ts"; import { initMetrics, resetMetrics, snapshotUnitMetrics } from "../metrics.ts"; import { setLogBasePath, logWarning } from "../workflow-logger.ts"; -import { setUnifiedAuditEnabled } from "../uok/audit-toggle.ts"; +import { setAuditEnvelopeEnabled } from "../uok/audit-toggle.ts"; function readAuditEvents(basePath: string): Array> { const file = join(basePath, ".gsd", "audit", "events.jsonl"); @@ -27,9 +27,9 @@ function makeMockContext(entries: unknown[]): any { }; } -test("unified audit plane bridges journal/activity/metrics/workflow logger into audit envelope log", () => { +test("audit envelope bridges journal/activity/metrics/workflow logger into audit event log", () => { const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-audit-")); - setUnifiedAuditEnabled(true); + setAuditEnvelopeEnabled(true); try { emitJournalEvent(basePath, { ts: new Date().toISOString(), @@ -77,15 +77,15 @@ test("unified audit plane bridges journal/activity/metrics/workflow logger into assert.ok(types.has("unit-metrics-snapshot")); assert.ok(types.has("workflow-log-warn")); } finally { - setUnifiedAuditEnabled(false); + setAuditEnvelopeEnabled(false); resetMetrics(); rmSync(basePath, { recursive: true, force: true }); } }); -test("unified audit bridge is disabled when toggle is off", () => { +test("audit envelope bridge is disabled when toggle is off", () => { const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-audit-off-")); - setUnifiedAuditEnabled(false); + setAuditEnvelopeEnabled(false); try { emitJournalEvent(basePath, { ts: new Date().toISOString(), diff --git a/src/resources/extensions/gsd/tests/uok-plan-v2-wiring.test.ts b/src/resources/extensions/gsd/tests/uok-plan-v2-wiring.test.ts index 34b2ba213..c0c4522ee 100644 --- a/src/resources/extensions/gsd/tests/uok-plan-v2-wiring.test.ts +++ b/src/resources/extensions/gsd/tests/uok-plan-v2-wiring.test.ts @@ -88,17 +88,17 @@ test.afterEach(() => { tempDirs.clear(); }); -test("guided flow enforces plan-v2 gate before execution-oriented dispatch", () => { +test("guided flow enforces planning-flow gate before execution-oriented dispatch", () => { const source = readFileSync(join(gsdDir, "guided-flow.ts"), "utf-8"); assert.ok( - source.includes("needsPlanV2Gate") && - source.includes("ensurePlanV2Graph") && + source.includes("needsPlanningFlowGate") && + source.includes("ensurePlanningFlowGraph") && source.includes("Plan gate failed-closed"), - "guided flow should fail-closed when plan-v2 graph compilation fails", + "guided flow should fail-closed when planning-flow graph compilation fails", ); }); -test("plan-v2 gate fails closed for execution phase when finalized context is missing", () => { +test("planning-flow gate fails closed for execution phase when finalized context is missing", () => { const basePath = createBasePath(); seedGraphRows(); @@ -109,7 +109,7 @@ test("plan-v2 gate fails closed for execution phase when finalized context is mi assert.match(compiled.reason ?? "", /CONTEXT\.md/i); }); -test("plan-v2 compiler writes pipeline metadata for clarify/research/draft stages", () => { +test("planning-flow compiler writes pipeline metadata for clarify/research/draft stages", () => { const basePath = createBasePath(); seedGraphRows(); diff --git a/src/resources/extensions/gsd/tests/uok-preferences.test.ts b/src/resources/extensions/gsd/tests/uok-preferences.test.ts index 31b141d46..e0f7fed8d 100644 --- a/src/resources/extensions/gsd/tests/uok-preferences.test.ts +++ b/src/resources/extensions/gsd/tests/uok-preferences.test.ts @@ -16,8 +16,8 @@ test("uok preferences validate nested flags and turn_action", () => { turn_action: "status-only", turn_push: false, }, - audit_unified: { enabled: true }, - plan_v2: { enabled: true }, + audit_envelope: { enabled: true }, + planning_flow: { enabled: true }, }, }; @@ -26,7 +26,7 @@ test("uok preferences validate nested flags and turn_action", () => { assert.equal(result.preferences.uok?.enabled, true); assert.equal(result.preferences.uok?.legacy_fallback?.enabled, false); assert.equal(result.preferences.uok?.gitops?.turn_action, "status-only"); - assert.equal(result.preferences.uok?.plan_v2?.enabled, true); + assert.equal(result.preferences.uok?.planning_flow?.enabled, true); }); test("uok preferences reject invalid turn_action", () => { @@ -40,3 +40,27 @@ test("uok preferences reject invalid turn_action", () => { assert.ok(result.errors.some((e) => e.includes("uok.gitops.turn_action"))); }); + +test("uok preferences accept deprecated plan_v2 alias", () => { + const result = validatePreferences({ + uok: { + plan_v2: { + enabled: true, + }, + }, + } as never); + + assert.equal(result.preferences.uok?.planning_flow?.enabled, true); +}); + +test("uok preferences accept deprecated audit_unified alias", () => { + const result = validatePreferences({ + uok: { + audit_unified: { + enabled: true, + }, + }, + } as never); + + assert.equal(result.preferences.uok?.audit_envelope?.enabled, true); +}); diff --git a/src/resources/extensions/gsd/tests/zombie-gsd-state.test.ts b/src/resources/extensions/gsd/tests/zombie-gsd-state.test.ts index d18a7fcf8..7f3dc8710 100644 --- a/src/resources/extensions/gsd/tests/zombie-gsd-state.test.ts +++ b/src/resources/extensions/gsd/tests/zombie-gsd-state.test.ts @@ -8,7 +8,7 @@ const { assertTrue, assertMatch, assertNoMatch, report } = createTestContext(); // ─── #2942: Zombie .gsd state skips init wizard ───────────────────────────── // // A partially initialized .gsd/ (symlink exists but no PREFERENCES.md or -// milestones/) causes the init wizard gate in showSmartEntry to be skipped, +// milestones/) causes the init wizard gate in showWorkflowEntry to be skipped, // resulting in an uninitialized project session. console.log("\n=== #2942: zombie .gsd state must not skip init wizard ==="); @@ -20,11 +20,11 @@ const guidedFlowSrc = readFileSync( "utf-8", ); -// Find the showSmartEntry function -const smartEntryIdx = guidedFlowSrc.indexOf("export async function showSmartEntry("); -assertTrue(smartEntryIdx >= 0, "guided-flow.ts defines showSmartEntry"); +// Find the showWorkflowEntry function +const smartEntryIdx = guidedFlowSrc.indexOf("export async function showWorkflowEntry("); +assertTrue(smartEntryIdx >= 0, "guided-flow.ts defines showWorkflowEntry"); -// Extract the region between showSmartEntry and the first showProjectInit call +// Extract the region between showWorkflowEntry and the first showProjectInit call // This is where the init wizard gate lives. const afterSmartEntry = smartEntryIdx >= 0 ? guidedFlowSrc.slice(smartEntryIdx, smartEntryIdx + 3000) : ""; diff --git a/src/resources/extensions/gsd/uok/audit-toggle.ts b/src/resources/extensions/gsd/uok/audit-toggle.ts index 688c5c53e..a9240c574 100644 --- a/src/resources/extensions/gsd/uok/audit-toggle.ts +++ b/src/resources/extensions/gsd/uok/audit-toggle.ts @@ -1,9 +1,11 @@ -const AUDIT_ENV_KEY = "GSD_UOK_AUDIT_UNIFIED"; +const AUDIT_ENV_KEY = "GSD_UOK_AUDIT_ENVELOPE"; +const LEGACY_AUDIT_ENV_KEY = "GSD_UOK_AUDIT_UNIFIED"; -export function setUnifiedAuditEnabled(enabled: boolean): void { +export function setAuditEnvelopeEnabled(enabled: boolean): void { process.env[AUDIT_ENV_KEY] = enabled ? "1" : "0"; + process.env[LEGACY_AUDIT_ENV_KEY] = enabled ? "1" : "0"; } -export function isUnifiedAuditEnabled(): boolean { - return process.env[AUDIT_ENV_KEY] === "1"; +export function isAuditEnvelopeEnabled(): boolean { + return process.env[AUDIT_ENV_KEY] === "1" || process.env[LEGACY_AUDIT_ENV_KEY] === "1"; } diff --git a/src/resources/extensions/gsd/uok/flags.ts b/src/resources/extensions/gsd/uok/flags.ts index 8eacf1dd7..73395d9e3 100644 --- a/src/resources/extensions/gsd/uok/flags.ts +++ b/src/resources/extensions/gsd/uok/flags.ts @@ -10,8 +10,8 @@ export interface UokFlags { gitops: boolean; gitopsTurnAction: "commit" | "snapshot" | "status-only"; gitopsTurnPush: boolean; - auditUnified: boolean; - planV2: boolean; + auditEnvelope: boolean; + planningFlow: boolean; } function envForcesLegacyFallback(): boolean { @@ -34,8 +34,8 @@ export function resolveUokFlags(prefs: GSDPreferences | undefined): UokFlags { gitops: uok?.gitops?.enabled === true, gitopsTurnAction: uok?.gitops?.turn_action ?? "status-only", gitopsTurnPush: uok?.gitops?.turn_push === true, - auditUnified: uok?.audit_unified?.enabled === true, - planV2: uok?.plan_v2?.enabled === true, + auditEnvelope: uok?.audit_envelope?.enabled === true || uok?.audit_unified?.enabled === true, + planningFlow: uok?.planning_flow?.enabled === true || uok?.plan_v2?.enabled === true, }; } diff --git a/src/resources/extensions/gsd/uok/kernel.ts b/src/resources/extensions/gsd/uok/kernel.ts index fbd39903f..cb318a986 100644 --- a/src/resources/extensions/gsd/uok/kernel.ts +++ b/src/resources/extensions/gsd/uok/kernel.ts @@ -6,7 +6,7 @@ import type { AutoSession } from "../auto/session.js"; import type { LoopDeps } from "../auto/loop-deps.js"; import { gsdRoot } from "../paths.js"; import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js"; -import { setUnifiedAuditEnabled } from "./audit-toggle.js"; +import { setAuditEnvelopeEnabled } from "./audit-toggle.js"; import { resolveUokFlags } from "./flags.js"; import { createTurnObserver } from "./loop-adapter.js"; @@ -45,7 +45,7 @@ export async function runAutoLoopWithUok(args: RunAutoLoopWithUokArgs): Promise< const { ctx, pi, s, deps, runLegacyLoop } = args; const prefs = deps.loadEffectiveGSDPreferences()?.preferences; const flags = resolveUokFlags(prefs); - setUnifiedAuditEnabled(flags.auditUnified); + setAuditEnvelopeEnabled(flags.auditEnvelope); writeParityEvent(s.basePath, { ts: new Date().toISOString(), @@ -54,7 +54,7 @@ export async function runAutoLoopWithUok(args: RunAutoLoopWithUokArgs): Promise< phase: "enter", }); - if (flags.auditUnified) { + if (flags.auditEnvelope) { emitUokAuditEvent( s.basePath, buildAuditEnvelope({ @@ -76,7 +76,7 @@ export async function runAutoLoopWithUok(args: RunAutoLoopWithUokArgs): Promise< basePath: s.basePath, gitAction: flags.gitopsTurnAction, gitPush: flags.gitopsTurnPush, - enableAudit: flags.auditUnified, + enableAudit: flags.auditEnvelope, enableGitops: flags.gitops, }), } diff --git a/src/resources/extensions/gsd/workflow-logger.ts b/src/resources/extensions/gsd/workflow-logger.ts index ec9fb55bb..0a58f3e22 100644 --- a/src/resources/extensions/gsd/workflow-logger.ts +++ b/src/resources/extensions/gsd/workflow-logger.ts @@ -21,7 +21,7 @@ import { join } from "node:path"; import { appendNotification } from "./notification-store.js"; import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js"; -import { isUnifiedAuditEnabled } from "./uok/audit-toggle.js"; +import { isAuditEnvelopeEnabled } from "./uok/audit-toggle.js"; // ─── Types ────────────────────────────────────────────────────────────── @@ -277,7 +277,7 @@ function _push( _buffer.shift(); } - if (_auditBasePath && isUnifiedAuditEnabled()) { + if (_auditBasePath && isAuditEnvelopeEnabled()) { try { emitUokAuditEvent( _auditBasePath, diff --git a/src/resources/extensions/gsd/workflow-reconcile.ts b/src/resources/extensions/gsd/workflow-reconcile.ts index 1887c9860..07902e591 100644 --- a/src/resources/extensions/gsd/workflow-reconcile.ts +++ b/src/resources/extensions/gsd/workflow-reconcile.ts @@ -48,7 +48,7 @@ export function replaySliceComplete(milestoneId: string, sliceId: string, ts: st const incompleteTasks = tasks.filter(t => !isClosedStatus(t.status)); if (incompleteTasks.length > 0) { process.stderr.write( - `[gsd] reconcile: skipping complete_slice replay for ${sliceId} — ` + + `[forge] reconcile: skipping complete_slice replay for ${sliceId} — ` + `${incompleteTasks.length} task(s) still pending\n`, ); return; diff --git a/src/web-mode.ts b/src/web-mode.ts index 3d917431c..622152025 100644 --- a/src/web-mode.ts +++ b/src/web-mode.ts @@ -210,13 +210,13 @@ export function stopWebMode(deps: Pick= 200 && response.statusCode < 300) { if (!hostUp) { hostUp = true - stderr?.write(`[gsd] Web host ready.\n`) + stderr?.write(`[forge] Web host ready.\n`) } consecutive5xx = 0 // Host responded successfully — it's ready for the browser @@ -508,9 +508,9 @@ async function waitForBootReady(url: string, timeoutMs = 180_000, stderr?: Writa if (now - lastTickAt >= TICKER_INTERVAL_MS) { lastTickAt = now if (hostUp) { - stderr?.write(`[gsd] Still waiting… (${elapsed()})\n`) + stderr?.write(`[forge] Still waiting… (${elapsed()})\n`) } else { - stderr?.write(`[gsd] Waiting for web host… (${elapsed()})\n`) + stderr?.write(`[forge] Waiting for web host… (${elapsed()})\n`) } } @@ -532,14 +532,14 @@ function cleanupStaleInstance(cwd: string, stderr: WritableLike, registryPath?: const stale = registry[key] if (!stale) return - stderr.write(`[gsd] Cleaning up stale web server for ${key} (pid=${stale.pid}, port=${stale.port})…\n`) + stderr.write(`[forge] Cleaning up stale web server for ${key} (pid=${stale.pid}, port=${stale.port})…\n`) const result = killPid(stale.pid) if (result === 'killed') { - stderr.write(`[gsd] Killed stale web server (pid=${stale.pid}).\n`) + stderr.write(`[forge] Killed stale web server (pid=${stale.pid}).\n`) } else if (result === 'already-dead') { - stderr.write(`[gsd] Stale web server was already stopped (pid=${stale.pid}) — clearing entry.\n`) + stderr.write(`[forge] Stale web server was already stopped (pid=${stale.pid}) — clearing entry.\n`) } else { - stderr.write(`[gsd] Could not kill stale web server (pid=${stale.pid}): ${result.error}\n`) + stderr.write(`[forge] Could not kill stale web server (pid=${stale.pid}): ${result.error}\n`) } unregisterInstance(cwd, registryPath) } @@ -574,7 +574,7 @@ export async function launchWebMode( return failure } - stderr.write(`[gsd] Starting web mode…\n`) + stderr.write(`[forge] Starting web mode…\n`) // Kill any stale server instance for this project before reserving a port. // This prevents EADDRINUSE when the previous `gsd --web` was terminated @@ -600,7 +600,7 @@ export async function launchWebMode( } try { - stderr.write(`[gsd] Initialising resources…\n`) + stderr.write(`[forge] Initialising resources…\n`) const bootstrap = deps.initResources ? { initResources: deps.initResources } : await loadResourceBootstrap() bootstrap.initResources(options.agentDir) } catch (error) { @@ -629,7 +629,7 @@ export async function launchWebMode( deps.execPath ?? process.execPath, ) - stderr.write(`[gsd] Launching web host on port ${port}…\n`) + stderr.write(`[forge] Launching web host on port ${port}…\n`) const spawnResult = await spawnDetachedProcess( deps.spawn ?? ((command, args, spawnOptions) => spawn(command, args, spawnOptions)), @@ -697,7 +697,7 @@ export async function launchWebMode( try { ;(deps.openBrowser ?? openBrowser)(authenticatedUrl) } catch (browserError) { - stderr.write(`[gsd] Could not open browser: ${browserError instanceof Error ? browserError.message : String(browserError)}\n`) + stderr.write(`[forge] Could not open browser: ${browserError instanceof Error ? browserError.message : String(browserError)}\n`) } } catch (error) { const failure: WebModeLaunchFailure = { @@ -730,7 +730,7 @@ export async function launchWebMode( hostPath: resolution.entryPath, hostRoot: resolution.hostRoot, } - stderr.write(`[gsd] Ready → ${authenticatedUrl}\n`) + stderr.write(`[forge] Ready → ${authenticatedUrl}\n`) emitLaunchStatus(stderr, success) return success } diff --git a/src/worktree-cli.ts b/src/worktree-cli.ts index 70abba856..23e87c8df 100644 --- a/src/worktree-cli.ts +++ b/src/worktree-cli.ts @@ -302,10 +302,10 @@ async function handleStatusBanner(basePath: string): Promise { const names = withChanges.map(w => chalk.cyan(w.name)).join(', ') process.stderr.write( - chalk.dim('[gsd] ') + + chalk.dim('[forge] ') + chalk.yellow(`${withChanges.length} worktree${withChanges.length === 1 ? '' : 's'} with unmerged changes: `) + names + '\n' + - chalk.dim('[gsd] ') + + chalk.dim('[forge] ') + chalk.dim('Resume: gsd -w | Merge: gsd worktree merge | List: gsd worktree list\n\n'), ) } @@ -378,7 +378,7 @@ async function createAndEnter(ext: ExtensionModules, basePath: string, name: str const hookError = ext.runWorktreePostCreateHook(basePath, info.path) if (hookError) { - process.stderr.write(chalk.yellow(`[gsd] ${hookError}\n`)) + process.stderr.write(chalk.yellow(`[forge] ${hookError}\n`)) } process.chdir(info.path) @@ -389,7 +389,7 @@ async function createAndEnter(ext: ExtensionModules, basePath: string, name: str process.stderr.write(chalk.dim(` branch ${info.branch}\n\n`)) } catch (err) { const msg = err instanceof Error ? err.message : String(err) - process.stderr.write(chalk.red(`[gsd] Failed to create worktree: ${msg}\n`)) + process.stderr.write(chalk.red(`[forge] Failed to create worktree: ${msg}\n`)) process.exit(1) } } diff --git a/web/components/sf/activity-view.tsx b/web/components/sf/activity-view.tsx new file mode 100644 index 000000000..b0eacb014 --- /dev/null +++ b/web/components/sf/activity-view.tsx @@ -0,0 +1,78 @@ +"use client" + +import { CheckCircle2, Play, Clock, Terminal, AlertCircle } from "lucide-react" +import { cn } from "@/lib/utils" +import { useGSDWorkspaceState, type TerminalLineType } from "@/lib/sf-workspace-store" + +function EventIcon({ type }: { type: TerminalLineType }) { + const baseClass = "h-4 w-4" + switch (type) { + case "system": + return + case "success": + return + case "error": + return + case "output": + return + case "input": + return + default: + return + } +} + +export function ActivityView() { + const workspace = useGSDWorkspaceState() + const terminalLines = workspace.terminalLines ?? [] + + // Show most recent events first + const reversedLines = [...terminalLines].reverse() + + return ( +
+
+

Activity Log

+

+ Execution history and git operations +

+
+ +
+ {reversedLines.length === 0 ? ( +
+ No activity yet. Events will appear here once the workspace is active. +
+ ) : ( +
+ {/* Timeline line */} +
+ +
+ {reversedLines.map((line) => ( +
+ {/* Timeline dot */} +
+ +
+ + {/* Content */} +
+
+
+

{line.content}

+
+ + {line.timestamp} + +
+
+
+ ))} +
+
+ )} +
+
+ ) +} diff --git a/web/components/sf/app-shell.tsx b/web/components/sf/app-shell.tsx new file mode 100644 index 000000000..df37b3ed9 --- /dev/null +++ b/web/components/sf/app-shell.tsx @@ -0,0 +1,605 @@ +"use client" + +import Image from "next/image" +import { useState, useEffect, useCallback, useRef, useSyncExternalStore } from "react" +import { Menu, X } from "lucide-react" +import { Sidebar, MilestoneExplorer, CollapsedMilestoneSidebar } from "@/components/sf/sidebar" +import { ShellTerminal } from "@/components/sf/shell-terminal" +import { Dashboard } from "@/components/sf/dashboard" +import { Roadmap } from "@/components/sf/roadmap" +import { FilesView } from "@/components/sf/files-view" +import { ActivityView } from "@/components/sf/activity-view" +import { VisualizerView } from "@/components/sf/visualizer-view" +import { StatusBar } from "@/components/sf/status-bar" +import { DualTerminal } from "@/components/sf/dual-terminal" +import { FocusedPanel } from "@/components/sf/focused-panel" +import { OnboardingGate } from "@/components/sf/onboarding-gate" +import { CommandSurface } from "@/components/sf/command-surface" +import { DevOverridesProvider } from "@/lib/dev-overrides" +import { ProjectStoreManagerProvider, useProjectStoreManager } from "@/lib/project-store-manager" +import { Skeleton } from "@/components/ui/skeleton" +import { cn } from "@/lib/utils" +import { toast } from "sonner" +import { + GSDWorkspaceProvider, + getCurrentScopeLabel, + getProjectDisplayName, + getStatusPresentation, + getVisibleWorkspaceError, + useGSDWorkspaceState, + useGSDWorkspaceActions, +} from "@/lib/sf-workspace-store" +import { ChatMode } from "@/components/sf/chat-mode" +import { ScopeBadge } from "@/components/sf/scope-badge" +import { Badge } from "@/components/ui/badge" +import { ProjectsPanel, ProjectSelectionGate } from "@/components/sf/projects-view" +import { UpdateBanner } from "@/components/sf/update-banner" +import { getAuthToken } from "@/lib/auth" + +const KNOWN_VIEWS = new Set(["dashboard", "power", "chat", "roadmap", "files", "activity", "visualize"]) + +function viewStorageKey(projectCwd: string): string { + return `gsd-active-view:${projectCwd}` +} + +function WorkspaceChrome() { + const [activeView, setActiveView] = useState("dashboard") + const [isTerminalExpanded, setIsTerminalExpanded] = useState(false) + const [terminalHeight, setTerminalHeight] = useState(300) + const [terminalDragActive, setTerminalDragActive] = useState(false) + const isDraggingTerminal = useRef(false) + const didDragTerminal = useRef(false) + const dragStartY = useRef(0) + const dragStartHeight = useRef(0) + const [sidebarWidth, setSidebarWidth] = useState(256) + const isDraggingSidebar = useRef(false) + const dragStartX = useRef(0) + const dragStartWidth = useRef(0) + const [sidebarCollapsed, setSidebarCollapsed] = useState(false) + const [viewRestored, setViewRestored] = useState(false) + const [projectsPanelOpen, setProjectsPanelOpen] = useState(false) + const [mobileNavOpen, setMobileNavOpen] = useState(false) + const [mobileMilestoneOpen, setMobileMilestoneOpen] = useState(false) + const workspace = useGSDWorkspaceState() + const { refreshBoot } = useGSDWorkspaceActions() + + const status = getStatusPresentation(workspace) + const projectPath = workspace.boot?.project.cwd + const projectLabel = getProjectDisplayName(projectPath) + const titleOverride = workspace.titleOverride?.trim() || null + const scopeLabel = getCurrentScopeLabel(workspace.boot?.workspace) + const visibleError = getVisibleWorkspaceError(workspace) + + // Restore persisted view once boot provides projectCwd + useEffect(() => { + if (viewRestored || !projectPath) return + const restoreTimer = window.setTimeout(() => { + try { + const stored = sessionStorage.getItem(viewStorageKey(projectPath)) + if (stored && KNOWN_VIEWS.has(stored)) { + setActiveView(stored) + } + } catch { + // sessionStorage may be unavailable (e.g. SSR, iframe sandbox) + } + setViewRestored(true) + }, 0) + return () => window.clearTimeout(restoreTimer) + }, [projectPath, viewRestored]) + + // Reset viewRestored when projectPath changes so the restore effect can + // fire for the newly-selected project (fixes #2711: tab reset on switch). + const prevProjectPath = useRef(projectPath) + useEffect(() => { + if (prevProjectPath.current !== projectPath) { + prevProjectPath.current = projectPath + setViewRestored(false) + } + }, [projectPath]) + + // Persist view changes to sessionStorage + useEffect(() => { + if (!projectPath) return + try { + sessionStorage.setItem(viewStorageKey(projectPath), activeView) + } catch { + // sessionStorage may be unavailable + } + }, [activeView, projectPath]) + + // Restore sidebar collapsed state from localStorage + useEffect(() => { + const restoreTimer = window.setTimeout(() => { + try { + const stored = localStorage.getItem("gsd-sidebar-collapsed") + if (stored === "true") setSidebarCollapsed(true) + } catch { + // localStorage may be unavailable + } + }, 0) + return () => window.clearTimeout(restoreTimer) + }, []) + + // Persist sidebar collapsed state + useEffect(() => { + try { + localStorage.setItem("gsd-sidebar-collapsed", String(sidebarCollapsed)) + } catch { + // localStorage may be unavailable + } + }, [sidebarCollapsed]) + + useEffect(() => { + if (typeof document === "undefined") return + const base = projectLabel ? `GSD - ${projectLabel}` : "GSD" + document.title = titleOverride ? `${titleOverride} · ${base}` : base + }, [titleOverride, projectLabel]) + + // Close mobile nav on view change + const handleViewChange = useCallback((view: string) => { + setActiveView(view) + setMobileNavOpen(false) + }, []) + + // Listen for cross-component file navigation events (e.g. sidebar task clicks) + useEffect(() => { + const handler = () => { + setActiveView("files") + } + window.addEventListener("gsd:open-file", handler) + return () => window.removeEventListener("gsd:open-file", handler) + }, []) + + // Listen for cross-component view navigation events (e.g. /gsd visualize dispatch) + useEffect(() => { + const handler = (e: CustomEvent<{ view: string }>) => { + if (KNOWN_VIEWS.has(e.detail.view)) { + handleViewChange(e.detail.view) + } + } + window.addEventListener("gsd:navigate-view", handler as EventListener) + return () => window.removeEventListener("gsd:navigate-view", handler as EventListener) + }, [handleViewChange]) + + // Listen for projects panel toggle (sidebar icon, or programmatic) + useEffect(() => { + const handler = () => setProjectsPanelOpen(true) + window.addEventListener("gsd:open-projects", handler) + return () => window.removeEventListener("gsd:open-projects", handler) + }, []) + + // Terminal + sidebar panel drag-to-resize + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (isDraggingTerminal.current) { + didDragTerminal.current = true + const delta = dragStartY.current - e.clientY + const newHeight = Math.max(150, Math.min(600, dragStartHeight.current + delta)) + setTerminalHeight(newHeight) + } + if (isDraggingSidebar.current) { + const delta = dragStartX.current - e.clientX + const newWidth = Math.max(180, Math.min(480, dragStartWidth.current + delta)) + setSidebarWidth(newWidth) + } + } + const handleMouseUp = () => { + isDraggingTerminal.current = false + isDraggingSidebar.current = false + setTerminalDragActive(false) + document.body.style.cursor = "" + document.body.style.userSelect = "" + } + document.addEventListener("mousemove", handleMouseMove) + document.addEventListener("mouseup", handleMouseUp) + return () => { + document.removeEventListener("mousemove", handleMouseMove) + document.removeEventListener("mouseup", handleMouseUp) + } + }, []) + + const handleTerminalDragStart = useCallback( + (e: React.MouseEvent) => { + isDraggingTerminal.current = true + setTerminalDragActive(true) + dragStartY.current = e.clientY + dragStartHeight.current = terminalHeight + document.body.style.cursor = "row-resize" + document.body.style.userSelect = "none" + }, + [terminalHeight], + ) + + const handleSidebarDragStart = useCallback( + (e: React.MouseEvent) => { + isDraggingSidebar.current = true + dragStartX.current = e.clientX + dragStartWidth.current = sidebarWidth + document.body.style.cursor = "col-resize" + document.body.style.userSelect = "none" + }, + [sidebarWidth], + ) + + const retryDisabled = !!workspace.commandInFlight || workspace.onboardingRequestState !== "idle" + const isConnecting = workspace.bootStatus === "idle" || workspace.bootStatus === "loading" + + // Persistent loading toast — dismissed the moment boot completes + useEffect(() => { + if (!isConnecting) return + const id = toast.loading("Connecting to workspace…", { + description: "Establishing the live bridge session", + duration: Infinity, + }) + return () => { + toast.dismiss(id) + } + }, [isConnecting]) + + // Detect project welcome state — hide chrome for v1-legacy, brownfield, blank projects + const detection = workspace.boot?.projectDetection + const isWelcomeState = + !isConnecting && + activeView === "dashboard" && + detection != null && + detection.kind !== "active-gsd" && + detection.kind !== "empty-gsd" + + // --- Unauthenticated gate --- + // Render a clear recovery screen before any workspace chrome is mounted so + // users who open a manually-typed URL (no #token= fragment) get actionable + // guidance instead of a cascade of 401 errors. + if (workspace.bootStatus === "unauthenticated") { + return ( +
+ GSD + GSD +
+

Authentication Required

+

+ This workspace requires an auth token. Copy the full URL from your terminal + (including the{" "} + #token=…{" "} + part) or restart with{" "} + gsd --web. +

+
+
+ ) + } + + return ( +
+
+
+ {/* Mobile hamburger menu */} + +
+ GSD + GSD + + beta + +
+ / + + {isConnecting ? ( + + ) : ( + <> + {projectLabel} + {titleOverride && ( + + {titleOverride} + + )} + + )} + +
+ +
+ {/* Hidden status marker for test instrumentation */} + {status.label} + + {isConnecting ? : } + +
+
+ + + + {!isConnecting && visibleError && ( +
+ {visibleError} + +
+ )} + + {/* Mobile navigation drawer */} + {mobileNavOpen && ( +
setMobileNavOpen(false)} + data-testid="mobile-nav-overlay" + /> + )} +
+ {} : handleViewChange} isConnecting={isConnecting} mobile /> +
+ + {/* Mobile milestone drawer */} + {mobileMilestoneOpen && ( +
setMobileMilestoneOpen(false)} + data-testid="mobile-milestone-overlay" + /> + )} + {!isWelcomeState && ( +
+ setMobileMilestoneOpen(false)} + /> +
+ )} + +
+ {/* Desktop sidebar — hidden on mobile */} +
+ {} : handleViewChange} isConnecting={isConnecting} /> +
+ +
+
+ {isConnecting ? ( + + ) : ( + <> + {activeView === "dashboard" && ( + setIsTerminalExpanded(true)} + /> + )} + {activeView === "power" && } + {activeView === "roadmap" && } + {activeView === "files" && } + {activeView === "activity" && } + {activeView === "visualize" && } + {activeView === "chat" && } + + )} +
+ + {activeView !== "power" && activeView !== "chat" && ( +
+ {/* Drag handle + toggle header — entire bar is clickable */} +
{ + if (didDragTerminal.current) { + didDragTerminal.current = false + return + } + if (!isConnecting) setIsTerminalExpanded(!isTerminalExpanded) + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + if (!isConnecting) setIsTerminalExpanded(!isTerminalExpanded) + } + }} + className={cn( + "flex h-8 w-full items-center justify-between bg-card px-3 text-xs select-none transition-colors", + isTerminalExpanded && "cursor-row-resize", + !isTerminalExpanded && !isConnecting && "cursor-pointer hover:bg-muted/50", + isConnecting && "cursor-default", + )} + onMouseDown={(e) => { + if (isTerminalExpanded) handleTerminalDragStart(e) + }} + > +
+ Terminal + + {isTerminalExpanded ? "▼" : "▲"} + +
+
+ {/* Terminal content */} +
+ +
+
+ )} +
+ + {/* Resizable milestone sidebar — hidden on mobile, hidden during project welcome */} + {!isWelcomeState && !sidebarCollapsed && ( +
+ {/* Thin visible border */} +
+ {/* Wide invisible grab area overlapping the border */} +
+
+ )} +
+ {!isWelcomeState && (sidebarCollapsed ? ( + setSidebarCollapsed(false)} /> + ) : ( + setSidebarCollapsed(true)} + /> + ))} +
+
+ + {/* Desktop status bar — hidden on mobile */} +
+ +
+ + {/* Mobile bottom bar — quick access to milestones + status */} + {!isWelcomeState && ( +
+
+ {status.label} + + {scopeLabel} +
+ +
+ )} + + + + + +
+ ) +} + +export function GSDAppShell() { + // Extract the auth token from the URL fragment on first render. + // Must happen before any API calls fire. + getAuthToken() + + return ( + + + + ) +} + +function ProjectAwareWorkspace() { + const manager = useProjectStoreManager() + const activeProjectCwd = useSyncExternalStore(manager.subscribe, manager.getSnapshot, manager.getSnapshot) + const activeStore = activeProjectCwd ? manager.getActiveStore() : null + + // Shut down all projects when the tab actually closes. + // IMPORTANT: pagehide fires both on real page unload AND on mobile/Safari + // tab switches (bfcache entry). When event.persisted is true the page is + // being cached for later reuse — the server must stay alive. Only send + // the shutdown beacon when the page is truly being discarded. + useEffect(() => { + const handlePageHide = (event: PageTransitionEvent) => { + if (event.persisted) { + // Page is entering bfcache (tab switch, app backgrounding) — keep + // the server alive so PTY sessions survive. + return + } + // sendBeacon cannot set custom headers, so pass the auth token as a + // query parameter instead (the proxy accepts `_token` as a fallback). + const token = getAuthToken() + const url = token ? `/api/shutdown?_token=${token}` : "/api/shutdown" + navigator.sendBeacon(url, "") + } + + window.addEventListener("pagehide", handlePageHide) + + return () => { + window.removeEventListener("pagehide", handlePageHide) + } + }, []) + + // No project selected yet — show project selection gate + if (!activeProjectCwd || !activeStore) { + return + } + + return ( + + + + + + ) +} diff --git a/web/components/sf/chat-mode.tsx b/web/components/sf/chat-mode.tsx new file mode 100644 index 000000000..68a37cbad --- /dev/null +++ b/web/components/sf/chat-mode.tsx @@ -0,0 +1,2346 @@ +"use client" + +import Image from "next/image" +import { useEffect, useRef, useCallback, useState, useMemo, KeyboardEvent, DragEvent, ClipboardEvent } from "react" +import { MessagesSquare, SendHorizonal, Check, Eye, EyeOff, Play, Loader2, Milestone, X, MessageCircle, FileEdit, FilePlus, Terminal, ChevronDown, ChevronRight, MoreHorizontal, Zap, Square, Pause, BarChart3, LayoutGrid, ListOrdered, History, Compass, PenLine, Inbox, SkipForward, Undo2, BookOpen, Settings, SlidersHorizontal, Stethoscope, FileOutput, Trash2, Globe, type LucideIcon } from "lucide-react" +import { cn } from "@/lib/utils" +import { Input } from "@/components/ui/input" +import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/components/ui/tooltip" +import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover" +import { ChatMessage, TuiPrompt } from "@/lib/pty-chat-parser" +import { PendingImage, processImageFile, generateImageId, MAX_PENDING_IMAGES } from "@/lib/image-utils" +import { + useGSDWorkspaceState, + useGSDWorkspaceActions, + buildPromptCommand, + type CompletedToolExecution, + type ActiveToolExecution, + type PendingUiRequest, + type TurnSegment, +} from "@/lib/sf-workspace-store" +import { deriveWorkflowAction } from "@/lib/workflow-actions" +import { useTerminalFontSize } from "@/lib/use-terminal-font-size" + +/* ─── ActionPanel types ─── */ + +// ActionPanelConfig removed — all commands now route through the main bridge. + +/* ─── GSD Action Definitions ─── */ + +/** + * Defines every /gsd subcommand available in the chat input bar. + * Top 3 are shown as standalone buttons; the rest live in the overflow menu. + * All commands dispatch through the main bridge session. + */ +interface GSDActionDef { + label: string + command: string + icon: LucideIcon + description: string + category: "workflow" | "visibility" | "correction" | "knowledge" | "config" | "maintenance" + /** When true, this command is disabled while auto-mode is active (injects competing LLM prompt) */ + disabledDuringAuto?: boolean +} + +const GSD_ACTIONS: GSDActionDef[] = [ + // ── Top 3 (standalone buttons) ── + { label: "Discuss", command: "/gsd discuss", icon: MessageCircle, description: "Start guided milestone/slice discussion", category: "workflow", disabledDuringAuto: true }, + { label: "Next", command: "/gsd next", icon: Play, description: "Execute next task, then pause", category: "workflow" }, + { label: "Auto", command: "/gsd auto", icon: Zap, description: "Run all queued units continuously", category: "workflow" }, + // ── Overflow: Workflow ── + { label: "Stop", command: "/gsd stop", icon: Square, description: "Stop auto-mode gracefully", category: "workflow" }, + { label: "Pause", command: "/gsd pause", icon: Pause, description: "Pause auto-mode (preserves state)", category: "workflow" }, + // ── Overflow: Visibility ── + { label: "Status", command: "/gsd status", icon: BarChart3, description: "Show progress dashboard", category: "visibility" }, + { label: "Visualize", command: "/gsd visualize", icon: LayoutGrid, description: "Interactive TUI (progress, deps, metrics, timeline)", category: "visibility" }, + { label: "Queue", command: "/gsd queue", icon: ListOrdered, description: "Show queued/dispatched units and execution order", category: "visibility" }, + { label: "History", command: "/gsd history", icon: History, description: "View execution history with cost/phase/model details", category: "visibility" }, + // ── Overflow: Course correction ── + { label: "Steer", command: "/gsd steer", icon: Compass, description: "Apply user override to active work", category: "correction" }, + { label: "Capture", command: "/gsd capture", icon: PenLine, description: "Quick-capture a thought to CAPTURES.md", category: "correction" }, + { label: "Triage", command: "/gsd triage", icon: Inbox, description: "Classify and route pending captures", category: "correction", disabledDuringAuto: true }, + { label: "Skip", command: "/gsd skip", icon: SkipForward, description: "Prevent a unit from auto-mode dispatch", category: "correction" }, + { label: "Undo", command: "/gsd undo", icon: Undo2, description: "Revert last completed unit", category: "correction" }, + // ── Overflow: Knowledge ── + { label: "Knowledge", command: "/gsd knowledge", icon: BookOpen, description: "Add rule, pattern, or lesson to KNOWLEDGE.md", category: "knowledge" }, + // ── Overflow: Configuration ── + { label: "Mode", command: "/gsd mode", icon: SlidersHorizontal, description: "Set workflow mode (solo/team)", category: "config" }, + { label: "Prefs", command: "/gsd prefs", icon: Settings, description: "Manage preferences (global/project)", category: "config" }, + // ── Overflow: Maintenance ── + { label: "Doctor", command: "/gsd doctor", icon: Stethoscope, description: "Diagnose and repair .gsd/ state", category: "maintenance" }, + { label: "Export", command: "/gsd export", icon: FileOutput, description: "Export milestone/slice results (JSON or Markdown)", category: "maintenance" }, + { label: "Cleanup", command: "/gsd cleanup", icon: Trash2, description: "Remove merged branches or snapshots", category: "maintenance" }, + { label: "Remote", command: "/gsd remote", icon: Globe, description: "Control remote auto-mode (Slack/Discord)", category: "maintenance" }, +] + +/** Top 3 shown as standalone buttons next to chat input */ +const TOP_ACTIONS = GSD_ACTIONS.slice(0, 3) +/** Remaining actions in the overflow menu */ +const OVERFLOW_ACTIONS = GSD_ACTIONS.slice(3) + +const CATEGORY_LABELS: Record = { + workflow: "Workflow", + visibility: "Visibility", + correction: "Course Correction", + knowledge: "Knowledge", + config: "Configuration", + maintenance: "Maintenance", +} + +function groupByCategory(actions: GSDActionDef[]): Array<{ category: GSDActionDef["category"]; label: string; items: GSDActionDef[] }> { + const seen = new Map() + for (const a of actions) { + let group = seen.get(a.category) + if (!group) { + group = [] + seen.set(a.category, group) + } + group.push(a) + } + return Array.from(seen.entries()).map(([cat, items]) => ({ category: cat, label: CATEGORY_LABELS[cat], items })) +} + +/** + * ChatMode — main view for the Chat tab. + * + * All /gsd commands dispatch through the main bridge session. + * Commands that inject competing LLM prompts (discuss, triage) + * are disabled while auto-mode is active. + * + * Observability: + * - This component mounts only when activeView === "chat" (no hidden pre-init). + * - sessionStorage key "gsd-active-view:" equals "chat" when this view is active. + * - Header toolbar: data-testid="chat-mode-action-bar" confirms toolbar rendered. + * - Primary button: data-testid="chat-primary-action" reflects current workflowAction label. + * - Secondary buttons: data-testid="chat-secondary-action-{command}". + */ +export function ChatMode({ className }: { className?: string }) { + const state = useGSDWorkspaceState() + const { sendCommand } = useGSDWorkspaceActions() + + const bridge = state.boot?.bridge ?? null + + const handleAction = useCallback( + (command: string) => { + void sendCommand(buildPromptCommand(command, bridge)) + }, + [sendCommand, bridge], + ) + + return ( +
+ {/* ── Header bar ── */} + + + {/* ── Main chat pane ── */} + handleAction(action.command)} + /> +
+ ) +} + +/* ─── Header ─── */ + +interface ChatModeHeaderProps { + onPrimaryAction: (command: string) => void + onSecondaryAction: (command: string) => void +} + +/** + * ChatModeHeader — action toolbar for Chat Mode. + * + * Single-row layout matching the Power User Mode header: title + badge left-aligned, + * workflow action buttons immediately to the right (no second row). + * + * Observability: + * - data-testid="chat-mode-action-bar" on the workflow button row + * - data-testid="chat-primary-action" on the primary button + * - data-testid="chat-secondary-action-{command}" on each secondary button + */ +function ChatModeHeader({ onPrimaryAction, onSecondaryAction }: ChatModeHeaderProps) { + const state = useGSDWorkspaceState() + + const boot = state.boot + const workspace = boot?.workspace ?? null + const auto = boot?.auto ?? null + + const workflowAction = deriveWorkflowAction({ + phase: workspace?.active.phase ?? "pre-planning", + autoActive: auto?.active ?? false, + autoPaused: auto?.paused ?? false, + onboardingLocked: boot?.onboarding.locked ?? false, + commandInFlight: state.commandInFlight, + bootStatus: state.bootStatus, + hasMilestones: (workspace?.milestones.length ?? 0) > 0, + projectDetectionKind: boot?.projectDetection?.kind ?? null, + }) + + const handlePrimary = () => { + if (!workflowAction.primary) return + onPrimaryAction(workflowAction.primary.command) + } + + // Derive a short GSD state badge label + const stateBadge = (() => { + if (state.bootStatus !== "ready") return state.bootStatus + const phase = workspace?.active.phase + if (!phase) return "idle" + if (auto?.active && !auto?.paused) return "auto" + if (auto?.paused) return "paused" + return phase + })() + + return ( +
+ {/* Left: title + state badge */} +
+ + Chat Mode + + {stateBadge} + +
+ + {/* Right: workflow action buttons */} +
+ {workflowAction.primary && ( + + )} + {workflowAction.secondaries.map((action) => ( + + ))} + {state.commandInFlight && ( + + + + )} +
+
+ ) +} + + +type ShikiHighlighter = { + codeToHtml: (code: string, options: { lang: string; theme: string }) => string +} + +let chatHighlighterPromise: Promise | null = null + +function getChatHighlighter(): Promise { + if (!chatHighlighterPromise) { + chatHighlighterPromise = import("shiki") + .then((mod) => + mod.createHighlighter({ + themes: ["github-dark-default", "github-light-default"], + langs: [ + "typescript", "tsx", "javascript", "jsx", + "json", "jsonc", "markdown", "mdx", + "css", "scss", "less", "html", "xml", + "yaml", "toml", "bash", "python", "ruby", + "rust", "go", "java", "kotlin", "swift", + "c", "cpp", "csharp", "php", "sql", + "graphql", "dockerfile", "makefile", + "lua", "diff", "ini", "dotenv", + ], + }), + ) + .catch((err) => { + chatHighlighterPromise = null + throw err + }) + } + return chatHighlighterPromise +} + +/* ─── Markdown renderer for assistant bubbles ─── */ + +/** + * Renders markdown content using react-markdown + remark-gfm + shiki code blocks. + * Dynamic imports keep the main bundle lean. + * Falls back to plain text if modules fail to load. + * + * Observability: + * - console.debug("[ChatBubble] markdown modules loaded") fires once on first render + */ +function MarkdownContent({ content }: { content: string }) { + const [rendered, setRendered] = useState(null) + const [ready, setReady] = useState(false) + const isDark = useIsDark() + + useEffect(() => { + let cancelled = false + + Promise.all([ + import("react-markdown"), + import("remark-gfm"), + getChatHighlighter(), + ]) + .then(([ReactMarkdownMod, remarkGfmMod, highlighter]) => { + if (cancelled) return + console.debug("[ChatBubble] markdown modules loaded") + + const ReactMarkdown = ReactMarkdownMod.default + const remarkGfm = remarkGfmMod.default + + const shikiTheme = isDark ? "github-dark-default" : "github-light-default" + + const buildComponents = (h: typeof highlighter) => ({ + code({ className, children, ...props }: React.HTMLAttributes & { children?: React.ReactNode }) { + const match = /language-(\w+)/.exec(className || "") + const codeStr = String(children).replace(/\n$/, "") + + if (match) { + try { + const highlighted = h.codeToHtml(codeStr, { + lang: match[1], + theme: shikiTheme, + }) + return ( +
+ ) + } catch { /* unsupported language — fall through */ } + } + + const isInline = !className && !String(children).includes("\n") + if (isInline) { + return ( + + {children} + + ) + } + + return ( +
+                {children}
+              
+ ) + }, + pre({ children }: { children?: React.ReactNode }) { + return <>{children} + }, + table({ children }: { children?: React.ReactNode }) { + return ( +
+ {children}
+
+ ) + }, + th({ children }: { children?: React.ReactNode }) { + return ( + + {children} + + ) + }, + td({ children }: { children?: React.ReactNode }) { + return ( + + {children} + + ) + }, + a({ href, children }: { href?: string; children?: React.ReactNode }) { + return ( + + {children} + + ) + }, + h1({ children }: { children?: React.ReactNode }) { + return

{children}

+ }, + h2({ children }: { children?: React.ReactNode }) { + return

{children}

+ }, + h3({ children }: { children?: React.ReactNode }) { + return

{children}

+ }, + ul({ children }: { children?: React.ReactNode }) { + return
    {children}
+ }, + ol({ children }: { children?: React.ReactNode }) { + return
    {children}
+ }, + blockquote({ children }: { children?: React.ReactNode }) { + return
{children}
+ }, + hr() { + return
+ }, + p({ children }: { children?: React.ReactNode }) { + return

{children}

+ }, + img({ alt, src }: { alt?: string; src?: string }) { + return ( + + 🖼 {alt || src || "image"} + + ) + }, + }) + + setRendered( + + {content} + , + ) + setReady(true) + }) + .catch(() => { + if (!cancelled) setReady(true) + }) + + return () => { cancelled = true } + + }, [content, isDark]) // re-render when content changes (streaming) or theme toggles + + if (!ready) { + // Plain text fallback while modules load + return ( + + {content} + + ) + } + + if (!rendered) { + return ( + + {content} + + ) + } + + return
{rendered}
+} + +/* ─── TuiSelectPrompt ─── */ + +/** + * Renders a GSD arrow-key select prompt as a native clickable list. + * + * Clicking an option calculates the arrow-key delta from the current + * PTY-tracked selection, sends that many \x1b[A/\x1b[B + \r to the PTY, + * and transitions to a static post-submission state. + * + * Observability: + * - Logs "[TuiSelectPrompt] mounted kind=select label=%s" on mount + * - Logs "[TuiSelectPrompt] submit delta=%d keystrokes=%j" on submit + * - data-testid="tui-select-prompt" on container + * - data-testid="tui-select-option-{i}" on each option button + * - data-testid="tui-prompt-submitted" on post-submission element + */ +function TuiSelectPrompt({ + prompt, + onSubmit, +}: { + prompt: TuiPrompt + onSubmit: (data: string) => void +}) { + const [localIndex, setLocalIndex] = useState(prompt.selectedIndex ?? 0) + const [submitted, setSubmitted] = useState(false) + const containerRef = useRef(null) + + useEffect(() => { + console.log("[TuiSelectPrompt] mounted kind=select label=%s", prompt.label) + // Auto-focus the container so keyboard events are captured immediately + containerRef.current?.focus() + }, [prompt.label]) + + const submitIndex = useCallback( + (clickedIndex: number) => { + const delta = clickedIndex - localIndex + let keystrokes = "" + if (delta > 0) { + keystrokes = "\x1b[B".repeat(delta) + } else if (delta < 0) { + keystrokes = "\x1b[A".repeat(Math.abs(delta)) + } + keystrokes += "\r" + + console.log( + "[TuiSelectPrompt] submit delta=%d keystrokes=%j", + delta, + keystrokes, + ) + + setLocalIndex(clickedIndex) + setSubmitted(true) + onSubmit(keystrokes) + }, + [localIndex, onSubmit], + ) + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (submitted) return + if (e.key === "ArrowUp") { + e.preventDefault() + setLocalIndex((i) => Math.max(0, i - 1)) + } else if (e.key === "ArrowDown") { + e.preventDefault() + setLocalIndex((i) => Math.min(prompt.options.length - 1, i + 1)) + } else if (e.key === "Enter") { + e.preventDefault() + submitIndex(localIndex) + } + }, + [submitted, localIndex, prompt.options.length, submitIndex], + ) + + if (submitted) { + const selectedLabel = prompt.options[localIndex] ?? "" + return ( +
+ + {selectedLabel} +
+ ) + } + + return ( +
+ {prompt.label && ( +

+ {prompt.label} +

+ )} + {prompt.options.map((option, i) => { + const isSelected = i === localIndex + const description = prompt.descriptions?.[i] + return ( + + ) + })} +
+ ) +} + +/* ─── TuiTextPrompt ─── */ + +/** + * Renders a GSD text prompt as a native labeled input field. + * + * Submitting sends the typed value + "\r" to the PTY (carriage return = Enter). + * After submission shows a static "✓ Submitted" confirmation (value not echoed). + * + * Observability: + * - Logs "[TuiTextPrompt] mounted kind=text label=%s" on mount + * - Logs "[TuiTextPrompt] submitted label=%s" on submit + * - data-testid="tui-text-prompt" on container + * - data-testid="tui-prompt-submitted" on post-submission element + */ +function TuiTextPrompt({ + prompt, + onSubmit, +}: { + prompt: TuiPrompt + onSubmit: (data: string) => void +}) { + const [value, setValue] = useState("") + const [submitted, setSubmitted] = useState(false) + const inputRef = useRef(null) + + useEffect(() => { + console.log("[TuiTextPrompt] mounted kind=text label=%s", prompt.label) + inputRef.current?.focus() + }, [prompt.label]) + + const handleSubmit = useCallback(() => { + if (submitted) return + console.log("[TuiTextPrompt] submitted label=%s", prompt.label) + setSubmitted(true) + onSubmit(value + "\r") + }, [submitted, value, prompt.label, onSubmit]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault() + handleSubmit() + } + }, + [handleSubmit], + ) + + if (submitted) { + return ( +
+ + ✓ Submitted +
+ ) + } + + return ( +
+ {prompt.label && ( +

+ {prompt.label} +

+ )} +
+ setValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Type your answer…" + className="flex-1 h-8 text-sm" + aria-label={prompt.label || "Text input"} + /> + +
+
+ ) +} + +/* ─── TuiPasswordPrompt ─── */ + +/** + * Renders a GSD password/API-key prompt as a native masked input field. + * + * Submitting sends the typed value + "\r" to the PTY. + * The entered value is NEVER shown in the DOM, logs, or post-submission text. + * After submission shows "{label} — entered ✓" with no value echo. + * + * Observability: + * - Logs "[TuiPasswordPrompt] mounted kind=password label=%s" on mount + * - Logs "[TuiPasswordPrompt] submitted label=%s" on submit (value not logged) + * - data-testid="tui-password-prompt" on container + * - data-testid="tui-prompt-submitted" on post-submission element + */ +function TuiPasswordPrompt({ + prompt, + onSubmit, +}: { + prompt: TuiPrompt + onSubmit: (data: string) => void +}) { + const [value, setValue] = useState("") + const [submitted, setSubmitted] = useState(false) + const [showPassword, setShowPassword] = useState(false) + const inputRef = useRef(null) + + useEffect(() => { + console.log("[TuiPasswordPrompt] mounted kind=password label=%s", prompt.label) + inputRef.current?.focus() + }, [prompt.label]) + + const handleSubmit = useCallback(() => { + if (submitted) return + // Value intentionally not logged — redaction constraint + console.log("[TuiPasswordPrompt] submitted label=%s", prompt.label) + setSubmitted(true) + onSubmit(value + "\r") + }, [submitted, value, prompt.label, onSubmit]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault() + handleSubmit() + } + }, + [handleSubmit], + ) + + if (submitted) { + const displayLabel = prompt.label || "Value" + return ( +
+ + {displayLabel} — entered ✓ +
+ ) + } + + return ( +
+ {prompt.label && ( +

+ {prompt.label} +

+ )} +
+
+ setValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Enter value…" + className="h-8 pr-9 text-sm" + aria-label={prompt.label || "Password input"} + autoComplete="off" + /> + +
+ +
+

+ Value is transmitted securely and not stored in chat history. +

+
+ ) +} + +/* ─── StreamingCursor ─── */ + +function StreamingCursor() { + return ( +