refactor(forge): complete gsd → forge rebrand across native, logging, and build system

- Rename native Rust crates: gsd-engine → forge-engine, gsd-ast → forge-ast, gsd-grep → forge-grep
- Update all crate dependencies (Cargo.toml, .rs source) and N-API artifacts
- Mass rename log prefix [gsd] → [forge] across 81 files (scripts, src/, extensions, tests)
- Rename log prefix "gsd-db:" → "forge-db:" in template literals
- Update nix flake: add sf-run-native devShell with Rust toolchain for native addon builds
- Update CI workflow artifact names (build-native.yml)
- Verify only packages/native/* touched (no upstream pi-* packages renamed)

Rationale: Complete gsd-2 → singularity-forge rebrand (2026-04-15). Native addon is
sf-run-specific; all gsd-prefixed logging and crate names must align with new identity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ace-pm 2026-04-15 14:11:45 +02:00
parent e5d655bdb3
commit 172753c3b2
119 changed files with 27638 additions and 280 deletions

0
.codex Normal file
View file

View file

@ -77,8 +77,8 @@ jobs:
if: runner.os != 'Windows' if: runner.os != 'Windows'
run: | run: |
mkdir -p artifacts 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/libforge_engine.dylib artifacts/forge_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.so artifacts/forge_engine.node 2>/dev/null || \
{ echo "::error::No library found for ${{ matrix.platform }}"; exit 1; } { echo "::error::No library found for ${{ matrix.platform }}"; exit 1; }
ls -la artifacts/ ls -la artifacts/
@ -86,13 +86,13 @@ jobs:
if: runner.os == 'Windows' if: runner.os == 'Windows'
run: | run: |
mkdir artifacts 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 - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: native-${{ matrix.platform }} name: native-${{ matrix.platform }}
path: artifacts/gsd_engine.node path: artifacts/forge_engine.node
if-no-files-found: error if-no-files-found: error
publish: publish:
@ -118,7 +118,7 @@ jobs:
- name: Copy binaries to platform packages - name: Copy binaries to platform packages
run: | run: |
for platform in darwin-arm64 darwin-x64 linux-x64-gnu linux-arm64-gnu win32-x64-msvc; do 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}" echo "Copied binary for ${platform}"
ls -la "native/npm/${platform}/" ls -la "native/npm/${platform}/"
done done

61
flake.lock generated Normal file
View file

@ -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
}

View file

@ -18,8 +18,13 @@
packages = with pkgs; [ packages = with pkgs; [
bash bash
bun bun
cargo
clippy
git git
nodejs_24 nodejs_24
rust-analyzer
rustc
rustfmt
]; ];
shellHook = '' shellHook = ''
@ -28,7 +33,9 @@
echo "gsd-2 runtime shell" echo "gsd-2 runtime shell"
echo " bun : $(command -v bun)" echo " bun : $(command -v bun)"
echo " cargo: $(command -v cargo)"
echo " node: $(command -v node)" echo " node: $(command -v node)"
echo " rustc: $(command -v rustc)"
''; '';
}; };
}); });

View file

@ -1,5 +1,5 @@
[package] [package]
name = "gsd-ast" name = "forge-ast"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true

View file

@ -383,7 +383,7 @@ mod tests {
impl Drop for TempTree { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.root); } } impl Drop for TempTree { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.root); } }
fn make_temp_tree() -> TempTree { fn make_temp_tree() -> TempTree {
let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); 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::create_dir_all(root.join("nested")).unwrap();
fs::write(root.join("a.ts"), "const a = 1;\n").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(); fs::write(root.join("nested").join("b.ts"), "const b = 2;\n").unwrap();
@ -421,7 +421,7 @@ mod tests {
#[test] #[test]
fn rejects_mixed_replace_lang() { fn rejects_mixed_replace_lang() {
let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); 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::create_dir_all(&root).unwrap();
fs::write(root.join("a.ts"), "const a = 1;\n").unwrap(); fs::write(root.join("a.ts"), "const a = 1;\n").unwrap();
fs::write(root.join("b.rs"), "fn main() {}\n").unwrap(); fs::write(root.join("b.rs"), "fn main() {}\n").unwrap();

View file

@ -1,5 +1,5 @@
[package] [package]
name = "gsd-engine" name = "forge-engine"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
@ -13,8 +13,8 @@ test = false
doctest = false doctest = false
[dependencies] [dependencies]
gsd-ast = { path = "../ast" } forge-ast = { path = "../ast" }
gsd-grep = { path = "../grep" } forge-grep = { path = "../grep" }
arboard = "3" arboard = "3"
dashmap = "6" dashmap = "6"
globset = "0.4" globset = "0.4"

View file

@ -1,2 +1,2 @@
//! Forces the linker to include gsd_ast napi registrations. //! Forces the linker to include forge_ast napi registrations.
use gsd_ast as _; use forge_ast as _;

View file

@ -1,13 +1,13 @@
//! N-API bindings for the grep module. //! 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::bindgen_prelude::*;
use napi_derive::napi; use napi_derive::napi;
use crate::task; 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)] #[napi(object)]
pub struct NapiContextLine { pub struct NapiContextLine {
@ -105,14 +105,14 @@ fn clamp_u32(value: u64) -> u32 {
value.min(u32::MAX as u64) as 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 { NapiContextLine {
line_number: clamp_u32(cl.line_number), line_number: clamp_u32(cl.line_number),
line: cl.line, line: cl.line,
} }
} }
fn convert_search_match(m: gsd_grep::SearchMatch) -> NapiSearchMatch { fn convert_search_match(m: forge_grep::SearchMatch) -> NapiSearchMatch {
NapiSearchMatch { NapiSearchMatch {
line_number: clamp_u32(m.line_number), line_number: clamp_u32(m.line_number),
line: m.line, 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 { NapiGrepMatch {
path: m.path, path: m.path,
line_number: clamp_u32(m.line_number), line_number: clamp_u32(m.line_number),
@ -157,7 +157,7 @@ fn convert_file_match(m: gsd_grep::FileMatch) -> NapiGrepMatch {
/// and optional context lines. /// and optional context lines.
#[napi(js_name = "search")] #[napi(js_name = "search")]
pub fn search(content: Buffer, options: NapiSearchOptions) -> Result<NapiSearchResult> { pub fn search(content: Buffer, options: NapiSearchOptions) -> Result<NapiSearchResult> {
let opts = gsd_grep::SearchOptions { let opts = forge_grep::SearchOptions {
pattern: options.pattern, pattern: options.pattern,
ignore_case: options.ignore_case.unwrap_or(false), ignore_case: options.ignore_case.unwrap_or(false),
multiline: options.multiline.unwrap_or(false), multiline: options.multiline.unwrap_or(false),
@ -167,7 +167,7 @@ pub fn search(content: Buffer, options: NapiSearchOptions) -> Result<NapiSearchR
max_columns: options.max_columns.map(|v| v as usize), max_columns: options.max_columns.map(|v| v as usize),
}; };
match gsd_grep::search_content(content.as_ref(), &opts) { match forge_grep::search_content(content.as_ref(), &opts) {
Ok(result) => Ok(NapiSearchResult { Ok(result) => Ok(NapiSearchResult {
matches: result matches: result
.matches .matches
@ -188,7 +188,7 @@ pub fn search(content: Buffer, options: NapiSearchOptions) -> Result<NapiSearchR
#[napi(js_name = "grep")] #[napi(js_name = "grep")]
pub fn grep(options: NapiGrepOptions) -> task::Async<NapiGrepResult> { pub fn grep(options: NapiGrepOptions) -> task::Async<NapiGrepResult> {
task::blocking("grep", (), move |_ct| { task::blocking("grep", (), move |_ct| {
let opts = gsd_grep::GrepOptions { let opts = forge_grep::GrepOptions {
pattern: options.pattern, pattern: options.pattern,
path: options.path, path: options.path,
glob: options.glob, glob: options.glob,
@ -202,7 +202,7 @@ pub fn grep(options: NapiGrepOptions) -> task::Async<NapiGrepResult> {
max_columns: options.max_columns.map(|v| v as usize), 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 { Ok(result) => Ok(NapiGrepResult {
matches: result.matches.into_iter().map(convert_file_match).collect(), matches: result.matches.into_iter().map(convert_file_match).collect(),
total_matches: clamp_u32(result.total_matches), total_matches: clamp_u32(result.total_matches),

View file

@ -1,5 +1,5 @@
[package] [package]
name = "gsd-grep" name = "forge-grep"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true

View file

@ -8,9 +8,9 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"main": "gsd_engine.node", "main": "forge_engine.node",
"files": [ "files": [
"gsd_engine.node" "forge_engine.node"
], ],
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View file

@ -8,9 +8,9 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"main": "gsd_engine.node", "main": "forge_engine.node",
"files": [ "files": [
"gsd_engine.node" "forge_engine.node"
], ],
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View file

@ -8,9 +8,9 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"main": "gsd_engine.node", "main": "forge_engine.node",
"files": [ "files": [
"gsd_engine.node" "forge_engine.node"
], ],
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View file

@ -8,9 +8,9 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"main": "gsd_engine.node", "main": "forge_engine.node",
"files": [ "files": [
"gsd_engine.node" "forge_engine.node"
], ],
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View file

@ -8,9 +8,9 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"main": "gsd_engine.node", "main": "forge_engine.node",
"files": [ "files": [
"gsd_engine.node" "forge_engine.node"
], ],
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View file

@ -27,7 +27,7 @@ const profile = isDev ? "debug" : "release";
const cargoArgs = ["build"]; const cargoArgs = ["build"];
if (!isDev) cargoArgs.push("--release"); if (!isDev) cargoArgs.push("--release");
console.log(`Building gsd-engine (${profile})...`); console.log(`Building forge-engine (${profile})...`);
try { try {
execSync(`cargo ${cargoArgs.join(" ")}`, { execSync(`cargo ${cargoArgs.join(" ")}`, {
@ -52,9 +52,9 @@ const targetDir = path.join(cargoTargetRoot, profile);
const platformTag = `${process.platform}-${process.arch}`; const platformTag = `${process.platform}-${process.arch}`;
const libraryNames = { const libraryNames = {
darwin: "libgsd_engine.dylib", darwin: "libforge_engine.dylib",
linux: "libgsd_engine.so", linux: "libforge_engine.so",
win32: "gsd_engine.dll", win32: "forge_engine.dll",
}; };
const libName = libraryNames[process.platform]; const libName = libraryNames[process.platform];
@ -72,8 +72,8 @@ if (!fs.existsSync(sourcePath)) {
fs.mkdirSync(addonDir, { recursive: true }); fs.mkdirSync(addonDir, { recursive: true });
const destFilename = isDev const destFilename = isDev
? "gsd_engine.dev.node" ? "forge_engine.dev.node"
: `gsd_engine.${platformTag}.node`; : `forge_engine.${platformTag}.node`;
const destPath = path.join(addonDir, destFilename); const destPath = path.join(addonDir, destFilename);
fs.copyFileSync(sourcePath, destPath); fs.copyFileSync(sourcePath, destPath);

View file

@ -10,8 +10,8 @@ const require = createRequire(import.meta.url);
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
const platformTag = `${process.platform}-${process.arch}`; const platformTag = `${process.platform}-${process.arch}`;
const candidates = [ const candidates = [
path.join(addonDir, `gsd_engine.${platformTag}.node`), path.join(addonDir, `forge_engine.${platformTag}.node`),
path.join(addonDir, "gsd_engine.dev.node"), path.join(addonDir, "forge_engine.dev.node"),
]; ];
let native; let native;

View file

@ -19,8 +19,8 @@ const addonDir = path.resolve(
); );
const platformTag = `${process.platform}-${process.arch}`; const platformTag = `${process.platform}-${process.arch}`;
const candidates = [ const candidates = [
path.join(addonDir, `gsd_engine.${platformTag}.node`), path.join(addonDir, `forge_engine.${platformTag}.node`),
path.join(addonDir, "gsd_engine.dev.node"), path.join(addonDir, "forge_engine.dev.node"),
]; ];
let native; let native;

View file

@ -13,8 +13,8 @@ const require = createRequire(import.meta.url);
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
const platformTag = `${process.platform}-${process.arch}`; const platformTag = `${process.platform}-${process.arch}`;
const candidates = [ const candidates = [
path.join(addonDir, `gsd_engine.${platformTag}.node`), path.join(addonDir, `forge_engine.${platformTag}.node`),
path.join(addonDir, "gsd_engine.dev.node"), path.join(addonDir, "forge_engine.dev.node"),
]; ];
let native; let native;

View file

@ -21,8 +21,8 @@ const addonDir = path.resolve(
); );
const platformTag = `${process.platform}-${process.arch}`; const platformTag = `${process.platform}-${process.arch}`;
const candidates = [ const candidates = [
path.join(addonDir, `gsd_engine.${platformTag}.node`), path.join(addonDir, `forge_engine.${platformTag}.node`),
path.join(addonDir, "gsd_engine.dev.node"), path.join(addonDir, "forge_engine.dev.node"),
]; ];
let native; let native;

View file

@ -13,8 +13,8 @@ const require = createRequire(import.meta.url);
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
const platformTag = `${process.platform}-${process.arch}`; const platformTag = `${process.platform}-${process.arch}`;
const candidates = [ const candidates = [
path.join(addonDir, `gsd_engine.${platformTag}.node`), path.join(addonDir, `forge_engine.${platformTag}.node`),
path.join(addonDir, "gsd_engine.dev.node"), path.join(addonDir, "forge_engine.dev.node"),
]; ];
let native; let native;

View file

@ -11,8 +11,8 @@ const require = createRequire(import.meta.url);
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
const platformTag = `${process.platform}-${process.arch}`; const platformTag = `${process.platform}-${process.arch}`;
const candidates = [ const candidates = [
path.join(addonDir, `gsd_engine.${platformTag}.node`), path.join(addonDir, `forge_engine.${platformTag}.node`),
path.join(addonDir, "gsd_engine.dev.node"), path.join(addonDir, "forge_engine.dev.node"),
]; ];
let native; let native;

View file

@ -10,8 +10,8 @@ const require = createRequire(import.meta.url);
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
const platformTag = `${process.platform}-${process.arch}`; const platformTag = `${process.platform}-${process.arch}`;
const candidates = [ const candidates = [
path.join(addonDir, `gsd_engine.${platformTag}.node`), path.join(addonDir, `forge_engine.${platformTag}.node`),
path.join(addonDir, "gsd_engine.dev.node"), path.join(addonDir, "forge_engine.dev.node"),
]; ];
let native; let native;

View file

@ -11,8 +11,8 @@ const require = createRequire(import.meta.url);
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
const platformTag = `${process.platform}-${process.arch}`; const platformTag = `${process.platform}-${process.arch}`;
const candidates = [ const candidates = [
path.join(addonDir, `gsd_engine.${platformTag}.node`), path.join(addonDir, `forge_engine.${platformTag}.node`),
path.join(addonDir, "gsd_engine.dev.node"), path.join(addonDir, "forge_engine.dev.node"),
]; ];
let native; let native;

View file

@ -10,8 +10,8 @@ const require = createRequire(import.meta.url);
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
const platformTag = `${process.platform}-${process.arch}`; const platformTag = `${process.platform}-${process.arch}`;
const candidates = [ const candidates = [
path.join(addonDir, `gsd_engine.${platformTag}.node`), path.join(addonDir, `forge_engine.${platformTag}.node`),
path.join(addonDir, "gsd_engine.dev.node"), path.join(addonDir, "forge_engine.dev.node"),
]; ];
let native; let native;

View file

@ -12,8 +12,8 @@ const require = createRequire(import.meta.url);
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
const platformTag = `${process.platform}-${process.arch}`; const platformTag = `${process.platform}-${process.arch}`;
const candidates = [ const candidates = [
path.join(addonDir, `gsd_engine.${platformTag}.node`), path.join(addonDir, `forge_engine.${platformTag}.node`),
path.join(addonDir, "gsd_engine.dev.node"), path.join(addonDir, "forge_engine.dev.node"),
]; ];
let native; let native;

View file

@ -19,8 +19,8 @@ const addonDir = path.resolve(
); );
const platformTag = `${process.platform}-${process.arch}`; const platformTag = `${process.platform}-${process.arch}`;
const candidates = [ const candidates = [
path.join(addonDir, `gsd_engine.${platformTag}.node`), path.join(addonDir, `forge_engine.${platformTag}.node`),
path.join(addonDir, "gsd_engine.dev.node"), path.join(addonDir, "forge_engine.dev.node"),
]; ];
let native; let native;

View file

@ -10,8 +10,8 @@ const require = createRequire(import.meta.url);
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
const platformTag = `${process.platform}-${process.arch}`; const platformTag = `${process.platform}-${process.arch}`;
const candidates = [ const candidates = [
path.join(addonDir, `gsd_engine.${platformTag}.node`), path.join(addonDir, `forge_engine.${platformTag}.node`),
path.join(addonDir, "gsd_engine.dev.node"), path.join(addonDir, "forge_engine.dev.node"),
]; ];
let native; let native;

View file

@ -11,8 +11,8 @@ const require = createRequire(import.meta.url);
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
const platformTag = `${process.platform}-${process.arch}`; const platformTag = `${process.platform}-${process.arch}`;
const candidates = [ const candidates = [
path.join(addonDir, `gsd_engine.${platformTag}.node`), path.join(addonDir, `forge_engine.${platformTag}.node`),
path.join(addonDir, "gsd_engine.dev.node"), path.join(addonDir, "forge_engine.dev.node"),
]; ];
let native; let native;

View file

@ -4,8 +4,8 @@
* Locates and loads the compiled Rust N-API addon (`.node` file). * Locates and loads the compiled Rust N-API addon (`.node` file).
* Resolution order: * Resolution order:
* 1. @singularity-forge/engine-{platform} npm optional dependency (production install) * 1. @singularity-forge/engine-{platform} npm optional dependency (production install)
* 2. native/addon/gsd_engine.{platform}.node (local release build) * 2. native/addon/forge_engine.{platform}.node (local release build)
* 3. native/addon/gsd_engine.dev.node (local debug build) * 3. native/addon/forge_engine.dev.node (local debug build)
*/ */
import * as path from "node:path"; import * as path from "node:path";
@ -44,8 +44,8 @@ function loadNative(): Record<string, unknown> {
} }
} }
// 2. Try local release build (native/addon/gsd_engine.{platform}.node) // 2. Try local release build (native/addon/forge_engine.{platform}.node)
const releasePath = path.join(addonDir, `gsd_engine.${platformTag}.node`); const releasePath = path.join(addonDir, `forge_engine.${platformTag}.node`);
try { try {
_loadedSuccessfully = true; return _require(releasePath) as Record<string, unknown>; _loadedSuccessfully = true; return _require(releasePath) as Record<string, unknown>;
} catch (err) { } catch (err) {
@ -53,8 +53,8 @@ function loadNative(): Record<string, unknown> {
errors.push(`${releasePath}: ${message}`); errors.push(`${releasePath}: ${message}`);
} }
// 3. Try local dev build (native/addon/gsd_engine.dev.node) // 3. Try local dev build (native/addon/forge_engine.dev.node)
const devPath = path.join(addonDir, "gsd_engine.dev.node"); const devPath = path.join(addonDir, "forge_engine.dev.node");
try { try {
_loadedSuccessfully = true; return _require(devPath) as Record<string, unknown>; _loadedSuccessfully = true; return _require(devPath) as Record<string, unknown>;
} catch (err) { } catch (err) {
@ -70,7 +70,7 @@ function loadNative(): Record<string, unknown> {
// entire import chain at startup (#1223). Consumers with JS fallbacks // entire import chain at startup (#1223). Consumers with JS fallbacks
// (parseRoadmap, parsePlan, fuzzyFind, etc.) catch these and degrade gracefully. // (parseRoadmap, parsePlan, fuzzyFind, etc.) catch these and degrade gracefully.
process.stderr.write( 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`, ` Supported native platforms: ${supportedPlatforms.join(", ")}\n`,
); );
return new Proxy({} as Record<string, unknown>, { return new Proxy({} as Record<string, unknown>, {

View file

@ -19,7 +19,7 @@ const { join, resolve } = require('node:path')
// Skip on Windows — Next.js webpack build hits EPERM scanning system dirs // Skip on Windows — Next.js webpack build hits EPERM scanning system dirs
if (process.platform === 'win32') { if (process.platform === 'win32') {
console.log('[gsd] Web build skipped on Windows.') console.log('[forge] Web build skipped on Windows.')
process.exit(0) process.exit(0)
} }
@ -83,7 +83,7 @@ function ensureWebBuildDependencies() {
return 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' }) execSync('npm --prefix web ci', { cwd: root, stdio: 'inherit' })
} }
@ -91,20 +91,20 @@ const sourceMtime = Math.max(newestMtime(webRoot), newestMtime(srcRoot))
const builtMtime = sentinelMtime() const builtMtime = sentinelMtime()
if (builtMtime > 0 && builtMtime >= sourceMtime) { 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) process.exit(0)
} }
if (builtMtime === 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 { } 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 { try {
ensureWebBuildDependencies() ensureWebBuildDependencies()
execSync('npm run build:web-host', { cwd: root, stdio: 'inherit' }) execSync('npm run build:web-host', { cwd: root, stdio: 'inherit' })
} catch (err) { } catch (err) {
console.error('[gsd] Web build failed:', err.message) console.error('[forge] Web build failed:', err.message)
process.exit(1) process.exit(1)
} }

View file

@ -20,7 +20,7 @@ const child = spawn(
) )
child.on('error', (error) => { 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) process.exit(1)
}) })

View file

@ -36,7 +36,7 @@ function run(cmd) {
} }
function logWarn(message) { function logWarn(message) {
process.stderr.write(`[gsd] postinstall: ${message}\n`) process.stderr.write(`[forge] postinstall: ${message}\n`)
} }
function resolveAssetName() { function resolveAssetName() {

View file

@ -43,7 +43,7 @@ function overlayNodePty(targetRoot) {
} }
if (!existsSync(standaloneAppRoot)) { 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) process.exit(1)
} }
@ -67,7 +67,7 @@ if (existsSync(publicRoot)) {
const hydratedTargets = overlayNodePty(distStandaloneRoot) 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) { 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).`)
} }

View file

@ -122,7 +122,7 @@ export function migrateLegacyFlatSessions(baseSessionsDir: string, projectSessio
function emitWebModeFailure(stderr: WritableLike, status: WebModeLaunchStatus): void { function emitWebModeFailure(stderr: WritableLike, status: WebModeLaunchStatus): void {
if (status.ok) return 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) currentCwd = resolve(defaultCwd, webPath)
const checkExists = existsSync const checkExists = existsSync
if (!checkExists(currentCwd)) { if (!checkExists(currentCwd)) {
stderr.write(`[gsd] Project path does not exist: ${currentCwd}\n`) stderr.write(`[forge] Project path does not exist: ${currentCwd}\n`)
return { return {
handled: true, handled: true,
exitCode: 1, exitCode: 1,
@ -270,7 +270,7 @@ export async function runWebCliBranch(
launchInputs: { cwd: currentCwd, projectSessionsDir: '', agentDir: deps.agentDir ?? defaultAgentDir }, 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 { } else {
currentCwd = defaultCwd currentCwd = defaultCwd
} }

View file

@ -681,7 +681,7 @@ if (!cliFlags.worktree && !isPrintMode) {
// which handles non-interactive output gracefully. // which handles non-interactive output gracefully.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
if (cliFlags.messages[0] === 'auto' && !process.stdout.isTTY) { 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)) await runHeadlessFromAuto(cliFlags.messages.slice(1))
} }

View file

@ -303,8 +303,8 @@ export function formatProgress(event: Record<string, unknown>, ctx: ProgressCont
// Bold important notifications // Bold important notifications
const isImportant = /^(committed:|verification gate:|milestone|blocked:)/i.test(msg) const isImportant = /^(committed:|verification gate:|milestone|blocked:)/i.test(msg)
return isImportant return isImportant
? `${c.bold}[gsd] ${msg}${c.reset}` ? `${c.bold}[forge] ${msg}${c.reset}`
: `[gsd] ${msg}` : `[forge] ${msg}`
} }
if (method === 'setStatus') { if (method === 'setStatus') {

View file

@ -134,5 +134,5 @@ export async function startMcpServer(options: {
// Connect to stdin/stdout transport // Connect to stdin/stdout transport
const transport = new StdioServerTransport() const transport = new StdioServerTransport()
await server.connect(transport) await server.connect(transport)
process.stderr.write(`[gsd] MCP server started (v${version})\n`) process.stderr.write(`[forge] MCP server started (v${version})\n`)
} }

View file

@ -107,7 +107,7 @@ async function loadClack(): Promise<ClackModule> {
try { try {
return await import('@clack/prompts') return await import('@clack/prompts')
} catch { } 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<void> {
;[p, pc] = await Promise.all([loadClack(), loadPico()]) ;[p, pc] = await Promise.all([loadClack(), loadPico()])
} catch (err) { } catch (err) {
// If clack isn't available, fall back silently — don't block boot // 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 return
} }

View file

@ -50,7 +50,7 @@ export function migratePiCredentials(authStorage: AuthStorage): boolean {
authStorage.set(providerId, credential) authStorage.set(providerId, credential)
const isLlm = LLM_PROVIDER_IDS.includes(providerId) const isLlm = LLM_PROVIDER_IDS.includes(providerId)
if (isLlm) migratedLlm = true 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 return migratedLlm

View file

@ -352,7 +352,7 @@ function reconcileSymlink(link: string, target: string): void {
try { try {
symlinkSync(target, link, 'junction') symlinkSync(target, link, 'junction')
} catch (err) { } 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 */ } try { symlinkSync(join(hoisted, entry.name), join(agentNodeModules, entry.name), 'junction'); linkedCount++ } catch { /* skip individual */ }
} }
} catch (err) { } 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. // 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 */ } try { symlinkSync(join(internal, entry.name), link, 'junction'); linkedCount++ } catch { /* skip individual */ }
} }
} catch (err) { } 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 // Only stamp marker if we actually linked something — avoids caching a broken state

View file

@ -17,7 +17,7 @@ const SEQ_PREFIX_RE = /^(\d+)-/;
import type { ExtensionContext } from "@sf-run/pi-coding-agent"; import type { ExtensionContext } from "@sf-run/pi-coding-agent";
import { gsdRoot } from "./paths.js"; import { gsdRoot } from "./paths.js";
import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js"; import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js";
import { isUnifiedAuditEnabled } from "./uok/audit-toggle.js"; import { isAuditEnvelopeEnabled } from "./uok/audit-toggle.js";
interface ActivityLogState { interface ActivityLogState {
nextSeq: number; nextSeq: number;
@ -135,7 +135,7 @@ export function saveActivityLog(
state.nextSeq += 1; state.nextSeq += 1;
state.lastSnapshotKeyByUnit.set(unitKey, key); state.lastSnapshotKeyByUnit.set(unitKey, key);
if (isUnifiedAuditEnabled()) { if (isAuditEnvelopeEnabled()) {
emitUokAuditEvent( emitUokAuditEvent(
basePath, basePath,
buildAuditEnvelope({ buildAuditEnvelope({

View file

@ -489,8 +489,8 @@ export async function bootstrapAutoSession(
// Route to the interactive discussion handler instead of falling through to // Route to the interactive discussion handler instead of falling through to
// auto-mode, which would immediately stop with "needs discussion". // auto-mode, which would immediately stop with "needs discussion".
if (hasSurvivorBranch && state.phase === "needs-discussion") { if (hasSurvivorBranch && state.phase === "needs-discussion") {
const { showSmartEntry } = await import("./guided-flow.js"); const { showWorkflowEntry } = await import("./guided-flow.js");
await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode });
invalidateAllCaches(); invalidateAllCaches();
const postState = await deriveState(base); const postState = await deriveState(base);
@ -547,8 +547,8 @@ export async function bootstrapAutoSession(
return releaseLockAndReturn(); return releaseLockAndReturn();
} }
const { showSmartEntry } = await import("./guided-flow.js"); const { showWorkflowEntry } = await import("./guided-flow.js");
await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode });
invalidateAllCaches(); invalidateAllCaches();
const postState = await deriveState(base); const postState = await deriveState(base);
@ -589,8 +589,8 @@ export async function bootstrapAutoSession(
const contextFile = resolveMilestoneFile(base, mid, "CONTEXT"); const contextFile = resolveMilestoneFile(base, mid, "CONTEXT");
const hasContext = !!(contextFile && (await loadFile(contextFile))); const hasContext = !!(contextFile && (await loadFile(contextFile)));
if (!hasContext) { if (!hasContext) {
const { showSmartEntry } = await import("./guided-flow.js"); const { showWorkflowEntry } = await import("./guided-flow.js");
await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode });
invalidateAllCaches(); invalidateAllCaches();
const postState = await deriveState(base); const postState = await deriveState(base);
@ -608,8 +608,8 @@ export async function bootstrapAutoSession(
// Active milestone has CONTEXT-DRAFT but no full context — needs discussion // Active milestone has CONTEXT-DRAFT but no full context — needs discussion
if (state.phase === "needs-discussion") { if (state.phase === "needs-discussion") {
const { showSmartEntry } = await import("./guided-flow.js"); const { showWorkflowEntry } = await import("./guided-flow.js");
await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode });
invalidateAllCaches(); invalidateAllCaches();
const postState = await deriveState(base); const postState = await deriveState(base);
@ -630,8 +630,8 @@ export async function bootstrapAutoSession(
// Unreachable safety check // Unreachable safety check
if (!state.activeMilestone) { if (!state.activeMilestone) {
const { showSmartEntry } = await import("./guided-flow.js"); const { showWorkflowEntry } = await import("./guided-flow.js");
await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode });
return releaseLockAndReturn(); return releaseLockAndReturn();
} }

View file

@ -48,7 +48,7 @@ import { withTimeout, FINALIZE_PRE_TIMEOUT_MS, FINALIZE_POST_TIMEOUT_MS } from "
import { getEligibleSlices } from "../slice-parallel-eligibility.js"; import { getEligibleSlices } from "../slice-parallel-eligibility.js";
import { startSliceParallel } from "../slice-parallel-orchestrator.js"; import { startSliceParallel } from "../slice-parallel-orchestrator.js";
import { isDbAvailable, getMilestoneSlices } from "../gsd-db.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 { resolveUokFlags } from "../uok/flags.js";
import { UokGateRunner } from "../uok/gate-runner.js"; import { UokGateRunner } from "../uok/gate-runner.js";
import { resetEvidence } from "../safety/evidence-collector.js"; import { resetEvidence } from "../safety/evidence-collector.js";
@ -84,15 +84,15 @@ export function _resolveDispatchGuardBasePath(
return s.originalBasePath || s.basePath; return s.originalBasePath || s.basePath;
} }
const PLAN_V2_GATE_PHASES: ReadonlySet<Phase> = new Set([ const PLANNING_FLOW_GATE_PHASES: ReadonlySet<Phase> = new Set([
"executing", "executing",
"summarizing", "summarizing",
"validating-milestone", "validating-milestone",
"completing-milestone", "completing-milestone",
]); ]);
function shouldRunPlanV2Gate(phase: Phase): boolean { function shouldRunPlanningFlowGate(phase: Phase): boolean {
return PLAN_V2_GATE_PHASES.has(phase); return PLANNING_FLOW_GATE_PHASES.has(phase);
} }
function shouldSkipArtifactVerification(unitType: string): boolean { function shouldSkipArtifactVerification(unitType: string): boolean {
@ -404,29 +404,30 @@ export async function runPreDispatch(
// Derive state // Derive state
let state = await deps.deriveState(s.basePath); let state = await deps.deriveState(s.basePath);
if (prefs?.uok?.plan_v2?.enabled && shouldRunPlanV2Gate(state.phase)) { const planningFlowEnabled = prefs?.uok?.planning_flow?.enabled === true || prefs?.uok?.plan_v2?.enabled === true;
const compiled = ensurePlanV2Graph(s.basePath, state); if (planningFlowEnabled && shouldRunPlanningFlowGate(state.phase)) {
const compiled = ensurePlanningFlowGraph(s.basePath, state);
if (!compiled.ok) { if (!compiled.ok) {
const reason = compiled.reason ?? "Plan v2 compilation failed"; const reason = compiled.reason ?? "Planning flow compilation failed";
await runPreDispatchGate({ await runPreDispatchGate({
gateId: "plan-v2-gate", gateId: "planning-flow-gate",
gateType: "policy", gateType: "policy",
outcome: "manual-attention", outcome: "manual-attention",
failureClass: "manual-attention", failureClass: "manual-attention",
rationale: "plan v2 compile gate failed", rationale: "planning flow compile gate failed",
findings: reason, findings: reason,
milestoneId: state.activeMilestone?.id ?? undefined, milestoneId: state.activeMilestone?.id ?? undefined,
}); });
ctx.ui.notify(`Plan gate failed-closed: ${reason}`, "error"); ctx.ui.notify(`Plan gate failed-closed: ${reason}`, "error");
await deps.pauseAuto(ctx, pi); await deps.pauseAuto(ctx, pi);
return { action: "break", reason: "plan-v2-gate-failed" }; return { action: "break", reason: "planning-flow-gate-failed" };
} }
await runPreDispatchGate({ await runPreDispatchGate({
gateId: "plan-v2-gate", gateId: "planning-flow-gate",
gateType: "policy", gateType: "policy",
outcome: "pass", outcome: "pass",
failureClass: "none", failureClass: "none",
rationale: "plan v2 compile gate passed", rationale: "planning flow compile gate passed",
milestoneId: state.activeMilestone?.id ?? undefined, milestoneId: state.activeMilestone?.id ?? undefined,
}); });
} }

View file

@ -21,7 +21,7 @@ export function writeCrashLog(err: Error, source: string): void {
const ts = new Date().toISOString().replace(/[:.]/g, "-"); const ts = new Date().toISOString().replace(/[:.]/g, "-");
const logPath = join(crashDir, `${ts}.log`); const logPath = join(crashDir, `${ts}.log`);
const lines = [ const lines = [
`[gsd] ${source}: ${err.message}`, `[forge] ${source}: ${err.message}`,
`timestamp: ${new Date().toISOString()}`, `timestamp: ${new Date().toISOString()}`,
`pid: ${process.pid}`, `pid: ${process.pid}`,
err.stack ?? "(no stack trace available)", err.stack ?? "(no stack trace available)",

View file

@ -22,11 +22,11 @@ export function handleRecoverableExtensionProcessError(err: Error): boolean {
if ((err as NodeJS.ErrnoException).code === "ENOENT") { if ((err as NodeJS.ErrnoException).code === "ENOENT") {
const syscall = (err as NodeJS.ErrnoException).syscall; const syscall = (err as NodeJS.ErrnoException).syscall;
if (syscall?.startsWith("spawn")) { 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; return true;
} }
if (syscall === "uv_cwd") { if (syscall === "uv_cwd") {
process.stderr.write(`[gsd] ENOENT (${syscall}): ${err.message}\n`); process.stderr.write(`[forge] ENOENT (${syscall}): ${err.message}\n`);
return true; return true;
} }
} }

View file

@ -44,7 +44,7 @@ export function registerHooks(pi: ExtensionAPI): void {
const sid = ctx.sessionManager?.getSessionId?.() ?? ""; const sid = ctx.sessionManager?.getSessionId?.() ?? "";
const sfile = ctx.sessionManager?.getSessionFile?.() ?? ""; const sfile = ctx.sessionManager?.getSessionFile?.() ?? "";
if (sid) { 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 { } catch {
/* non-fatal */ /* non-fatal */

View file

@ -1,4 +1,4 @@
// GSD Extension — Unified Cache Invalidation // GSD Extension — Cache Invalidation
// //
// Three module-scoped caches exist across the GSD extension: // Three module-scoped caches exist across the GSD extension:
// 1. State cache (state.ts) — memoized deriveState() result // 1. State cache (state.ts) — memoized deriveState() result

View file

@ -267,8 +267,8 @@ export async function handleWorkflowCommand(trimmed: string, ctx: ExtensionComma
try { unlinkSync(headlessContextPath); } catch { /* non-fatal */ } try { unlinkSync(headlessContextPath); } catch { /* non-fatal */ }
await showHeadlessMilestoneCreation(ctx, pi, basePath, seedContext); await showHeadlessMilestoneCreation(ctx, pi, basePath, seedContext);
} else { } else {
const { showSmartEntry } = await import("../../guided-flow.js"); const { showWorkflowEntry } = await import("../../guided-flow.js");
await showSmartEntry(ctx, pi, basePath); await showWorkflowEntry(ctx, pi, basePath);
} }
return true; return true;
} }

View file

@ -193,7 +193,7 @@ Setting `prefer_skills: []` does **not** disable skill discovery — it just mea
- `hooks`: boolean — enable routing hooks. Default: `true`. - `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`. - `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`. - `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`. - `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. - 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.enabled`: boolean — persist turn-level git transaction records.
- `gitops.turn_action`: `"commit"` | `"snapshot"` | `"status-only"` — turn transaction mode. - `gitops.turn_action`: `"commit"` | `"snapshot"` | `"status-only"` — turn transaction mode.
- `gitops.turn_push`: boolean — whether turn transactions should include push intent metadata. - `gitops.turn_push`: boolean — whether turn transactions should include push intent metadata.
- `audit_unified.enabled`: boolean — dual-write unified audit envelope events. - `audit_envelope.enabled`: boolean — dual-write audit envelope events.
- `plan_v2.enabled`: boolean — enable bounded clarify/research/draft/compile planning flow. - `planning_flow.enabled`: boolean — enable bounded clarify/research/draft/compile planning flow.
- `context_management`: configures context hygiene for auto-mode sessions. Keys: - `context_management`: configures context hygiene for auto-mode sessions. Keys:
- `observation_masking`: boolean — mask old tool results to reduce context bloat. Default: `true`. - `observation_masking`: boolean — mask old tool results to reduce context bloat. Default: `true`.

View file

@ -1,5 +1,5 @@
/** /**
* Unified error classifier for provider/network/server errors. * Error classifier for provider/network/server failures.
* *
* Consolidates patterns from: * Consolidates patterns from:
* - isTransientNetworkError() in preferences-models.ts * - isTransientNetworkError() in preferences-models.ts

View file

@ -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 * Primary entrypoints: `showWorkflowEntry()` and the legacy `showSmartEntry()`
* wizard via showNextAction(), and dispatches through GSD-WORKFLOW.md. * 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. * 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 { ensureGitignore, ensurePreferences, untrackRuntimeFiles } from "./gitignore.js";
import { loadEffectiveGSDPreferences } from "./preferences.js"; import { loadEffectiveGSDPreferences } from "./preferences.js";
import { resolveUokFlags } from "./uok/flags.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 { detectProjectState } from "./detection.js";
import { showProjectInit, offerMigration } from "./init-wizard.js"; import { showProjectInit, offerMigration } from "./init-wizard.js";
import { validateDirectory } from "./validate-directory.js"; import { validateDirectory } from "./validate-directory.js";
@ -86,24 +87,24 @@ function nextMilestoneIdReserved(existingIds: string[], uniqueEnabled: boolean):
return id; return id;
} }
function needsPlanV2Gate(state: GSDState): boolean { function needsPlanningFlowGate(state: GSDState): boolean {
return state.phase === "executing" return state.phase === "executing"
|| state.phase === "summarizing" || state.phase === "summarizing"
|| state.phase === "validating-milestone" || state.phase === "validating-milestone"
|| state.phase === "completing-milestone"; || state.phase === "completing-milestone";
} }
function runPlanV2Gate( function runPlanningFlowGate(
ctx: ExtensionContext, ctx: ExtensionContext,
basePath: string, basePath: string,
state: GSDState, state: GSDState,
): boolean { ): boolean {
const prefs = loadEffectiveGSDPreferences()?.preferences; const prefs = loadEffectiveGSDPreferences()?.preferences;
const uokFlags = resolveUokFlags(prefs); const uokFlags = resolveUokFlags(prefs);
if (!uokFlags.planV2 || !needsPlanV2Gate(state)) return true; if (!uokFlags.planningFlow || !needsPlanningFlowGate(state)) return true;
const compiled = ensurePlanV2Graph(basePath, state); const compiled = ensurePlanningFlowGraph(basePath, state);
if (!compiled.ok) { if (!compiled.ok) {
const reason = compiled.reason ?? "plan-v2 compilation failed"; const reason = compiled.reason ?? "planning-flow compilation failed";
ctx.ui.notify( ctx.ui.notify(
`Plan gate failed-closed: ${reason}. Complete plan/discuss artifacts before execution.`, `Plan gate failed-closed: ${reason}. Complete plan/discuss artifacts before execution.`,
"error", "error",
@ -1079,16 +1080,16 @@ async function dispatchDiscussForMilestone(
await dispatchWorkflow(pi, prompt, "gsd-discuss", ctx, "discuss-milestone"); 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 * Self-heal: scan runtime records and clear stale ones left behind when
* auto-mode crashed mid-unit. auto.ts has its own selfHealRuntimeRecords() * auto-mode crashed mid-unit. auto.ts has its own selfHealRuntimeRecords()
* but guided-flow (manual /gsd mode) never called it meaning stale records * 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. * starts from a clean state regardless of how the previous session ended.
*/ */
function selfHealRuntimeRecords(basePath: string, ctx: ExtensionContext): { cleared: number } { function selfHealRuntimeRecords(basePath: string, ctx: ExtensionContext): { cleared: number } {
@ -1224,7 +1225,7 @@ async function handleMilestoneActions(
return false; return false;
} }
export async function showSmartEntry( export async function showWorkflowEntry(
ctx: ExtensionCommandContext, ctx: ExtensionCommandContext,
pi: ExtensionAPI, pi: ExtensionAPI,
basePath: string, basePath: string,
@ -1350,7 +1351,7 @@ export async function showSmartEntry(
logWarning("guided", `STATE.md rebuild failed: ${(err as Error).message}`); 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) { if (!state.activeMilestone?.id) {
// Guard: if a discuss session is already in flight, don't re-inject the prompt. // 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) { if (confirmed) {
discardMilestone(basePath, milestoneId); discardMilestone(basePath, milestoneId);
return showSmartEntry(ctx, pi, basePath, options); return showWorkflowEntry(ctx, pi, basePath, options);
} }
} }
} else { } else {
@ -1680,7 +1681,7 @@ export async function showSmartEntry(
await fireStatusViaCommand(ctx); await fireStatusViaCommand(ctx);
} else if (choice === "milestone_actions") { } else if (choice === "milestone_actions") {
const acted = await handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneTitle, options); 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; return;
@ -1780,7 +1781,7 @@ export async function showSmartEntry(
await fireStatusViaCommand(ctx); await fireStatusViaCommand(ctx);
} else if (choice === "milestone_actions") { } else if (choice === "milestone_actions") {
const acted = await handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneTitle, options); 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; return;
} }
@ -1835,7 +1836,7 @@ export async function showSmartEntry(
await fireStatusViaCommand(ctx); await fireStatusViaCommand(ctx);
} else if (choice === "milestone_actions") { } else if (choice === "milestone_actions") {
const acted = await handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneTitle, options); 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; return;
} }
@ -1926,7 +1927,7 @@ export async function showSmartEntry(
await fireStatusViaCommand(ctx); await fireStatusViaCommand(ctx);
} else if (choice === "milestone_actions") { } else if (choice === "milestone_actions") {
const acted = await handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneTitle, options); 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; return;
} }
@ -1935,3 +1936,5 @@ export async function showSmartEntry(
const { fireStatusViaCommand } = await import("./commands.js"); const { fireStatusViaCommand } = await import("./commands.js");
await fireStatusViaCommand(ctx); await fireStatusViaCommand(ctx);
} }
export const showSmartEntry = showWorkflowEntry;

View file

@ -261,7 +261,7 @@ export async function showProjectInit(
} }
// Write initial STATE.md so it exists before the first /gsd invocation. // 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. // which would otherwise generate STATE.md at guided-flow.ts:1358.
try { try {
const { deriveState } = await import("./state.js"); const { deriveState } = await import("./state.js");

View file

@ -16,7 +16,7 @@ import { appendFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { gsdRoot } from "./paths.js"; import { gsdRoot } from "./paths.js";
import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js"; import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js";
import { isUnifiedAuditEnabled } from "./uok/audit-toggle.js"; import { isAuditEnvelopeEnabled } from "./uok/audit-toggle.js";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@ -93,7 +93,7 @@ export function emitJournalEvent(basePath: string, entry: JournalEntry): void {
// Silent failure — journal must never break auto-mode // Silent failure — journal must never break auto-mode
} }
if (!isUnifiedAuditEnabled()) return; if (!isAuditEnvelopeEnabled()) return;
try { try {
const causedBy = entry.causedBy const causedBy = entry.causedBy
? `${entry.causedBy.flowId}:${entry.causedBy.seq}` ? `${entry.causedBy.flowId}:${entry.causedBy.seq}`

View file

@ -20,7 +20,7 @@ import { getAndClearSkills } from "./skill-telemetry.js";
import { loadJsonFile, loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js"; import { loadJsonFile, loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js";
import { parseUnitId } from "./unit-id.js"; import { parseUnitId } from "./unit-id.js";
import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.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"; import { getDatabase } from "./gsd-db.js";
// Re-export from shared — import directly from format-utils to avoid pulling // 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 // Background outcome recording for Bayesian learning
recordUnitOutcome(unit).catch(() => { /* fire-and-forget */ }); recordUnitOutcome(unit).catch(() => { /* fire-and-forget */ });
if (isUnifiedAuditEnabled()) { if (isAuditEnvelopeEnabled()) {
emitUokAuditEvent( emitUokAuditEvent(
basePath, basePath,
buildAuditEnvelope({ buildAuditEnvelope({

View file

@ -231,9 +231,17 @@ export interface UokPreferences {
turn_action?: UokTurnActionMode; turn_action?: UokTurnActionMode;
turn_push?: boolean; turn_push?: boolean;
}; };
audit_envelope?: {
enabled?: boolean;
};
/** @deprecated Use `audit_envelope` instead. */
audit_unified?: { audit_unified?: {
enabled?: boolean; enabled?: boolean;
}; };
planning_flow?: {
enabled?: boolean;
};
/** @deprecated Use `planning_flow` instead. */
plan_v2?: { plan_v2?: {
enabled?: boolean; enabled?: boolean;
}; };
@ -289,7 +297,7 @@ export interface GSDPreferences {
post_unit_hooks?: PostUnitHookConfig[]; post_unit_hooks?: PostUnitHookConfig[];
pre_dispatch_hooks?: PreDispatchHookConfig[]; pre_dispatch_hooks?: PreDispatchHookConfig[];
dynamic_routing?: DynamicRoutingConfig; dynamic_routing?: DynamicRoutingConfig;
/** Unified Orchestration Kernel controls (all flags default off). */ /** Orchestration kernel controls (all flags default off). */
uok?: UokPreferences; uok?: UokPreferences;
/** Per-model capability overrides. Deep-merged with built-in profiles for capability-aware routing (ADR-004). */ /** Per-model capability overrides. Deep-merged with built-in profiles for capability-aware routing (ADR-004). */
modelOverrides?: Record<string, { capabilities?: Partial<ModelCapabilities> }>; modelOverrides?: Record<string, { capabilities?: Partial<ModelCapabilities> }>;

View file

@ -186,8 +186,10 @@ export function validatePreferences(preferences: GSDPreferences): {
} }
const parseEnabledBlock = ( 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 => { ): void => {
const normalizedTargetKey = targetKey ?? (key === "plan_v2" ? "planning_flow" : key);
const value = raw[key]; const value = raw[key];
if (value === undefined) return; if (value === undefined) return;
if (typeof value !== "object" || value === null) { if (typeof value !== "object" || value === null) {
@ -205,7 +207,7 @@ export function validatePreferences(preferences: GSDPreferences): {
warnings.push(`unknown uok.${key} key "${unk}" — ignored`); warnings.push(`unknown uok.${key} key "${unk}" — ignored`);
} }
if (Object.keys(parsed).length > 0) { if (Object.keys(parsed).length > 0) {
valid[key] = parsed; valid[normalizedTargetKey] = parsed;
} }
}; };
@ -213,8 +215,16 @@ export function validatePreferences(preferences: GSDPreferences): {
parseEnabledBlock("gates"); parseEnabledBlock("gates");
parseEnabledBlock("model_policy"); parseEnabledBlock("model_policy");
parseEnabledBlock("execution_graph"); parseEnabledBlock("execution_graph");
parseEnabledBlock("audit_unified"); parseEnabledBlock("audit_envelope");
parseEnabledBlock("plan_v2"); 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 (raw.gitops !== undefined) {
if (typeof raw.gitops !== "object" || raw.gitops === null) { if (typeof raw.gitops !== "object" || raw.gitops === null) {
@ -257,7 +267,9 @@ export function validatePreferences(preferences: GSDPreferences): {
"model_policy", "model_policy",
"execution_graph", "execution_graph",
"gitops", "gitops",
"audit_envelope",
"audit_unified", "audit_unified",
"planning_flow",
"plan_v2", "plan_v2",
]); ]);
for (const key of Object.keys(raw)) { for (const key of Object.keys(raw)) {

View file

@ -400,11 +400,27 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr
gitops: (base.uok?.gitops || override.uok?.gitops) gitops: (base.uok?.gitops || override.uok?.gitops)
? { ...(base.uok?.gitops ?? {}), ...(override.uok?.gitops ?? {}) } ? { ...(base.uok?.gitops ?? {}), ...(override.uok?.gitops ?? {}) }
: undefined, : undefined,
audit_unified: (base.uok?.audit_unified || override.uok?.audit_unified) audit_envelope: (
? { ...(base.uok?.audit_unified ?? {}), ...(override.uok?.audit_unified ?? {}) } 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, : undefined,
plan_v2: (base.uok?.plan_v2 || override.uok?.plan_v2) planning_flow: (
? { ...(base.uok?.plan_v2 ?? {}), ...(override.uok?.plan_v2 ?? {}) } 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,
} }
: undefined, : undefined,

View file

@ -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) // Provides evaluation methods for each phase (dispatch, post-unit, pre-dispatch)
// and encapsulates mutable hook state as instance fields. // and encapsulates mutable hook state as instance fields.
// //
// A module-level singleton accessor allows existing code to migrate incrementally. // A module-level singleton accessor allows existing code to migrate incrementally.
import { logWarning } from "./workflow-logger.js"; 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 { DispatchAction, DispatchContext, DispatchRule } from "./auto-dispatch.js";
import type { import type {
PostUnitHookConfig, PostUnitHookConfig,
@ -39,10 +39,10 @@ export function resolveHookArtifactPath(basePath: string, unitId: string, artifa
// ─── Dispatch Rule Conversion ────────────────────────────────────────────── // ─── 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). * 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) => ({ return rules.map((rule) => ({
name: rule.name, name: rule.name,
when: "dispatch" as const, when: "dispatch" as const,
@ -59,7 +59,7 @@ const HOOK_STATE_FILE = "hook-state.json";
export class RuleRegistry { export class RuleRegistry {
/** Static dispatch rules provided at construction time. */ /** Static dispatch rules provided at construction time. */
private readonly dispatchRules: UnifiedRule[]; private readonly dispatchRules: RegistryRule[];
// ── Mutable hook state (encapsulated, not module-level) ────────────── // ── Mutable hook state (encapsulated, not module-level) ──────────────
@ -73,7 +73,7 @@ export class RuleRegistry {
retryPending: boolean = false; retryPending: boolean = false;
retryTrigger: { unitType: string; unitId: string; retryArtifact: string } | null = null; retryTrigger: { unitType: string; unitId: string; retryArtifact: string } | null = null;
constructor(dispatchRules: UnifiedRule[]) { constructor(dispatchRules: RegistryRule[]) {
this.dispatchRules = dispatchRules; this.dispatchRules = dispatchRules;
} }
@ -83,8 +83,8 @@ export class RuleRegistry {
* Returns all rules: static dispatch rules + dynamically loaded hook rules. * Returns all rules: static dispatch rules + dynamically loaded hook rules.
* Hook rules are loaded fresh from preferences on each call (not cached). * Hook rules are loaded fresh from preferences on each call (not cached).
*/ */
listRules(): UnifiedRule[] { listRules(): RegistryRule[] {
const rules: UnifiedRule[] = [...this.dispatchRules]; const rules: RegistryRule[] = [...this.dispatchRules];
// Convert post-unit hooks to unified rules // Convert post-unit hooks to unified rules
const postHooks = resolvePostUnitHooks(); const postHooks = resolvePostUnitHooks();
@ -575,7 +575,7 @@ export function setRegistry(r: RuleRegistry): void {
} }
/** Create and set the singleton registry with the given dispatch rules. */ /** 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); const registry = new RuleRegistry(dispatchRules);
setRegistry(registry); setRegistry(registry);
return registry; return registry;

View file

@ -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; // consistent when/where/then shape. This file defines the type system;
// the `RuleRegistry` class in rule-registry.ts holds instances at runtime. // the `RuleRegistry` class in rule-registry.ts holds instances at runtime.
@ -36,13 +36,13 @@ export interface RuleLifecycle {
idempotency_key?: string; idempotency_key?: string;
} }
// ─── Unified Rule ─────────────────────────────────────────────────────────── // ─── Registry Rule ───────────────────────────────────────────────────────────
/** /**
* A single entry in the rule registry. Dispatch rules, post-unit hooks, * A single entry in the rule registry. Dispatch rules, post-unit hooks,
* and pre-dispatch hooks all share this shape. * and pre-dispatch hooks all share this shape.
*/ */
export interface UnifiedRule { export interface RegistryRule {
/** Stable human-readable identifier (existing names preserved per D005). */ /** Stable human-readable identifier (existing names preserved per D005). */
name: string; name: string;
/** Which phase/event this rule responds to. */ /** Which phase/event this rule responds to. */

View file

@ -222,14 +222,14 @@ function createLockCompromisedHandler(lockFilePath: string): () => void {
const elapsed = Date.now() - _lockAcquiredAt; const elapsed = Date.now() - _lockAcquiredAt;
if (elapsed < 1_800_000) { if (elapsed < 1_800_000) {
process.stderr.write( 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; return;
} }
const existing = readExistingLockDataWithRetry(lockFilePath); const existing = readExistingLockDataWithRetry(lockFilePath);
if (existing && existing.pid === process.pid) { if (existing && existing.pid === process.pid) {
process.stderr.write( 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; return;
} }
@ -463,7 +463,7 @@ export function getSessionLockStatus(basePath: string): SessionLockStatus {
const result = acquireSessionLock(basePath); const result = acquireSessionLock(basePath);
if (result.acquired) { if (result.acquired) {
process.stderr.write( 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 }; return { valid: true, recovered: true };
} }

View file

@ -53,9 +53,9 @@ uok:
enabled: false enabled: false
turn_action: status-only turn_action: status-only
turn_push: false turn_push: false
audit_unified: audit_envelope:
enabled: false enabled: false
plan_v2: planning_flow:
enabled: false enabled: false
auto_visualize: auto_visualize:
auto_report: auto_report:

View file

@ -11,8 +11,8 @@ test("bootstrapAutoSession snapshots ctx.model before guided-flow entry (#2829)"
const snapshotIdx = source.indexOf("const startModelSnapshot = manualSessionOverride"); const snapshotIdx = source.indexOf("const startModelSnapshot = manualSessionOverride");
assert.ok(snapshotIdx > -1, "auto-start.ts should snapshot model at bootstrap start"); assert.ok(snapshotIdx > -1, "auto-start.ts should snapshot model at bootstrap start");
const firstDiscussIdx = source.indexOf('await showSmartEntry(ctx, pi, base, { step: requestedStepMode });'); const firstDiscussIdx = source.indexOf('await showWorkflowEntry(ctx, pi, base, { step: requestedStepMode });');
assert.ok(firstDiscussIdx > -1, "auto-start.ts should route through showSmartEntry during guided flow"); assert.ok(firstDiscussIdx > -1, "auto-start.ts should route through showWorkflowEntry during guided flow");
assert.ok( assert.ok(
snapshotIdx < firstDiscussIdx, snapshotIdx < firstDiscussIdx,

View file

@ -6,7 +6,7 @@
* *
* 1. The survivor branch check included needs-discussion, so a branch * 1. The survivor branch check included needs-discussion, so a branch
* created by a prior failed bootstrap caused hasSurvivorBranch = true, * 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, * 2. No needs-discussion handler existed in the !hasSurvivorBranch block,
* so the phase fell through to auto-mode which immediately stopped * so the phase fell through to auto-mode which immediately stopped
@ -118,12 +118,12 @@ describe("auto-start-needs-discussion (#1726)", () => {
const source = readAutoStartSource(); const source = readAutoStartSource();
// After the pre-planning handler, there should be a needs-discussion handler // After the pre-planning handler, there should be a needs-discussion handler
// that calls showSmartEntry // that calls showWorkflowEntry
const needsDiscussionHandler = source.match( 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, 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", () => { 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 // if discussion didn't promote the draft
assert.ok( assert.ok(
source.includes('postState.phase !== "needs-discussion"'), 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( assert.ok(
source.includes("milestone draft was not promoted"), 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(); const source = readAutoStartSource();
// Verify needs-discussion does NOT appear in auto-dispatch trigger conditions // Verify needs-discussion does NOT appear in auto-dispatch trigger conditions
// within auto-start.ts. The only place needs-discussion should appear is in // 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( const survivorSection = source.match(
/\/\/ Milestone branch recovery.*?let hasSurvivorBranch = false;[\s\S]*?if\s*\([^)]*state\.phase[^)]*\)\s*\{/, /\/\/ 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(); const source = readAutoStartSource();
// When hasSurvivorBranch is true AND phase is needs-discussion, the code // 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( 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, 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 // Verify the handler checks if the discussion succeeded
const handlerBlock = source.match( const handlerBlock = source.match(

View file

@ -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 original #3696 fix replaced "throw err" with a log-and-continue.
// The secondary fix replaces that with writeCrashLog + process.exit(1). // The secondary fix replaces that with writeCrashLog + process.exit(1).
assert.ok( 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', '_gsdEpipeGuard should NOT log errors as non-fatal and continue',
); );
assert.match( assert.match(

View file

@ -1,6 +1,6 @@
// GSD Extension — Rule Registry Tests // 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. // and evaluation methods using mock rules.
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@ -14,14 +14,14 @@ import {
convertDispatchRules, convertDispatchRules,
getOrCreateRegistry, getOrCreateRegistry,
} from "../rule-registry.ts"; } 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 type { DispatchAction, DispatchContext } from "../auto-dispatch.ts";
import { DISPATCH_RULES, getDispatchRuleNames } from "../auto-dispatch.ts"; import { DISPATCH_RULES, getDispatchRuleNames } from "../auto-dispatch.ts";
import type { GSDState } from "../types.ts"; import type { GSDState } from "../types.ts";
// ─── Mock Rule Factories ────────────────────────────────────────────────── // ─── Mock Rule Factories ──────────────────────────────────────────────────
function mockDispatchRule(name: string, matchPhase: string): UnifiedRule { function mockDispatchRule(name: string, matchPhase: string): RegistryRule {
return { return {
name, name,
when: "dispatch", when: "dispatch",
@ -69,7 +69,7 @@ describe("RuleRegistry", () => {
}); });
test("construct with dispatch rules, listRules returns them", () => { test("construct with dispatch rules, listRules returns them", () => {
const rules: UnifiedRule[] = [ const rules: RegistryRule[] = [
mockDispatchRule("rule-a", "planning"), mockDispatchRule("rule-a", "planning"),
mockDispatchRule("rule-b", "executing"), mockDispatchRule("rule-b", "executing"),
mockDispatchRule("rule-c", "complete"), mockDispatchRule("rule-c", "complete"),
@ -86,7 +86,7 @@ describe("RuleRegistry", () => {
}); });
test("listRules returns correct fields on each rule", () => { test("listRules returns correct fields on each rule", () => {
const rules: UnifiedRule[] = [ const rules: RegistryRule[] = [
mockDispatchRule("check-fields", "planning"), mockDispatchRule("check-fields", "planning"),
]; ];
const registry = new RuleRegistry(rules); const registry = new RuleRegistry(rules);
@ -102,7 +102,7 @@ describe("RuleRegistry", () => {
}); });
test("evaluateDispatch returns first matching rule", async () => { test("evaluateDispatch returns first matching rule", async () => {
const rules: UnifiedRule[] = [ const rules: RegistryRule[] = [
mockDispatchRule("rule-planning", "planning"), mockDispatchRule("rule-planning", "planning"),
mockDispatchRule("rule-executing", "executing"), mockDispatchRule("rule-executing", "executing"),
mockDispatchRule("rule-complete", "complete"), mockDispatchRule("rule-complete", "complete"),
@ -119,7 +119,7 @@ describe("RuleRegistry", () => {
}); });
test("evaluateDispatch returns stop when no rule matches", async () => { test("evaluateDispatch returns stop when no rule matches", async () => {
const rules: UnifiedRule[] = [ const rules: RegistryRule[] = [
mockDispatchRule("only-planning", "planning"), mockDispatchRule("only-planning", "planning"),
]; ];
const registry = new RuleRegistry(rules); const registry = new RuleRegistry(rules);
@ -133,7 +133,7 @@ describe("RuleRegistry", () => {
}); });
test("evaluateDispatch works with async where predicate", async () => { test("evaluateDispatch works with async where predicate", async () => {
const asyncRule: UnifiedRule = { const asyncRule: RegistryRule = {
name: "async-rule", name: "async-rule",
when: "dispatch", when: "dispatch",
evaluation: "first-match", evaluation: "first-match",
@ -227,7 +227,7 @@ describe("RuleRegistry", () => {
test("evaluateDispatch respects rule order (first match wins)", async () => { test("evaluateDispatch respects rule order (first match wins)", async () => {
// Both rules match "planning" but rule-first should win // Both rules match "planning" but rule-first should win
const ruleFirst: UnifiedRule = { const ruleFirst: RegistryRule = {
name: "rule-first", name: "rule-first",
when: "dispatch", when: "dispatch",
evaluation: "first-match", evaluation: "first-match",
@ -239,7 +239,7 @@ describe("RuleRegistry", () => {
}, },
then: () => {}, then: () => {},
}; };
const ruleSecond: UnifiedRule = { const ruleSecond: RegistryRule = {
name: "rule-second", name: "rule-second",
when: "dispatch", when: "dispatch",
evaluation: "first-match", evaluation: "first-match",
@ -264,7 +264,7 @@ describe("RuleRegistry", () => {
// ── Dispatch rule conversion tests ───────────────────────────────── // ── 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); const converted = convertDispatchRules(DISPATCH_RULES);
assert.deepStrictEqual(converted.length, DISPATCH_RULES.length, `convertDispatchRules produces ${DISPATCH_RULES.length} 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) ─────────────────── // ── matchedRule provenance (S02 journal support) ───────────────────
test("evaluateDispatch result includes matchedRule on dispatch match", async () => { test("evaluateDispatch result includes matchedRule on dispatch match", async () => {
const rules: UnifiedRule[] = [ const rules: RegistryRule[] = [
mockDispatchRule("my-planning-rule", "planning"), mockDispatchRule("my-planning-rule", "planning"),
]; ];
const registry = new RuleRegistry(rules); const registry = new RuleRegistry(rules);
@ -398,7 +398,7 @@ describe("RuleRegistry", () => {
}); });
test("evaluateDispatch result includes matchedRule '<no-match>' on fallback stop", async () => { test("evaluateDispatch result includes matchedRule '<no-match>' on fallback stop", async () => {
const rules: UnifiedRule[] = [ const rules: RegistryRule[] = [
mockDispatchRule("only-planning", "planning"), mockDispatchRule("only-planning", "planning"),
]; ];
const registry = new RuleRegistry(rules); const registry = new RuleRegistry(rules);

View file

@ -76,7 +76,7 @@ const guidedFlowSource = readFileSync(
assert( assert(
guidedFlowSource.includes('state.phase === "needs-discussion"'), 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 // Check the branch has draft-aware menu options

View file

@ -76,13 +76,13 @@ const { assertTrue, assertEq, report } = createTestContext();
// Simulate the decision logic after the fix: // Simulate the decision logic after the fix:
// if (hasSurvivorBranch && state.phase === "complete") -> finalize // if (hasSurvivorBranch && state.phase === "complete") -> finalize
// if (hasSurvivorBranch && state.phase === "needs-discussion") -> discuss // if (hasSurvivorBranch && state.phase === "needs-discussion") -> discuss
// if (!hasSurvivorBranch && state.phase === "complete") -> showSmartEntry // if (!hasSurvivorBranch && state.phase === "complete") -> showWorkflowEntry
const scenarios = [ const scenarios = [
{ hasSurvivorBranch: true, phase: "complete", expected: "finalize" }, { hasSurvivorBranch: true, phase: "complete", expected: "finalize" },
{ hasSurvivorBranch: true, phase: "needs-discussion", expected: "discuss" }, { hasSurvivorBranch: true, phase: "needs-discussion", expected: "discuss" },
{ hasSurvivorBranch: true, phase: "pre-planning", expected: "continue" }, { 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) { for (const { hasSurvivorBranch, phase, expected } of scenarios) {
@ -92,7 +92,7 @@ const { assertTrue, assertEq, report } = createTestContext();
} else if (hasSurvivorBranch && phase === "needs-discussion") { } else if (hasSurvivorBranch && phase === "needs-discussion") {
result = "discuss"; result = "discuss";
} else if (!hasSurvivorBranch && (!phase || phase === "complete")) { } else if (!hasSurvivorBranch && (!phase || phase === "complete")) {
result = "showSmartEntry"; result = "showWorkflowEntry";
} else { } else {
result = "continue"; result = "continue";
} }

View file

@ -7,7 +7,7 @@ import { emitJournalEvent } from "../journal.ts";
import { saveActivityLog } from "../activity-log.ts"; import { saveActivityLog } from "../activity-log.ts";
import { initMetrics, resetMetrics, snapshotUnitMetrics } from "../metrics.ts"; import { initMetrics, resetMetrics, snapshotUnitMetrics } from "../metrics.ts";
import { setLogBasePath, logWarning } from "../workflow-logger.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<Record<string, unknown>> { function readAuditEvents(basePath: string): Array<Record<string, unknown>> {
const file = join(basePath, ".gsd", "audit", "events.jsonl"); 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-")); const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-audit-"));
setUnifiedAuditEnabled(true); setAuditEnvelopeEnabled(true);
try { try {
emitJournalEvent(basePath, { emitJournalEvent(basePath, {
ts: new Date().toISOString(), 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("unit-metrics-snapshot"));
assert.ok(types.has("workflow-log-warn")); assert.ok(types.has("workflow-log-warn"));
} finally { } finally {
setUnifiedAuditEnabled(false); setAuditEnvelopeEnabled(false);
resetMetrics(); resetMetrics();
rmSync(basePath, { recursive: true, force: true }); 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-")); const basePath = mkdtempSync(join(tmpdir(), "gsd-uok-audit-off-"));
setUnifiedAuditEnabled(false); setAuditEnvelopeEnabled(false);
try { try {
emitJournalEvent(basePath, { emitJournalEvent(basePath, {
ts: new Date().toISOString(), ts: new Date().toISOString(),

View file

@ -88,17 +88,17 @@ test.afterEach(() => {
tempDirs.clear(); 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"); const source = readFileSync(join(gsdDir, "guided-flow.ts"), "utf-8");
assert.ok( assert.ok(
source.includes("needsPlanV2Gate") && source.includes("needsPlanningFlowGate") &&
source.includes("ensurePlanV2Graph") && source.includes("ensurePlanningFlowGraph") &&
source.includes("Plan gate failed-closed"), 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(); const basePath = createBasePath();
seedGraphRows(); 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); 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(); const basePath = createBasePath();
seedGraphRows(); seedGraphRows();

View file

@ -16,8 +16,8 @@ test("uok preferences validate nested flags and turn_action", () => {
turn_action: "status-only", turn_action: "status-only",
turn_push: false, turn_push: false,
}, },
audit_unified: { enabled: true }, audit_envelope: { enabled: true },
plan_v2: { 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?.enabled, true);
assert.equal(result.preferences.uok?.legacy_fallback?.enabled, false); assert.equal(result.preferences.uok?.legacy_fallback?.enabled, false);
assert.equal(result.preferences.uok?.gitops?.turn_action, "status-only"); 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", () => { 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"))); 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);
});

View file

@ -8,7 +8,7 @@ const { assertTrue, assertMatch, assertNoMatch, report } = createTestContext();
// ─── #2942: Zombie .gsd state skips init wizard ───────────────────────────── // ─── #2942: Zombie .gsd state skips init wizard ─────────────────────────────
// //
// A partially initialized .gsd/ (symlink exists but no PREFERENCES.md or // 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. // resulting in an uninitialized project session.
console.log("\n=== #2942: zombie .gsd state must not skip init wizard ==="); console.log("\n=== #2942: zombie .gsd state must not skip init wizard ===");
@ -20,11 +20,11 @@ const guidedFlowSrc = readFileSync(
"utf-8", "utf-8",
); );
// Find the showSmartEntry function // Find the showWorkflowEntry function
const smartEntryIdx = guidedFlowSrc.indexOf("export async function showSmartEntry("); const smartEntryIdx = guidedFlowSrc.indexOf("export async function showWorkflowEntry(");
assertTrue(smartEntryIdx >= 0, "guided-flow.ts defines showSmartEntry"); 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. // This is where the init wizard gate lives.
const afterSmartEntry = smartEntryIdx >= 0 ? guidedFlowSrc.slice(smartEntryIdx, smartEntryIdx + 3000) : ""; const afterSmartEntry = smartEntryIdx >= 0 ? guidedFlowSrc.slice(smartEntryIdx, smartEntryIdx + 3000) : "";

View file

@ -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[AUDIT_ENV_KEY] = enabled ? "1" : "0";
process.env[LEGACY_AUDIT_ENV_KEY] = enabled ? "1" : "0";
} }
export function isUnifiedAuditEnabled(): boolean { export function isAuditEnvelopeEnabled(): boolean {
return process.env[AUDIT_ENV_KEY] === "1"; return process.env[AUDIT_ENV_KEY] === "1" || process.env[LEGACY_AUDIT_ENV_KEY] === "1";
} }

View file

@ -10,8 +10,8 @@ export interface UokFlags {
gitops: boolean; gitops: boolean;
gitopsTurnAction: "commit" | "snapshot" | "status-only"; gitopsTurnAction: "commit" | "snapshot" | "status-only";
gitopsTurnPush: boolean; gitopsTurnPush: boolean;
auditUnified: boolean; auditEnvelope: boolean;
planV2: boolean; planningFlow: boolean;
} }
function envForcesLegacyFallback(): boolean { function envForcesLegacyFallback(): boolean {
@ -34,8 +34,8 @@ export function resolveUokFlags(prefs: GSDPreferences | undefined): UokFlags {
gitops: uok?.gitops?.enabled === true, gitops: uok?.gitops?.enabled === true,
gitopsTurnAction: uok?.gitops?.turn_action ?? "status-only", gitopsTurnAction: uok?.gitops?.turn_action ?? "status-only",
gitopsTurnPush: uok?.gitops?.turn_push === true, gitopsTurnPush: uok?.gitops?.turn_push === true,
auditUnified: uok?.audit_unified?.enabled === true, auditEnvelope: uok?.audit_envelope?.enabled === true || uok?.audit_unified?.enabled === true,
planV2: uok?.plan_v2?.enabled === true, planningFlow: uok?.planning_flow?.enabled === true || uok?.plan_v2?.enabled === true,
}; };
} }

View file

@ -6,7 +6,7 @@ import type { AutoSession } from "../auto/session.js";
import type { LoopDeps } from "../auto/loop-deps.js"; import type { LoopDeps } from "../auto/loop-deps.js";
import { gsdRoot } from "../paths.js"; import { gsdRoot } from "../paths.js";
import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.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 { resolveUokFlags } from "./flags.js";
import { createTurnObserver } from "./loop-adapter.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 { ctx, pi, s, deps, runLegacyLoop } = args;
const prefs = deps.loadEffectiveGSDPreferences()?.preferences; const prefs = deps.loadEffectiveGSDPreferences()?.preferences;
const flags = resolveUokFlags(prefs); const flags = resolveUokFlags(prefs);
setUnifiedAuditEnabled(flags.auditUnified); setAuditEnvelopeEnabled(flags.auditEnvelope);
writeParityEvent(s.basePath, { writeParityEvent(s.basePath, {
ts: new Date().toISOString(), ts: new Date().toISOString(),
@ -54,7 +54,7 @@ export async function runAutoLoopWithUok(args: RunAutoLoopWithUokArgs): Promise<
phase: "enter", phase: "enter",
}); });
if (flags.auditUnified) { if (flags.auditEnvelope) {
emitUokAuditEvent( emitUokAuditEvent(
s.basePath, s.basePath,
buildAuditEnvelope({ buildAuditEnvelope({
@ -76,7 +76,7 @@ export async function runAutoLoopWithUok(args: RunAutoLoopWithUokArgs): Promise<
basePath: s.basePath, basePath: s.basePath,
gitAction: flags.gitopsTurnAction, gitAction: flags.gitopsTurnAction,
gitPush: flags.gitopsTurnPush, gitPush: flags.gitopsTurnPush,
enableAudit: flags.auditUnified, enableAudit: flags.auditEnvelope,
enableGitops: flags.gitops, enableGitops: flags.gitops,
}), }),
} }

View file

@ -21,7 +21,7 @@ import { join } from "node:path";
import { appendNotification } from "./notification-store.js"; import { appendNotification } from "./notification-store.js";
import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js"; import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js";
import { isUnifiedAuditEnabled } from "./uok/audit-toggle.js"; import { isAuditEnvelopeEnabled } from "./uok/audit-toggle.js";
// ─── Types ────────────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────────────
@ -277,7 +277,7 @@ function _push(
_buffer.shift(); _buffer.shift();
} }
if (_auditBasePath && isUnifiedAuditEnabled()) { if (_auditBasePath && isAuditEnvelopeEnabled()) {
try { try {
emitUokAuditEvent( emitUokAuditEvent(
_auditBasePath, _auditBasePath,

View file

@ -48,7 +48,7 @@ export function replaySliceComplete(milestoneId: string, sliceId: string, ts: st
const incompleteTasks = tasks.filter(t => !isClosedStatus(t.status)); const incompleteTasks = tasks.filter(t => !isClosedStatus(t.status));
if (incompleteTasks.length > 0) { if (incompleteTasks.length > 0) {
process.stderr.write( 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`, `${incompleteTasks.length} task(s) still pending\n`,
); );
return; return;

View file

@ -210,13 +210,13 @@ export function stopWebMode(deps: Pick<WebModeDeps, 'pidFilePath' | 'readPidFile
for (const [cwd, entry] of entries) { for (const [cwd, entry] of entries) {
const result = killPid(entry.pid) const result = killPid(entry.pid)
if (result === 'killed') { if (result === 'killed') {
stderr.write(`[gsd] Stopped web server for ${cwd} (pid=${entry.pid})\n`) stderr.write(`[forge] Stopped web server for ${cwd} (pid=${entry.pid})\n`)
stopped++ stopped++
} else if (result === 'already-dead') { } else if (result === 'already-dead') {
stderr.write(`[gsd] Web server for ${cwd} was already stopped (pid=${entry.pid})\n`) stderr.write(`[forge] Web server for ${cwd} was already stopped (pid=${entry.pid})\n`)
stopped++ stopped++
} else { } else {
stderr.write(`[gsd] Failed to stop web server for ${cwd}: ${result.error}\n`) stderr.write(`[forge] Failed to stop web server for ${cwd}: ${result.error}\n`)
} }
unregisterInstance(cwd) unregisterInstance(cwd)
} }
@ -224,7 +224,7 @@ export function stopWebMode(deps: Pick<WebModeDeps, 'pidFilePath' | 'readPidFile
const deletePid = deps.deletePidFile ?? deletePidFile const deletePid = deps.deletePidFile ?? deletePidFile
const pidFilePath = deps.pidFilePath ?? defaultWebPidFilePath const pidFilePath = deps.pidFilePath ?? defaultWebPidFilePath
deletePid(pidFilePath) deletePid(pidFilePath)
stderr.write(`[gsd] Stopped ${stopped} instance${stopped === 1 ? '' : 's'}.\n`) stderr.write(`[forge] Stopped ${stopped} instance${stopped === 1 ? '' : 's'}.\n`)
return { ok: true, stoppedCount: stopped } return { ok: true, stoppedCount: stopped }
} }
@ -234,19 +234,19 @@ export function stopWebMode(deps: Pick<WebModeDeps, 'pidFilePath' | 'readPidFile
const registry = readInstanceRegistry() const registry = readInstanceRegistry()
const entry = registry[resolvedCwd] const entry = registry[resolvedCwd]
if (!entry) { if (!entry) {
stderr.write(`[gsd] No web server running for ${resolvedCwd}\n`) stderr.write(`[forge] No web server running for ${resolvedCwd}\n`)
return { ok: false, reason: 'not-found' } return { ok: false, reason: 'not-found' }
} }
const result = killPid(entry.pid) const result = killPid(entry.pid)
unregisterInstance(resolvedCwd) unregisterInstance(resolvedCwd)
if (result === 'killed') { if (result === 'killed') {
stderr.write(`[gsd] Stopped web server for ${resolvedCwd} (pid=${entry.pid})\n`) stderr.write(`[forge] Stopped web server for ${resolvedCwd} (pid=${entry.pid})\n`)
return { ok: true, stoppedCount: 1 } return { ok: true, stoppedCount: 1 }
} else if (result === 'already-dead') { } else if (result === 'already-dead') {
stderr.write(`[gsd] Web server for ${resolvedCwd} was already stopped — cleared stale entry.\n`) stderr.write(`[forge] Web server for ${resolvedCwd} was already stopped — cleared stale entry.\n`)
return { ok: true, stoppedCount: 1 } return { ok: true, stoppedCount: 1 }
} else { } else {
stderr.write(`[gsd] Failed to stop web server for ${resolvedCwd}: ${result.error}\n`) stderr.write(`[forge] Failed to stop web server for ${resolvedCwd}: ${result.error}\n`)
return { ok: false, reason: result.error } return { ok: false, reason: result.error }
} }
} }
@ -263,22 +263,22 @@ function stopLegacyPidFile(deps: Pick<WebModeDeps, 'pidFilePath' | 'readPidFile'
const pid = readPid(pidFilePath) const pid = readPid(pidFilePath)
if (pid === null) { if (pid === null) {
stderr.write(`[gsd] Web server is not running (no PID file found)\n`) stderr.write(`[forge] Web server is not running (no PID file found)\n`)
return { ok: false, reason: 'no-pid-file' } return { ok: false, reason: 'no-pid-file' }
} }
stderr.write(`[gsd] Stopping web server (pid=${pid})…\n`) stderr.write(`[forge] Stopping web server (pid=${pid})…\n`)
const result = killPid(pid) const result = killPid(pid)
deletePid(pidFilePath) deletePid(pidFilePath)
if (result === 'killed') { if (result === 'killed') {
stderr.write(`[gsd] Web server stopped.\n`) stderr.write(`[forge] Web server stopped.\n`)
return { ok: true } return { ok: true }
} else if (result === 'already-dead') { } else if (result === 'already-dead') {
stderr.write(`[gsd] Web server was already stopped — cleared stale PID file.\n`) stderr.write(`[forge] Web server was already stopped — cleared stale PID file.\n`)
return { ok: true } return { ok: true }
} else { } else {
stderr.write(`[gsd] Failed to stop web server: ${result.error}\n`) stderr.write(`[forge] Failed to stop web server: ${result.error}\n`)
return { ok: false, reason: result.error } return { ok: false, reason: result.error }
} }
} }
@ -359,10 +359,10 @@ function needsWindowsShell(command: string, platform: NodeJS.Platform): boolean
function formatLaunchStatus(status: WebModeLaunchStatus): string { function formatLaunchStatus(status: WebModeLaunchStatus): string {
if (status.ok) { if (status.ok) {
return `[gsd] Web mode startup: status=started cwd=${status.cwd} port=${status.port} host=${status.hostPath} kind=${status.hostKind} url=${status.url}\n` return `[forge] Web mode startup: status=started cwd=${status.cwd} port=${status.port} host=${status.hostPath} kind=${status.hostKind} url=${status.url}\n`
} }
return `[gsd] Web mode startup: status=failed cwd=${status.cwd} port=${status.port ?? 'n/a'} host=${status.hostPath ?? 'unresolved'} kind=${status.hostKind} reason=${status.failureReason}\n` return `[forge] Web mode startup: status=failed cwd=${status.cwd} port=${status.port ?? 'n/a'} host=${status.hostPath ?? 'unresolved'} kind=${status.hostKind} reason=${status.failureReason}\n`
} }
function emitLaunchStatus(stderr: WritableLike, status: WebModeLaunchStatus): void { function emitLaunchStatus(stderr: WritableLike, status: WebModeLaunchStatus): void {
@ -475,7 +475,7 @@ async function waitForBootReady(url: string, timeoutMs = 180_000, stderr?: Writa
if (response.statusCode >= 200 && response.statusCode < 300) { if (response.statusCode >= 200 && response.statusCode < 300) {
if (!hostUp) { if (!hostUp) {
hostUp = true hostUp = true
stderr?.write(`[gsd] Web host ready.\n`) stderr?.write(`[forge] Web host ready.\n`)
} }
consecutive5xx = 0 consecutive5xx = 0
// Host responded successfully — it's ready for the browser // 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) { if (now - lastTickAt >= TICKER_INTERVAL_MS) {
lastTickAt = now lastTickAt = now
if (hostUp) { if (hostUp) {
stderr?.write(`[gsd] Still waiting… (${elapsed()})\n`) stderr?.write(`[forge] Still waiting… (${elapsed()})\n`)
} else { } 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] const stale = registry[key]
if (!stale) return 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) const result = killPid(stale.pid)
if (result === 'killed') { 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') { } 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 { } 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) unregisterInstance(cwd, registryPath)
} }
@ -574,7 +574,7 @@ export async function launchWebMode(
return failure 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. // Kill any stale server instance for this project before reserving a port.
// This prevents EADDRINUSE when the previous `gsd --web` was terminated // This prevents EADDRINUSE when the previous `gsd --web` was terminated
@ -600,7 +600,7 @@ export async function launchWebMode(
} }
try { try {
stderr.write(`[gsd] Initialising resources…\n`) stderr.write(`[forge] Initialising resources…\n`)
const bootstrap = deps.initResources ? { initResources: deps.initResources } : await loadResourceBootstrap() const bootstrap = deps.initResources ? { initResources: deps.initResources } : await loadResourceBootstrap()
bootstrap.initResources(options.agentDir) bootstrap.initResources(options.agentDir)
} catch (error) { } catch (error) {
@ -629,7 +629,7 @@ export async function launchWebMode(
deps.execPath ?? process.execPath, 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( const spawnResult = await spawnDetachedProcess(
deps.spawn ?? ((command, args, spawnOptions) => spawn(command, args, spawnOptions)), deps.spawn ?? ((command, args, spawnOptions) => spawn(command, args, spawnOptions)),
@ -697,7 +697,7 @@ export async function launchWebMode(
try { try {
;(deps.openBrowser ?? openBrowser)(authenticatedUrl) ;(deps.openBrowser ?? openBrowser)(authenticatedUrl)
} catch (browserError) { } 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) { } catch (error) {
const failure: WebModeLaunchFailure = { const failure: WebModeLaunchFailure = {
@ -730,7 +730,7 @@ export async function launchWebMode(
hostPath: resolution.entryPath, hostPath: resolution.entryPath,
hostRoot: resolution.hostRoot, hostRoot: resolution.hostRoot,
} }
stderr.write(`[gsd] Ready → ${authenticatedUrl}\n`) stderr.write(`[forge] Ready → ${authenticatedUrl}\n`)
emitLaunchStatus(stderr, success) emitLaunchStatus(stderr, success)
return success return success
} }

View file

@ -302,10 +302,10 @@ async function handleStatusBanner(basePath: string): Promise<void> {
const names = withChanges.map(w => chalk.cyan(w.name)).join(', ') const names = withChanges.map(w => chalk.cyan(w.name)).join(', ')
process.stderr.write( process.stderr.write(
chalk.dim('[gsd] ') + chalk.dim('[forge] ') +
chalk.yellow(`${withChanges.length} worktree${withChanges.length === 1 ? '' : 's'} with unmerged changes: `) + chalk.yellow(`${withChanges.length} worktree${withChanges.length === 1 ? '' : 's'} with unmerged changes: `) +
names + '\n' + names + '\n' +
chalk.dim('[gsd] ') + chalk.dim('[forge] ') +
chalk.dim('Resume: gsd -w <name> | Merge: gsd worktree merge <name> | List: gsd worktree list\n\n'), chalk.dim('Resume: gsd -w <name> | Merge: gsd worktree merge <name> | 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) const hookError = ext.runWorktreePostCreateHook(basePath, info.path)
if (hookError) { if (hookError) {
process.stderr.write(chalk.yellow(`[gsd] ${hookError}\n`)) process.stderr.write(chalk.yellow(`[forge] ${hookError}\n`))
} }
process.chdir(info.path) 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`)) process.stderr.write(chalk.dim(` branch ${info.branch}\n\n`))
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(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) process.exit(1)
} }
} }

View file

@ -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 <Clock className={cn(baseClass, "text-info")} />
case "success":
return <CheckCircle2 className={cn(baseClass, "text-success")} />
case "error":
return <AlertCircle className={cn(baseClass, "text-destructive")} />
case "output":
return <Terminal className={cn(baseClass, "text-foreground")} />
case "input":
return <Play className={cn(baseClass, "text-warning")} />
default:
return <Clock className={cn(baseClass, "text-muted-foreground")} />
}
}
export function ActivityView() {
const workspace = useGSDWorkspaceState()
const terminalLines = workspace.terminalLines ?? []
// Show most recent events first
const reversedLines = [...terminalLines].reverse()
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="border-b border-border px-6 py-3">
<h1 className="text-lg font-semibold">Activity Log</h1>
<p className="text-sm text-muted-foreground">
Execution history and git operations
</p>
</div>
<div className="flex-1 overflow-y-auto">
{reversedLines.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
No activity yet. Events will appear here once the workspace is active.
</div>
) : (
<div className="relative px-6 py-4">
{/* Timeline line */}
<div className="absolute left-10 top-6 bottom-6 w-px bg-border" />
<div className="space-y-4">
{reversedLines.map((line) => (
<div key={line.id} className="relative flex gap-4">
{/* Timeline dot */}
<div className="relative z-10 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full border border-border bg-card">
<EventIcon type={line.type} />
</div>
{/* Content */}
<div className="flex-1 pt-0.5">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium">{line.content}</p>
</div>
<span className="flex-shrink-0 font-mono text-xs text-muted-foreground">
{line.timestamp}
</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
)
}

View file

@ -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 (
<div className="flex h-dvh flex-col items-center justify-center gap-6 bg-background p-8 text-center">
<Image
src="/logo-black.svg"
alt="GSD"
width={57}
height={16}
className="shrink-0 h-4 w-auto dark:hidden"
/>
<Image
src="/logo-white.svg"
alt="GSD"
width={57}
height={16}
className="shrink-0 h-4 w-auto hidden dark:block"
/>
<div className="flex flex-col items-center gap-2">
<h1 className="text-lg font-semibold text-foreground">Authentication Required</h1>
<p className="max-w-sm text-sm text-muted-foreground">
This workspace requires an auth token. Copy the full URL from your terminal
(including the{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">#token=</code>{" "}
part) or restart with{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">gsd --web</code>.
</p>
</div>
</div>
)
}
return (
<div className="relative flex h-screen flex-col overflow-hidden bg-background text-foreground">
<header className="flex h-12 flex-shrink-0 items-center justify-between border-b border-border bg-card px-2 md:px-4">
<div className="flex items-center gap-2 md:gap-3 min-w-0">
{/* Mobile hamburger menu */}
<button
className="flex md:hidden h-10 w-10 items-center justify-center rounded-md text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
onClick={() => setMobileNavOpen(!mobileNavOpen)}
aria-label={mobileNavOpen ? "Close navigation" : "Open navigation"}
data-testid="mobile-nav-toggle"
>
{mobileNavOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
<div className="flex items-center gap-2">
<Image
src="/logo-black.svg"
alt="GSD"
width={57}
height={16}
className="shrink-0 h-4 w-auto dark:hidden"
/>
<Image
src="/logo-white.svg"
alt="GSD"
width={57}
height={16}
className="shrink-0 h-4 w-auto hidden dark:block"
/>
<Badge variant="outline" className="hidden sm:inline-flex text-[10px] rounded-full border-foreground/15 bg-accent/40 text-muted-foreground font-normal">
beta
</Badge>
</div>
<span className="hidden sm:inline text-2xl font-thin text-muted-foreground leading-none select-none">/</span>
<span className="hidden sm:inline text-sm text-muted-foreground truncate" data-testid="workspace-project-cwd" title={projectPath ?? undefined}>
{isConnecting ? (
<Skeleton className="inline-block h-4 w-28 align-middle" />
) : (
<>
{projectLabel}
{titleOverride && (
<span
className="ml-2 inline-flex items-center rounded-full border border-foreground/15 bg-accent/60 px-2 py-0.5 text-[10px] font-medium text-foreground"
data-testid="workspace-title-override"
title={titleOverride}
>
{titleOverride}
</span>
)}
</>
)}
</span>
</div>
<div className="flex items-center gap-2 md:gap-3">
{/* Hidden status marker for test instrumentation */}
<span className="sr-only" data-testid="workspace-connection-status">{status.label}</span>
<span
className="hidden sm:inline text-xs text-muted-foreground"
data-testid="workspace-scope-label"
>
{isConnecting ? <Skeleton className="inline-block h-3.5 w-40 align-middle" /> : <ScopeBadge label={scopeLabel} size="sm" />}
</span>
</div>
</header>
<UpdateBanner />
{!isConnecting && visibleError && (
<div
className="flex items-center gap-3 border-b border-destructive/20 bg-destructive/10 px-4 py-2 text-xs text-destructive"
data-testid="workspace-error-banner"
>
<span className="flex-1">{visibleError}</span>
<button
onClick={() => void refreshBoot()}
disabled={retryDisabled}
className={cn(
"flex-shrink-0 rounded border border-destructive/30 bg-background px-2 py-0.5 text-xs font-medium text-destructive transition-colors hover:bg-destructive/10",
retryDisabled && "cursor-not-allowed opacity-50",
)}
>
Retry
</button>
</div>
)}
{/* Mobile navigation drawer */}
{mobileNavOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 md:hidden"
onClick={() => setMobileNavOpen(false)}
data-testid="mobile-nav-overlay"
/>
)}
<div
className={cn(
"fixed inset-y-0 left-0 z-50 w-64 transform bg-sidebar border-r border-border transition-transform duration-200 ease-out md:hidden",
mobileNavOpen ? "translate-x-0" : "-translate-x-full",
)}
data-testid="mobile-nav-drawer"
>
<Sidebar activeView={activeView} onViewChange={isConnecting ? () => {} : handleViewChange} isConnecting={isConnecting} mobile />
</div>
{/* Mobile milestone drawer */}
{mobileMilestoneOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 md:hidden"
onClick={() => setMobileMilestoneOpen(false)}
data-testid="mobile-milestone-overlay"
/>
)}
{!isWelcomeState && (
<div
className={cn(
"fixed inset-y-0 right-0 z-50 w-72 transform bg-sidebar border-l border-border transition-transform duration-200 ease-out md:hidden",
mobileMilestoneOpen ? "translate-x-0" : "translate-x-full",
)}
data-testid="mobile-milestone-drawer"
>
<MilestoneExplorer
isConnecting={isConnecting}
width={288}
onCollapse={() => setMobileMilestoneOpen(false)}
/>
</div>
)}
<div className="flex flex-1 overflow-hidden">
{/* Desktop sidebar — hidden on mobile */}
<div className="hidden md:flex">
<Sidebar activeView={activeView} onViewChange={isConnecting ? () => {} : handleViewChange} isConnecting={isConnecting} />
</div>
<div className="flex flex-1 flex-col overflow-hidden">
<div
className={cn(
"flex-1 overflow-hidden transition-all",
isTerminalExpanded && "h-1/3",
)}
>
{isConnecting ? (
<Dashboard />
) : (
<>
{activeView === "dashboard" && (
<Dashboard
onSwitchView={handleViewChange}
onExpandTerminal={() => setIsTerminalExpanded(true)}
/>
)}
{activeView === "power" && <DualTerminal />}
{activeView === "roadmap" && <Roadmap />}
{activeView === "files" && <FilesView />}
{activeView === "activity" && <ActivityView />}
{activeView === "visualize" && <VisualizerView />}
{activeView === "chat" && <ChatMode />}
</>
)}
</div>
{activeView !== "power" && activeView !== "chat" && (
<div className="border-t border-border flex flex-col" style={{ flexShrink: 0 }}>
{/* Drag handle + toggle header — entire bar is clickable */}
<div
role="button"
tabIndex={0}
onClick={() => {
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)
}}
>
<div className="flex items-center gap-2 text-muted-foreground">
<span className="font-medium text-foreground">Terminal</span>
<span className="text-[10px] text-muted-foreground">
{isTerminalExpanded ? "▼" : "▲"}
</span>
</div>
</div>
{/* Terminal content */}
<div
className="overflow-hidden"
style={{ height: isTerminalExpanded ? terminalHeight : 0, transition: terminalDragActive ? "none" : "height 200ms" }}
>
<ShellTerminal className="h-full" projectCwd={workspace.boot?.project.cwd} />
</div>
</div>
)}
</div>
{/* Resizable milestone sidebar — hidden on mobile, hidden during project welcome */}
{!isWelcomeState && !sidebarCollapsed && (
<div
className="relative hidden md:flex h-full items-stretch"
style={{ flexShrink: 0 }}
>
{/* Thin visible border */}
<div className="w-px bg-border" />
{/* Wide invisible grab area overlapping the border */}
<div
className="absolute left-[-3px] top-0 bottom-0 w-[7px] cursor-col-resize z-10 hover:bg-muted-foreground/20 transition-colors"
onMouseDown={handleSidebarDragStart}
/>
</div>
)}
<div className="hidden md:flex">
{!isWelcomeState && (sidebarCollapsed ? (
<CollapsedMilestoneSidebar onExpand={() => setSidebarCollapsed(false)} />
) : (
<MilestoneExplorer
isConnecting={isConnecting}
width={sidebarWidth}
onCollapse={() => setSidebarCollapsed(true)}
/>
))}
</div>
</div>
{/* Desktop status bar — hidden on mobile */}
<div className="hidden md:block">
<StatusBar />
</div>
{/* Mobile bottom bar — quick access to milestones + status */}
{!isWelcomeState && (
<div className="flex md:hidden h-12 items-center justify-between border-t border-border bg-card px-3" data-testid="mobile-bottom-bar">
<div className="flex items-center gap-2 text-xs text-muted-foreground truncate">
<span className="sr-only" data-testid="workspace-connection-status-mobile">{status.label}</span>
<span className={cn("h-2 w-2 rounded-full shrink-0", status.tone === "success" ? "bg-success" : status.tone === "warning" ? "bg-warning" : status.tone === "danger" ? "bg-destructive" : "bg-muted-foreground")} />
<span className="truncate">{scopeLabel}</span>
</div>
<button
onClick={() => setMobileMilestoneOpen(!mobileMilestoneOpen)}
className="flex h-10 items-center gap-2 rounded-md px-3 text-xs font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
data-testid="mobile-milestone-toggle"
>
Milestones
</button>
</div>
)}
<ProjectsPanel open={projectsPanelOpen} onOpenChange={setProjectsPanelOpen} />
<CommandSurface />
<FocusedPanel />
<OnboardingGate />
</div>
)
}
export function GSDAppShell() {
// Extract the auth token from the URL fragment on first render.
// Must happen before any API calls fire.
getAuthToken()
return (
<ProjectStoreManagerProvider>
<ProjectAwareWorkspace />
</ProjectStoreManagerProvider>
)
}
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 <ProjectSelectionGate />
}
return (
<GSDWorkspaceProvider store={activeStore}>
<DevOverridesProvider>
<WorkspaceChrome />
</DevOverridesProvider>
</GSDWorkspaceProvider>
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,221 @@
"use client"
import { useMemo } from "react"
import dynamic from "next/dynamic"
import { useTheme } from "next-themes"
import { Loader2 } from "lucide-react"
import { createTheme } from "@uiw/codemirror-themes"
import { tags as t } from "@lezer/highlight"
import { loadLanguage, type LanguageName } from "@uiw/codemirror-extensions-langs"
import { EditorView } from "@codemirror/view"
import { cn } from "@/lib/utils"
/* ── Dynamic import (no SSR — CodeMirror needs browser DOM) ── */
const ReactCodeMirror = dynamic(() => import("@uiw/react-codemirror"), {
ssr: false,
loading: () => (
<div className="flex h-full min-h-[120px] items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
),
})
/* ── Syntax highlighting styles ── */
const darkStyles = [
{ tag: [t.comment, t.lineComment, t.blockComment], color: "#6a737d" },
{ tag: [t.keyword], color: "#ff7b72" },
{ tag: [t.operator], color: "#79c0ff" },
{ tag: [t.string, t.special(t.string)], color: "#a5d6ff" },
{ tag: [t.number, t.bool, t.null], color: "#79c0ff" },
{ tag: [t.variableName], color: "#c9d1d9" },
{ tag: [t.definition(t.variableName)], color: "#d2a8ff" },
{ tag: [t.function(t.variableName)], color: "#d2a8ff" },
{ tag: [t.typeName, t.className], color: "#ffa657" },
{ tag: [t.propertyName], color: "#79c0ff" },
{ tag: [t.definition(t.propertyName)], color: "#c9d1d9" },
{ tag: [t.bracket], color: "#8b949e" },
{ tag: [t.punctuation], color: "#8b949e" },
{ tag: [t.tagName], color: "#7ee787" },
{ tag: [t.attributeName], color: "#79c0ff" },
{ tag: [t.attributeValue], color: "#a5d6ff" },
{ tag: [t.regexp], color: "#7ee787" },
{ tag: [t.escape], color: "#79c0ff" },
{ tag: [t.meta], color: "#8b949e" },
]
const lightStyles = [
{ tag: [t.comment, t.lineComment, t.blockComment], color: "#6a737d" },
{ tag: [t.keyword], color: "#cf222e" },
{ tag: [t.operator], color: "#0550ae" },
{ tag: [t.string, t.special(t.string)], color: "#0a3069" },
{ tag: [t.number, t.bool, t.null], color: "#0550ae" },
{ tag: [t.variableName], color: "#24292f" },
{ tag: [t.definition(t.variableName)], color: "#8250df" },
{ tag: [t.function(t.variableName)], color: "#8250df" },
{ tag: [t.typeName, t.className], color: "#953800" },
{ tag: [t.propertyName], color: "#0550ae" },
{ tag: [t.definition(t.propertyName)], color: "#24292f" },
{ tag: [t.bracket], color: "#57606a" },
{ tag: [t.punctuation], color: "#57606a" },
{ tag: [t.tagName], color: "#116329" },
{ tag: [t.attributeName], color: "#0550ae" },
{ tag: [t.attributeValue], color: "#0a3069" },
{ tag: [t.regexp], color: "#116329" },
{ tag: [t.escape], color: "#0550ae" },
{ tag: [t.meta], color: "#57606a" },
]
/* ── Static theme objects (module-level, never recreated on render) ── */
const darkTheme = createTheme({
theme: "dark",
settings: {
background: "oklch(0.09 0 0)",
foreground: "oklch(0.9 0 0)",
caret: "oklch(0.9 0 0)",
selection: "oklch(0.2 0 0)",
lineHighlight: "oklch(0.12 0 0)",
gutterBackground: "oklch(0.09 0 0)",
gutterForeground: "oklch(0.42 0 0)",
gutterBorder: "transparent",
},
styles: darkStyles,
})
const lightTheme = createTheme({
theme: "light",
settings: {
background: "oklch(0.98 0 0)",
foreground: "oklch(0.15 0 0)",
caret: "oklch(0.15 0 0)",
selection: "oklch(0.9 0 0)",
lineHighlight: "oklch(0.96 0 0)",
gutterBackground: "oklch(0.98 0 0)",
gutterForeground: "oklch(0.55 0 0)",
gutterBorder: "transparent",
},
styles: lightStyles,
})
/* ── Language mapping (shiki lang names → CodeMirror loadLanguage names) ── */
const CM_LANG_MAP: Record<string, LanguageName | null> = {
// TypeScript / JavaScript family
typescript: "ts",
tsx: "tsx",
javascript: "js",
jsx: "jsx",
// Shell variants
bash: "bash",
sh: "sh",
zsh: "sh",
// Data formats
json: "json",
jsonc: "json",
yaml: "yaml",
toml: "toml",
// Markup
markdown: "markdown",
mdx: "markdown", // CM has no mdx — use markdown
html: "html",
xml: "xml",
// Styles
css: "css",
scss: "scss",
less: "less",
// Systems
python: "py",
ruby: "rb",
rust: "rs",
go: "go",
java: "java",
kotlin: "kt",
swift: "swift",
c: "c",
cpp: "cpp",
csharp: "cs",
// Other
php: "php",
sql: "sql",
graphql: null, // CM has no graphql support
dockerfile: null, // CM has no dockerfile support
makefile: null, // CM has no makefile support
lua: "lua",
r: "r",
latex: "tex",
diff: "diff",
// No CM equivalent → plain text
viml: null,
dotenv: null,
fish: null,
ini: "ini",
}
/* ── Component ── */
interface CodeEditorProps {
value: string
onChange: (value: string) => void
language: string | null
fontSize: number
className?: string
}
export function CodeEditor({
value,
onChange,
language,
fontSize,
className,
}: CodeEditorProps) {
const { resolvedTheme } = useTheme()
const theme = resolvedTheme !== "light" ? darkTheme : lightTheme
// Resolve and cache language extension
const langExtension = useMemo(() => {
if (!language) return null
const cmName = CM_LANG_MAP[language]
if (cmName === undefined || cmName === null) return null
return loadLanguage(cmName)
}, [language])
// Font size extension
const fontSizeExt = useMemo(
() =>
EditorView.theme({
"&": { fontSize: `${fontSize}px` },
".cm-gutters": { fontSize: `${fontSize}px` },
}),
[fontSize],
)
// Combined extensions (memoized to avoid re-initialization)
const extensions = useMemo(() => {
const exts = [fontSizeExt]
if (langExtension) exts.push(langExtension)
return exts
}, [fontSizeExt, langExtension])
return (
<ReactCodeMirror
value={value}
onChange={onChange}
theme={theme}
extensions={extensions}
height="100%"
basicSetup={{
lineNumbers: true,
highlightActiveLine: true,
highlightActiveLineGutter: true,
foldGutter: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: false,
tabSize: 2,
}}
className={cn("overflow-hidden rounded-md border", className)}
/>
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,442 @@
"use client"
import { useEffect, useState, useCallback } from "react"
import {
Activity,
Clock,
DollarSign,
Zap,
CheckCircle2,
Circle,
Play,
GitBranch,
TrendingDown,
} from "lucide-react"
import { cn } from "@/lib/utils"
import {
useGSDWorkspaceState,
useGSDWorkspaceActions,
buildPromptCommand,
buildProjectUrl,
formatDuration,
formatCost,
formatTokens,
getCurrentScopeLabel,
getCurrentBranch,
getCurrentSlice,
getLiveAutoDashboard,
getLiveWorkspaceIndex,
type WorkspaceTerminalLine,
type TerminalLineType,
} from "@/lib/sf-workspace-store"
import { getTaskStatus, type ItemStatus } from "@/lib/workspace-status"
import { deriveWorkflowAction } from "@/lib/workflow-actions"
import { executeWorkflowActionInPowerMode } from "@/lib/workflow-action-execution"
import { Skeleton } from "@/components/ui/skeleton"
import {
CurrentSliceCardSkeleton,
ActivityCardSkeleton,
} from "@/components/sf/loading-skeletons"
import { ScopeBadge } from "@/components/sf/scope-badge"
import { ProjectWelcome } from "@/components/sf/project-welcome"
import { authFetch } from "@/lib/auth"
import { type ProjectTotals } from "@/lib/visualizer-types"
/** Interpolate progress bar color from red (0%) through yellow (50%) to green (100%) using oklch. */
function getProgressColor(percent: number): string {
const p = Math.max(0, Math.min(100, percent))
// Hue: 25 (red) → 85 (yellow) at 50% → 145 (green) at 100%
const hue = 25 + (p / 100) * 120
return `oklch(0.65 0.16 ${hue.toFixed(1)})`
}
interface MetricCardProps {
label: string
value: string | null
subtext?: string | null
icon: React.ReactNode
}
function MetricCard({ label, value, subtext, icon }: MetricCardProps) {
return (
<div className="rounded-md border border-border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{label}
</p>
{value === null ? (
<>
<Skeleton className="mt-2 h-7 w-20" />
<Skeleton className="mt-1.5 h-3 w-16" />
</>
) : (
<>
<p className="mt-1 truncate text-2xl font-semibold tracking-tight">{value}</p>
{subtext && <p className="mt-0.5 truncate text-xs text-muted-foreground">{subtext}</p>}
</>
)}
</div>
<div className="shrink-0 rounded-md bg-accent p-2 text-muted-foreground">{icon}</div>
</div>
</div>
)
}
function taskStatusIcon(status: ItemStatus) {
switch (status) {
case "done":
return <CheckCircle2 className="h-4 w-4 text-muted-foreground" />
case "in-progress":
return <Play className="h-4 w-4 text-foreground" />
case "pending":
return <Circle className="h-4 w-4 text-muted-foreground" />
}
}
function activityDotColor(type: TerminalLineType): string {
switch (type) {
case "success":
return "bg-success"
case "error":
return "bg-destructive"
default:
return "bg-foreground/50"
}
}
interface DashboardProps {
onSwitchView?: (view: string) => void
onExpandTerminal?: () => void
}
export function Dashboard({ onSwitchView, onExpandTerminal }: DashboardProps = {}) {
const state = useGSDWorkspaceState()
const { sendCommand } = useGSDWorkspaceActions()
const boot = state.boot
const workspace = getLiveWorkspaceIndex(state)
const auto = getLiveAutoDashboard(state)
const bridge = boot?.bridge ?? null
const freshness = state.live.freshness
const projectCwd = boot?.project.cwd
// ── Project-level totals from visualizer API ──
// Provides fallback metrics when auto-mode is not active (#2709).
// Same polling pattern as status-bar.tsx.
const [projectTotals, setProjectTotals] = useState<ProjectTotals | null>(null)
const fetchProjectTotals = useCallback(async () => {
try {
const resp = await authFetch(buildProjectUrl("/api/visualizer", projectCwd))
if (!resp.ok) return
const json = await resp.json()
if (json.totals) setProjectTotals(json.totals)
} catch {
// Silently ignore — dashboard metrics are non-critical
}
}, [projectCwd])
useEffect(() => {
const timeout = window.setTimeout(() => {
void fetchProjectTotals()
}, 0)
const interval = window.setInterval(() => {
void fetchProjectTotals()
}, 30_000)
return () => {
window.clearTimeout(timeout)
window.clearInterval(interval)
}
}, [fetchProjectTotals])
const elapsed = projectTotals?.duration ?? auto?.elapsed ?? 0
const totalCost = projectTotals?.cost ?? auto?.totalCost ?? 0
const totalTokens = projectTotals?.tokens.total ?? auto?.totalTokens ?? 0
const rtkSavings = auto?.rtkSavings ?? null
const rtkEnabled = auto?.rtkEnabled === true
const currentSlice = getCurrentSlice(workspace)
const doneTasks = currentSlice?.tasks.filter((t) => t.done).length ?? 0
const totalTasks = currentSlice?.tasks.length ?? 0
const progressPercent = totalTasks > 0 ? Math.round((doneTasks / totalTasks) * 100) : 0
const scopeLabel = getCurrentScopeLabel(workspace)
const branch = getCurrentBranch(workspace)
const isAutoActive = auto?.active ?? false
const currentUnitLabel = auto?.currentUnit?.id ?? scopeLabel
const currentUnitFreshness = freshness.auto.stale ? "stale" : freshness.auto.status
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 handleWorkflowAction = (command: string) => {
executeWorkflowActionInPowerMode({
dispatch: () => sendCommand(buildPromptCommand(command, bridge)),
})
}
const handlePrimaryAction = () => {
if (!workflowAction.primary) return
handleWorkflowAction(workflowAction.primary.command)
}
const recentLines: WorkspaceTerminalLine[] = (state.terminalLines ?? []).slice(-6)
const isConnecting = state.bootStatus === "idle" || state.bootStatus === "loading"
const rtkValue = isConnecting ? null : formatTokens(rtkSavings?.savedTokens ?? 0)
const rtkSubtext = isConnecting
? null
: rtkSavings && rtkSavings.commands > 0
? `${Math.round(rtkSavings.savingsPct)}% saved • ${rtkSavings.commands} cmd${rtkSavings.commands === 1 ? "" : "s"}`
: "Waiting for shell usage"
// ─── Project Welcome Gate ───────────────────────────────────────────
// Show welcome screen for projects that aren't initialized with GSD yet
const detection = boot?.projectDetection
const showWelcome =
!isConnecting &&
detection &&
detection.kind !== "active-gsd" &&
detection.kind !== "empty-gsd"
if (showWelcome) {
return (
<div className="flex h-full flex-col overflow-hidden">
<ProjectWelcome
detection={detection}
onCommand={(cmd) => handleWorkflowAction(cmd)}
onSwitchView={(view) => onSwitchView?.(view)}
disabled={!!state.commandInFlight || boot?.onboarding.locked}
/>
</div>
)
}
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex items-center justify-between border-b border-border px-3 py-2 md:px-6 md:py-3">
<div className="flex items-center gap-2 min-w-0">
<h1 className="text-base md:text-lg font-semibold shrink-0">Dashboard</h1>
{!isConnecting && scopeLabel && (
<>
<span className="hidden sm:inline text-lg font-thin text-muted-foreground select-none">/</span>
<span className="hidden sm:inline"><ScopeBadge label={scopeLabel} size="sm" /></span>
</>
)}
{isConnecting && <Skeleton className="h-4 w-40" />}
</div>
<div className="flex items-center gap-2 md:gap-3" data-testid="dashboard-action-bar">
{isConnecting ? (
<>
<Skeleton className="h-8 w-40 rounded-md" />
</>
) : null}
{!isConnecting && (
<div className="flex items-center gap-2 rounded-md border border-border bg-card px-3 py-1.5 text-sm">
<span
className={cn(
"h-2 w-2 rounded-full",
isAutoActive ? "animate-pulse bg-success" : "bg-muted-foreground/50",
)}
/>
<span className="font-medium">
{isAutoActive ? "Auto Mode Active" : "Auto Mode Inactive"}
</span>
</div>
)}
{!isConnecting && branch && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<GitBranch className="h-4 w-4" />
<span className="font-mono">{branch}</span>
</div>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto p-3 md:p-6">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
<div className="rounded-md border border-border bg-card p-4" data-testid="dashboard-current-unit">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Current Unit</p>
{isConnecting ? (
<>
<Skeleton className="mt-2 h-7 w-20" />
<Skeleton className="mt-1.5 h-3 w-16" />
</>
) : (
<>
<div className="mt-2">
<ScopeBadge label={currentUnitLabel} />
</div>
<p className="mt-1.5 text-xs text-muted-foreground" data-testid="dashboard-current-unit-freshness">
Auto freshness: {currentUnitFreshness}
</p>
</>
)}
</div>
<div className="shrink-0 rounded-md bg-accent p-2 text-muted-foreground">
<Activity className="h-5 w-5" />
</div>
</div>
</div>
<MetricCard
label="Elapsed Time"
value={isConnecting ? null : formatDuration(elapsed)}
icon={<Clock className="h-5 w-5" />}
/>
<MetricCard
label="Total Cost"
value={isConnecting ? null : formatCost(totalCost)}
icon={<DollarSign className="h-5 w-5" />}
/>
<MetricCard
label="Tokens Used"
value={isConnecting ? null : formatTokens(totalTokens)}
icon={<Zap className="h-5 w-5" />}
/>
{rtkEnabled && (
<MetricCard
label="RTK Saved"
value={rtkValue}
subtext={rtkSubtext}
icon={<TrendingDown className="h-5 w-5" />}
/>
)}
</div>
<div className="mt-6">
{/* Current Slice */}
{isConnecting ? (
<CurrentSliceCardSkeleton />
) : (
<div className="flex flex-col rounded-md border border-border bg-card">
{/* Header */}
<div className="border-b border-border px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Current Slice</h2>
{currentSlice ? (
<p className="mt-0.5 truncate text-sm font-medium text-foreground">
{currentSlice.id} {currentSlice.title}
</p>
) : (
<p className="mt-0.5 text-sm text-muted-foreground">No active slice</p>
)}
</div>
{currentSlice && totalTasks > 0 && (
<div className="shrink-0 text-right">
<span className="text-2xl font-bold tabular-nums leading-none">{progressPercent}</span>
<span className="text-xs text-muted-foreground">%</span>
</div>
)}
</div>
{currentSlice && totalTasks > 0 && (
<div className="mt-3">
<div className="h-1 w-full overflow-hidden rounded-full bg-accent">
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${progressPercent}%`, backgroundColor: getProgressColor(progressPercent) }}
/>
</div>
<p className="mt-1.5 text-xs text-muted-foreground">{doneTasks} of {totalTasks} tasks complete</p>
</div>
)}
</div>
{/* Task list */}
<div className="flex-1 p-3">
{currentSlice && currentSlice.tasks.length > 0 ? (
<div className="space-y-0.5">
{currentSlice.tasks.map((task) => {
const status = getTaskStatus(
workspace!.active.milestoneId!,
currentSlice.id,
task,
workspace!.active,
)
return (
<div
key={task.id}
className={cn(
"flex items-center gap-2.5 rounded px-2 py-1.5 transition-colors",
status === "in-progress" && "bg-accent",
)}
>
{taskStatusIcon(status)}
<span
className={cn(
"min-w-0 flex-1 truncate text-xs",
status === "done" && "text-muted-foreground line-through decoration-muted-foreground/40",
status === "pending" && "text-muted-foreground",
status === "in-progress" && "font-medium text-foreground",
)}
>
<span className="font-mono text-muted-foreground">{task.id}</span>
<span className="mx-1.5 text-border">·</span>
{task.title}
</span>
{status === "in-progress" && (
<span className="shrink-0 rounded-sm bg-foreground/10 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
active
</span>
)}
</div>
)
})}
</div>
) : (
<p className="px-2 py-2 text-xs text-muted-foreground">
No active slice or no tasks defined yet.
</p>
)}
</div>
</div>
)}
</div>
{isConnecting ? (
<div className="mt-6">
<ActivityCardSkeleton />
</div>
) : (
<div className="mt-6 rounded-md border border-border bg-card">
<div className="border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">Recent Activity</h2>
</div>
{recentLines.length > 0 ? (
<div className="divide-y divide-border">
{recentLines.map((line) => (
<div key={line.id} className="flex items-center gap-3 px-4 py-2.5">
<span className="w-16 flex-shrink-0 font-mono text-xs text-muted-foreground">
{line.timestamp}
</span>
<span
className={cn(
"h-1.5 w-1.5 flex-shrink-0 rounded-full",
activityDotColor(line.type),
)}
/>
<span className="truncate text-sm">{line.content}</span>
</div>
))}
</div>
) : (
<div className="px-4 py-4 text-sm text-muted-foreground">
No activity yet.
</div>
)}
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,523 @@
"use client"
import { AlertTriangle, CheckCircle2, Info, LoaderCircle, RefreshCw, ShieldAlert, Wrench, XCircle } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import type {
DoctorIssue,
ForensicAnomaly,
ForensicReport,
DoctorReport,
SkillHealthReport,
SkillHealSuggestion,
} from "@/lib/diagnostics-types"
import { cn } from "@/lib/utils"
import {
formatCost,
useGSDWorkspaceActions,
useGSDWorkspaceState,
} from "@/lib/sf-workspace-store"
// ═══════════════════════════════════════════════════════════════════════
// SHARED
// ═══════════════════════════════════════════════════════════════════════
function SeverityIcon({ severity, className }: { severity: "info" | "warning" | "error" | "critical"; className?: string }) {
const base = cn("h-3.5 w-3.5 shrink-0", className)
switch (severity) {
case "error":
case "critical":
return <XCircle className={cn(base, "text-destructive")} />
case "warning":
return <AlertTriangle className={cn(base, "text-warning")} />
default:
return <Info className={cn(base, "text-info")} />
}
}
function severityBadgeVariant(s: string): "destructive" | "secondary" | "outline" {
if (s === "error" || s === "critical") return "destructive"
if (s === "warning") return "secondary"
return "outline"
}
function DiagHeader({
title,
subtitle,
status,
onRefresh,
refreshing,
}: {
title: string
subtitle?: string | null
status?: React.ReactNode
onRefresh: () => void
refreshing: boolean
}) {
return (
<div className="flex items-center justify-between gap-3 pb-4">
<div className="flex items-center gap-2.5">
<h3 className="text-[13px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{title}</h3>
{status}
{subtitle && <span className="text-[11px] text-muted-foreground">{subtitle}</span>}
</div>
<Button type="button" variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing} className="h-7 gap-1.5 text-xs">
<RefreshCw className={cn("h-3 w-3", refreshing && "animate-spin")} />
Refresh
</Button>
</div>
)
}
function DiagError({ message }: { message: string }) {
return (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2.5 text-xs text-destructive">
{message}
</div>
)
}
function DiagLoading({ label }: { label: string }) {
return (
<div className="flex items-center gap-2 py-6 text-xs text-muted-foreground">
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
{label}
</div>
)
}
function DiagEmpty({ message }: { message: string }) {
return (
<div className="rounded-lg border border-border/50 bg-card/50 px-4 py-5 text-center text-xs text-muted-foreground">
{message}
</div>
)
}
function StatPill({ label, value, variant }: { label: string; value: number | string; variant?: "default" | "error" | "warning" | "info" }) {
return (
<div className={cn(
"flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs",
variant === "error" && "border-destructive/20 bg-destructive/5 text-destructive",
variant === "warning" && "border-warning/20 bg-warning/5 text-warning",
variant === "info" && "border-info/20 bg-info/5 text-info",
(!variant || variant === "default") && "border-border/50 bg-card/50 text-foreground/80",
)}>
<span className="text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{value}</span>
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════
// FORENSICS PANEL
// ═══════════════════════════════════════════════════════════════════════
function AnomalyRow({ anomaly }: { anomaly: ForensicAnomaly }) {
return (
<div className="rounded-lg border border-border/50 bg-card/50 px-3 py-2.5 space-y-1">
<div className="flex items-center gap-2">
<SeverityIcon severity={anomaly.severity} />
<Badge variant={severityBadgeVariant(anomaly.severity)} className="text-[10px] px-1.5 py-0">{anomaly.severity}</Badge>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 font-mono">{anomaly.type}</Badge>
{anomaly.unitId && (
<span className="text-[10px] text-muted-foreground font-mono truncate">{anomaly.unitType}/{anomaly.unitId}</span>
)}
</div>
<p className="text-xs text-foreground">{anomaly.summary}</p>
{anomaly.details && anomaly.details !== anomaly.summary && (
<p className="text-[11px] text-muted-foreground leading-relaxed">{anomaly.details}</p>
)}
</div>
)
}
export function ForensicsPanel() {
const workspace = useGSDWorkspaceState()
const { loadForensicsDiagnostics } = useGSDWorkspaceActions()
const state = workspace.commandSurface.diagnostics.forensics
const data = state.data as ForensicReport | null
const busy = state.phase === "loading"
return (
<div className="space-y-4" data-testid="diagnostics-forensics">
<DiagHeader
title="Forensic Analysis"
subtitle={data ? new Date(data.timestamp).toLocaleString() : null}
status={data ? (
<span className={cn(
"inline-block h-1.5 w-1.5 rounded-full",
data.anomalies.length > 0 ? "bg-warning" : "bg-success",
)} />
) : null}
onRefresh={() => void loadForensicsDiagnostics()}
refreshing={busy}
/>
{state.error && <DiagError message={state.error} />}
{busy && !data && <DiagLoading label="Running forensic analysis…" />}
{data && (
<>
{/* Metrics summary */}
{data.metrics && (
<div className="flex flex-wrap gap-2">
<StatPill label="Units" value={data.metrics.totalUnits} />
<StatPill label="Cost" value={formatCost(data.metrics.totalCost)} />
<StatPill label="Duration" value={`${Math.round(data.metrics.totalDuration / 1000)}s`} />
<StatPill label="Traces" value={data.unitTraceCount} />
</div>
)}
{/* Crash lock */}
{data.crashLock ? (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2.5 space-y-1">
<div className="flex items-center gap-2">
<ShieldAlert className="h-3.5 w-3.5 text-destructive" />
<span className="text-xs font-medium text-destructive">Crash Lock Active</span>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-0.5 text-[11px]">
<span className="text-muted-foreground">PID</span>
<span className="font-mono text-foreground/80">{data.crashLock.pid}</span>
<span className="text-muted-foreground">Started</span>
<span className="text-foreground/80">{new Date(data.crashLock.startedAt).toLocaleString()}</span>
<span className="text-muted-foreground">Unit</span>
<span className="font-mono text-foreground/80">{data.crashLock.unitType}/{data.crashLock.unitId}</span>
</div>
</div>
) : (
<div className="flex items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-2 text-xs text-muted-foreground">
<CheckCircle2 className="h-3.5 w-3.5 text-success" />
No crash lock
</div>
)}
{/* Anomalies */}
{data.anomalies.length > 0 ? (
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground">Anomalies ({data.anomalies.length})</h4>
{data.anomalies.map((a, i) => <AnomalyRow key={i} anomaly={a} />)}
</div>
) : (
<DiagEmpty message="No anomalies detected" />
)}
{/* Recent units */}
{data.recentUnits.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground">Recent Units ({data.recentUnits.length})</h4>
<div className="overflow-x-auto rounded-lg border border-border/50">
<table className="w-full text-[11px]">
<thead>
<tr className="border-b border-border/50 bg-card/50">
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">Type</th>
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">ID</th>
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">Model</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Cost</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Duration</th>
</tr>
</thead>
<tbody>
{data.recentUnits.map((u, i) => (
<tr key={i} className="border-b border-border/50 last:border-0">
<td className="px-2.5 py-1.5 font-mono text-foreground/80">{u.type}</td>
<td className="px-2.5 py-1.5 font-mono text-foreground/80 truncate max-w-[120px]">{u.id}</td>
<td className="px-2.5 py-1.5 text-muted-foreground">{u.model}</td>
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{formatCost(u.cost)}</td>
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{Math.round(u.duration / 1000)}s</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════
// DOCTOR PANEL
// ═══════════════════════════════════════════════════════════════════════
function humanizeCode(code: string): string {
return code.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
}
function IssueRow({ issue }: { issue: DoctorIssue }) {
return (
<div className="rounded-lg border border-border/50 bg-card/50 px-3 py-2.5 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<SeverityIcon severity={issue.severity} />
<Badge variant={severityBadgeVariant(issue.severity)} className="text-[10px] px-1.5 py-0">{issue.severity}</Badge>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 font-mono">{humanizeCode(issue.code)}</Badge>
{issue.scope && <span className="text-[10px] text-muted-foreground font-mono">{issue.scope}</span>}
{issue.fixable && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-success/30 text-success">
<Wrench className="h-2.5 w-2.5 mr-0.5" />fixable
</Badge>
)}
</div>
<p className="text-xs text-foreground">{issue.message}</p>
{issue.file && <p className="text-[10px] font-mono text-muted-foreground truncate">{issue.file}</p>}
</div>
)
}
export function DoctorPanel() {
const workspace = useGSDWorkspaceState()
const { loadDoctorDiagnostics, applyDoctorFixes } = useGSDWorkspaceActions()
const state = workspace.commandSurface.diagnostics.doctor
const data = state.data as DoctorReport | null
const busy = state.phase === "loading"
const fixableCount = data?.summary.fixable ?? 0
return (
<div className="space-y-4" data-testid="diagnostics-doctor">
<DiagHeader
title="Doctor Health Check"
status={data ? (
<span className={cn(
"inline-block h-1.5 w-1.5 rounded-full",
data.ok ? "bg-success" : "bg-destructive",
)} />
) : null}
onRefresh={() => void loadDoctorDiagnostics()}
refreshing={busy}
/>
{state.error && <DiagError message={state.error} />}
{busy && !data && <DiagLoading label="Running health check…" />}
{data && (
<>
{/* Summary bar */}
<div className="flex flex-wrap gap-2">
<StatPill label="Total" value={data.summary.total} />
{data.summary.errors > 0 && <StatPill label="Errors" value={data.summary.errors} variant="error" />}
{data.summary.warnings > 0 && <StatPill label="Warnings" value={data.summary.warnings} variant="warning" />}
{data.summary.infos > 0 && <StatPill label="Info" value={data.summary.infos} variant="info" />}
{fixableCount > 0 && (
<StatPill label="Fixable" value={fixableCount} variant="info" />
)}
</div>
{/* Apply fixes button */}
{fixableCount > 0 && (
<div className="flex items-center gap-3">
<Button
type="button"
variant="default"
size="sm"
onClick={() => void applyDoctorFixes()}
disabled={state.fixPending}
className="h-7 gap-1.5 text-xs"
data-testid="doctor-apply-fixes"
>
{state.fixPending ? (
<LoaderCircle className="h-3 w-3 animate-spin" />
) : (
<Wrench className="h-3 w-3" />
)}
Apply Fixes ({fixableCount})
</Button>
{state.lastFixError && (
<span className="text-[11px] text-destructive">{state.lastFixError}</span>
)}
</div>
)}
{/* Fix results */}
{state.lastFixResult && state.lastFixResult.fixesApplied.length > 0 && (
<div className="rounded-lg border border-success/20 bg-success/5 px-3 py-2.5 space-y-1">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-3.5 w-3.5 text-success" />
<span className="text-xs font-medium text-success">Fixes Applied</span>
</div>
<ul className="space-y-0.5 pl-5">
{state.lastFixResult.fixesApplied.map((fix, i) => (
<li key={i} className="text-[11px] text-foreground/80 list-disc">{fix}</li>
))}
</ul>
</div>
)}
{/* Issue list */}
{data.issues.length > 0 ? (
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground">Issues ({data.issues.length})</h4>
{data.issues.map((issue, i) => <IssueRow key={i} issue={issue} />)}
</div>
) : (
<DiagEmpty message="No issues found — workspace is healthy" />
)}
</>
)}
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════
// SKILL HEALTH PANEL
// ═══════════════════════════════════════════════════════════════════════
function trendArrow(trend: "stable" | "rising" | "declining"): string {
if (trend === "rising") return "↑"
if (trend === "declining") return "↓"
return "→"
}
function trendColor(trend: "stable" | "rising" | "declining"): string {
if (trend === "rising") return "text-warning"
if (trend === "declining") return "text-destructive"
return "text-muted-foreground"
}
function SuggestionRow({ suggestion }: { suggestion: SkillHealSuggestion }) {
return (
<div className="rounded-lg border border-border/50 bg-card/50 px-3 py-2.5 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<SeverityIcon severity={suggestion.severity} />
<Badge variant={severityBadgeVariant(suggestion.severity)} className="text-[10px] px-1.5 py-0">{suggestion.severity}</Badge>
<span className="text-[11px] font-medium text-foreground/80">{suggestion.skillName}</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 font-mono">{suggestion.trigger.replace(/_/g, " ")}</Badge>
</div>
<p className="text-xs text-foreground">{suggestion.message}</p>
</div>
)
}
export function SkillHealthPanel() {
const workspace = useGSDWorkspaceState()
const { loadSkillHealthDiagnostics } = useGSDWorkspaceActions()
const state = workspace.commandSurface.diagnostics.skillHealth
const data = state.data as SkillHealthReport | null
const busy = state.phase === "loading"
return (
<div className="space-y-4" data-testid="diagnostics-skill-health">
<DiagHeader
title="Skill Health"
subtitle={data ? new Date(data.generatedAt).toLocaleString() : null}
status={data ? (
<span className={cn(
"inline-block h-1.5 w-1.5 rounded-full",
data.decliningSkills.length > 0 ? "bg-warning" : "bg-success",
)} />
) : null}
onRefresh={() => void loadSkillHealthDiagnostics()}
refreshing={busy}
/>
{state.error && <DiagError message={state.error} />}
{busy && !data && <DiagLoading label="Analyzing skill health…" />}
{data && (
<>
{/* Stats bar */}
<div className="flex flex-wrap gap-2">
<StatPill label="Skills" value={data.skills.length} />
{data.staleSkills.length > 0 && <StatPill label="Stale" value={data.staleSkills.length} variant="warning" />}
{data.decliningSkills.length > 0 && <StatPill label="Declining" value={data.decliningSkills.length} variant="error" />}
<StatPill label="Total units" value={data.totalUnitsWithSkills} />
</div>
{/* Skill table */}
{data.skills.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground">Skills ({data.skills.length})</h4>
<div className="overflow-x-auto rounded-lg border border-border/50">
<table className="w-full text-[11px]">
<thead>
<tr className="border-b border-border/50 bg-card/50">
<th className="px-2.5 py-1.5 text-left font-medium text-muted-foreground">Skill</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Uses</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Success</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Tokens</th>
<th className="px-2.5 py-1.5 text-center font-medium text-muted-foreground">Trend</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Stale</th>
<th className="px-2.5 py-1.5 text-right font-medium text-muted-foreground">Cost</th>
</tr>
</thead>
<tbody>
{data.skills.map((skill) => (
<tr key={skill.name} className={cn(
"border-b border-border/50 last:border-0",
skill.flagged && "bg-destructive/3",
)}>
<td className="px-2.5 py-1.5 font-mono text-foreground/80">
<span className="flex items-center gap-1.5">
{skill.name}
{skill.flagged && <AlertTriangle className="h-3 w-3 text-warning shrink-0" />}
</span>
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{skill.totalUses}</td>
<td className={cn(
"px-2.5 py-1.5 text-right tabular-nums",
skill.successRate >= 0.9 ? "text-success" : skill.successRate >= 0.7 ? "text-warning" : "text-destructive",
)}>
{(skill.successRate * 100).toFixed(0)}%
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{Math.round(skill.avgTokens)}</td>
<td className={cn("px-2.5 py-1.5 text-center", trendColor(skill.tokenTrend))}>
{trendArrow(skill.tokenTrend)}
</td>
<td className={cn(
"px-2.5 py-1.5 text-right tabular-nums",
skill.staleDays > 30 ? "text-warning" : "text-foreground/80",
)}>
{skill.staleDays > 0 ? `${skill.staleDays}d` : "—"}
</td>
<td className="px-2.5 py-1.5 text-right tabular-nums text-foreground/80">{formatCost(skill.avgCost)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Stale skills */}
{data.staleSkills.length > 0 && (
<div className="space-y-1.5">
<h4 className="text-xs font-medium text-muted-foreground">Stale Skills</h4>
<div className="flex flex-wrap gap-1.5">
{data.staleSkills.map((name) => (
<Badge key={name} variant="secondary" className="text-[10px] font-mono">{name}</Badge>
))}
</div>
</div>
)}
{/* Declining skills */}
{data.decliningSkills.length > 0 && (
<div className="space-y-1.5">
<h4 className="text-xs font-medium text-muted-foreground">Declining Skills</h4>
<div className="flex flex-wrap gap-1.5">
{data.decliningSkills.map((name) => (
<Badge key={name} variant="destructive" className="text-[10px] font-mono">{name}</Badge>
))}
</div>
</div>
)}
{/* Suggestions */}
{data.suggestions.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-medium text-muted-foreground">Suggestions ({data.suggestions.length})</h4>
{data.suggestions.map((s, i) => <SuggestionRow key={i} suggestion={s} />)}
</div>
)}
{data.skills.length === 0 && data.suggestions.length === 0 && (
<DiagEmpty message="No skill usage data available" />
)}
</>
)}
</div>
)
}

View file

@ -0,0 +1,119 @@
"use client"
import { useState, useRef, useEffect } from "react"
import { GripVertical, Loader2 } from "lucide-react"
import { MainSessionTerminal } from "@/components/sf/main-session-terminal"
import { ShellTerminal } from "@/components/sf/shell-terminal"
import { useTerminalFontSize } from "@/lib/use-terminal-font-size"
import { useGSDWorkspaceState } from "@/lib/sf-workspace-store"
import { derivePendingWorkflowCommandLabel } from "@/lib/workflow-action-execution"
export function DualTerminal() {
const [splitPosition, setSplitPosition] = useState(50)
const containerRef = useRef<HTMLDivElement>(null)
const rootRef = useRef<HTMLDivElement>(null)
const isDragging = useRef(false)
const [terminalFontSize] = useTerminalFontSize()
const workspace = useGSDWorkspaceState()
const projectCwd = workspace.boot?.project.cwd
const pendingCommandLabel = derivePendingWorkflowCommandLabel({
commandInFlight: workspace.commandInFlight,
terminalLines: workspace.terminalLines,
})
const handleMouseDown = () => {
isDragging.current = true
}
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.current || !containerRef.current) return
const rect = containerRef.current.getBoundingClientRect()
const x = e.clientX - rect.left
const percent = (x / rect.width) * 100
setSplitPosition(Math.max(20, Math.min(80, percent)))
}
const handleMouseUp = () => {
isDragging.current = false
}
useEffect(() => {
document.addEventListener("mousemove", handleMouseMove)
document.addEventListener("mouseup", handleMouseUp)
return () => {
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
}
}, [])
// Prevent browser default file-open on drag/drop anywhere in the dual terminal.
// Uses native DOM listeners so xterm's internal DOM can't swallow the events first.
useEffect(() => {
const el = rootRef.current
if (!el) return
const preventDragDefault = (e: DragEvent) => {
e.preventDefault()
}
// Capture phase ensures we fire before any child element can consume the event
el.addEventListener("dragover", preventDragDefault, true)
el.addEventListener("drop", preventDragDefault, true)
return () => {
el.removeEventListener("dragover", preventDragDefault, true)
el.removeEventListener("drop", preventDragDefault, true)
}
}, [])
return (
<div ref={rootRef} className="flex h-full flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b border-border bg-card px-4 py-2">
<span className="font-medium">Power User Mode</span>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
{pendingCommandLabel && (
<span
className="inline-flex items-center gap-1.5 rounded-full border border-primary/20 bg-primary/10 px-2.5 py-1 text-primary"
data-testid="power-mode-pending-command"
title={pendingCommandLabel}
>
<Loader2 className="h-3 w-3 animate-spin" />
Sending {pendingCommandLabel}
</span>
)}
<span>Left: Main Session TUI</span>
<span className="text-border">|</span>
<span>Right: Interactive GSD</span>
</div>
</div>
{/* Split terminals */}
<div ref={containerRef} className="flex flex-1 overflow-hidden">
{/* Left terminal - Main bridge native TUI */}
<div style={{ width: `${splitPosition}%` }} className="flex h-full min-w-0 flex-col overflow-hidden bg-terminal">
<MainSessionTerminal className="min-h-0 flex-1" fontSize={terminalFontSize} projectCwd={projectCwd} />
</div>
{/* Divider */}
<div
className="flex w-1 cursor-col-resize items-center justify-center bg-border hover:bg-muted-foreground/30 transition-colors"
onMouseDown={handleMouseDown}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</div>
{/* Right terminal - Interactive GSD instance */}
<div style={{ width: `${100 - splitPosition}%` }} className="h-full min-w-0 overflow-hidden bg-terminal">
<ShellTerminal
className="h-full"
command="gsd"
sessionPrefix="gsd-interactive"
fontSize={terminalFontSize}
hideInitialGsdHeader
projectCwd={projectCwd}
/>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,740 @@
"use client"
import { useEffect, useMemo, useRef, useState, useCallback } from "react"
import { Loader2, Save, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
import { CodeEditor } from "@/components/sf/code-editor"
import { useEditorFontSize } from "@/lib/use-editor-font-size"
import { useTheme } from "next-themes"
/* ── Language detection ── */
const EXT_TO_LANG: Record<string, string> = {
ts: "typescript",
tsx: "tsx",
js: "javascript",
jsx: "jsx",
mjs: "javascript",
cjs: "javascript",
json: "json",
jsonc: "jsonc",
md: "markdown",
mdx: "mdx",
css: "css",
scss: "scss",
less: "less",
html: "html",
htm: "html",
xml: "xml",
svg: "xml",
yaml: "yaml",
yml: "yaml",
toml: "toml",
sh: "bash",
bash: "bash",
zsh: "bash",
fish: "fish",
py: "python",
rb: "ruby",
rs: "rust",
go: "go",
java: "java",
kt: "kotlin",
swift: "swift",
c: "c",
cpp: "cpp",
h: "c",
hpp: "cpp",
cs: "csharp",
php: "php",
sql: "sql",
graphql: "graphql",
gql: "graphql",
dockerfile: "dockerfile",
makefile: "makefile",
lua: "lua",
vim: "viml",
r: "r",
tex: "latex",
diff: "diff",
ini: "ini",
conf: "ini",
env: "dotenv",
}
const SPECIAL_FILENAMES: Record<string, string> = {
Dockerfile: "dockerfile",
Makefile: "makefile",
Containerfile: "dockerfile",
Justfile: "makefile",
Rakefile: "ruby",
Gemfile: "ruby",
".env": "dotenv",
".env.local": "dotenv",
".env.example": "dotenv",
".eslintrc": "json",
".prettierrc": "json",
"tsconfig.json": "jsonc",
"jsconfig.json": "jsonc",
}
function detectLanguage(filepath: string): string | null {
const filename = filepath.split("/").pop() ?? ""
// Check special filenames first
if (SPECIAL_FILENAMES[filename]) return SPECIAL_FILENAMES[filename]
const ext = filename.includes(".") ? filename.split(".").pop()?.toLowerCase() : null
if (ext && EXT_TO_LANG[ext]) return EXT_TO_LANG[ext]
return null
}
function isMarkdown(filepath: string): boolean {
const ext = filepath.split(".").pop()?.toLowerCase()
return ext === "md" || ext === "mdx"
}
/* ── Shiki singleton ── */
type ShikiHighlighter = {
codeToHtml: (code: string, options: { lang: string; theme: string }) => string
}
let highlighterPromise: Promise<ShikiHighlighter> | null = null
async function getHighlighter(): Promise<ShikiHighlighter> {
if (!highlighterPromise) {
highlighterPromise = 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) => {
// Reset so the next call retries instead of returning a rejected promise forever
highlighterPromise = null
throw err
})
}
return highlighterPromise
}
/* ── Code viewer (syntax highlighted) ── */
function CodeViewer({ content, filepath, shikiTheme = "github-dark-default" }: { content: string; filepath: string; shikiTheme?: string }) {
const [html, setHtml] = useState<string | null>(null)
const [ready, setReady] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const lang = detectLanguage(filepath)
useEffect(() => {
let cancelled = false
if (!lang) {
const readyTimer = window.setTimeout(() => {
setReady(true)
}, 0)
return () => window.clearTimeout(readyTimer)
}
getHighlighter().then((highlighter) => {
if (cancelled) return
try {
const highlighted = highlighter.codeToHtml(content, {
lang,
theme: shikiTheme,
})
setHtml(highlighted)
} catch {
// Language not loaded or unsupported — fall back to plain
setHtml(null)
}
setReady(true)
}).catch(() => {
if (!cancelled) setReady(true)
})
return () => { cancelled = true }
}, [content, lang, shikiTheme])
if (!ready) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Highlighting
</div>
)
}
if (html) {
return (
<div
ref={containerRef}
className="file-viewer-code overflow-x-auto text-sm leading-relaxed"
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
// Fallback: plain text with line numbers
return <PlainViewer content={content} />
}
/* ── Plain text viewer with line numbers ── */
function PlainViewer({ content }: { content: string }) {
const lines = useMemo(() => content.split("\n"), [content])
const gutterWidth = String(lines.length).length
return (
<div className="overflow-x-auto text-sm leading-relaxed font-mono">
<table className="border-collapse">
<tbody>
{lines.map((line, i) => (
<tr key={i} className="hover:bg-accent/20">
<td
className="select-none pr-4 text-right text-muted-foreground align-top"
style={{ minWidth: `${gutterWidth + 1}ch` }}
>
{i + 1}
</td>
<td className="whitespace-pre text-muted-foreground">{line || " "}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
/* ── Markdown viewer ── */
function MarkdownViewer({ content, filepath, shikiTheme = "github-dark-default" }: { content: string; filepath: string; shikiTheme?: string }) {
const [rendered, setRendered] = useState<React.ReactNode | null>(null)
const [ready, setReady] = useState(false)
useEffect(() => {
let cancelled = false
// Dynamic import to keep the main bundle lean
Promise.all([
import("react-markdown"),
import("remark-gfm"),
getHighlighter(),
]).then(([ReactMarkdownMod, remarkGfmMod, highlighter]) => {
if (cancelled) return
const ReactMarkdown = ReactMarkdownMod.default
const remarkGfm = remarkGfmMod.default
setRendered(
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "")
const codeStr = String(children).replace(/\n$/, "")
if (match) {
try {
const highlighted = highlighter.codeToHtml(codeStr, {
lang: match[1],
theme: shikiTheme,
})
return (
<div
className="file-viewer-code my-3 rounded-md overflow-x-auto text-sm"
dangerouslySetInnerHTML={{ __html: highlighted }}
/>
)
} catch {
// Fall through to default rendering
}
}
// Inline code or unknown language
const isInline = !className && !String(children).includes("\n")
if (isInline) {
return (
<code className="rounded bg-muted px-1.5 py-0.5 text-sm font-mono" {...props}>
{children}
</code>
)
}
return (
<pre className="my-3 overflow-x-auto rounded-md bg-[#0d1117] p-4 text-sm">
<code>{children}</code>
</pre>
)
},
pre({ children }) {
// Unwrap <pre> since code blocks handle their own wrapper
return <>{children}</>
},
table({ children }) {
return (
<div className="my-4 overflow-x-auto">
<table className="min-w-full border-collapse border border-border text-sm">
{children}
</table>
</div>
)
},
th({ children }) {
return (
<th className="border border-border bg-muted/50 px-3 py-2 text-left font-medium">
{children}
</th>
)
},
td({ children }) {
return (
<td className="border border-border px-3 py-2">{children}</td>
)
},
a({ href, children }) {
return (
<a href={href} className="text-info hover:underline" target="_blank" rel="noopener noreferrer">
{children}
</a>
)
},
img({ src, alt }) {
return (
<span className="my-2 block rounded border border-border bg-muted/50 px-3 py-2 text-xs text-muted-foreground italic">
🖼 {alt || (typeof src === "string" ? src : "") || "image"}
</span>
)
},
}}
>
{content}
</ReactMarkdown>,
)
setReady(true)
}).catch(() => {
if (!cancelled) setReady(true)
})
return () => { cancelled = true }
}, [content, filepath, shikiTheme])
if (!ready) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Rendering
</div>
)
}
if (!rendered) {
return <PlainViewer content={content} />
}
return <div className="markdown-body">{rendered}</div>
}
/* ── Inline diff viewer — shows before/after with red/green line highlights ── */
function computeDiffLines(before: string, after: string): Array<{ type: "add" | "remove" | "context"; lineNum: number | null; text: string }> {
const oldLines = before.split("\n")
const newLines = after.split("\n")
const result: Array<{ type: "add" | "remove" | "context"; lineNum: number | null; text: string }> = []
// Simple LCS-based diff for inline display
const n = oldLines.length
const m = newLines.length
// For files that are too large, fall back to showing just additions/removals
if (n + m > 5000) {
oldLines.forEach((l, i) => result.push({ type: "remove", lineNum: i + 1, text: l }))
newLines.forEach((l, i) => result.push({ type: "add", lineNum: i + 1, text: l }))
return result
}
// Build edit script using O(ND) algorithm (simplified Myers)
const max = n + m
const v = new Int32Array(2 * max + 1)
const trace: Int32Array[] = []
outer:
for (let d = 0; d <= max; d++) {
const vCopy = new Int32Array(v)
trace.push(vCopy)
for (let k = -d; k <= d; k += 2) {
let x: number
if (k === -d || (k !== d && v[k - 1 + max] < v[k + 1 + max])) {
x = v[k + 1 + max]
} else {
x = v[k - 1 + max] + 1
}
let y = x - k
while (x < n && y < m && oldLines[x] === newLines[y]) {
x++
y++
}
v[k + max] = x
if (x >= n && y >= m) break outer
}
}
// Backtrack to produce diff
type Edit = { type: "add" | "remove" | "context"; oldIdx: number; newIdx: number }
const edits: Edit[] = []
let x = n, y = m
for (let d = trace.length - 1; d >= 0; d--) {
const vPrev = trace[d]
const k = x - y
let prevK: number
if (k === -d || (k !== d && vPrev[k - 1 + max] < vPrev[k + 1 + max])) {
prevK = k + 1
} else {
prevK = k - 1
}
const prevX = vPrev[prevK + max]
const prevY = prevX - prevK
// Diag moves = context lines
while (x > prevX && y > prevY) {
x--; y--
edits.push({ type: "context", oldIdx: x, newIdx: y })
}
if (d > 0) {
if (x === prevX) {
// Insert
y--
edits.push({ type: "add", oldIdx: x, newIdx: y })
} else {
// Delete
x--
edits.push({ type: "remove", oldIdx: x, newIdx: y })
}
}
}
edits.reverse()
// Convert to output lines, showing only changed regions with ±3 lines of context
const CONTEXT = 3
const important = new Set<number>()
edits.forEach((e, i) => {
if (e.type !== "context") {
for (let j = Math.max(0, i - CONTEXT); j <= Math.min(edits.length - 1, i + CONTEXT); j++) {
important.add(j)
}
}
})
let lastIncluded = -1
for (let i = 0; i < edits.length; i++) {
if (!important.has(i)) continue
if (lastIncluded >= 0 && i - lastIncluded > 1) {
result.push({ type: "context", lineNum: null, text: "···" })
}
const e = edits[i]
if (e.type === "context") {
result.push({ type: "context", lineNum: e.newIdx + 1, text: newLines[e.newIdx] })
} else if (e.type === "remove") {
result.push({ type: "remove", lineNum: e.oldIdx + 1, text: oldLines[e.oldIdx] })
} else {
result.push({ type: "add", lineNum: e.newIdx + 1, text: newLines[e.newIdx] })
}
lastIncluded = i
}
return result
}
function InlineDiffViewer({ before, after, onDismiss }: { before: string; after: string; onDismiss?: () => void }) {
const lines = useMemo(() => computeDiffLines(before, after), [before, after])
return (
<div className="flex-1 overflow-y-auto font-mono text-sm leading-relaxed">
<table className="w-full border-collapse">
<tbody>
{lines.map((line, i) => (
<tr
key={i}
className={cn(
line.type === "add" && "bg-emerald-500/10",
line.type === "remove" && "bg-red-500/10",
)}
>
<td className="select-none w-[1ch] pl-2 pr-1 text-center align-top">
{line.type === "add" ? (
<span className="text-emerald-400/80">+</span>
) : line.type === "remove" ? (
<span className="text-red-400/80"></span>
) : null}
</td>
<td
className={cn(
"select-none pr-3 text-right align-top min-w-[3ch]",
line.type === "add" ? "text-emerald-400/40" :
line.type === "remove" ? "text-red-400/40" :
"text-muted-foreground/50",
)}
>
{line.lineNum ?? ""}
</td>
<td
className={cn(
"whitespace-pre pr-4",
line.type === "add" && "text-emerald-300",
line.type === "remove" && "text-red-300 line-through decoration-red-400/30",
line.type === "context" && line.text === "···" && "text-muted-foreground/50 text-center italic",
line.type === "context" && line.text !== "···" && "text-muted-foreground",
)}
>
{line.text || " "}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
/* ── Read-only content renderer (shared between standalone and tab modes) ── */
function ReadOnlyContent({ content, filepath, fontSize, shikiTheme }: { content: string; filepath: string; fontSize?: number; shikiTheme?: string }) {
return (
<div style={fontSize ? { fontSize } : undefined}>
{isMarkdown(filepath) ? (
<MarkdownViewer content={content} filepath={filepath} shikiTheme={shikiTheme} />
) : (
<CodeViewer content={content} filepath={filepath} shikiTheme={shikiTheme} />
)}
</div>
)
}
/* ── Exported component ── */
interface FileContentViewerProps {
content: string
filepath: string
className?: string
/** Required for editing — the root context for the file */
root?: "gsd" | "project"
/** Required for editing — the relative path within the root */
path?: string
/** Required for editing — called with new content when the user saves */
onSave?: (newContent: string) => Promise<void>
/** When set, shows an inline diff overlay (before/after content) */
diff?: { before: string; after: string }
/** Called to dismiss the diff overlay */
onDismissDiff?: () => void
/** When true, MD files default to Edit tab so the raw changes are visible */
agentOpened?: boolean
}
export function FileContentViewer({
content,
filepath,
className,
root,
path,
onSave,
diff,
onDismissDiff,
agentOpened,
}: FileContentViewerProps) {
const canEdit = root !== undefined && path !== undefined && onSave !== undefined
// ── Dirty state tracking ──
const [editContent, setEditContent] = useState(content)
const [isSaving, setIsSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
// Reset edit content when the source content changes (e.g. after save + re-fetch)
useEffect(() => {
setEditContent(content)
}, [content])
const isDirty = editContent !== content
const [fontSize] = useEditorFontSize()
const { resolvedTheme } = useTheme()
const shikiTheme = resolvedTheme === "light" ? "github-light-default" : "github-dark-default"
const language = detectLanguage(filepath)
const handleSave = useCallback(async () => {
if (!onSave || !isDirty || isSaving) return
setIsSaving(true)
setSaveError(null)
try {
await onSave(editContent)
} catch (err) {
setSaveError(err instanceof Error ? err.message : "Failed to save")
} finally {
setIsSaving(false)
}
}, [onSave, isDirty, isSaving, editContent])
// ── Ctrl+S / Cmd+S keyboard shortcut ──
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault()
handleSave()
}
}
document.addEventListener("keydown", handler)
return () => document.removeEventListener("keydown", handler)
}, [handleSave])
// ── Read-only mode (backward compatible) ──
if (!canEdit) {
return (
<div className={cn("flex-1 overflow-y-auto p-4", className)} style={{ fontSize }}>
<ReadOnlyContent content={content} filepath={filepath} fontSize={fontSize} shikiTheme={shikiTheme} />
</div>
)
}
// ── Diff overlay mode: agent just edited this file ──
if (diff) {
return (
<div className={cn("flex flex-1 flex-col overflow-hidden min-h-0", className)}>
<div className="flex items-center gap-2 border-b border-border px-4 h-9">
<span className="text-sm font-medium font-mono truncate">{filepath}</span>
<span className="ml-2 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-400 uppercase tracking-wide">
Changed
</span>
<div className="ml-auto flex items-center gap-2">
<button
onClick={onDismissDiff}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<X className="h-3 w-3" />
Dismiss
</button>
</div>
</div>
<InlineDiffViewer before={diff.before} after={diff.after} onDismiss={onDismissDiff} />
</div>
)
}
// ── Editable mode: markdown keeps View/Edit tabs ──
if (isMarkdown(filepath)) {
return (
<Tabs key={agentOpened ? "agent-edit" : "normal"} defaultValue={agentOpened ? "edit" : "view"} className={cn("flex flex-1 flex-col overflow-hidden min-h-0", className)}>
<div className="flex items-center gap-2 border-b border-border px-4 h-9">
<span className="text-sm font-medium font-mono truncate mr-2">{filepath}</span>
<TabsList className="h-7 bg-transparent p-0 ml-auto">
<TabsTrigger
value="view"
className="h-6 rounded-md px-2 text-xs data-[state=active]:bg-muted"
>
View
</TabsTrigger>
<TabsTrigger
value="edit"
className="h-6 rounded-md px-2 text-xs data-[state=active]:bg-muted"
>
Edit
</TabsTrigger>
</TabsList>
{/* Save button */}
<div className="flex items-center gap-2">
{saveError && (
<span className="text-xs text-destructive max-w-[200px] truncate" title={saveError}>
{saveError}
</span>
)}
<button
onClick={handleSave}
disabled={!isDirty || isSaving}
className={cn(
"inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
isDirty && !isSaving
? "bg-foreground text-background hover:bg-foreground/90"
: "bg-muted text-muted-foreground cursor-not-allowed opacity-50",
)}
>
{isSaving ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Save className="h-3 w-3" />
)}
Save
</button>
</div>
</div>
<TabsContent value="view" className="flex-1 overflow-y-auto p-4 mt-0" style={{ fontSize }}>
<ReadOnlyContent content={content} filepath={filepath} fontSize={fontSize} shikiTheme={shikiTheme} />
</TabsContent>
<TabsContent value="edit" className="flex-1 overflow-hidden mt-0 min-h-0">
<CodeEditor
value={editContent}
onChange={setEditContent}
language={language}
fontSize={fontSize}
className="h-full border-0 rounded-none"
/>
</TabsContent>
</Tabs>
)
}
// ── Editable mode: non-markdown gets single CodeEditor view ──
return (
<div className={cn("flex flex-1 flex-col overflow-hidden min-h-0", className)}>
{/* Header bar with filepath and save button */}
<div className="flex items-center gap-2 border-b border-border px-4 h-9">
<span className="text-sm font-medium font-mono truncate">{filepath}</span>
<div className="ml-auto flex items-center gap-2">
{saveError && (
<span className="text-xs text-destructive max-w-[200px] truncate" title={saveError}>
{saveError}
</span>
)}
<button
onClick={handleSave}
disabled={!isDirty || isSaving}
className={cn(
"inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
isDirty && !isSaving
? "bg-foreground text-background hover:bg-foreground/90"
: "bg-muted text-muted-foreground cursor-not-allowed opacity-50",
)}
>
{isSaving ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Save className="h-3 w-3" />
)}
Save
</button>
</div>
</div>
{/* CodeEditor fills remaining space */}
<CodeEditor
value={editContent}
onChange={setEditContent}
language={language}
fontSize={fontSize}
className="flex-1 min-h-0 border-0 rounded-none"
/>
</div>
)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,332 @@
"use client"
import { useState } from "react"
import { CheckSquare, MessageSquare, Send, TextCursorInput, Type } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Textarea } from "@/components/ui/textarea"
import {
type PendingUiRequest,
useGSDWorkspaceActions,
useGSDWorkspaceState,
} from "@/lib/sf-workspace-store"
import { cn } from "@/lib/utils"
function methodIcon(method: PendingUiRequest["method"]) {
switch (method) {
case "select":
return <CheckSquare className="h-4 w-4" />
case "confirm":
return <MessageSquare className="h-4 w-4" />
case "input":
return <TextCursorInput className="h-4 w-4" />
case "editor":
return <Type className="h-4 w-4" />
}
}
function methodLabel(method: PendingUiRequest["method"]): string {
switch (method) {
case "select":
return "Selection"
case "confirm":
return "Confirmation"
case "input":
return "Input"
case "editor":
return "Editor"
}
}
// --- Renderers for each blocking UI request type ---
function SelectRenderer({
request,
onSubmit,
disabled,
}: {
request: Extract<PendingUiRequest, { method: "select" }>
onSubmit: (value: Record<string, unknown>) => void
disabled: boolean
}) {
const isMulti = Boolean(request.allowMultiple)
const [singleValue, setSingleValue] = useState("")
const [multiValues, setMultiValues] = useState<Set<string>>(new Set())
const handleSubmit = () => {
if (isMulti) {
onSubmit({ value: Array.from(multiValues) })
} else {
onSubmit({ value: singleValue })
}
}
const canSubmit = isMulti ? multiValues.size > 0 : singleValue !== ""
if (isMulti) {
return (
<div className="space-y-4">
<div className="space-y-2">
{request.options.map((option) => (
<label
key={option}
className="flex cursor-pointer items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 transition-colors hover:bg-accent/40"
>
<Checkbox
checked={multiValues.has(option)}
onCheckedChange={(checked) => {
const next = new Set(multiValues)
if (checked) {
next.add(option)
} else {
next.delete(option)
}
setMultiValues(next)
}}
disabled={disabled}
/>
<span className="text-sm">{option}</span>
</label>
))}
</div>
<Button onClick={handleSubmit} disabled={disabled || !canSubmit} className="w-full">
<Send className="h-4 w-4" />
Submit selection ({multiValues.size})
</Button>
</div>
)
}
return (
<div className="space-y-4">
<RadioGroup value={singleValue} onValueChange={setSingleValue} disabled={disabled}>
{request.options.map((option) => (
<label
key={option}
className="flex cursor-pointer items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 transition-colors hover:bg-accent/40"
>
<RadioGroupItem value={option} id={`select-${option}`} />
<Label htmlFor={`select-${option}`} className="cursor-pointer text-sm font-normal">
{option}
</Label>
</label>
))}
</RadioGroup>
<Button onClick={handleSubmit} disabled={disabled || !canSubmit} className="w-full">
<Send className="h-4 w-4" />
Submit
</Button>
</div>
)
}
function ConfirmRenderer({
request,
onSubmit,
onCancel,
disabled,
}: {
request: Extract<PendingUiRequest, { method: "confirm" }>
onSubmit: (value: Record<string, unknown>) => void
onCancel: () => void
disabled: boolean
}) {
return (
<div className="space-y-4">
<div className="rounded-lg border border-border bg-background px-4 py-3 text-sm leading-relaxed">
{request.message}
</div>
<div className="flex gap-3">
<Button onClick={() => onSubmit({ value: true })} disabled={disabled} className="flex-1">
Confirm
</Button>
<Button onClick={onCancel} disabled={disabled} variant="outline" className="flex-1">
Cancel
</Button>
</div>
</div>
)
}
function InputRenderer({
request,
onSubmit,
disabled,
}: {
request: Extract<PendingUiRequest, { method: "input" }>
onSubmit: (value: Record<string, unknown>) => void
disabled: boolean
}) {
const [value, setValue] = useState("")
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault()
if (value.trim()) onSubmit({ value })
}}
>
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={request.placeholder || "Enter a value"}
disabled={disabled}
autoFocus
/>
<Button type="submit" disabled={disabled || !value.trim()} className="w-full">
<Send className="h-4 w-4" />
Submit
</Button>
</form>
)
}
function EditorRenderer({
request,
onSubmit,
disabled,
}: {
request: Extract<PendingUiRequest, { method: "editor" }>
onSubmit: (value: Record<string, unknown>) => void
disabled: boolean
}) {
const [value, setValue] = useState(request.prefill || "")
return (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault()
onSubmit({ value })
}}
>
<Textarea
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={disabled}
className="min-h-[200px] font-mono text-sm"
autoFocus
/>
<Button type="submit" disabled={disabled} className="w-full">
<Send className="h-4 w-4" />
Submit
</Button>
</form>
)
}
function RequestBody({
request,
onSubmit,
onCancel,
disabled,
}: {
request: PendingUiRequest
onSubmit: (value: Record<string, unknown>) => void
onCancel: () => void
disabled: boolean
}) {
switch (request.method) {
case "select":
return <SelectRenderer request={request} onSubmit={onSubmit} disabled={disabled} />
case "confirm":
return <ConfirmRenderer request={request} onSubmit={onSubmit} onCancel={onCancel} disabled={disabled} />
case "input":
return <InputRenderer request={request} onSubmit={onSubmit} disabled={disabled} />
case "editor":
return <EditorRenderer request={request} onSubmit={onSubmit} disabled={disabled} />
}
}
export function FocusedPanel() {
const workspace = useGSDWorkspaceState()
const { respondToUiRequest, dismissUiRequest } = useGSDWorkspaceActions()
const pending = workspace.pendingUiRequests
const isOpen = pending.length > 0
const current = pending[0] ?? null
const isSubmitting = workspace.commandInFlight === "extension_ui_response"
const handleSubmit = (response: Record<string, unknown>) => {
if (!current) return
void respondToUiRequest(current.id, response)
}
const handleDismiss = () => {
if (!current) return
void dismissUiRequest(current.id)
}
// Prevent the Sheet from closing via overlay click / escape while submitting
const handleOpenChange = (open: boolean) => {
if (!open && !isSubmitting && current) {
handleDismiss()
}
}
return (
<Sheet open={isOpen} onOpenChange={handleOpenChange}>
<SheetContent side="right" className="flex flex-col sm:max-w-md" data-testid="focused-panel">
{current && (
<>
<SheetHeader>
<div className="flex items-center gap-2">
{methodIcon(current.method)}
<SheetTitle>{current.title || methodLabel(current.method)}</SheetTitle>
</div>
<SheetDescription>
<span className="flex items-center gap-2">
<span>{methodLabel(current.method)} requested by the agent</span>
{pending.length > 1 && (
<span
className={cn(
"inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-foreground px-1.5 text-[11px] font-semibold text-background",
)}
data-testid="focused-panel-queue-badge"
>
+{pending.length - 1}
</span>
)}
</span>
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-4 py-2">
<RequestBody
request={current}
onSubmit={handleSubmit}
onCancel={handleDismiss}
disabled={isSubmitting}
/>
</div>
<SheetFooter>
<Button
variant="ghost"
size="sm"
onClick={handleDismiss}
disabled={isSubmitting}
className="text-muted-foreground"
>
Dismiss
</Button>
</SheetFooter>
</>
)}
</SheetContent>
</Sheet>
)
}

View file

@ -0,0 +1,74 @@
"use client"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { ChatPane } from "@/components/sf/chat-mode"
// ─── Types ──────────────────────────────────────────────────────────────────
export interface GuidedDialogProps {
/** Whether the dialog is open */
open: boolean
/** Callback when open state changes (e.g. close button clicked) */
onOpenChange: (open: boolean) => void
/** Detection kind for contextual title */
detectionKind?: string
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function getDialogTitle(detectionKind?: string): string {
switch (detectionKind) {
case "v1-legacy":
return "Migrating to GSD v2"
case "brownfield":
return "Mapping Your Project"
case "blank":
return "Setting Up Your Project"
default:
return "Getting Started"
}
}
// ─── Component ──────────────────────────────────────────────────────────────
/**
* Full-screen dialog that embeds ChatPane to render the bridge session
* response to an onboarding CTA command.
*
* The initial command dispatch is NOT handled here it is managed by
* the parent (Dashboard) via a useEffect keyed on open + command.
*/
export function GuidedDialog({
open,
onOpenChange,
detectionKind,
}: GuidedDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-4xl h-[85vh] flex flex-col p-0 gap-0"
data-testid="guided-dialog"
>
<DialogHeader className="px-6 py-4 border-b border-border shrink-0">
<DialogTitle className="text-base font-semibold">
{getDialogTitle(detectionKind)}
</DialogTitle>
<DialogDescription className="sr-only">
Interactive guided setup responses stream below as they are generated.
</DialogDescription>
</DialogHeader>
{/* ChatPane without onOpenAction hides the Discuss/Next/Auto action buttons */}
<div className="flex-1 min-h-0 overflow-hidden">
<ChatPane className="h-full" />
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,457 @@
"use client"
import { useState } from "react"
import {
BookOpen,
InboxIcon,
LoaderCircle,
RefreshCw,
Zap,
Clock,
Tag,
FileText,
Lightbulb,
Repeat2,
StickyNote,
ArrowRightLeft,
CalendarClock,
ListTodo,
} from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import type {
KnowledgeData,
KnowledgeEntry,
CapturesData,
CaptureEntry,
Classification,
} from "@/lib/knowledge-captures-types"
import { cn } from "@/lib/utils"
import {
useGSDWorkspaceActions,
useGSDWorkspaceState,
} from "@/lib/sf-workspace-store"
// ═══════════════════════════════════════════════════════════════════════
// SHARED HELPERS
// ═══════════════════════════════════════════════════════════════════════
function PanelHeader({
title,
subtitle,
status,
onRefresh,
refreshing,
}: {
title: string
subtitle?: string | null
status?: React.ReactNode
onRefresh: () => void
refreshing: boolean
}) {
return (
<div className="flex items-center justify-between gap-3 pb-4">
<div className="flex items-center gap-2.5">
<h3 className="text-[13px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{title}</h3>
{status}
{subtitle && <span className="text-[11px] text-muted-foreground">{subtitle}</span>}
</div>
<Button type="button" variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing} className="h-7 gap-1.5 text-xs">
<RefreshCw className={cn("h-3 w-3", refreshing && "animate-spin")} />
Refresh
</Button>
</div>
)
}
function PanelError({ message }: { message: string }) {
return (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2.5 text-xs text-destructive">
{message}
</div>
)
}
function PanelLoading({ label }: { label: string }) {
return (
<div className="flex items-center gap-2 py-6 text-xs text-muted-foreground">
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
{label}
</div>
)
}
function PanelEmpty({ message }: { message: string }) {
return (
<div className="rounded-lg border border-border/50 bg-card/50 px-4 py-5 text-center text-xs text-muted-foreground">
{message}
</div>
)
}
function StatPill({ label, value, variant }: { label: string; value: number | string; variant?: "default" | "error" | "warning" | "info" }) {
return (
<div className={cn(
"flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs",
variant === "error" && "border-destructive/20 bg-destructive/5 text-destructive",
variant === "warning" && "border-warning/20 bg-warning/5 text-warning",
variant === "info" && "border-info/20 bg-info/5 text-info",
(!variant || variant === "default") && "border-border/50 bg-card/50 text-foreground/80",
)}>
<span className="text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">{value}</span>
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════
// KNOWLEDGE TYPE STYLING
// ═══════════════════════════════════════════════════════════════════════
function knowledgeTypeBadge(type: KnowledgeEntry["type"]) {
switch (type) {
case "rule":
return { label: "Rule", className: "border-violet-500/30 bg-violet-500/10 text-violet-400" }
case "pattern":
return { label: "Pattern", className: "border-info/30 bg-info/10 text-info" }
case "lesson":
return { label: "Lesson", className: "border-warning/30 bg-warning/10 text-warning" }
case "freeform":
return { label: "Freeform", className: "border-success/30 bg-success/10 text-success" }
}
}
function KnowledgeTypeIcon({ type, className }: { type: KnowledgeEntry["type"]; className?: string }) {
const base = cn("h-3.5 w-3.5 shrink-0", className)
switch (type) {
case "rule":
return <Tag className={cn(base, "text-violet-400")} />
case "pattern":
return <Repeat2 className={cn(base, "text-info")} />
case "lesson":
return <Lightbulb className={cn(base, "text-warning")} />
case "freeform":
return <FileText className={cn(base, "text-success")} />
}
}
// ═══════════════════════════════════════════════════════════════════════
// CAPTURE STATUS STYLING
// ═══════════════════════════════════════════════════════════════════════
function captureStatusStyle(status: CaptureEntry["status"]) {
switch (status) {
case "pending":
return { label: "Pending", className: "border-warning/30 bg-warning/10 text-warning" }
case "triaged":
return { label: "Triaged", className: "border-info/30 bg-info/10 text-info" }
case "resolved":
return { label: "Resolved", className: "border-success/30 bg-success/10 text-success" }
}
}
function classificationLabel(c: Classification): string {
switch (c) {
case "quick-task": return "Quick Task"
case "inject": return "Inject"
case "defer": return "Defer"
case "replan": return "Replan"
case "note": return "Note"
}
}
function ClassificationIcon({ classification, className }: { classification: Classification; className?: string }) {
const base = cn("h-3 w-3 shrink-0", className)
switch (classification) {
case "quick-task": return <Zap className={base} />
case "inject": return <ArrowRightLeft className={base} />
case "defer": return <CalendarClock className={base} />
case "replan": return <ListTodo className={base} />
case "note": return <StickyNote className={base} />
}
}
const CLASSIFICATION_OPTIONS: Classification[] = ["quick-task", "inject", "defer", "replan", "note"]
// ═══════════════════════════════════════════════════════════════════════
// KNOWLEDGE TAB CONTENT
// ═══════════════════════════════════════════════════════════════════════
function KnowledgeEntryRow({ entry }: { entry: KnowledgeEntry }) {
const badge = knowledgeTypeBadge(entry.type)
return (
<div className="group rounded-lg border border-border/50 bg-card/50 px-3 py-2.5 transition-colors hover:bg-card/50">
<div className="flex items-start gap-2.5">
<KnowledgeTypeIcon type={entry.type} className="mt-0.5" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-foreground truncate">{entry.title}</span>
<Badge variant="outline" className={cn("text-[10px] px-1.5 py-0 h-4 shrink-0", badge.className)}>
{badge.label}
</Badge>
</div>
{entry.content && (
<p className="mt-1 text-[11px] text-muted-foreground line-clamp-2 leading-relaxed">
{entry.content}
</p>
)}
</div>
</div>
</div>
)
}
function KnowledgeTabContent({
data,
phase,
error,
onRefresh,
}: {
data: KnowledgeData | null
phase: string
error: string | null
onRefresh: () => void
}) {
if (phase === "loading") return <PanelLoading label="Loading knowledge base…" />
if (phase === "error" && error) return <PanelError message={error} />
if (!data || data.entries.length === 0) return <PanelEmpty message="No knowledge entries found" />
return (
<div className="space-y-3">
<PanelHeader
title="Knowledge Base"
subtitle={`${data.entries.length} entries`}
onRefresh={onRefresh}
refreshing={phase === "loading"}
/>
<div className="space-y-1.5">
{data.entries.map((entry) => (
<KnowledgeEntryRow key={entry.id} entry={entry} />
))}
</div>
{data.lastModified && (
<p className="pt-2 text-[10px] text-muted-foreground">
Last modified: {new Date(data.lastModified).toLocaleString()}
</p>
)}
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════
// CAPTURES TAB CONTENT
// ═══════════════════════════════════════════════════════════════════════
function CaptureEntryRow({
entry,
onResolve,
resolvePending,
}: {
entry: CaptureEntry
onResolve: (captureId: string, classification: Classification) => void
resolvePending: boolean
}) {
const status = captureStatusStyle(entry.status)
return (
<div className="group rounded-lg border border-border/50 bg-card/50 px-3 py-2.5 transition-colors hover:bg-card/50">
<div className="flex items-start gap-2.5">
<div className={cn(
"mt-1 h-2 w-2 shrink-0 rounded-full",
entry.status === "pending" && "bg-warning",
entry.status === "triaged" && "bg-info",
entry.status === "resolved" && "bg-success",
)} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-foreground">{entry.text}</span>
<Badge variant="outline" className={cn("text-[10px] px-1.5 py-0 h-4 shrink-0", status.className)}>
{status.label}
</Badge>
{entry.classification && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 shrink-0 border-border/50 text-muted-foreground">
{classificationLabel(entry.classification)}
</Badge>
)}
</div>
{entry.timestamp && (
<div className="mt-1 flex items-center gap-1 text-[10px] text-muted-foreground">
<Clock className="h-2.5 w-2.5" />
{entry.timestamp}
</div>
)}
{entry.resolution && (
<p className="mt-1 text-[10px] text-muted-foreground italic">{entry.resolution}</p>
)}
{entry.status === "pending" && (
<div className="mt-2 flex flex-wrap gap-1">
{CLASSIFICATION_OPTIONS.map((c) => (
<Button
key={c}
type="button"
variant="outline"
size="sm"
disabled={resolvePending}
onClick={() => onResolve(entry.id, c)}
className="h-6 gap-1 px-2 text-[10px] font-normal border-border/50 hover:bg-foreground/5"
>
<ClassificationIcon classification={c} />
{classificationLabel(c)}
</Button>
))}
</div>
)}
</div>
</div>
</div>
)
}
function CapturesTabContent({
data,
phase,
error,
resolvePending,
resolveError,
onRefresh,
onResolve,
}: {
data: CapturesData | null
phase: string
error: string | null
resolvePending: boolean
resolveError: string | null
onRefresh: () => void
onResolve: (captureId: string, classification: Classification) => void
}) {
if (phase === "loading") return <PanelLoading label="Loading captures…" />
if (phase === "error" && error) return <PanelError message={error} />
if (!data || data.entries.length === 0) return <PanelEmpty message="No captures found" />
return (
<div className="space-y-3">
<PanelHeader
title="Captures"
subtitle={`${data.entries.length} total`}
status={
<div className="flex gap-1.5">
<StatPill label="Pending" value={data.pendingCount} variant={data.pendingCount > 0 ? "warning" : "default"} />
<StatPill label="Actionable" value={data.actionableCount} variant={data.actionableCount > 0 ? "info" : "default"} />
</div>
}
onRefresh={onRefresh}
refreshing={phase === "loading"}
/>
{resolveError && (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2 text-[11px] text-destructive">
Resolve error: {resolveError}
</div>
)}
<div className="space-y-1.5">
{data.entries.map((entry) => (
<CaptureEntryRow
key={entry.id}
entry={entry}
onResolve={onResolve}
resolvePending={resolvePending}
/>
))}
</div>
</div>
)
}
// ═══════════════════════════════════════════════════════════════════════
// MAIN PANEL COMPONENT
// ═══════════════════════════════════════════════════════════════════════
interface KnowledgeCapturesPanelProps {
initialTab: "knowledge" | "captures"
}
export function KnowledgeCapturesPanel({ initialTab }: KnowledgeCapturesPanelProps) {
const [activeTab, setActiveTab] = useState<"knowledge" | "captures">(initialTab)
const workspace = useGSDWorkspaceState()
const { loadKnowledgeData, loadCapturesData, resolveCaptureAction } = useGSDWorkspaceActions()
const knowledgeCaptures = workspace.commandSurface.knowledgeCaptures
const knowledgeState = knowledgeCaptures.knowledge
const capturesState = knowledgeCaptures.captures
const resolveState = knowledgeCaptures.resolveRequest
const capturesData = capturesState.data as CapturesData | null
const pendingCount = capturesData?.pendingCount ?? 0
const handleResolve = (captureId: string, classification: Classification) => {
void resolveCaptureAction({
captureId,
classification,
resolution: "Manual browser triage",
rationale: "Triaged via web UI",
})
}
return (
<div className="space-y-0">
{/* Tab bar */}
<div className="flex items-center gap-0.5 border-b border-border/50 px-1">
<button
type="button"
onClick={() => setActiveTab("knowledge")}
className={cn(
"flex items-center gap-1.5 px-3 py-2 text-xs font-medium transition-all border-b-2 -mb-px",
activeTab === "knowledge"
? "border-foreground/60 text-foreground"
: "border-transparent text-muted-foreground hover:text-muted-foreground",
)}
>
<BookOpen className="h-3.5 w-3.5" />
Knowledge
</button>
<button
type="button"
onClick={() => setActiveTab("captures")}
className={cn(
"flex items-center gap-1.5 px-3 py-2 text-xs font-medium transition-all border-b-2 -mb-px",
activeTab === "captures"
? "border-foreground/60 text-foreground"
: "border-transparent text-muted-foreground hover:text-muted-foreground",
)}
>
<InboxIcon className="h-3.5 w-3.5" />
Captures
{pendingCount > 0 && (
<Badge variant="outline" className="ml-1 h-4 px-1.5 py-0 text-[10px] border-warning/30 bg-warning/10 text-warning">
{pendingCount} pending
</Badge>
)}
</button>
</div>
{/* Tab content */}
<div className="p-4">
{activeTab === "knowledge" ? (
<KnowledgeTabContent
data={knowledgeState.data as KnowledgeData | null}
phase={knowledgeState.phase}
error={knowledgeState.error}
onRefresh={() => void loadKnowledgeData()}
/>
) : (
<CapturesTabContent
data={capturesData}
phase={capturesState.phase}
error={capturesState.error}
resolvePending={resolveState.pending}
resolveError={resolveState.lastError}
onRefresh={() => void loadCapturesData()}
onResolve={handleResolve}
/>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,198 @@
"use client"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
// ─── Dashboard skeletons ──────────────────────────────────────────────────────
function MetricCardSkeleton({ label, icon }: { label: string; icon: React.ReactNode }) {
return (
<div className="rounded-md border border-border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">{label}</p>
<Skeleton className="mt-2 h-7 w-24" />
<Skeleton className="mt-1.5 h-3 w-20" />
</div>
<div className="shrink-0 rounded-md bg-accent p-2 text-muted-foreground">{icon}</div>
</div>
</div>
)
}
function CurrentUnitCardSkeleton({ icon }: { icon: React.ReactNode }) {
return (
<div className="rounded-md border border-border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Current Unit</p>
<Skeleton className="mt-2 h-7 w-20" />
<Skeleton className="mt-1.5 h-3 w-16" />
</div>
<div className="shrink-0 rounded-md bg-accent p-2 text-muted-foreground">{icon}</div>
</div>
</div>
)
}
export function CurrentSliceCardSkeleton() {
return (
<div className="rounded-md border border-border bg-card">
<div className="border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">Current Slice</h2>
</div>
<div className="space-y-3 p-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-4 w-4 shrink-0 rounded-full" />
<Skeleton className={cn("h-4", i === 1 ? "w-48" : i === 2 ? "w-40" : "w-36")} />
</div>
))}
</div>
</div>
)
}
export function SessionCardSkeleton() {
return (
<div className="rounded-md border border-border bg-card">
<div className="border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">Session</h2>
</div>
<div className="p-4">
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<Skeleton className="h-3.5 w-3.5 rounded" />
<span className="text-muted-foreground">{i === 1 ? "Model" : i === 2 ? "Cost" : "Tokens"}</span>
</div>
<Skeleton className={cn("h-4", i === 1 ? "w-28" : "w-12")} />
</div>
))}
</div>
</div>
</div>
)
}
export function RecoveryCardSkeleton() {
return (
<div className="rounded-md border border-border bg-card">
<div className="border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">Recovery Summary</h2>
</div>
<div className="space-y-4 p-4">
<div className="space-y-1.5">
<Skeleton className="h-4 w-44" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-3/4" />
</div>
<div className="space-y-1.5">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className={cn("h-3", i % 2 === 0 ? "w-28" : "w-36")} />
))}
</div>
<Skeleton className="h-9 w-36 rounded-md" />
</div>
</div>
)
}
export function ActivityCardSkeleton() {
return (
<div className="rounded-md border border-border bg-card">
<div className="border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">Recent Activity</h2>
</div>
<div className="divide-y divide-border">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center gap-3 px-4 py-2.5">
<Skeleton className="h-3 w-16 shrink-0" />
<Skeleton className="h-1.5 w-1.5 shrink-0 rounded-full" />
<Skeleton className={cn("h-4 flex-1", i % 3 === 0 ? "max-w-xs" : i % 3 === 1 ? "max-w-sm" : "max-w-md")} />
</div>
))}
</div>
</div>
)
}
interface DashboardSkeletonProps {
icons: {
Activity: React.ReactNode
Clock: React.ReactNode
DollarSign: React.ReactNode
Zap: React.ReactNode
}
}
export function DashboardMetricsSkeleton({ icons }: DashboardSkeletonProps) {
return (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5">
<CurrentUnitCardSkeleton icon={icons.Activity} />
<MetricCardSkeleton label="Elapsed Time" icon={icons.Clock} />
<MetricCardSkeleton label="Total Cost" icon={icons.DollarSign} />
<MetricCardSkeleton label="Tokens Used" icon={icons.Zap} />
<MetricCardSkeleton label="Progress" icon={icons.Activity} />
</div>
)
}
// ─── Sidebar skeletons ────────────────────────────────────────────────────────
/** Only the data-dependent portion of the sidebar content panel */
export function SidebarDataSkeleton() {
return (
<>
{/* Project path */}
<Skeleton className="mt-2 h-3 w-36" />
{/* Scope section */}
<div className="border-b border-border px-3 py-3">
<div className="space-y-1.5">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">Active scope</p>
<Skeleton className="h-3.5 w-32" />
<Skeleton className="h-2.5 w-28" />
</div>
</div>
{/* Milestones list */}
<div className="flex-1 overflow-y-auto py-1">
<div className="px-2 py-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Milestones
</span>
</div>
<div className="space-y-0.5 px-1">
{[1, 2].map((m) => (
<div key={m}>
<div className="flex items-center gap-1.5 px-2 py-1.5">
<Skeleton className="h-4 w-4 shrink-0 rounded" />
<Skeleton className="h-4 w-4 shrink-0 rounded-full" />
<Skeleton className={cn("h-4", m === 1 ? "w-40" : "w-32")} />
</div>
{m === 1 && (
<div className="ml-4 space-y-0.5">
{[1, 2, 3].map((s) => (
<div key={s} className="flex items-center gap-1.5 px-2 py-1.5">
<Skeleton className="h-4 w-4 shrink-0 rounded" />
<Skeleton className="h-4 w-4 shrink-0 rounded-full" />
<Skeleton className={cn("h-3.5", s === 1 ? "w-32" : s === 2 ? "w-28" : "w-24")} />
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
</>
)
}
// ─── Status bar value skeletons ───────────────────────────────────────────────
export function StatusBarValueSkeleton({ width = "w-16" }: { width?: string }) {
return <Skeleton className={cn("h-3 inline-block", width)} />
}

View file

@ -0,0 +1,394 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { useTheme } from "next-themes"
import { Loader2, ImagePlus } from "lucide-react"
import { cn } from "@/lib/utils"
import { validateImageFile } from "@/lib/image-utils"
import { buildProjectAbsoluteUrl, buildProjectPath } from "@/lib/project-url"
import { authFetch, appendAuthParam } from "@/lib/auth"
import { getXtermOptions, getXtermTheme } from "@/lib/xterm-theme"
import "@xterm/xterm/css/xterm.css"
type XTerminal = import("@xterm/xterm").Terminal
type XFitAddon = import("@xterm/addon-fit").FitAddon
interface MainSessionTerminalProps {
className?: string
fontSize?: number
projectCwd?: string
}
const MIN_INITIAL_ATTACH_WIDTH = 180
const MIN_INITIAL_ATTACH_HEIGHT = 120
const MIN_INITIAL_ATTACH_COLS = 20
const MIN_INITIAL_ATTACH_ROWS = 8
function getAttachableTerminalSize(container: HTMLDivElement | null, terminal: XTerminal | null): { cols: number; rows: number } | null {
if (!container || !terminal) return null
const rect = container.getBoundingClientRect()
if (rect.width < MIN_INITIAL_ATTACH_WIDTH || rect.height < MIN_INITIAL_ATTACH_HEIGHT) {
return null
}
if (terminal.cols < MIN_INITIAL_ATTACH_COLS || terminal.rows < MIN_INITIAL_ATTACH_ROWS) {
return null
}
return { cols: terminal.cols, rows: terminal.rows }
}
async function settleTerminalLayout(
container: HTMLDivElement | null,
terminal: XTerminal | null,
fitAddon: XFitAddon | null,
isDisposed: () => boolean,
): Promise<{ cols: number; rows: number } | null> {
if (typeof document !== "undefined" && "fonts" in document) {
try {
await Promise.race([
document.fonts.ready,
new Promise<void>((resolve) => setTimeout(resolve, 1000)),
])
} catch {
// Ignore font loading failures and fall through to repeated fit attempts.
}
}
for (let attempt = 0; attempt < 12; attempt++) {
if (isDisposed()) return null
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve())
})
if (isDisposed()) return null
try {
fitAddon?.fit()
} catch {
// Hidden or detached.
}
const size = getAttachableTerminalSize(container, terminal)
if (size) {
return size
}
await new Promise((resolve) => setTimeout(resolve, 50))
}
return getAttachableTerminalSize(container, terminal)
}
export function MainSessionTerminal({ className, fontSize, projectCwd }: MainSessionTerminalProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme !== "light"
const wrapperRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const termRef = useRef<XTerminal | null>(null)
const fitAddonRef = useRef<XFitAddon | null>(null)
const eventSourceRef = useRef<EventSource | null>(null)
const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const inputQueueRef = useRef<string[]>([])
const flushingRef = useRef(false)
const [connectionState, setConnectionState] = useState<"connecting" | "connected" | "error">("connecting")
const [hasOutput, setHasOutput] = useState(false)
const [isDragOver, setIsDragOver] = useState(false)
const flushInputQueue = useCallback(async () => {
if (flushingRef.current) return
flushingRef.current = true
while (inputQueueRef.current.length > 0) {
const data = inputQueueRef.current.shift()!
try {
await authFetch(buildProjectPath("/api/bridge-terminal/input", projectCwd), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data }),
})
} catch {
inputQueueRef.current.unshift(data)
break
}
}
flushingRef.current = false
}, [projectCwd])
const sendInput = useCallback((data: string) => {
inputQueueRef.current.push(data)
void flushInputQueue()
}, [flushInputQueue])
const sendResize = useCallback((cols: number, rows: number) => {
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current)
resizeTimeoutRef.current = setTimeout(() => {
void authFetch(buildProjectPath("/api/bridge-terminal/resize", projectCwd), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cols, rows }),
})
}, 75)
}, [projectCwd])
useEffect(() => {
if (termRef.current) {
termRef.current.options.theme = getXtermTheme(isDark)
}
}, [isDark])
useEffect(() => {
if (!termRef.current) return
termRef.current.options.fontSize = fontSize ?? 13
try {
fitAddonRef.current?.fit()
sendResize(termRef.current.cols, termRef.current.rows)
} catch {
// Hidden or not mounted yet.
}
}, [fontSize, sendResize])
useEffect(() => {
if (!containerRef.current) return
let disposed = false
let resizeObserver: ResizeObserver | null = null
let terminal: XTerminal | null = null
let fitAddon: XFitAddon | null = null
const init = async () => {
const [{ Terminal }, { FitAddon }] = await Promise.all([
import("@xterm/xterm"),
import("@xterm/addon-fit"),
])
if (disposed) return
terminal = new Terminal(getXtermOptions(isDark, fontSize))
fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.open(containerRef.current!)
termRef.current = terminal
fitAddonRef.current = fitAddon
const initialSize = await settleTerminalLayout(containerRef.current, terminal, fitAddon, () => disposed)
if (disposed) return
terminal.onData((data) => {
sendInput(data)
})
terminal.onBinary((data) => {
sendInput(data)
})
const connectStream = (preferredSize: { cols: number; rows: number } | null) => {
const streamUrl = buildProjectAbsoluteUrl(
"/api/bridge-terminal/stream",
window.location.origin,
projectCwd,
)
if (preferredSize) {
streamUrl.searchParams.set("cols", String(preferredSize.cols))
streamUrl.searchParams.set("rows", String(preferredSize.rows))
}
const es = new EventSource(appendAuthParam(streamUrl.toString()))
eventSourceRef.current = es
setConnectionState((current) => (current === "connected" ? current : "connecting"))
es.onmessage = (event) => {
try {
const message = JSON.parse(event.data) as { type: string; data?: string }
if (message.type === "connected") {
setConnectionState("connected")
void settleTerminalLayout(containerRef.current, termRef.current, fitAddonRef.current, () => disposed).then((size) => {
if (!size) return
sendResize(size.cols, size.rows)
})
return
}
if (message.type === "output" && typeof message.data === "string") {
termRef.current?.write(message.data)
setHasOutput(true)
}
} catch {
setConnectionState("error")
}
}
es.onerror = () => {
setConnectionState("error")
}
}
connectStream(initialSize)
resizeObserver = new ResizeObserver(() => {
if (disposed) return
try {
fitAddon?.fit()
if (terminal) {
sendResize(terminal.cols, terminal.rows)
}
} catch {
// Hidden or detached.
}
})
resizeObserver.observe(containerRef.current!)
}
void init()
return () => {
disposed = true
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current)
eventSourceRef.current?.close()
eventSourceRef.current = null
resizeObserver?.disconnect()
terminal?.dispose()
termRef.current = null
fitAddonRef.current = null
}
}, [fontSize, isDark, projectCwd, sendInput, sendResize])
const handleClick = useCallback(() => {
termRef.current?.focus()
}, [])
// ── Shift+Enter → newline (native DOM, capture phase) ────────────────────
// xterm.js sends \r for both Enter and Shift+Enter. The pi TUI editor
// recognizes \n (LF) as "insert newline". Capture-phase keydown intercepts
// before xterm's internal textarea processes the event.
useEffect(() => {
const el = wrapperRef.current
if (!el) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
e.preventDefault()
e.stopPropagation()
sendInput("\n")
}
}
el.addEventListener("keydown", onKeyDown, true)
return () => el.removeEventListener("keydown", onKeyDown, true)
}, [sendInput])
// ── Drag-and-drop image upload (native DOM, capture phase) ──────────────
// React synthetic events don't reliably fire through xterm's internal DOM.
// Native capture-phase listeners intercept before xterm can swallow them —
// same pattern used for paste in ShellTerminal.
useEffect(() => {
const el = wrapperRef.current
if (!el) return
let counter = 0
const onDragEnter = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
counter += 1
if (counter === 1) setIsDragOver(true)
}
const onDragOver = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const onDragLeave = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
counter -= 1
if (counter <= 0) {
counter = 0
setIsDragOver(false)
}
}
const onDrop = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
counter = 0
setIsDragOver(false)
const files = Array.from(e.dataTransfer?.files ?? [])
const imageFile = files.find((f) => f.type.startsWith("image/"))
if (!imageFile) return
const validation = validateImageFile(imageFile)
if (!validation.valid) {
console.warn("[main-terminal-upload] validation failed:", validation.error)
return
}
const formData = new FormData()
formData.append("file", imageFile)
void (async () => {
try {
const res = await authFetch(buildProjectPath("/api/terminal/upload", projectCwd), {
method: "POST",
body: formData,
})
const data = (await res.json()) as { ok?: boolean; path?: string; error?: string }
if (!res.ok || !data.path) {
console.error("[main-terminal-upload] upload failed:", data.error ?? `HTTP ${res.status}`)
return
}
console.log("[main-terminal-upload] injecting path:", data.path)
sendInput(`@${data.path} `)
} catch (err) {
console.error("[main-terminal-upload] upload request failed:", err)
}
})()
}
el.addEventListener("dragenter", onDragEnter, true)
el.addEventListener("dragover", onDragOver, true)
el.addEventListener("dragleave", onDragLeave, true)
el.addEventListener("drop", onDrop, true)
return () => {
el.removeEventListener("dragenter", onDragEnter, true)
el.removeEventListener("dragover", onDragOver, true)
el.removeEventListener("dragleave", onDragLeave, true)
el.removeEventListener("drop", onDrop, true)
}
}, [projectCwd, sendInput])
useEffect(() => {
const timer = setTimeout(() => termRef.current?.focus(), 80)
return () => clearTimeout(timer)
}, [])
return (
<div
ref={wrapperRef}
className={cn("relative h-full w-full bg-terminal", className)}
onClick={handleClick}
data-testid="main-session-native-terminal"
>
{!hasOutput && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-terminal">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground">
{connectionState === "error" ? "Reconnecting main session terminal…" : "Connecting to main session…"}
</span>
</div>
)}
{/* Drop overlay */}
{isDragOver && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 bg-background backdrop-blur-sm border-2 border-dashed border-primary rounded-md pointer-events-none">
<ImagePlus className="h-8 w-8 text-primary" />
<span className="text-sm font-medium text-primary">Drop image here</span>
</div>
)}
<div ref={containerRef} className="h-full w-full" style={{ padding: "8px 4px 4px 8px" }} />
</div>
)
}

View file

@ -0,0 +1,303 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import { AnimatePresence, motion } from "motion/react"
import Image from "next/image"
import {
type WorkspaceOnboardingProviderState,
useGSDWorkspaceActions,
useGSDWorkspaceState,
} from "@/lib/sf-workspace-store"
import { useDevOverrides } from "@/lib/dev-overrides"
import { useUserMode, type UserMode } from "@/lib/use-user-mode"
import { navigateToGSDView } from "@/lib/workflow-action-execution"
import { cn } from "@/lib/utils"
import { StepWelcome } from "./onboarding/step-welcome"
import { StepMode } from "./onboarding/step-mode"
import { StepProvider } from "./onboarding/step-provider"
import { StepAuthenticate } from "./onboarding/step-authenticate"
import { StepDevRoot } from "./onboarding/step-dev-root"
import { StepOptional } from "./onboarding/step-optional"
import { StepRemote } from "./onboarding/step-remote"
import { StepReady } from "./onboarding/step-ready"
import { StepProject } from "./onboarding/step-project"
// ─── Constants ──────────────────────────────────────────────────────
const WIZARD_STEPS = [
{ id: "welcome", label: "Welcome" },
{ id: "mode", label: "Mode" },
{ id: "provider", label: "Provider" },
{ id: "authenticate", label: "Auth" },
{ id: "devRoot", label: "Root" },
{ id: "optional", label: "Extras" },
{ id: "remote", label: "Remote" },
{ id: "ready", label: "Ready" },
{ id: "project", label: "Project" },
] as const
const TOTAL_STEPS = WIZARD_STEPS.length
const EMPTY_PROVIDERS: WorkspaceOnboardingProviderState[] = []
// ─── Helpers ────────────────────────────────────────────────────────
function chooseDefaultProvider(providers: WorkspaceOnboardingProviderState[]): string | null {
const unresolvedRecommended = providers.find((p) => !p.configured && p.recommended)
if (unresolvedRecommended) return unresolvedRecommended.id
const unresolved = providers.find((p) => !p.configured)
if (unresolved) return unresolved.id
return providers[0]?.id ?? null
}
// Slide animation
const slideVariants = {
enter: (dir: number) => ({ x: dir > 0 ? 50 : -50, opacity: 0 }),
center: { x: 0, opacity: 1 },
exit: (dir: number) => ({ x: dir < 0 ? 50 : -50, opacity: 0 }),
}
// ─── Step indicator (centered row of dots with labels) ──────────────
function StepIndicator({ current, total }: { current: number; total: number }) {
return (
<div className="flex items-center gap-1">
{Array.from({ length: total }, (_, i) => (
<div
key={i}
className={cn(
"rounded-full transition-all duration-300",
i === current
? "h-1.5 w-5 bg-foreground"
: i < current
? "h-1.5 w-1.5 bg-foreground/40"
: "h-1.5 w-1.5 bg-foreground/10",
)}
/>
))}
</div>
)
}
// ─── Main Component ─────────────────────────────────────────────────
export function OnboardingGate() {
const workspace = useGSDWorkspaceState()
const {
refreshOnboarding,
saveApiKey,
startProviderFlow,
submitProviderFlowInput,
cancelProviderFlow,
refreshBoot,
} = useGSDWorkspaceActions()
const devOverrides = useDevOverrides()
const onboarding = workspace.boot?.onboarding
const forceVisible = devOverrides.isActive("forceOnboarding")
const isBusy = workspace.onboardingRequestState !== "idle"
// ─── Wizard state ───
const [stepIndex, setStepIndex] = useState(0)
const [direction, setDirection] = useState(0)
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null)
const [dismissedAfterSuccess, setDismissedAfterSuccess] = useState(false)
const [userMode, setUserMode] = useUserMode()
const [selectedMode, setSelectedMode] = useState<UserMode | null>(userMode)
const providers = onboarding?.required.providers ?? EMPTY_PROVIDERS
const effectiveSelectedProviderId = useMemo(() => {
if (onboarding?.activeFlow?.providerId) return onboarding.activeFlow.providerId
if (selectedProviderId && providers.some((p) => p.id === selectedProviderId)) return selectedProviderId
return chooseDefaultProvider(providers)
}, [onboarding?.activeFlow?.providerId, providers, selectedProviderId])
const shouldHideAfterSuccess = dismissedAfterSuccess && !onboarding?.locked && !isBusy
// Track whether auth was locked when the user arrived at step 3.
// Auto-advance only fires when auth transitions from locked → unlocked
// while the user is on the auth step — not when navigating back or
// when the provider was already configured.
const [authWasLockedOnArrival, setAuthWasLockedOnArrival] = useState(false)
const goTo = useCallback(
(target: number) => {
// When arriving at auth step, snapshot the locked state
if (target === 3 && onboarding?.locked) {
setAuthWasLockedOnArrival(true)
} else if (target === 3 && !onboarding?.locked) {
// Already unlocked — don't set the flag (prevents auto-advance)
setAuthWasLockedOnArrival(false)
}
setDirection(target > stepIndex ? 1 : -1)
setStepIndex(target)
},
[stepIndex, onboarding?.locked],
)
// Auto-advance past auth only when it just succeeded during this visit
useEffect(() => {
if (!onboarding) return
if (stepIndex !== 3) return
if (!authWasLockedOnArrival) return
const isUnlocked = !onboarding.locked
const bridgeDone = onboarding.bridgeAuthRefresh.phase === "succeeded" || onboarding.bridgeAuthRefresh.phase === "idle"
if (!isUnlocked || !bridgeDone) return
const t = window.setTimeout(() => goTo(4), 0)
return () => window.clearTimeout(t)
}, [onboarding, goTo, stepIndex, authWasLockedOnArrival])
const selectedProvider = useMemo(() => {
return providers.find((p) => p.id === effectiveSelectedProviderId) ?? null
}, [effectiveSelectedProviderId, providers])
// ─── Gate check ───
if (!onboarding) return null
const onboardingSettled =
!onboarding.locked ||
(onboarding.lastValidation?.status === "succeeded" &&
(onboarding.bridgeAuthRefresh.phase === "succeeded" || onboarding.bridgeAuthRefresh.phase === "idle"))
if (!forceVisible && (onboardingSettled || shouldHideAfterSuccess)) return null
const stepLabel = WIZARD_STEPS[stepIndex]?.label ?? ""
return (
<div className="pointer-events-auto absolute inset-0 z-30 flex flex-col bg-background" data-testid="onboarding-gate">
{/* Header */}
<header className="relative z-10 flex h-12 shrink-0 items-center justify-between px-5 md:px-8">
{/* Left — logo */}
<div className="flex w-24 items-center gap-2">
<Image src="/logo-white.svg" alt="GSD" width={57} height={16} className="hidden h-4 w-auto dark:block" />
<Image src="/logo-black.svg" alt="GSD" width={57} height={16} className="h-4 w-auto dark:hidden" />
</div>
{/* Center — step indicator */}
<div className="absolute inset-x-0 flex justify-center pointer-events-none">
<div className="pointer-events-auto">
<StepIndicator current={stepIndex} total={TOTAL_STEPS} />
</div>
</div>
{/* Right — step label */}
<div className="flex w-24 justify-end">
<span className="text-xs text-muted-foreground">{stepLabel}</span>
</div>
</header>
{/* Thin progress — hidden when not needed */}
{/* Content — full remaining height, scrollable */}
<div className="flex-1 overflow-y-auto">
<div className="mx-auto flex min-h-full w-full max-w-2xl flex-col justify-center px-5 py-10 md:px-8 md:py-16">
<AnimatePresence mode="wait" custom={direction}>
<motion.div
key={stepIndex}
custom={direction}
variants={slideVariants}
initial="enter"
animate="center"
exit="exit"
transition={{ type: "spring", stiffness: 400, damping: 35, opacity: { duration: 0.15 } }}
>
{stepIndex === 0 && <StepWelcome onNext={() => goTo(1)} />}
{stepIndex === 1 && (
<StepMode
selected={selectedMode}
onSelect={(mode) => { setSelectedMode(mode); setUserMode(mode) }}
onNext={() => goTo(2)}
onBack={() => goTo(0)}
/>
)}
{stepIndex === 2 && (
<StepProvider
providers={onboarding.required.providers}
selectedId={effectiveSelectedProviderId}
onSelect={(id) => {
setSelectedProviderId(id)
goTo(3)
}}
onNext={() => goTo(4)}
onBack={() => goTo(1)}
/>
)}
{stepIndex === 3 && selectedProvider && (
<StepAuthenticate
provider={selectedProvider}
activeFlow={onboarding.activeFlow}
lastValidation={onboarding.lastValidation}
requestState={workspace.onboardingRequestState}
requestProviderId={workspace.onboardingRequestProviderId}
onSaveApiKey={async (pid, key) => {
const next = await saveApiKey(pid, key)
const settled = Boolean(
next && !next.locked &&
(next.bridgeAuthRefresh.phase === "succeeded" || next.bridgeAuthRefresh.phase === "idle"),
)
if (settled) { setDismissedAfterSuccess(true); void refreshBoot() }
return next
}}
onStartFlow={(pid) => void startProviderFlow(pid)}
onSubmitFlowInput={(fid, input) => void submitProviderFlowInput(fid, input)}
onCancelFlow={(fid) => void cancelProviderFlow(fid)}
onBack={() => goTo(2)}
onNext={() => goTo(2)}
bridgeRefreshPhase={onboarding.bridgeAuthRefresh.phase}
bridgeRefreshError={onboarding.bridgeAuthRefresh.error}
/>
)}
{stepIndex === 4 && <StepDevRoot onBack={() => goTo(2)} onNext={() => goTo(5)} />}
{stepIndex === 5 && (
<StepOptional
sections={onboarding.optional.sections}
onBack={() => goTo(4)}
onNext={() => goTo(6)}
/>
)}
{stepIndex === 6 && (
<StepRemote
onBack={() => goTo(5)}
onNext={() => goTo(7)}
/>
)}
{stepIndex === 7 && (
<StepReady
providerLabel={
onboarding.lastValidation?.providerId
? onboarding.required.providers.find((p) => p.id === onboarding.lastValidation?.providerId)?.label ?? "Provider"
: "Provider"
}
onFinish={() => goTo(8)}
/>
)}
{stepIndex === 8 && (
<StepProject
onBack={() => goTo(7)}
onBeforeSwitch={() => {
// Disarm the gate BEFORE switchProject triggers a store remount
if (devOverrides.isActive("forceOnboarding")) {
devOverrides.toggle("forceOnboarding")
}
setDismissedAfterSuccess(true)
}}
onFinish={() => {
const mode = selectedMode ?? userMode
navigateToGSDView("dashboard")
void refreshBoot()
}}
/>
)}
</motion.div>
</AnimatePresence>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,496 @@
"use client"
import { useEffect, useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import {
ArrowRight,
ArrowUpRight,
CheckCircle2,
ClipboardCopy,
ExternalLink,
KeyRound,
LoaderCircle,
RotateCcw,
ShieldAlert,
ShieldCheck,
XCircle,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Progress } from "@/components/ui/progress"
import type {
WorkspaceOnboardingFlowState,
WorkspaceOnboardingProviderState,
WorkspaceOnboardingRequestState,
WorkspaceOnboardingState,
WorkspaceOnboardingValidationResult,
} from "@/lib/sf-workspace-store"
import { cn } from "@/lib/utils"
// ─── Error parsing ──────────────────────────────────────────────────
function parseValidationError(raw: string | null | undefined): { title: string; detail: string | null } {
if (!raw) return { title: "Validation failed", detail: null }
const jsonInStatusMatch = raw.match(/^\d{3}\s+[^:]+:\s*(.+)$/s)
const jsonCandidate = jsonInStatusMatch?.[1] ?? raw
try {
const parsed = JSON.parse(jsonCandidate)
if (typeof parsed === "object" && parsed !== null) {
const message = parsed.error_details?.message ?? parsed.error?.message ?? parsed.message ?? parsed.error ?? null
if (typeof message === "string" && message.length > 0) {
if (/subscription.*(ended|expired|cancelled)/i.test(message))
return { title: "Subscription expired", detail: message.replace(/\.$/, "") + ". Check your plan status with this provider." }
if (/rate.limit/i.test(message))
return { title: "Rate limited", detail: "Too many requests. Wait a moment and try again." }
if (/invalid.*key|invalid.*token|incorrect.*key/i.test(message))
return { title: "Invalid credentials", detail: "The API key was rejected. Double-check and try again." }
if (/quota|billing|payment/i.test(message))
return { title: "Billing issue", detail: message }
return { title: "Provider error", detail: message }
}
}
} catch { /* not JSON */ }
if (/^401\b/i.test(raw)) return { title: "Unauthorized", detail: "The credentials were rejected. Double-check your API key." }
if (/^403\b/i.test(raw)) return { title: "Access denied", detail: "Your account doesn't have access. Check your subscription or permissions." }
if (/^429\b/i.test(raw)) return { title: "Rate limited", detail: "Too many requests. Wait a moment and try again." }
if (/^5\d{2}\b/i.test(raw)) return { title: "Server error", detail: "The provider returned an error. Try again in a minute." }
return { title: "Validation failed", detail: raw.length > 200 ? raw.slice(0, 200) + "…" : raw }
}
/** Extract a device code from instructions/prompt text */
function extractDeviceCode(flow: WorkspaceOnboardingFlowState): string | null {
const sources = [flow.prompt?.message, flow.auth?.instructions].filter(Boolean)
for (const src of sources) {
const match = src?.match(/(?:code|Code)[:\s]+([A-Z0-9]{4}[-][A-Z0-9]{4})/i)
if (match) return match[1]
}
return null
}
// ─── Component ──────────────────────────────────────────────────────
interface StepAuthenticateProps {
provider: WorkspaceOnboardingProviderState
activeFlow: WorkspaceOnboardingFlowState | null
lastValidation: WorkspaceOnboardingValidationResult | null
requestState: WorkspaceOnboardingRequestState
requestProviderId: string | null
onSaveApiKey: (providerId: string, apiKey: string) => Promise<WorkspaceOnboardingState | null>
onStartFlow: (providerId: string) => void
onSubmitFlowInput: (flowId: string, input: string) => void
onCancelFlow: (flowId: string) => void
onBack: () => void
onNext: () => void
bridgeRefreshPhase: "idle" | "pending" | "succeeded" | "failed"
bridgeRefreshError: string | null
}
export function StepAuthenticate({
provider,
activeFlow,
lastValidation,
requestState,
requestProviderId,
onSaveApiKey,
onStartFlow,
onSubmitFlowInput,
onCancelFlow,
onBack,
onNext,
bridgeRefreshPhase,
bridgeRefreshError,
}: StepAuthenticateProps) {
const [apiKey, setApiKey] = useState("")
const [flowInput, setFlowInput] = useState("")
const [copied, setCopied] = useState(false)
const isBusy = requestState !== "idle"
const isThisProviderBusy = requestProviderId === provider.id && isBusy
const isValidated = lastValidation?.status === "succeeded" && lastValidation.providerId === provider.id
const isBridgeDone = bridgeRefreshPhase === "succeeded" || bridgeRefreshPhase === "idle"
const canProceed = isValidated && isBridgeDone
const validationFailed = lastValidation?.status === "failed" && lastValidation.providerId === provider.id
const parsedError = validationFailed ? parseValidationError(lastValidation.message) : null
const isOAuthOnly = !provider.supports.apiKey && provider.supports.oauth
const hasOAuth = provider.supports.oauth && provider.supports.oauthAvailable
const hasApiKey = provider.supports.apiKey
// Active flow state
const flowActive = activeFlow && activeFlow.providerId === provider.id && !canProceed
const flowFailed = flowActive && activeFlow.status === "failed"
const flowRunning = flowActive && (activeFlow.status === "running" || activeFlow.status === "awaiting_browser_auth")
const flowWaiting = flowActive && activeFlow.status === "awaiting_input"
const deviceCode = flowActive ? extractDeviceCode(activeFlow) : null
useEffect(() => {
if (lastValidation?.status !== "succeeded") return
const t = window.setTimeout(() => setApiKey(""), 0)
return () => window.clearTimeout(t)
}, [lastValidation?.checkedAt, lastValidation?.status])
useEffect(() => {
const t = window.setTimeout(() => setFlowInput(""), 0)
return () => window.clearTimeout(t)
}, [activeFlow?.flowId])
useEffect(() => {
if (!copied) return
const t = window.setTimeout(() => setCopied(false), 2000)
return () => window.clearTimeout(t)
}, [copied])
const copyCode = (code: string) => {
navigator.clipboard.writeText(code).then(() => setCopied(true)).catch(() => {})
}
return (
<div className="flex flex-col items-center">
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="text-center"
>
<h2 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
Connect {provider.label}
</h2>
<p className="mt-2 max-w-sm text-sm leading-relaxed text-muted-foreground">
{canProceed
? "Authenticated and ready to go."
: hasApiKey && hasOAuth
? "Paste an API key or sign in through your browser."
: hasApiKey
? "Paste your API key to authenticate."
: "Sign in through your browser to authenticate."}
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.08, duration: 0.45 }}
className="mt-8 w-full max-w-md space-y-4"
>
{/* ─── Success state ─── */}
<AnimatePresence>
{canProceed && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", duration: 0.4, bounce: 0 }}
className="flex flex-col items-center gap-3 rounded-xl border border-success/15 bg-success/[0.04] px-6 py-6 text-center"
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-success/15">
<ShieldCheck className="h-5 w-5 text-success" />
</div>
<div className="text-sm font-medium text-foreground">{provider.label} authenticated</div>
</motion.div>
)}
</AnimatePresence>
{/* ─── Validation error ─── */}
{validationFailed && parsedError && (
<div className="flex items-start gap-3 rounded-xl border border-destructive/20 bg-destructive/[0.06] px-4 py-3 text-sm">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div>
<div className="font-medium text-destructive">{parsedError.title}</div>
{parsedError.detail && <div className="mt-0.5 text-muted-foreground">{parsedError.detail}</div>}
</div>
</div>
)}
{/* ─── Bridge refresh ─── */}
{bridgeRefreshPhase === "pending" && (
<div className="space-y-2">
<div className="flex items-center gap-3 rounded-xl border border-foreground/10 bg-foreground/[0.03] px-4 py-3 text-sm text-foreground/80">
<LoaderCircle className="h-4 w-4 shrink-0 animate-spin" />
Connecting to provider
</div>
<Progress value={66} className="h-1" />
</div>
)}
{bridgeRefreshPhase === "failed" && bridgeRefreshError && (
<div className="flex items-start gap-3 rounded-xl border border-destructive/20 bg-destructive/[0.06] px-4 py-3 text-sm">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div>
<div className="font-medium text-destructive">Connection failed</div>
<div className="mt-0.5 text-muted-foreground">{bridgeRefreshError}</div>
</div>
</div>
)}
{/* ─── API key form ─── */}
{hasApiKey && !canProceed && (
<div className="space-y-3 rounded-xl border border-border/50 bg-card/50 p-4">
<div className="text-sm font-medium text-foreground">API key</div>
<form
className="space-y-3"
onSubmit={async (e) => {
e.preventDefault()
if (!apiKey.trim()) return
const next = await onSaveApiKey(provider.id, apiKey)
if (next && !next.locked && (next.bridgeAuthRefresh.phase === "succeeded" || next.bridgeAuthRefresh.phase === "idle")) {
onNext()
}
}}
>
<Input
data-testid="onboarding-api-key-input"
type="password"
autoComplete="off"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={`Paste your ${provider.label} API key`}
disabled={isBusy}
className="font-mono text-sm"
/>
<div className="flex items-center gap-2">
<Button
type="submit"
disabled={!apiKey.trim() || isBusy}
className="gap-2 transition-transform active:scale-[0.96]"
data-testid="onboarding-save-api-key"
>
{isThisProviderBusy && requestState === "saving_api_key" ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<KeyRound className="h-4 w-4" />
)}
Validate & save
</Button>
</div>
</form>
</div>
)}
{/* ─── OAuth section ─── */}
{hasOAuth && !canProceed && (
<div className="space-y-3">
{/* Divider between API key and OAuth */}
{hasApiKey && (
<div className="flex items-center gap-3 py-1">
<div className="h-px flex-1 bg-border/50" />
<span className="text-xs text-muted-foreground">or</span>
<div className="h-px flex-1 bg-border/50" />
</div>
)}
{/* ─── No active flow: show start button ─── */}
{!flowActive && (
<div className="rounded-xl border border-border/50 bg-card/50 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-foreground">Browser sign-in</div>
<p className="mt-0.5 text-xs text-muted-foreground">
Opens a new tab to authenticate with {provider.label}
</p>
</div>
<Button
variant="outline"
disabled={isBusy}
onClick={() => onStartFlow(provider.id)}
className="shrink-0 gap-2 transition-transform active:scale-[0.96]"
data-testid="onboarding-start-provider-flow"
>
{isThisProviderBusy && requestState === "starting_provider_flow" ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<ArrowUpRight className="h-4 w-4" />
)}
Sign in
</Button>
</div>
</div>
)}
{/* ─── Active flow: device code UX ─── */}
{flowActive && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="rounded-xl border border-border/50 bg-card/50 p-4 space-y-4"
data-testid="onboarding-active-flow"
>
{/* Device code — big and prominent */}
{deviceCode && (
<div className="flex flex-col items-center gap-3 py-2">
<div className="text-xs text-muted-foreground">Enter this code on the sign-in page</div>
<button
type="button"
onClick={() => copyCode(deviceCode)}
className="group flex items-center gap-3 rounded-lg border border-border bg-background/50 px-5 py-3 transition-colors hover:border-foreground/20 active:scale-[0.98]"
>
<span className="font-mono text-2xl font-bold tracking-[0.15em] text-foreground">
{deviceCode}
</span>
<span className="text-muted-foreground transition-colors group-hover:text-muted-foreground">
{copied ? (
<CheckCircle2 className="h-4 w-4 text-success" />
) : (
<ClipboardCopy className="h-4 w-4" />
)}
</span>
</button>
<div className="text-[11px] text-muted-foreground">
{copied ? "Copied!" : "Click to copy"}
</div>
</div>
)}
{/* Instructions text (when no device code extracted) */}
{!deviceCode && activeFlow.auth?.instructions && (
<p className="text-sm text-muted-foreground">{activeFlow.auth.instructions}</p>
)}
{/* Open sign-in page button */}
{activeFlow.auth?.url && (
<Button asChild className="w-full gap-2 transition-transform active:scale-[0.96]">
<a href={activeFlow.auth.url} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
Open sign-in page
</a>
</Button>
)}
{/* Status indicator */}
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2 text-muted-foreground">
{flowRunning && (
<>
<LoaderCircle className="h-3 w-3 animate-spin" />
<span>Waiting for authentication</span>
</>
)}
{flowFailed && (
<>
<XCircle className="h-3 w-3 text-destructive" />
<span className="text-destructive">Sign-in failed or timed out</span>
</>
)}
{flowWaiting && !deviceCode && (
<>
<LoaderCircle className="h-3 w-3 animate-spin" />
<span>Waiting for input</span>
</>
)}
</div>
<div className="flex items-center gap-1">
{flowFailed && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onStartFlow(provider.id)}
disabled={isBusy}
className="h-7 gap-1.5 text-xs text-muted-foreground"
>
<RotateCcw className="h-3 w-3" />
Retry
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onCancelFlow(activeFlow.flowId)}
disabled={isBusy}
className="h-7 text-xs text-muted-foreground"
>
Cancel
</Button>
</div>
</div>
{/* Generic prompt input (non-device-code) */}
{activeFlow.prompt && !deviceCode && (
<form
className="space-y-2 border-t border-border/50 pt-3"
onSubmit={(e) => {
e.preventDefault()
if (!activeFlow.prompt?.allowEmpty && !flowInput.trim()) return
onSubmitFlowInput(activeFlow.flowId, flowInput)
}}
>
<div className="text-xs text-muted-foreground">{activeFlow.prompt.message}</div>
<div className="flex gap-2">
<Input
data-testid="onboarding-flow-input"
value={flowInput}
onChange={(e) => setFlowInput(e.target.value)}
placeholder={activeFlow.prompt.placeholder || "Enter value"}
disabled={isBusy}
className="text-sm"
/>
<Button
type="submit"
disabled={isBusy || (!activeFlow.prompt.allowEmpty && !flowInput.trim())}
className="shrink-0 transition-transform active:scale-[0.96]"
>
{requestState === "submitting_provider_flow_input" ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
"Submit"
)}
</Button>
</div>
</form>
)}
{/* Progress messages */}
{activeFlow.progress.length > 0 && (
<div className="space-y-1 border-t border-border/50 pt-3">
{activeFlow.progress.map((message, i) => (
<div key={`${activeFlow.flowId}-${i}`} className="text-xs text-muted-foreground">
{message}
</div>
))}
</div>
)}
</motion.div>
)}
</div>
)}
{/* OAuth unavailable */}
{provider.supports.oauth && !provider.supports.oauthAvailable && !hasApiKey && (
<div className="rounded-xl border border-border/50 bg-card/50 px-4 py-3.5 text-sm text-muted-foreground">
Browser sign-in is not available in this runtime. Go back and choose a provider with API-key support.
</div>
)}
</motion.div>
{/* Navigation */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.15, duration: 0.3 }}
className="mt-8 flex w-full max-w-md items-center justify-between"
>
<Button
variant="ghost"
onClick={onBack}
className="text-muted-foreground transition-transform active:scale-[0.96]"
>
Back
</Button>
<Button
onClick={onNext}
disabled={!canProceed}
className="group gap-2 transition-transform active:scale-[0.96]"
data-testid="onboarding-auth-continue"
>
Configure another provider
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</Button>
</motion.div>
</div>
)
}

View file

@ -0,0 +1,369 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { motion, AnimatePresence } from "motion/react"
import {
ArrowRight,
ChevronRight,
CornerLeftUp,
Folder,
FolderOpen,
FolderRoot,
Loader2,
SkipForward,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ScrollArea } from "@/components/ui/scroll-area"
import { cn } from "@/lib/utils"
import { authFetch } from "@/lib/auth"
interface StepDevRootProps {
onNext: () => void
onBack: () => void
}
const SUGGESTED_PATHS = ["~/Projects", "~/Developer", "~/Code", "~/dev"]
// ─── Inline folder browser ──────────────────────────────────────────
interface BrowseEntry {
name: string
path: string
}
function InlineFolderBrowser({
onSelect,
onCancel,
}: {
onSelect: (path: string) => void
onCancel: () => void
}) {
const [currentPath, setCurrentPath] = useState("")
const [parentPath, setParentPath] = useState<string | null>(null)
const [entries, setEntries] = useState<BrowseEntry[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const browse = useCallback(async (targetPath?: string) => {
setLoading(true)
setError(null)
try {
const param = targetPath ? `?path=${encodeURIComponent(targetPath)}` : ""
const res = await authFetch(`/api/browse-directories${param}`)
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error((body as { error?: string }).error ?? `${res.status}`)
}
const data = (await res.json()) as { current: string; parent: string | null; entries: BrowseEntry[] }
setCurrentPath(data.current)
setParentPath(data.parent)
setEntries(data.entries)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to browse")
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void browse()
}, [browse])
return (
<div className="rounded-xl border border-border/50 bg-card/50 overflow-hidden">
{/* Current path */}
<div className="flex items-center justify-between gap-2 border-b border-border/50 px-4 py-2.5">
<p className="min-w-0 truncate font-mono text-xs text-muted-foreground" title={currentPath}>
{currentPath}
</p>
<Button
type="button"
size="sm"
onClick={() => onSelect(currentPath)}
className="shrink-0 h-7 gap-1.5 text-xs transition-transform active:scale-[0.96]"
>
Select this folder
</Button>
</div>
{/* Directory listing */}
<ScrollArea className="h-[240px]">
<div className="px-1.5 py-1">
{loading && (
<div className="flex items-center justify-center py-10">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
{error && (
<div className="px-3 py-4 text-center text-xs text-destructive">{error}</div>
)}
{!loading && !error && (
<>
{parentPath && (
<button
type="button"
onClick={() => void browse(parentPath)}
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent/50"
>
<CornerLeftUp className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">..</span>
</button>
)}
{entries.map((entry) => (
<button
key={entry.path}
type="button"
onClick={() => void browse(entry.path)}
className="group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent/50"
>
<Folder className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-foreground">{entry.name}</span>
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/50 opacity-0 transition-opacity group-hover:opacity-100" />
</button>
))}
{entries.length === 0 && !parentPath && (
<div className="px-3 py-8 text-center text-xs text-muted-foreground">
No subdirectories
</div>
)}
</>
)}
</div>
</ScrollArea>
{/* Cancel */}
<div className="border-t border-border/50 px-4 py-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onCancel}
className="h-7 text-xs text-muted-foreground"
>
Cancel
</Button>
</div>
</div>
)
}
// ─── Main step ──────────────────────────────────────────────────────
export function StepDevRoot({ onNext, onBack }: StepDevRootProps) {
const [path, setPath] = useState("")
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [browsing, setBrowsing] = useState(false)
const handleSuggestionClick = useCallback((suggestion: string) => {
setPath(suggestion)
setError(null)
}, [])
const handleContinue = useCallback(async () => {
const trimmed = path.trim()
if (!trimmed) {
setError("Enter a path or skip this step")
return
}
setSaving(true)
setError(null)
try {
const res = await authFetch("/api/preferences", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ devRoot: trimmed }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(
(body as { error?: string }).error ?? `Request failed (${res.status})`,
)
}
onNext()
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save preference")
} finally {
setSaving(false)
}
}, [path, onNext])
return (
<div className="flex flex-col items-center text-center">
{/* Icon */}
<motion.div
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", duration: 0.5, bounce: 0 }}
className="mb-8"
>
<div className="flex h-14 w-14 items-center justify-center rounded-xl border border-border/50 bg-card/50">
<FolderRoot className="h-7 w-7 text-foreground/80" strokeWidth={1.5} />
</div>
</motion.div>
<motion.h2
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.06, duration: 0.4 }}
className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl"
>
Dev root
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.12, duration: 0.4 }}
className="mt-2 max-w-sm text-sm leading-relaxed text-muted-foreground"
>
The folder that contains your projects. GSD discovers and manages workspaces inside it.
</motion.p>
{/* Input + browse */}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.18, duration: 0.45 }}
className="mt-8 w-full max-w-md space-y-4"
>
<AnimatePresence mode="wait">
{browsing ? (
<motion.div
key="browser"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
>
<InlineFolderBrowser
onSelect={(selected) => {
setPath(selected)
setBrowsing(false)
setError(null)
}}
onCancel={() => setBrowsing(false)}
/>
</motion.div>
) : (
<motion.div key="input" className="space-y-4">
<div className="flex gap-2">
<Input
value={path}
onChange={(e) => {
setPath(e.target.value)
if (error) setError(null)
}}
placeholder="/Users/you/Projects"
className={cn(
"h-11 flex-1 font-mono text-sm",
error && "border-destructive/50 focus-visible:ring-destructive/30",
)}
data-testid="onboarding-devroot-input"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && path.trim()) {
void handleContinue()
}
}}
/>
<Button
type="button"
variant="outline"
onClick={() => setBrowsing(true)}
className="h-11 gap-2 shrink-0 transition-transform active:scale-[0.96]"
>
<FolderOpen className="h-4 w-4" />
Browse
</Button>
</div>
{error && (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
)}
{/* Suggestions */}
<div className="flex flex-wrap items-center justify-center gap-2">
{SUGGESTED_PATHS.map((suggestion) => (
<button
key={suggestion}
type="button"
onClick={() => handleSuggestionClick(suggestion)}
className={cn(
"rounded-full border px-3 py-1 font-mono text-xs transition-all duration-150",
"active:scale-[0.96]",
path === suggestion
? "border-foreground/25 bg-foreground/10 text-foreground"
: "border-border/50 text-muted-foreground hover:border-foreground/15 hover:text-foreground",
)}
>
{suggestion}
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
{/* Navigation */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.25, duration: 0.3 }}
className="mt-8 flex w-full max-w-md items-center justify-between"
>
<Button
variant="ghost"
onClick={onBack}
className="text-muted-foreground transition-transform active:scale-[0.96]"
>
Back
</Button>
<div className="flex items-center gap-2">
<Button
variant="ghost"
onClick={onNext}
className="gap-1.5 text-muted-foreground transition-transform active:scale-[0.96]"
data-testid="onboarding-devroot-skip"
>
Skip
<SkipForward className="h-3.5 w-3.5" />
</Button>
<Button
onClick={() => void handleContinue()}
className="group gap-2 transition-transform active:scale-[0.96]"
disabled={saving || browsing}
data-testid="onboarding-devroot-continue"
>
{saving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Saving
</>
) : (
<>
Continue
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</>
)}
</Button>
</div>
</motion.div>
</div>
)
}

View file

@ -0,0 +1,186 @@
"use client"
import { motion } from "motion/react"
import { ArrowRight, Code2, MessageCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import type { UserMode } from "@/lib/use-user-mode"
interface StepModeProps {
selected: UserMode | null
onSelect: (mode: UserMode) => void
onNext: () => void
onBack: () => void
}
const MODE_OPTIONS: {
id: UserMode
label: string
icon: typeof Code2
tagline: string
description: string
}[] = [
{
id: "expert",
label: "Expert",
icon: Code2,
tagline: "Full control",
description:
"Dashboard metrics, dual-pane power mode, and direct /gsd command access. Built for people who want visibility into every milestone and task.",
},
{
id: "vibe-coder",
label: "Vibe Coder",
icon: MessageCircle,
tagline: "Just chat",
description:
"Conversational interface with the AI agent. Describe what you want and let GSD handle the structure. Same engine, friendlier surface.",
},
]
export function StepMode({ selected, onSelect, onNext, onBack }: StepModeProps) {
return (
<div className="flex flex-col items-center text-center">
<motion.h2
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl"
style={{ textWrap: "balance" } as React.CSSProperties}
>
How do you want to work?
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.06, duration: 0.4 }}
className="mt-2 max-w-sm text-sm leading-relaxed text-muted-foreground"
>
You can switch anytime from settings.
</motion.p>
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.12, duration: 0.45 }}
className="mt-8 grid w-full max-w-lg gap-3 sm:grid-cols-2"
>
{MODE_OPTIONS.map((opt) => {
const isSelected = selected === opt.id
const Icon = opt.icon
return (
<button
key={opt.id}
type="button"
onClick={() => onSelect(opt.id)}
className={cn(
"group relative flex flex-col rounded-xl border px-5 py-5 text-left transition-all duration-200",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"active:scale-[0.98]",
isSelected
? "border-foreground/30 bg-foreground/[0.06] shadow-[0_0_0_1px_rgba(255,255,255,0.06)]"
: "border-border/50 bg-card/50 hover:border-foreground/15 hover:bg-card/50",
)}
data-testid={`onboarding-mode-${opt.id}`}
>
{/* Selection indicator */}
<div className="absolute right-3.5 top-3.5">
<div
className={cn(
"flex h-5 w-5 items-center justify-center rounded-full border-[1.5px] transition-all duration-200",
isSelected
? "border-foreground bg-foreground"
: "border-foreground/20",
)}
>
{isSelected && (
<motion.svg
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
viewBox="0 0 12 12"
className="h-2.5 w-2.5 text-background"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="2.5 6 5 8.5 9.5 3.5" />
</motion.svg>
)}
</div>
</div>
{/* Icon */}
<div
className={cn(
"mb-4 flex h-10 w-10 items-center justify-center rounded-lg transition-colors duration-200",
isSelected
? "bg-foreground/10"
: "bg-foreground/[0.04]",
)}
>
<Icon
className={cn(
"h-5 w-5 transition-colors duration-200",
isSelected ? "text-foreground" : "text-muted-foreground",
)}
strokeWidth={1.5}
/>
</div>
{/* Label + tagline */}
<div className="pr-7">
<span className="text-[15px] font-semibold text-foreground">
{opt.label}
</span>
<span
className={cn(
"ml-2 text-xs font-medium transition-colors duration-200",
isSelected ? "text-muted-foreground" : "text-muted-foreground",
)}
>
{opt.tagline}
</span>
</div>
{/* Description */}
<p className="mt-2 text-[13px] leading-relaxed text-muted-foreground">
{opt.description}
</p>
</button>
)
})}
</motion.div>
{/* Navigation */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.3 }}
className="mt-8 flex w-full max-w-lg items-center justify-between"
>
<Button
variant="ghost"
onClick={onBack}
className="text-muted-foreground transition-transform active:scale-[0.96]"
>
Back
</Button>
<Button
onClick={onNext}
disabled={!selected}
className="group gap-2 transition-transform active:scale-[0.96]"
data-testid="onboarding-mode-continue"
>
Continue
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</Button>
</motion.div>
</div>
)
}

View file

@ -0,0 +1,161 @@
"use client"
import { motion } from "motion/react"
import { ArrowRight, Check, CircleDashed } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import type { WorkspaceOnboardingOptionalSectionState } from "@/lib/sf-workspace-store"
import { cn } from "@/lib/utils"
interface StepOptionalProps {
sections: WorkspaceOnboardingOptionalSectionState[]
onBack: () => void
onNext: () => void
}
export function StepOptional({ sections, onBack, onNext }: StepOptionalProps) {
// Remote questions has its own dedicated step — don't show it here
const filtered = sections.filter((s) => s.id !== "remote_questions")
const configuredCount = filtered.filter((s) => s.configured).length
return (
<div className="flex flex-col items-center">
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="text-center"
>
<h2 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
Integrations
</h2>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
Optional tools. Nothing here blocks the workspace configure later from settings.
</p>
</motion.div>
{configuredCount > 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.08, duration: 0.3 }}
className="mt-4"
>
<span className="text-xs text-muted-foreground">
<span className="font-medium text-success">{configuredCount}</span>
{" of "}
{filtered.length} configured
</span>
</motion.div>
)}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1, duration: 0.45 }}
className="mt-8 w-full space-y-2"
>
{filtered.map((section) => (
<div
key={section.id}
className={cn(
"flex items-start gap-3.5 rounded-xl border px-4 py-3.5 transition-colors",
section.configured
? "border-success/15 bg-success/[0.03]"
: "border-border/50 bg-card/50",
)}
data-testid={`onboarding-optional-${section.id}`}
>
{/* Status dot */}
<div
className={cn(
"mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full",
section.configured
? "bg-success/15 text-success"
: "bg-foreground/[0.05] text-muted-foreground",
)}
>
{section.configured ? (
<Check className="h-3 w-3" strokeWidth={3} />
) : (
<CircleDashed className="h-3 w-3" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-foreground">{section.label}</span>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className={cn(
"text-[10px]",
section.configured
? "border-success/15 text-success/70"
: "border-border/50 text-muted-foreground",
)}
>
{section.configured ? "Ready" : "Skipped"}
</Badge>
</TooltipTrigger>
<TooltipContent>
{section.configured
? "This integration is configured and active"
: "You can set this up later from workspace settings"}
</TooltipContent>
</Tooltip>
</div>
{section.configuredItems.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{section.configuredItems.map((item) => (
<Badge
key={item}
variant="outline"
className="border-border/50 text-[10px] text-muted-foreground"
>
{item}
</Badge>
))}
</div>
)}
{section.configuredItems.length === 0 && (
<p className="mt-0.5 text-xs text-muted-foreground">
Not configured add later from settings.
</p>
)}
</div>
</div>
))}
</motion.div>
{/* Navigation */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.18, duration: 0.3 }}
className="mt-8 flex w-full items-center justify-between"
>
<Button
variant="ghost"
onClick={onBack}
className="text-muted-foreground transition-transform active:scale-[0.96]"
>
Back
</Button>
<Button
onClick={onNext}
className="group gap-2 transition-transform active:scale-[0.96]"
data-testid="onboarding-optional-continue"
>
Continue
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</Button>
</motion.div>
</div>
)
}

View file

@ -0,0 +1,470 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { motion } from "motion/react"
import {
ArrowRight,
FolderOpen,
GitBranch,
Layers,
Loader2,
Plus,
Sparkles,
Zap,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { useProjectStoreManager } from "@/lib/project-store-manager"
import { cn } from "@/lib/utils"
import { authFetch } from "@/lib/auth"
// ─── Types ──────────────────────────────────────────────────────────
type ProjectDetectionKind = "active-gsd" | "empty-gsd" | "v1-legacy" | "brownfield" | "blank"
interface ProjectDetectionSignals {
hasGsdFolder: boolean
hasPlanningFolder: boolean
hasGitRepo: boolean
hasPackageJson: boolean
fileCount: number
hasMilestones?: boolean
hasCargo?: boolean
hasGoMod?: boolean
hasPyproject?: boolean
isMonorepo?: boolean
}
interface ProjectProgressInfo {
activeMilestone: string | null
activeSlice: string | null
phase: string | null
milestonesCompleted: number
milestonesTotal: number
}
interface ProjectMetadata {
name: string
path: string
kind: ProjectDetectionKind
signals: ProjectDetectionSignals
lastModified: number
progress?: ProjectProgressInfo | null
}
// ─── Helpers ────────────────────────────────────────────────────────
const KIND_STYLE: Record<ProjectDetectionKind, { label: string; color: string; icon: typeof Layers }> = {
"active-gsd": { label: "Active", color: "text-success", icon: Layers },
"empty-gsd": { label: "Initialized", color: "text-info", icon: FolderOpen },
brownfield: { label: "Existing", color: "text-warning", icon: GitBranch },
"v1-legacy": { label: "Legacy", color: "text-warning", icon: GitBranch },
blank: { label: "New", color: "text-muted-foreground", icon: Sparkles },
}
function techStack(signals: ProjectDetectionSignals): string[] {
const tags: string[] = []
if (signals.isMonorepo) tags.push("Monorepo")
if (signals.hasGitRepo) tags.push("Git")
if (signals.hasPackageJson) tags.push("Node.js")
if (signals.hasCargo) tags.push("Rust")
if (signals.hasGoMod) tags.push("Go")
if (signals.hasPyproject) tags.push("Python")
return tags
}
function progressLabel(p: ProjectProgressInfo): string | null {
if (p.milestonesTotal === 0) return null
const parts: string[] = []
if (p.activeMilestone) parts.push(p.activeMilestone)
if (p.activeSlice) parts.push(p.activeSlice)
if (p.phase) parts.push(p.phase)
return parts.join(" · ") || null
}
function shortenPath(p: string): string {
const home = typeof window !== "undefined" ? "" : ""
// Show last 2-3 segments
const parts = p.split("/").filter(Boolean)
if (parts.length <= 3) return p
return "…/" + parts.slice(-2).join("/")
}
// ─── Component ──────────────────────────────────────────────────────
interface StepProjectProps {
onFinish: (projectPath: string) => void
onBack: () => void
/** Called immediately before a project switch starts — use to disarm gates. */
onBeforeSwitch?: () => void
}
export function StepProject({ onFinish, onBack, onBeforeSwitch }: StepProjectProps) {
const manager = useProjectStoreManager()
const [devRoot, setDevRoot] = useState<string | null>(null)
const [projects, setProjects] = useState<ProjectMetadata[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showCreate, setShowCreate] = useState(false)
const [newName, setNewName] = useState("")
const [creating, setCreating] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const createInputRef = useRef<HTMLInputElement>(null)
const [switchingTo, setSwitchingTo] = useState<string | null>(null)
const switchPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
setLoading(true)
setError(null)
try {
const prefsRes = await authFetch("/api/preferences")
if (!prefsRes.ok) throw new Error("Failed to load preferences")
const prefs = await prefsRes.json()
if (!prefs.devRoot) { setDevRoot(null); setProjects([]); setLoading(false); return }
setDevRoot(prefs.devRoot)
const projRes = await authFetch(`/api/projects?root=${encodeURIComponent(prefs.devRoot)}&detail=true`)
if (!projRes.ok) throw new Error("Failed to discover projects")
const discovered = (await projRes.json()) as ProjectMetadata[]
if (!cancelled) setProjects(discovered)
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : "Unknown error")
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => { cancelled = true }
}, [])
useEffect(() => {
return () => { if (switchPollRef.current) clearInterval(switchPollRef.current) }
}, [])
useEffect(() => {
if (showCreate) {
const t = setTimeout(() => createInputRef.current?.focus(), 50)
return () => clearTimeout(t)
}
}, [showCreate])
const existingNames = projects.map((p) => p.name)
const nameValid = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(newName)
const nameConflict = existingNames.includes(newName)
const canCreate = newName.length > 0 && nameValid && !nameConflict && !creating
const handleSelectProject = useCallback((project: ProjectMetadata) => {
onBeforeSwitch?.()
setSwitchingTo(project.path)
const store = manager.switchProject(project.path)
if (switchPollRef.current) clearInterval(switchPollRef.current)
const startTime = Date.now()
switchPollRef.current = setInterval(() => {
const state = store.getSnapshot()
const elapsed = Date.now() - startTime
if (state.bootStatus === "ready" || state.bootStatus === "error" || elapsed > 30000) {
if (switchPollRef.current) clearInterval(switchPollRef.current)
switchPollRef.current = null
setSwitchingTo(null)
onFinish(project.path)
}
}, 150)
}, [manager, onFinish, onBeforeSwitch])
const handleCreate = useCallback(async () => {
if (!canCreate || !devRoot) return
setCreating(true)
setCreateError(null)
try {
const res = await authFetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ devRoot, name: newName }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error((body as { error?: string }).error ?? `Failed (${res.status})`)
}
const project = (await res.json()) as ProjectMetadata
setProjects((prev) => [...prev, project].sort((a, b) => a.name.localeCompare(b.name)))
setNewName("")
setShowCreate(false)
handleSelectProject(project)
} catch (err) {
setCreateError(err instanceof Error ? err.message : "Failed to create project")
setCreating(false)
}
}, [canCreate, devRoot, newName, handleSelectProject])
const noDevRoot = !loading && !devRoot
// Sort: active-gsd first, then by name
const sortedProjects = [...projects].sort((a, b) => {
const kindOrder: Record<ProjectDetectionKind, number> = { "active-gsd": 0, "empty-gsd": 1, brownfield: 2, "v1-legacy": 3, blank: 4 }
const ka = kindOrder[a.kind] ?? 5
const kb = kindOrder[b.kind] ?? 5
if (ka !== kb) return ka - kb
return a.name.localeCompare(b.name)
})
return (
<div className="flex flex-col items-center">
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="text-center"
>
<h2 className="text-2xl font-bold tracking-tight text-foreground sm:text-3xl">
Open a project
</h2>
<p className="mt-2 max-w-sm text-sm leading-relaxed text-muted-foreground">
{noDevRoot
? "Set a dev root first to discover your projects."
: "Pick a project to start working in, or create a new one."}
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.08, duration: 0.45 }}
className="mt-8 w-full max-w-lg space-y-2"
>
{loading && (
<div className="flex items-center justify-center gap-2 py-10 text-xs text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Discovering projects
</div>
)}
{error && (
<div className="rounded-xl border border-destructive/20 bg-destructive/[0.06] px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
{noDevRoot && (
<div className="rounded-xl border border-border/50 bg-card/50 px-4 py-6 text-center text-sm text-muted-foreground">
No dev root configured. Go back and set one, or finish setup to configure later.
</div>
)}
{/* Project cards */}
{!loading && sortedProjects.length > 0 && (
<div className="space-y-2">
{sortedProjects.map((project) => {
const isSwitching = switchingTo === project.path
const style = KIND_STYLE[project.kind]
const KindIcon = style.icon
const stack = techStack(project.signals)
const progress = project.progress ? progressLabel(project.progress) : null
const milestoneCount = project.progress
? `${project.progress.milestonesCompleted}/${project.progress.milestonesTotal}`
: null
return (
<button
key={project.path}
type="button"
onClick={() => handleSelectProject(project)}
disabled={!!switchingTo}
className={cn(
"group flex w-full items-start gap-3.5 rounded-xl border px-4 py-3.5 text-left transition-all duration-200",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"active:scale-[0.98]",
isSwitching
? "border-foreground/30 bg-foreground/[0.06]"
: "border-border/50 bg-card/50 hover:border-foreground/15 hover:bg-card/50",
switchingTo && !isSwitching && "opacity-40 pointer-events-none",
)}
>
{/* Icon */}
<div className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg mt-0.5",
project.kind === "active-gsd" ? "bg-success/10" : "bg-foreground/[0.04]",
)}>
{isSwitching ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<KindIcon className={cn("h-4 w-4", style.color)} />
)}
</div>
{/* Content */}
<div className="min-w-0 flex-1">
{/* Row 1: name + kind badge */}
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground truncate">{project.name}</span>
<span className={cn("text-[10px] font-medium shrink-0", style.color)}>
{style.label}
</span>
</div>
{/* Row 2: tech stack tags */}
{stack.length > 0 && (
<div className="mt-1 flex items-center gap-1.5">
{stack.map((tag) => (
<span
key={tag}
className="rounded bg-foreground/[0.04] px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
{tag}
</span>
))}
</div>
)}
{/* Row 3: progress info (for active-gsd projects) */}
{progress && (
<div className="mt-1.5 text-[11px] text-muted-foreground">
{progress}
</div>
)}
{/* Row 4: milestone bar (for active-gsd with milestones) */}
{project.progress && project.progress.milestonesTotal > 0 && (
<div className="mt-2 flex items-center gap-2">
<div className="h-1 flex-1 overflow-hidden rounded-full bg-foreground/[0.06]">
<div
className="h-full rounded-full bg-success/60 transition-all"
style={{
width: `${Math.round((project.progress.milestonesCompleted / project.progress.milestonesTotal) * 100)}%`,
}}
/>
</div>
<span className="text-[10px] tabular-nums text-muted-foreground">
{milestoneCount}
</span>
</div>
)}
</div>
{/* Arrow */}
<ArrowRight className="mt-1 h-4 w-4 shrink-0 text-muted-foreground/50 transition-all group-hover:text-muted-foreground group-hover:translate-x-0.5" />
</button>
)
})}
</div>
)}
{!loading && devRoot && projects.length === 0 && !error && (
<div className="rounded-xl border border-border/50 bg-card/50 px-4 py-6 text-center text-sm text-muted-foreground">
No projects found in {devRoot}
</div>
)}
{/* Create new project */}
{!loading && devRoot && (
<>
{!showCreate ? (
<button
type="button"
onClick={() => setShowCreate(true)}
disabled={!!switchingTo}
className={cn(
"flex w-full items-center gap-3.5 rounded-xl border border-dashed px-4 py-3.5 text-left transition-all duration-200",
"border-border/50 text-muted-foreground hover:border-foreground/15 hover:text-foreground",
"active:scale-[0.98]",
switchingTo && "opacity-40 pointer-events-none",
)}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-foreground/[0.04]">
<Plus className="h-4 w-4" />
</div>
<div>
<span className="text-sm font-medium">Create new project</span>
<p className="mt-0.5 text-[11px] text-muted-foreground">Initialize a new directory with Git</p>
</div>
</button>
) : (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
transition={{ duration: 0.2 }}
className="rounded-xl border border-border/50 bg-card/50 p-4 space-y-3"
>
<div className="text-sm font-medium text-foreground">New project</div>
<form
onSubmit={(e) => { e.preventDefault(); void handleCreate() }}
className="space-y-2"
>
<Input
ref={createInputRef}
value={newName}
onChange={(e) => { setNewName(e.target.value); setCreateError(null) }}
placeholder="my-project"
autoComplete="off"
className="text-sm"
disabled={creating}
/>
{newName && !nameValid && (
<p className="text-xs text-destructive">Letters, numbers, hyphens, underscores, dots. Must start with a letter or number.</p>
)}
{nameConflict && (
<p className="text-xs text-destructive">A project with this name already exists</p>
)}
{createError && (
<p className="text-xs text-destructive">{createError}</p>
)}
{newName && nameValid && !nameConflict && (
<p className="font-mono text-xs text-muted-foreground">{devRoot}/{newName}</p>
)}
<div className="flex items-center gap-2 pt-1">
<Button
type="submit"
size="sm"
disabled={!canCreate}
className="gap-1.5 transition-transform active:scale-[0.96]"
>
{creating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
Create & open
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => { setShowCreate(false); setNewName(""); setCreateError(null) }}
disabled={creating}
className="text-muted-foreground"
>
Cancel
</Button>
</div>
</form>
</motion.div>
)}
</>
)}
</motion.div>
{/* Navigation */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.15, duration: 0.3 }}
className="mt-8 flex w-full max-w-lg items-center justify-between"
>
<Button
variant="ghost"
onClick={onBack}
className="text-muted-foreground transition-transform active:scale-[0.96]"
>
Back
</Button>
<Button
onClick={() => { onBeforeSwitch?.(); onFinish("") }}
className="group gap-2 transition-transform active:scale-[0.96]"
>
Finish setup
<Zap className="h-4 w-4 transition-transform group-hover:scale-110" />
</Button>
</motion.div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show more