diff --git a/autoplan/SKILL.md b/autoplan/SKILL.md index ec75c5507..5d6e1268b 100644 --- a/autoplan/SKILL.md +++ b/autoplan/SKILL.md @@ -474,7 +474,7 @@ Then prepend a one-line HTML comment to the plan file: ### Step 2: Read context - Read CLAUDE.md, TODOS.md, git log -30, git diff against the base branch --stat -- Discover design docs: `ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null | head -1` +- Discover design docs: `~/.claude/skills/gstack/bin/gstack-find-artifact design-doc 2>/dev/null || true` - Detect UI scope: grep the plan for view/rendering terms (component, screen, form, button, modal, layout, dashboard, sidebar, nav, dialog). Require 2+ matches. Exclude false positives ("page" alone, "UI" in acronyms). diff --git a/autoplan/SKILL.md.tmpl b/autoplan/SKILL.md.tmpl index 2213c8b9d..0bbf3a71f 100644 --- a/autoplan/SKILL.md.tmpl +++ b/autoplan/SKILL.md.tmpl @@ -132,7 +132,7 @@ Then prepend a one-line HTML comment to the plan file: ### Step 2: Read context - Read CLAUDE.md, TODOS.md, git log -30, git diff against the base branch --stat -- Discover design docs: `ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null | head -1` +- Discover design docs: `~/.claude/skills/gstack/bin/gstack-find-artifact design-doc 2>/dev/null || true` - Detect UI scope: grep the plan for view/rendering terms (component, screen, form, button, modal, layout, dashboard, sidebar, nav, dialog). Require 2+ matches. Exclude false positives ("page" alone, "UI" in acronyms). diff --git a/bin/gstack-find-artifact b/bin/gstack-find-artifact new file mode 100755 index 000000000..0ccf215d1 --- /dev/null +++ b/bin/gstack-find-artifact @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# gstack-find-artifact — locate the best project artifact for the current repo/branch +# Usage: gstack-find-artifact design-doc|ceo-plan +set -euo pipefail + +ARTIFACT="${1:-}" +if [ -z "$ARTIFACT" ]; then + echo "Usage: gstack-find-artifact design-doc|ceo-plan" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)" +SLUG="${SLUG:-}" +BRANCH="${BRANCH:-}" + +if [ -z "$SLUG" ]; then + ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" + SLUG="$(basename "$ROOT" | tr -cd 'a-zA-Z0-9._-')" +fi + +if [ -z "$BRANCH" ]; then + BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-' | tr -cd 'a-zA-Z0-9._-' || echo 'no-branch')" +fi + +GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" +PROJECT_DIR="$GSTACK_HOME/projects/$SLUG" + +shopt -s nullglob + +latest_file() { + [ "$#" -gt 0 ] || return 1 + ls -t "$@" 2>/dev/null | head -1 +} + +case "$ARTIFACT" in + design-doc) + branch_docs=("$PROJECT_DIR"/*-"$BRANCH"-design-*.md) + if [ ${#branch_docs[@]} -gt 0 ]; then + latest_file "${branch_docs[@]}" + exit 0 + fi + + all_docs=("$PROJECT_DIR"/*-design-*.md) + if [ ${#all_docs[@]} -gt 0 ]; then + latest_file "${all_docs[@]}" + exit 0 + fi + ;; + + ceo-plan) + plan_dir="$PROJECT_DIR/ceo-plans" + all_plans=("$plan_dir"/*.md) + if [ ${#all_plans[@]} -gt 0 ]; then + same_branch_active=() + active_plans=() + for plan in "${all_plans[@]}"; do + if grep -q '^status: ACTIVE$' "$plan" 2>/dev/null; then + active_plans+=("$plan") + if grep -q "^Branch: $BRANCH\$" "$plan" 2>/dev/null; then + same_branch_active+=("$plan") + fi + fi + done + + if [ ${#same_branch_active[@]} -gt 0 ]; then + latest_file "${same_branch_active[@]}" + exit 0 + fi + + if [ ${#active_plans[@]} -gt 0 ]; then + latest_file "${active_plans[@]}" + exit 0 + fi + + latest_file "${all_plans[@]}" + exit 0 + fi + ;; + + *) + echo "Unknown artifact type: $ARTIFACT" >&2 + exit 1 + ;; +esac + +exit 1 diff --git a/bin/gstack-review-log b/bin/gstack-review-log index d7235bc3a..974ea9865 100755 --- a/bin/gstack-review-log +++ b/bin/gstack-review-log @@ -3,7 +3,17 @@ # Usage: gstack-review-log '{"skill":"...","timestamp":"...","status":"..."}' set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" +eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)" +SLUG="${SLUG:-}" +BRANCH="${BRANCH:-}" +if [ -z "$SLUG" ]; then + ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" + SLUG="$(basename "$ROOT" | tr -cd 'a-zA-Z0-9._-')" +fi +if [ -z "$BRANCH" ]; then + BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-' | tr -cd 'a-zA-Z0-9._-' || echo 'no-branch')" +fi GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" mkdir -p "$GSTACK_HOME/projects/$SLUG" +echo "$1" >> "$GSTACK_HOME/projects/$SLUG/reviews.jsonl" echo "$1" >> "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl" diff --git a/bin/gstack-review-read b/bin/gstack-review-read index ccf1d70f6..244ab0704 100755 --- a/bin/gstack-review-read +++ b/bin/gstack-review-read @@ -3,9 +3,30 @@ # Usage: gstack-review-read set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)" +eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null || true)" +SLUG="${SLUG:-}" +BRANCH="${BRANCH:-}" +if [ -z "$SLUG" ]; then + ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" + SLUG="$(basename "$ROOT" | tr -cd 'a-zA-Z0-9._-')" +fi +if [ -z "$BRANCH" ]; then + BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-' | tr -cd 'a-zA-Z0-9._-' || echo 'no-branch')" +fi GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}" -cat "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl" 2>/dev/null || echo "NO_REVIEWS" +PROJECT_DIR="$GSTACK_HOME/projects/$SLUG" +PROJECT_LOG="$PROJECT_DIR/reviews.jsonl" +if [ -s "$PROJECT_LOG" ]; then + cat "$PROJECT_LOG" +else + shopt -s nullglob + LEGACY_LOGS=("$PROJECT_DIR"/*-reviews.jsonl) + if [ ${#LEGACY_LOGS[@]} -gt 0 ]; then + cat "${LEGACY_LOGS[@]}" + else + echo "NO_REVIEWS" + fi +fi echo "---CONFIG---" "$SCRIPT_DIR/gstack-config" get skip_eng_review 2>/dev/null || echo "false" echo "---HEAD---" diff --git a/plan-ceo-review/SKILL.md b/plan-ceo-review/SKILL.md index a6365fca5..6a2f58cfc 100644 --- a/plan-ceo-review/SKILL.md +++ b/plan-ceo-review/SKILL.md @@ -399,8 +399,7 @@ Then read CLAUDE.md, TODOS.md, and any existing architecture docs. ```bash SLUG=$(~/.claude/skills/gstack/browse/bin/remote-slug 2>/dev/null || basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)") BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-' || echo 'no-branch') -DESIGN=$(ls -t ~/.gstack/projects/$SLUG/*-$BRANCH-design-*.md 2>/dev/null | head -1) -[ -z "$DESIGN" ] && DESIGN=$(ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null | head -1) +DESIGN=$(~/.claude/skills/gstack/bin/gstack-find-artifact design-doc 2>/dev/null || true) [ -n "$DESIGN" ] && echo "Design doc found: $DESIGN" || echo "No design doc found" ``` If a design doc exists (from `/office-hours`), read it. Use it as the source of truth for the problem statement, constraints, and chosen approach. If it has a `Supersedes:` field, note that this is a revised design. diff --git a/plan-ceo-review/SKILL.md.tmpl b/plan-ceo-review/SKILL.md.tmpl index 945fcaa6a..9fdb8d913 100644 --- a/plan-ceo-review/SKILL.md.tmpl +++ b/plan-ceo-review/SKILL.md.tmpl @@ -106,8 +106,7 @@ Then read CLAUDE.md, TODOS.md, and any existing architecture docs. ```bash SLUG=$(~/.claude/skills/gstack/browse/bin/remote-slug 2>/dev/null || basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)") BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-' || echo 'no-branch') -DESIGN=$(ls -t ~/.gstack/projects/$SLUG/*-$BRANCH-design-*.md 2>/dev/null | head -1) -[ -z "$DESIGN" ] && DESIGN=$(ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null | head -1) +DESIGN=$(~/.claude/skills/gstack/bin/gstack-find-artifact design-doc 2>/dev/null || true) [ -n "$DESIGN" ] && echo "Design doc found: $DESIGN" || echo "No design doc found" ``` If a design doc exists (from `/office-hours`), read it. Use it as the source of truth for the problem statement, constraints, and chosen approach. If it has a `Supersedes:` field, note that this is a revised design. diff --git a/plan-eng-review/SKILL.md b/plan-eng-review/SKILL.md index 54d68fcc5..6400eb8de 100644 --- a/plan-eng-review/SKILL.md +++ b/plan-eng-review/SKILL.md @@ -343,13 +343,13 @@ When evaluating architecture, think "boring by default." When reviewing tests, t ### Design Doc Check ```bash -SLUG=$(~/.claude/skills/gstack/browse/bin/remote-slug 2>/dev/null || basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)") -BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-' || echo 'no-branch') -DESIGN=$(ls -t ~/.gstack/projects/$SLUG/*-$BRANCH-design-*.md 2>/dev/null | head -1) -[ -z "$DESIGN" ] && DESIGN=$(ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null | head -1) +DESIGN=$(~/.claude/skills/gstack/bin/gstack-find-artifact design-doc 2>/dev/null || true) +CEO_PLAN=$(~/.claude/skills/gstack/bin/gstack-find-artifact ceo-plan 2>/dev/null || true) [ -n "$DESIGN" ] && echo "Design doc found: $DESIGN" || echo "No design doc found" +[ -n "$CEO_PLAN" ] && echo "CEO plan found: $CEO_PLAN" || echo "No CEO plan found" ``` If a design doc exists, read it. Use it as the source of truth for the problem statement, constraints, and chosen approach. If it has a `Supersedes:` field, note that this is a revised design — check the prior version for context on what changed and why. +If a CEO plan exists, read it too. Use it as the cross-branch scope and vision handoff, especially when the current branch has not produced a fresh design doc yet. ## Prerequisite Skill Offer diff --git a/plan-eng-review/SKILL.md.tmpl b/plan-eng-review/SKILL.md.tmpl index 44d64a0e8..5a1a1aff5 100644 --- a/plan-eng-review/SKILL.md.tmpl +++ b/plan-eng-review/SKILL.md.tmpl @@ -67,13 +67,13 @@ When evaluating architecture, think "boring by default." When reviewing tests, t ### Design Doc Check ```bash -SLUG=$(~/.claude/skills/gstack/browse/bin/remote-slug 2>/dev/null || basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)") -BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null | tr '/' '-' || echo 'no-branch') -DESIGN=$(ls -t ~/.gstack/projects/$SLUG/*-$BRANCH-design-*.md 2>/dev/null | head -1) -[ -z "$DESIGN" ] && DESIGN=$(ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null | head -1) +DESIGN=$(~/.claude/skills/gstack/bin/gstack-find-artifact design-doc 2>/dev/null || true) +CEO_PLAN=$(~/.claude/skills/gstack/bin/gstack-find-artifact ceo-plan 2>/dev/null || true) [ -n "$DESIGN" ] && echo "Design doc found: $DESIGN" || echo "No design doc found" +[ -n "$CEO_PLAN" ] && echo "CEO plan found: $CEO_PLAN" || echo "No CEO plan found" ``` If a design doc exists, read it. Use it as the source of truth for the problem statement, constraints, and chosen approach. If it has a `Supersedes:` field, note that this is a revised design — check the prior version for context on what changed and why. +If a CEO plan exists, read it too. Use it as the cross-branch scope and vision handoff, especially when the current branch has not produced a fresh design doc yet. {{BENEFITS_FROM}} diff --git a/test/workflow-handoff.test.ts b/test/workflow-handoff.test.ts new file mode 100644 index 000000000..f37bac916 --- /dev/null +++ b/test/workflow-handoff.test.ts @@ -0,0 +1,122 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { spawnSync } from "child_process"; + +const ROOT = join(import.meta.dir, ".."); +const reviewLogBin = join(ROOT, "bin", "gstack-review-log"); +const reviewReadBin = join(ROOT, "bin", "gstack-review-read"); +const findArtifactBin = join(ROOT, "bin", "gstack-find-artifact"); + +function run(cmd: string, args: string[], cwd: string, env: Record) { + const result = spawnSync(cmd, args, { + cwd, + env: { ...process.env, ...env }, + encoding: "utf-8", + timeout: 10000, + }); + return result; +} + +function initRepo(): { repoDir: string; stateDir: string; projectDir: string } { + const repoDir = mkdtempSync(join(tmpdir(), "gstack-workflow-")); + const stateDir = mkdtempSync(join(tmpdir(), "gstack-state-")); + + run("git", ["init", "-b", "main"], repoDir, {}); + run("git", ["config", "user.name", "Test User"], repoDir, {}); + run("git", ["config", "user.email", "test@example.com"], repoDir, {}); + run("git", ["remote", "add", "origin", "https://github.com/example-org/example-repo.git"], repoDir, {}); + writeFileSync(join(repoDir, "README.md"), "test\n"); + run("git", ["add", "README.md"], repoDir, {}); + run("git", ["commit", "-m", "initial"], repoDir, {}); + + const projectDir = join(stateDir, "projects", "example-org-example-repo"); + mkdirSync(projectDir, { recursive: true }); + + return { repoDir, stateDir, projectDir }; +} + +describe("workflow handoff helpers", () => { + let repoDir: string; + let stateDir: string; + let projectDir: string; + + beforeEach(() => { + ({ repoDir, stateDir, projectDir } = initRepo()); + }); + + afterEach(() => { + rmSync(repoDir, { recursive: true, force: true }); + rmSync(stateDir, { recursive: true, force: true }); + }); + + test("gstack-review-read sees reviews logged on a different branch via project-scoped JSONL", () => { + let result = run("git", ["checkout", "-b", "feature/one"], repoDir, {}); + expect(result.status).toBe(0); + + result = run("bash", [reviewLogBin, '{"skill":"review","timestamp":"2026-03-23T10:00:00Z","status":"clean"}'], repoDir, { + GSTACK_HOME: stateDir, + }); + expect(result.status).toBe(0); + + result = run("git", ["checkout", "main"], repoDir, {}); + expect(result.status).toBe(0); + result = run("git", ["checkout", "-b", "feature/two"], repoDir, {}); + expect(result.status).toBe(0); + + result = run("bash", [reviewReadBin], repoDir, { GSTACK_HOME: stateDir }); + expect(result.status).toBe(0); + expect(result.stdout).toContain('"skill":"review"'); + expect(result.stdout).toContain('2026-03-23T10:00:00Z'); + }); + + test("gstack-review-read falls back to legacy per-branch logs when project log does not exist", () => { + writeFileSync( + join(projectDir, "feature-one-reviews.jsonl"), + '{"skill":"plan-ceo-review","timestamp":"2026-03-23T09:00:00Z","status":"clean"}\n' + ); + writeFileSync( + join(projectDir, "feature-two-reviews.jsonl"), + '{"skill":"review","timestamp":"2026-03-23T11:00:00Z","status":"clean"}\n' + ); + + const result = run("bash", [reviewReadBin], repoDir, { GSTACK_HOME: stateDir }); + expect(result.status).toBe(0); + expect(result.stdout).toContain('"skill":"plan-ceo-review"'); + expect(result.stdout).toContain('"skill":"review"'); + }); + + test("gstack-find-artifact prefers the current branch design doc", () => { + run("git", ["checkout", "-b", "feature/two"], repoDir, {}); + writeFileSync( + join(projectDir, "alice-feature-one-design-20260323-090000.md"), + "Branch: feature-one\nStatus: DRAFT\n" + ); + writeFileSync( + join(projectDir, "alice-feature-two-design-20260323-100000.md"), + "Branch: feature-two\nStatus: DRAFT\n" + ); + + const result = run("bash", [findArtifactBin, "design-doc"], repoDir, { GSTACK_HOME: stateDir }); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toContain("feature-two-design"); + }); + + test("gstack-find-artifact finds the active CEO plan for the current branch", () => { + run("git", ["checkout", "-b", "feature/two"], repoDir, {}); + mkdirSync(join(projectDir, "ceo-plans"), { recursive: true }); + writeFileSync( + join(projectDir, "ceo-plans", "2026-03-20-other-plan.md"), + "---\nstatus: ACTIVE\n---\nBranch: feature-one\n" + ); + writeFileSync( + join(projectDir, "ceo-plans", "2026-03-21-current-plan.md"), + "---\nstatus: ACTIVE\n---\nBranch: feature-two\n" + ); + + const result = run("bash", [findArtifactBin, "ceo-plan"], repoDir, { GSTACK_HOME: stateDir }); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toContain("current-plan"); + }); +});