feat: add issue-only Improve Kandev workflow (#1994)

This commit is contained in:
Carlos Florêncio
2026-07-28 21:25:39 +01:00
committed by GitHub
parent 09b5c4df7a
commit 7598880e67
27 changed files with 1938 additions and 202 deletions
@@ -19,6 +19,22 @@ Intent -> Spec -> Plan + task files -> User model switch -> Sequential TDD imple
Do not replace the spec, plan, or task files with chat-only notes. Do not skip
from intent to code unless the user explicitly opts out of this workflow.
## Prompt-Precedence Gate
Treat workflow-generated envelopes such as `[IMPROVE PHASE]`, "implement the
requested change", Red-Green-Refactor checklists, verification commands, and
commit instructions as execution guidance for Phase 5, not as permission to
skip Phases 1–4. Before editing production or permanent test files, verify one
of these conditions:
1. The request references an approved spec, plan, and current task file; or
2. The user explicitly says to skip spec/plan/task creation.
If neither is true, create or update the spec, plan, and task files, record any
existing partial implementation as continuation context, and stop at the model
switch checkpoint. A phase label or generic "implement" directive generated by
a Kandev workflow is not an explicit opt-out.
When a user explicitly asks to proceed with feature planning, complete the whole
strong-model design package — spec, plan, and task files — before pausing for
review or a model switch. Pause after a standalone spec only when the user asks
+11 -1
View File
@@ -112,6 +112,16 @@ session to a lower-cost implementation model. Detailed feature, fix,
validation, and delegation routing lives in the relevant skills, especially
`planner-orchestration`.
Workflow-generated phase text such as `[IMPROVE PHASE]`, "implement the
requested change", TDD checklists, verification commands, or commit steps does
not opt a feature or behavior-changing fix out of spec-driven development. If
the request does not reference an approved spec, plan, and task file, run
`spec-driven-development` through the plan/task checkpoint and stop before
production or permanent test changes. Continue directly to implementation only
when approved task artifacts already exist, or when the user explicitly says
to skip planning; a generic implementation envelope is not that explicit
opt-out.
### Kandev Task Creation
Use Kandev task/session MCP APIs only when the user explicitly asks to create or
@@ -167,4 +177,4 @@ For developing in ephemeral cloud VMs (Cursor Cloud, Codex, GitHub Codespaces, e
---
**Last Updated**: 2026-07-21
**Last Updated**: 2026-07-27
@@ -37,6 +37,17 @@ steps:
Re-read the title and description carefully. If anything is unclear
or ambiguous, use the ask_user_question_kandev tool before coding.
PLANNING GATE
If this request adds a feature or changes product behavior and does not
reference an approved spec, plan, and current task file, follow the
`spec-driven-development` skill through spec, plan, and task creation.
Record any partial implementation as continuation context, then stop at
the model-switch checkpoint before changing more production or permanent
test files. The [IMPROVE PHASE] label and implementation checklist below
are Phase 5 guidance; they do not opt the task out of planning. Continue
directly only when approved task artifacts already exist or the user
explicitly says to skip planning.
STEP 2: EXPLORE THE CODEBASE
- Find similar features and understand how they are implemented.
- Identify the architecture and patterns in the relevant area.
+57 -9
View File
@@ -2,6 +2,7 @@ package workflows
import (
"fmt"
"strings"
"testing"
"github.com/kandev/kandev/internal/workflow/models"
@@ -168,14 +169,15 @@ func TestLoadTemplates_ExpectedTemplateIDs(t *testing.T) {
}
expected := map[string]bool{
"simple": false,
"standard": false,
"architecture": false,
"pr-review": false,
"feature-dev": false,
"improve-kandev": false,
"office-default": false,
"routine": false,
"simple": false,
"standard": false,
"architecture": false,
"pr-review": false,
"feature-dev": false,
"improve-kandev": false,
"report-kandev-issue": false,
"office-default": false,
"routine": false,
}
for _, tmpl := range templates {
@@ -205,7 +207,7 @@ func TestLoadTemplates_HiddenFlag(t *testing.T) {
hiddenByID[tmpl.ID] = tmpl.Hidden
}
for _, id := range []string{"improve-kandev", "office-default", "routine"} {
for _, id := range []string{"improve-kandev", "report-kandev-issue", "office-default", "routine"} {
if !hiddenByID[id] {
t.Errorf("expected template %q to be hidden", id)
}
@@ -216,3 +218,49 @@ func TestLoadTemplates_HiddenFlag(t *testing.T) {
}
}
}
func TestLoadTemplates_ReportKandevIssuePromptContract(t *testing.T) {
templates, err := LoadTemplates()
if err != nil {
t.Fatalf("LoadTemplates() returned error: %v", err)
}
var report *models.WorkflowTemplate
for _, tmpl := range templates {
if tmpl.ID == "report-kandev-issue" {
report = tmpl
break
}
}
if report == nil {
t.Fatal("report-kandev-issue template not found")
}
if !report.Hidden {
t.Error("report-kandev-issue must be hidden")
}
if len(report.Steps) != 1 {
t.Fatalf("report-kandev-issue steps = %d, want 1", len(report.Steps))
}
step := report.Steps[0]
if !step.IsStartStep {
t.Error("issue step must be the start step")
}
if len(step.Events.OnEnter) != 1 ||
step.Events.OnEnter[0].Type != models.OnEnterAutoStartAgent {
t.Errorf("issue step on_enter = %+v, want auto_start_agent", step.Events.OnEnter)
}
for _, required := range []string{
"ask_user_question_kandev",
".github/ISSUE_TEMPLATE",
"gh issue create",
"sensitive",
"duplicate",
"skip STEP 4",
"security-advisory",
} {
if !strings.Contains(step.Prompt, required) {
t.Errorf("issue prompt must contain %q", required)
}
}
}
@@ -0,0 +1,67 @@
# Internal workflow TEMPLATE definition — NOT the portable import/export format.
# Format reference: docs/workflow-import-export.md
id: report-kandev-issue
name: Report Kandev Issue
description: >-
Hidden one-step workflow that gathers the information required by the
kdlbs/kandev issue forms and publishes a GitHub issue without changing code.
hidden: true
steps:
- id: open-issue
name: Open issue
position: 0
color: bg-amber-500
is_start_step: true
show_in_command_panel: false
allow_manual_move: true
prompt: |
[REPORT ISSUE PHASE]
Gather a complete report from the user and publish it as a GitHub issue
in `kdlbs/kandev`. This workflow only reports the problem or request.
Do not implement code, create a branch, commit, push, or open a pull
request.
{{task_prompt}}
STEP 1: READ THE REPOSITORY REPORTING RULES
- Read `AGENTS.md`, `.github/ISSUE_TEMPLATE/config.yml`, and the current
`.github/ISSUE_TEMPLATE/bug_report.yml` and `feature_request.yml`.
- Classify the report as a bug or feature request. If it describes a
security vulnerability, do not publish it publicly; direct the user to
the private security-advisory link from the repository configuration.
STEP 2: GATHER EVERY REQUIRED FIELD
- Compare the task description with every required field in the matching
issue form.
- Use `ask_user_question_kandev` to ask concise follow-up questions for
all missing or ambiguous information. Ask in batches and repeat until
every required field has a concrete answer.
- Never invent environment details, reproduction steps, expected or
actual behavior, acceptance criteria, confirmations, or other missing
facts.
- Remind the user to remove secrets, tokens, private repository URLs,
private prompts, customer data, and other sensitive information from
text, logs, and attachments.
STEP 3: CHECK FOR DUPLICATES
Search existing open and closed `kdlbs/kandev` issues for the same
behavior. If a likely duplicate exists, show it to the user and use
`ask_user_question_kandev` to confirm whether to add context there or
continue with a new issue.
STEP 4: PREVIEW AND CONFIRM
If STEP 1 classified this as a security vulnerability, skip STEP 4 and
STEP 5 entirely. Point the user to the private security-advisory link
from the repository configuration and stop.
Draft a title and body whose headings and answers follow the matching
repository issue template. Show the complete public text to the user and
use `ask_user_question_kandev` to obtain explicit publication approval.
STEP 5: PUBLISH
After approval, write the body to a temporary Markdown file and run
`gh issue create --repo kdlbs/kandev` with the matching title, label, and
body file. Report the resulting issue URL to the user and stop.
events:
on_enter:
- type: auto_start_agent
+103 -42
View File
@@ -28,9 +28,13 @@ const (
repoProvider = "github"
defaultBranch = "main"
templateID = "improve-kandev"
workflowName = "Improve Kandev"
workflowDesc = "Hidden workflow for filing improvements via the in-app entry point."
improveTemplateID = "improve-kandev"
improveWorkflowName = "Improve Kandev"
improveWorkflowDesc = "Hidden workflow for filing improvements via the in-app entry point."
issueTemplateID = "report-kandev-issue"
issueWorkflowName = "Report Kandev Issue"
issueWorkflowDesc = "Hidden workflow for publishing a kdlbs/kandev issue without changing code."
)
// Cloner is the minimal subset of repoclone.Cloner the bootstrap endpoint uses.
@@ -111,15 +115,16 @@ const (
// BootstrapResponse describes the artifacts the dialog needs to submit a task.
type BootstrapResponse struct {
RepositoryID string `json:"repository_id"`
WorkflowID string `json:"workflow_id"`
Branch string `json:"branch"`
BundleDir string `json:"bundle_dir"`
BundleFiles map[string]string `json:"bundle_files"`
GitHubLogin string `json:"github_login"`
HasWriteAccess bool `json:"has_write_access"`
ForkStatus ForkStatus `json:"fork_status"`
ForkMessage string `json:"fork_message,omitempty"`
RepositoryID string `json:"repository_id"`
WorkflowID string `json:"workflow_id"`
IssueWorkflowID string `json:"issue_workflow_id"`
Branch string `json:"branch"`
BundleDir string `json:"bundle_dir"`
BundleFiles map[string]string `json:"bundle_files"`
GitHubLogin string `json:"github_login"`
HasWriteAccess bool `json:"has_write_access"`
ForkStatus ForkStatus `json:"fork_status"`
ForkMessage string `json:"fork_message,omitempty"`
}
func (h *Handler) httpBootstrap(c *gin.Context) {
@@ -142,12 +147,38 @@ func (h *Handler) httpBootstrap(c *gin.Context) {
return
}
workflow, err := h.ensureWorkflow(ctx, workspaceID)
workflows, err := h.taskSvc.ListWorkflows(ctx, workspaceID, true)
if err != nil {
h.log.Error("improve-kandev: workflow list failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list improve-kandev workflows"})
return
}
workflow, err := h.ensureWorkflow(
ctx,
workspaceID,
workflows,
improveTemplateID,
improveWorkflowName,
improveWorkflowDesc,
)
if err != nil {
h.log.Error("improve-kandev: workflow upsert failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to ensure improve-kandev workflow"})
return
}
issueWorkflow, err := h.ensureWorkflow(
ctx,
workspaceID,
workflows,
issueTemplateID,
issueWorkflowName,
issueWorkflowDesc,
)
if err != nil {
h.log.Error("improve-kandev: issue workflow upsert failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to ensure report-kandev-issue workflow"})
return
}
dir, err := createBundleDir()
if err != nil {
@@ -169,10 +200,11 @@ func (h *Handler) httpBootstrap(c *gin.Context) {
access := h.resolveGitHubAccess(ctx)
c.JSON(http.StatusOK, BootstrapResponse{
RepositoryID: repo.ID,
WorkflowID: workflow.ID,
Branch: defaultBranch,
BundleDir: dir,
RepositoryID: repo.ID,
WorkflowID: workflow.ID,
IssueWorkflowID: issueWorkflow.ID,
Branch: defaultBranch,
BundleDir: dir,
BundleFiles: map[string]string{
"metadata": filepath.Join(dir, "metadata.json"),
"backend_log": filepath.Join(dir, "backend.log"),
@@ -345,37 +377,66 @@ func (h *Handler) resolveGitHubAccess(ctx context.Context) githubAccess {
return out
}
// ensureWorkflow finds or creates the hidden improve-kandev workflow in the
// given workspace. Idempotent: matches by WorkflowTemplateID == templateID.
func (h *Handler) ensureWorkflow(ctx context.Context, workspaceID string) (*taskmodels.Workflow, error) {
existing, err := h.taskSvc.ListWorkflows(ctx, workspaceID, true)
if err != nil {
return nil, err
}
for _, w := range existing {
if w.WorkflowTemplateID != nil && *w.WorkflowTemplateID == templateID {
// Heal records created before the workflow honored Hidden on insert.
// Best-effort: a DB failure here must not block the caller from getting
// their workflow ID, since the workflow itself is already usable.
if !w.Hidden {
if err := h.taskSvc.SetWorkflowHidden(ctx, w.ID, true); err != nil {
h.log.Warn("improve-kandev: failed to heal hidden flag on stale record",
zap.String("workflow_id", w.ID), zap.Error(err))
} else {
w.Hidden = true
}
}
return w, nil
}
// ensureWorkflow finds or creates a hidden Improve Kandev workflow in the
// given workspace. Idempotent: matches by WorkflowTemplateID.
func (h *Handler) ensureWorkflow(
ctx context.Context,
workspaceID string,
existing []*taskmodels.Workflow,
templateID string,
name string,
description string,
) (*taskmodels.Workflow, error) {
if workflow := h.findAndHealWorkflow(ctx, existing, templateID); workflow != nil {
return workflow, nil
}
tmplID := templateID
return h.taskSvc.CreateWorkflow(ctx, &taskservice.CreateWorkflowRequest{
created, err := h.taskSvc.CreateWorkflow(ctx, &taskservice.CreateWorkflowRequest{
WorkspaceID: workspaceID,
Name: workflowName,
Description: workflowDesc,
Name: name,
Description: description,
WorkflowTemplateID: &tmplID,
Hidden: true,
})
if err == nil {
return created, nil
}
// A concurrent bootstrap may have inserted the same template after the
// initial snapshot. Re-read after a uniqueness failure and converge on the
// row that won the race instead of surfacing a transient 500.
latest, listErr := h.taskSvc.ListWorkflows(ctx, workspaceID, true)
if listErr == nil {
if workflow := h.findAndHealWorkflow(ctx, latest, templateID); workflow != nil {
return workflow, nil
}
}
return nil, err
}
func (h *Handler) findAndHealWorkflow(
ctx context.Context,
workflows []*taskmodels.Workflow,
templateID string,
) *taskmodels.Workflow {
for _, workflow := range workflows {
if workflow.WorkflowTemplateID == nil || *workflow.WorkflowTemplateID != templateID {
continue
}
// Heal records created before the workflow honored Hidden on insert.
// Best-effort: a DB failure here must not block the caller from getting
// their workflow ID, since the workflow itself is already usable.
if !workflow.Hidden {
if err := h.taskSvc.SetWorkflowHidden(ctx, workflow.ID, true); err != nil {
h.log.Warn("improve-kandev: failed to heal hidden flag on stale record",
zap.String("workflow_id", workflow.ID), zap.Error(err))
} else {
workflow.Hidden = true
}
}
return workflow
}
return nil
}
// FrontendLogRequest is the JSON body for POST /bundle/frontend-log.
@@ -0,0 +1,84 @@
package integration
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os/exec"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/improvekandev"
"github.com/kandev/kandev/internal/task/models"
)
type improveKandevTestCloner struct {
path string
}
func (c improveKandevTestCloner) EnsureWorkspaceCloned(
_ context.Context, _, _, _, _, _ string,
) (string, error) {
return c.path, nil
}
func TestImproveKandevBootstrapCreatesBothHiddenWorkflowsIdempotently(t *testing.T) {
ts := NewOrchestratorTestServer(t)
workspaceID := "123e4567-e89b-12d3-a456-426614174000"
require.NoError(t, ts.TaskRepo.CreateWorkspace(context.Background(), &models.Workspace{
ID: workspaceID,
Name: "Improve Kandev",
}))
repoPath := t.TempDir()
require.NoError(t, exec.Command("git", "init", repoPath).Run())
router := gin.New()
handler := improvekandev.NewHandler(ts.TaskSvc, improveKandevTestCloner{path: repoPath}, "test", ts.Logger)
improvekandev.RegisterRoutes(router, handler)
bootstrap := func() improvekandev.BootstrapResponse {
t.Helper()
body, err := json.Marshal(improvekandev.BootstrapRequest{WorkspaceID: workspaceID})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/improve-kandev/bootstrap", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
var response improvekandev.BootstrapResponse
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &response))
return response
}
first := bootstrap()
second := bootstrap()
require.NotEmpty(t, first.WorkflowID)
require.NotEmpty(t, first.IssueWorkflowID)
require.NotEqual(t, first.WorkflowID, first.IssueWorkflowID)
require.Equal(t, first.WorkflowID, second.WorkflowID)
require.Equal(t, first.IssueWorkflowID, second.IssueWorkflowID)
workflows, err := ts.TaskRepo.ListWorkflows(context.Background(), workspaceID, true)
require.NoError(t, err)
byTemplate := make(map[string]*models.Workflow, len(workflows))
for _, workflow := range workflows {
if workflow.WorkflowTemplateID != nil {
byTemplate[*workflow.WorkflowTemplateID] = workflow
}
}
for _, templateID := range []string{"improve-kandev", "report-kandev-issue"} {
workflow := byTemplate[templateID]
require.NotNil(t, workflow, "workflow template %s", templateID)
require.True(t, workflow.Hidden, "workflow template %s should stay hidden", templateID)
}
issueSteps, err := ts.WorkflowSvc.ListStepsByWorkflow(context.Background(), first.IssueWorkflowID)
require.NoError(t, err)
require.Len(t, issueSteps, 1)
require.Equal(t, "Open issue", issueSteps[0].Name)
require.True(t, issueSteps[0].IsStartStep)
}
@@ -175,6 +175,9 @@ func (r *Repository) runMigrations() error {
// file path it was synced from.
r.migrate.Apply("workflows.source", `ALTER TABLE workflows ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'`)
r.migrate.Apply("workflows.source_path", `ALTER TABLE workflows ADD COLUMN source_path TEXT NOT NULL DEFAULT ''`)
if err := r.ensureImproveKandevWorkflowTemplateUniqueness(); err != nil {
return err
}
// Native code review — indexes for the task_review_* tables. Declared here
// rather than in initTaskReviewSchema because schema init is a no-op on an
@@ -193,6 +196,55 @@ func (r *Repository) runMigrations() error {
return nil
}
// ensureImproveKandevWorkflowTemplateUniqueness removes the broad index from
// the initial implementation, reconciles legacy bootstrap duplicates, then
// enforces uniqueness only for the two hidden workflows created by this flow.
func (r *Repository) ensureImproveKandevWorkflowTemplateUniqueness() error {
tx, err := r.db.Beginx()
if err != nil {
return fmt.Errorf("begin improve kandev workflow migration: %w", err)
}
// Keep this template list synchronized with every hidden Improve Kandev
// workflow bootstrapped by internal/improvekandev. Add new IDs to both this
// reconciliation query and the partial-index predicate below.
if _, err := tx.Exec(`
UPDATE workflows
SET workflow_template_id = ''
WHERE id IN (
SELECT duplicate.id
FROM workflows AS duplicate
WHERE duplicate.workflow_template_id IN ('improve-kandev', 'report-kandev-issue')
AND EXISTS (
SELECT 1
FROM workflows AS canonical
WHERE canonical.workspace_id = duplicate.workspace_id
AND canonical.workflow_template_id = duplicate.workflow_template_id
AND canonical.hidden = duplicate.hidden
AND (
canonical.created_at < duplicate.created_at
OR (canonical.created_at = duplicate.created_at AND canonical.id < duplicate.id)
)
)
)`); err != nil {
_ = tx.Rollback()
return fmt.Errorf("reconcile improve kandev workflow duplicates: %w", err)
}
if _, err := tx.Exec(`DROP INDEX IF EXISTS uniq_workflows_workspace_template_hidden`); err != nil {
_ = tx.Rollback()
return fmt.Errorf("drop broad workflow template index: %w", err)
}
if _, err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uniq_improve_kandev_workflows
ON workflows(workspace_id, workflow_template_id, hidden)
WHERE workflow_template_id IN ('improve-kandev', 'report-kandev-issue')`); err != nil {
_ = tx.Rollback()
return fmt.Errorf("create improve kandev workflow index: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit improve kandev workflow migration: %w", err)
}
return nil
}
// ensureTaskWorkspaceFoldersSchema upgrades databases created before durable
// folder attachments existed. CREATE TABLE/INDEX IF NOT EXISTS is replay-safe
// on SQLite and Postgres.
@@ -74,6 +74,42 @@ func TestPostgresExecutorRunningLocalPIDMigration(t *testing.T) {
}
}
func TestPostgresImproveKandevWorkflowIndexMigration(t *testing.T) {
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
repo, err := NewWithDB(db, db, nil)
if err != nil {
t.Fatalf("init postgres schema: %v", err)
}
if _, err := db.Exec(`DROP INDEX IF EXISTS uniq_improve_kandev_workflows`); err != nil {
t.Fatalf("drop improve kandev index: %v", err)
}
now := time.Now().UTC()
for _, id := range []string{"wf-pg-improve-first", "wf-pg-improve-second"} {
if _, err := db.Exec(db.Rebind(`
INSERT INTO workflows (id, workspace_id, workflow_template_id, name, hidden, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`), id, "ws-pg-improve-kandev", "improve-kandev", "Improve Kandev", 1, now, now); err != nil {
t.Fatalf("seed legacy workflow %q: %v", id, err)
}
}
if err := repo.runMigrations(); err != nil {
t.Fatalf("migrate legacy postgres workflow duplicates: %v", err)
}
if err := repo.runMigrations(); err != nil {
t.Fatalf("replay postgres improve kandev migration: %v", err)
}
var count int
if err := db.QueryRow(db.Rebind(`
SELECT COUNT(*) FROM workflows
WHERE workspace_id = ? AND workflow_template_id = ? AND hidden = ?
`), "ws-pg-improve-kandev", "improve-kandev", 1).Scan(&count); err != nil {
t.Fatalf("count reconciled workflows: %v", err)
}
if count != 1 {
t.Fatalf("improve-kandev workflow template rows = %d, want 1", count)
}
}
func TestPostgresExecutionProfileMigration(t *testing.T) {
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
repo, err := NewWithDB(db, db, nil)
@@ -37,6 +37,164 @@ func seedWorkspace(t *testing.T, repo *Repository, id string) {
}
}
func strptr(value string) *string { return &value }
func TestCreateWorkflowRejectsDuplicateHiddenTemplatePerWorkspace(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
seedWorkspace(t, repo, "ws-template-unique")
first := &models.Workflow{
ID: "wf-template-first",
WorkspaceID: "ws-template-unique",
Name: "Improve Kandev",
WorkflowTemplateID: strptr("improve-kandev"),
Hidden: true,
}
if err := repo.CreateWorkflow(ctx, first); err != nil {
t.Fatalf("create first template workflow: %v", err)
}
duplicate := &models.Workflow{
ID: "wf-template-duplicate",
WorkspaceID: first.WorkspaceID,
Name: first.Name,
WorkflowTemplateID: strptr("improve-kandev"),
Hidden: true,
}
if err := repo.CreateWorkflow(ctx, duplicate); err == nil {
t.Fatal("duplicate hidden template workflow was accepted")
}
for _, id := range []string{"wf-other-template-first", "wf-other-template-second"} {
if err := repo.CreateWorkflow(ctx, &models.Workflow{
ID: id,
WorkspaceID: first.WorkspaceID,
Name: "Reusable template workflow",
WorkflowTemplateID: strptr("reusable-template"),
Hidden: true,
}); err != nil {
t.Fatalf("create non-Improve-Kandev template workflow %q: %v", id, err)
}
}
}
func TestImproveKandevWorkflowIndexMigratesFormerBroadIndex(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "improve-kandev-index-replay.db")
openRepo := func() (*Repository, *sqlx.DB) {
t.Helper()
dbConn, err := db.OpenSQLite(dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
sqlxDB := sqlx.NewDb(dbConn, "sqlite3")
repo, err := NewWithDB(sqlxDB, sqlxDB, nil)
if err != nil {
_ = sqlxDB.Close()
t.Fatalf("new repo: %v", err)
}
return repo, sqlxDB
}
repo, sqlxDB := openRepo()
seedWorkspace(t, repo, "ws-improve-kandev-index-replay")
if _, err := sqlxDB.Exec(`DROP INDEX IF EXISTS uniq_improve_kandev_workflows`); err != nil {
t.Fatalf("drop scoped index: %v", err)
}
if _, err := sqlxDB.Exec(`DROP INDEX IF EXISTS uniq_workflows_workspace_template_hidden`); err != nil {
t.Fatalf("drop index: %v", err)
}
if _, err := sqlxDB.Exec(`CREATE UNIQUE INDEX uniq_workflows_workspace_template_hidden
ON workflows(workspace_id, workflow_template_id, hidden)
WHERE workflow_template_id <> ''`); err != nil {
t.Fatalf("create former broad index: %v", err)
}
if err := sqlxDB.Close(); err != nil {
t.Fatalf("close first database: %v", err)
}
repo, sqlxDB = openRepo()
t.Cleanup(func() { _ = sqlxDB.Close() })
for indexName, want := range map[string]int{
"uniq_workflows_workspace_template_hidden": 0,
"uniq_improve_kandev_workflows": 1,
} {
var got int
if err := sqlxDB.Get(&got, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?`, indexName); err != nil {
t.Fatalf("count %s: %v", indexName, err)
}
if got != want {
t.Errorf("%s count = %d, want %d", indexName, got, want)
}
}
ctx := context.Background()
for _, id := range []string{"wf-replay-first", "wf-replay-second"} {
if err := repo.CreateWorkflow(ctx, &models.Workflow{
ID: id,
WorkspaceID: "ws-improve-kandev-index-replay",
Name: "Reusable template workflow",
WorkflowTemplateID: strptr("reusable-template"),
Hidden: true,
}); err != nil {
t.Fatalf("create non-Improve-Kandev template workflow %q after replay: %v", id, err)
}
}
}
func TestImproveKandevWorkflowIndexReconcilesLegacyDuplicates(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "improve-kandev-duplicates.db")
openRepo := func() (*Repository, *sqlx.DB) {
t.Helper()
dbConn, err := db.OpenSQLite(dbPath)
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
sqlxDB := sqlx.NewDb(dbConn, "sqlite3")
repo, err := NewWithDB(sqlxDB, sqlxDB, nil)
if err != nil {
_ = sqlxDB.Close()
t.Fatalf("new repo: %v", err)
}
return repo, sqlxDB
}
repo, sqlxDB := openRepo()
workspaceID := "ws-improve-kandev-duplicates"
seedWorkspace(t, repo, workspaceID)
if _, err := sqlxDB.Exec(`DROP INDEX IF EXISTS uniq_improve_kandev_workflows`); err != nil {
t.Fatalf("drop improve kandev index: %v", err)
}
ctx := context.Background()
for _, id := range []string{"wf-legacy-first", "wf-legacy-second"} {
if err := repo.CreateWorkflow(ctx, &models.Workflow{
ID: id,
WorkspaceID: workspaceID,
Name: "Improve Kandev",
WorkflowTemplateID: strptr("improve-kandev"),
Hidden: true,
}); err != nil {
t.Fatalf("create legacy duplicate %q: %v", id, err)
}
}
if err := sqlxDB.Close(); err != nil {
t.Fatalf("close legacy database: %v", err)
}
repo, sqlxDB = openRepo()
t.Cleanup(func() { _ = sqlxDB.Close() })
workflows, err := repo.ListWorkflows(ctx, workspaceID, true)
if err != nil {
t.Fatalf("list reconciled workflows: %v", err)
}
var matchingTemplates int
for _, workflow := range workflows {
if workflow.WorkflowTemplateID != nil && *workflow.WorkflowTemplateID == "improve-kandev" {
matchingTemplates++
}
}
if matchingTemplates != 1 {
t.Fatalf("improve-kandev workflow template rows = %d, want 1", matchingTemplates)
}
}
func TestDeleteRepositoryIfUnreferenced_PreservesTaskAdoptedRepository(t *testing.T) {
repo := newRepoForEntityTests(t)
ctx := context.Background()
@@ -1,8 +1,6 @@
"use client";
import { Fragment, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@kandev/ui/dialog";
import { Button } from "@kandev/ui/button";
import { Checkbox } from "@kandev/ui/checkbox";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible";
import { Tabs, TabsList, TabsTrigger } from "@kandev/ui/tabs";
@@ -15,22 +13,32 @@ import {
IconChevronDown,
IconBrandGithub,
IconGitFork,
IconStethoscope,
} from "@tabler/icons-react";
import { TaskCreateDialog } from "@/components/task-create-dialog";
import type { ImproveKandevBootstrapResponse } from "@/lib/api/domains/improve-kandev-api";
import { cn } from "@/lib/utils";
import type { Task, WorkflowStep } from "@/lib/types/http";
import {
contributorAccessMessage,
getImproveKandevForkBlockedReason,
getImproveKandevStepDescription,
resolveImproveKandevWorkflow,
type ImproveKandevKind,
} from "./improve-kandev-dialog-model";
export type BootstrapState =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "ready"; data: ImproveKandevBootstrapResponse; steps: WorkflowStep[] }
| { kind: "error"; message: string }
| { kind: "blocked"; message: string };
| {
kind: "ready";
data: ImproveKandevBootstrapResponse;
steps: WorkflowStep[];
issueSteps: WorkflowStep[];
}
| { kind: "error"; message: string };
type ImproveKind = "bug" | "feature";
type ImproveKind = ImproveKandevKind;
// Surfaced in the dialog's submit-button tooltip while the bootstrap probe is
// in flight, so the disabled state has an explanation the user can act on
@@ -39,6 +47,9 @@ function bootstrapBlockedReason(bootstrap: BootstrapState): string | null {
if (bootstrap.kind === "loading" || bootstrap.kind === "idle") {
return "Preparing kandev repository…";
}
if (bootstrap.kind === "error") {
return "Bootstrap failed — close and reopen to retry.";
}
return null;
}
@@ -46,6 +57,8 @@ const PLACEHOLDERS: Record<ImproveKind, string> = {
bug: "E.g. When I open the kanban board, the column header text overlaps the task count badge on screens narrower than 1200px...",
feature:
"E.g. Add a keyboard shortcut to mark a task as Done from the task detail view, and show the shortcut in the task action menu...",
issue:
"Describe the problem or request you want published. The agent will ask for any details required by the matching GitHub issue template...",
};
type CreateModeViewProps = {
@@ -57,42 +70,32 @@ type CreateModeViewProps = {
setCaptureLogs: (v: boolean) => void;
transformDescription: (description: string) => Promise<string>;
onTaskCreated: (task: Task) => void;
externalBlockedReason?: string | null;
};
export function CreateModeView(props: CreateModeViewProps) {
const { bootstrap, captureLogs, setCaptureLogs } = props;
const [kind, setKind] = useState<ImproveKind>("bug");
const ready = bootstrap.kind === "ready" ? bootstrap : null;
const startStep = ready ? (ready.steps.find((s) => s.is_start_step) ?? ready.steps[0]) : null;
const workflow = resolveImproveKandevWorkflow(ready, kind);
const forkBlockedReason = ready
? getImproveKandevForkBlockedReason(kind, ready.data.fork_status, ready.data.fork_message)
: null;
const handleKindChange = (next: ImproveKind) => {
setKind(next);
setCaptureLogs(next === "bug");
};
// Hard-block the contribution flow if the bootstrap probe detected the
// user can't fork kdlbs/kandev (e.g., EMU enterprise restriction). Showing
// the task form here would only let them fill out a task that fails at
// the PR step.
if (bootstrap.kind === "blocked") {
return (
<BlockedDialog
open={props.open}
onOpenChange={props.onOpenChange}
message={bootstrap.message}
/>
);
}
return (
<TaskCreateDialog
open={props.open}
onOpenChange={props.onOpenChange}
mode="create"
workspaceId={props.workspaceId}
workflowId={ready?.data.workflow_id ?? null}
defaultStepId={startStep?.id ?? null}
steps={ready ? ready.steps.map((s) => ({ id: s.id, title: s.name, events: s.events })) : []}
workflowId={workflow.workflowId}
defaultStepId={workflow.startStep?.id ?? null}
steps={workflow.steps.map((s) => ({ id: s.id, title: s.name, events: s.events }))}
initialValues={{
title: "",
repositoryId: ready?.data.repository_id ?? "",
@@ -100,12 +103,15 @@ export function CreateModeView(props: CreateModeViewProps) {
}}
onSuccess={props.onTaskCreated}
lockedFields={{ repository: true, branch: true, workflow: true }}
submitBlockedReason={bootstrapBlockedReason(bootstrap)}
submitBlockedReason={
props.externalBlockedReason ?? forkBlockedReason ?? bootstrapBlockedReason(bootstrap)
}
descriptionPlaceholder={PLACEHOLDERS[kind]}
aboveDescriptionSlot={<KindTabs kind={kind} onChange={handleKindChange} />}
extraFormSlot={
<BootstrapStatusSlot
bootstrap={bootstrap}
kind={kind}
captureLogs={captureLogs}
setCaptureLogs={setCaptureLogs}
/>
@@ -113,8 +119,7 @@ export function CreateModeView(props: CreateModeViewProps) {
bottomSlot={
ready && (
<div className="space-y-2">
<WorkflowStepsPreview steps={ready.steps} />
<UsefulInfoCollapsible />
<CreateModeBottomSlot kind={kind} steps={workflow.steps} />
</div>
)
}
@@ -123,12 +128,14 @@ export function CreateModeView(props: CreateModeViewProps) {
);
}
const STEP_DESCRIPTIONS: Record<string, string> = {
improve:
"Agent reads the report, explores the codebase, and implements the change with TDD. Runs make fmt, typecheck, test, lint, then commits.",
test: "Agent boots a secondary kandev instance with make dev and tells you what to verify. You confirm the change works in the running app.",
pr: "Agent runs the pr skill: pushes the branch and opens a pull request to kdlbs/kandev for the maintainers to review.",
};
function CreateModeBottomSlot({ kind, steps }: { kind: ImproveKind; steps: WorkflowStep[] }) {
return (
<>
<WorkflowStepsPreview steps={steps} />
{kind !== "issue" && <UsefulInfoCollapsible />}
</>
);
}
function WorkflowStepsPreview({ steps }: { steps: WorkflowStep[] }) {
if (steps.length === 0) return null;
@@ -156,7 +163,7 @@ function WorkflowStepsPreview({ steps }: { steps: WorkflowStep[] }) {
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-xs leading-relaxed">
{STEP_DESCRIPTIONS[s.name.toLowerCase()] ?? s.name}
{getImproveKandevStepDescription(s)}
</TooltipContent>
</Tooltip>
</Fragment>
@@ -244,6 +251,9 @@ function KindTabs({
<TabsTrigger value="feature" className="cursor-pointer">
Feature request
</TabsTrigger>
<TabsTrigger value="issue" className="cursor-pointer">
Open issue
</TabsTrigger>
</TabsList>
</Tabs>
);
@@ -251,16 +261,28 @@ function KindTabs({
function BootstrapStatusSlot({
bootstrap,
kind,
captureLogs,
setCaptureLogs,
}: {
bootstrap: BootstrapState;
kind: ImproveKind;
captureLogs: boolean;
setCaptureLogs: (v: boolean) => void;
}) {
return (
<div className="space-y-2">
<BootstrapBanner bootstrap={bootstrap} />
<BootstrapBanner bootstrap={bootstrap} kind={kind} />
{kind === "issue" && (
<div
className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-muted-foreground"
data-testid="improve-kandev-issue-only-notice"
>
This workflow will only create a GitHub issue from the repository template. The agent will
ask for missing details and publication approval; it will not implement the change or open
a pull request.
</div>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={captureLogs} onCheckedChange={(v) => setCaptureLogs(v === true)} />
@@ -290,7 +312,7 @@ function BootstrapStatusSlot({
);
}
function BootstrapBanner({ bootstrap }: { bootstrap: BootstrapState }) {
function BootstrapBanner({ bootstrap, kind }: { bootstrap: BootstrapState; kind: ImproveKind }) {
if (bootstrap.kind === "loading" || bootstrap.kind === "idle") {
return (
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
@@ -299,10 +321,7 @@ function BootstrapBanner({ bootstrap }: { bootstrap: BootstrapState }) {
</div>
);
}
// The "blocked" kind is intercepted earlier in CreateModeView and routed to
// BlockedDialog, so the form (and this banner) are not rendered for it. It
// is still listed here to keep the union exhaustive for the type narrower.
if (bootstrap.kind === "error" || bootstrap.kind === "blocked") {
if (bootstrap.kind === "error") {
return (
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<IconAlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
@@ -310,50 +329,26 @@ function BootstrapBanner({ bootstrap }: { bootstrap: BootstrapState }) {
</div>
);
}
return <ContributorBanner data={bootstrap.data} />;
return <ContributorBanner data={bootstrap.data} kind={kind} />;
}
function BlockedDialog({
open,
onOpenChange,
message,
function ContributorBanner({
data,
kind,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
message: string;
data: ImproveKandevBootstrapResponse;
kind: ImproveKind;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconStethoscope className="h-5 w-5" />
Improve Kandev
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<IconAlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<span>{message}</span>
</div>
<div className="flex justify-end">
<Button
data-testid="improve-kandev-blocked-close"
variant="outline"
onClick={() => onOpenChange(false)}
className="cursor-pointer"
>
Close
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
function ContributorBanner({ data }: { data: ImproveKandevBootstrapResponse }) {
const { github_login: login, has_write_access: hasWrite } = data;
if (data.fork_status === "blocked_emu" && kind !== "issue") {
return (
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<IconAlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>{data.fork_message ?? "This GitHub account cannot fork kdlbs/kandev."}</span>
</div>
);
}
const accessMessage = contributorAccessMessage(kind, hasWrite);
return (
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
{hasWrite ? (
@@ -368,10 +363,7 @@ function ContributorBanner({ data }: { data: ImproveKandevBootstrapResponse }) {
) : (
<span>your GitHub account</span>
)}
.{" "}
{hasWrite
? "You have write access to kdlbs/kandev, so the agent will push directly to a branch on the upstream repo."
: "The agent will fork kdlbs/kandev to your account during the PR step and open a pull request from your fork."}
. {accessMessage}
</span>
</div>
);
@@ -18,6 +18,7 @@ vi.mock("@/lib/logger/buffer", () => ({
const bootstrap: ImproveKandevBootstrapResponse = {
repository_id: "r1",
workflow_id: "w1",
issue_workflow_id: "w2",
branch: "main",
bundle_dir: "/tmp/kandev-improve-abc",
bundle_files: {
@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";
import {
getImproveKandevStepDescription,
getImproveKandevBrowserStorage,
getImproveKandevForkBlockedReason,
initialImproveKandevMode,
readImproveKandevSkipIntro,
contributorAccessMessage,
resolveImproveKandevWorkflow,
writeImproveKandevSkipIntro,
} from "./improve-kandev-dialog-model";
import type { WorkflowStep } from "@/lib/types/http";
class MemoryStorage {
private values = new Map<string, string>();
getItem(key: string) {
return this.values.get(key) ?? null;
}
setItem(key: string, value: string) {
this.values.set(key, value);
}
removeItem(key: string) {
this.values.delete(key);
}
}
describe("improve kandev dialog model", () => {
it("reads and writes the intro preference without requiring browser storage", () => {
const storage = new MemoryStorage();
expect(readImproveKandevSkipIntro(undefined)).toBe(false);
expect(readImproveKandevSkipIntro(storage)).toBe(false);
writeImproveKandevSkipIntro(storage, true);
expect(readImproveKandevSkipIntro(storage)).toBe(true);
writeImproveKandevSkipIntro(storage, false);
expect(readImproveKandevSkipIntro(storage)).toBe(false);
});
it("falls back safely when storage throws", () => {
const storage = {
getItem: () => {
throw new Error("blocked");
},
setItem: () => {
throw new Error("blocked");
},
removeItem: () => {
throw new Error("blocked");
},
};
expect(readImproveKandevSkipIntro(storage)).toBe(false);
expect(() => writeImproveKandevSkipIntro(storage, true)).not.toThrow();
});
it("falls back safely when the browser localStorage property throws", () => {
const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
get() {
throw new Error("blocked");
},
});
try {
expect(getImproveKandevBrowserStorage()).toBeUndefined();
} finally {
if (original) Object.defineProperty(globalThis, "localStorage", original);
else delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
it("chooses create mode only after the intro has been dismissed", () => {
expect(initialImproveKandevMode(false)).toBe("intro");
expect(initialImproveKandevMode(true)).toBe("create");
});
it("selects the issue workflow and its start step for Open issue", () => {
const implementStep = {
id: "implement-step",
name: "Improve",
position: 0,
is_start_step: true,
} as WorkflowStep;
const issueStep = {
id: "issue-step",
name: "Open issue",
position: 0,
is_start_step: true,
} as WorkflowStep;
const ready = {
data: {
workflow_id: "implementation",
issue_workflow_id: "issue-report",
repository_id: "repo",
branch: "main",
fork_status: "unknown" as const,
},
steps: [implementStep],
issueSteps: [issueStep],
};
expect(resolveImproveKandevWorkflow(ready, "issue")).toEqual({
workflowId: "issue-report",
steps: ready.issueSteps,
startStep: ready.issueSteps[0],
});
expect(resolveImproveKandevWorkflow(ready, "bug")).toEqual({
workflowId: "implementation",
steps: ready.steps,
startStep: ready.steps[0],
});
});
it("blocks only implementation kinds for EMU fork restrictions", () => {
const message = "Forks are unavailable";
expect(getImproveKandevForkBlockedReason("bug", "blocked_emu", message)).toBe(message);
expect(getImproveKandevForkBlockedReason("feature", "blocked_emu", message)).toBe(message);
expect(getImproveKandevForkBlockedReason("issue", "blocked_emu", message)).toBeNull();
expect(getImproveKandevForkBlockedReason("bug", "unknown", null)).toBeNull();
});
it("describes contributor access for each workflow kind", () => {
expect(contributorAccessMessage("issue", false)).toContain("does not require");
expect(contributorAccessMessage("bug", true)).toContain("write access");
expect(contributorAccessMessage("feature", false)).toContain("fork");
});
it("prefers stable workflow step ids for descriptions", () => {
expect(
getImproveKandevStepDescription({ id: "improve", name: "Renamed phase" } as WorkflowStep),
).toContain("implements the change");
});
});
@@ -0,0 +1,109 @@
import type { ImproveKandevBootstrapResponse } from "@/lib/api/domains/improve-kandev-api";
import type { WorkflowStep } from "@/lib/types/http";
export const IMPROVE_KANDEV_SKIP_INTRO_KEY = "kandev.improveKandev.skipIntro";
export type ImproveKandevKind = "bug" | "feature" | "issue";
export type ImproveKandevMode = "intro" | "create";
type StorageLike = Pick<Storage, "getItem" | "setItem" | "removeItem">;
const STEP_DESCRIPTIONS: Record<string, string> = {
improve:
"Agent reads the report, explores the codebase, and implements the change with TDD. Runs make fmt, typecheck, test, lint, then commits.",
test: "Agent boots a secondary kandev instance with make dev and tells you what to verify. You confirm the change works in the running app.",
pr: "Agent runs the pr skill: pushes the branch and opens a pull request to kdlbs/kandev for the maintainers to review.",
"open-issue":
"Agent gathers every required field from the repository template, confirms the public draft with you, and publishes the GitHub issue.",
"open issue":
"Agent gathers every required field from the repository template, confirms the public draft with you, and publishes the GitHub issue.",
};
export type ImproveKandevReadyState = {
data: Pick<
ImproveKandevBootstrapResponse,
"workflow_id" | "issue_workflow_id" | "fork_status" | "fork_message"
>;
steps: WorkflowStep[];
issueSteps: WorkflowStep[];
};
export function readImproveKandevSkipIntro(storage: StorageLike | null | undefined): boolean {
try {
return storage?.getItem(IMPROVE_KANDEV_SKIP_INTRO_KEY) === "true";
} catch {
return false;
}
}
export function getImproveKandevBrowserStorage(): StorageLike | undefined {
try {
return globalThis.localStorage;
} catch {
return undefined;
}
}
export function contributorAccessMessage(kind: ImproveKandevKind, hasWrite: boolean): string {
if (kind === "issue") return "Opening an issue does not require a fork or push access.";
if (hasWrite) {
return "You have write access to kdlbs/kandev, so the agent will push directly to a branch on the upstream repo.";
}
return "The agent will fork kdlbs/kandev to your account during the PR step and open a pull request from your fork.";
}
export function getImproveKandevStepDescription(step: Pick<WorkflowStep, "id" | "name">): string {
return STEP_DESCRIPTIONS[step.id] ?? STEP_DESCRIPTIONS[step.name.toLowerCase()] ?? step.name;
}
export function writeImproveKandevSkipIntro(
storage: StorageLike | null | undefined,
skip: boolean,
): void {
try {
if (skip) {
storage?.setItem(IMPROVE_KANDEV_SKIP_INTRO_KEY, "true");
} else {
storage?.removeItem(IMPROVE_KANDEV_SKIP_INTRO_KEY);
}
} catch {
// Browser privacy settings can reject localStorage writes. The preference
// is best-effort and must never prevent the dialog from opening.
}
}
export function initialImproveKandevMode(skipIntro: boolean): ImproveKandevMode {
return skipIntro ? "create" : "intro";
}
export function resolveImproveKandevWorkflow(
ready: ImproveKandevReadyState | null,
kind: ImproveKandevKind,
): {
workflowId: string | null;
steps: WorkflowStep[];
startStep: WorkflowStep | null;
} {
let steps: WorkflowStep[] = [];
if (ready) {
steps = kind === "issue" ? ready.issueSteps : ready.steps;
}
let workflowId: string | null = null;
if (ready) {
workflowId = kind === "issue" ? ready.data.issue_workflow_id : ready.data.workflow_id;
}
return {
workflowId,
steps,
startStep: steps.find((step) => step.is_start_step) ?? steps[0] ?? null,
};
}
export function getImproveKandevForkBlockedReason(
kind: ImproveKandevKind,
forkStatus: ImproveKandevBootstrapResponse["fork_status"] | undefined,
forkMessage: string | null | undefined,
): string | null {
if (kind === "issue" || forkStatus !== "blocked_emu") return null;
return forkMessage ?? "This GitHub account cannot fork kdlbs/kandev.";
}
+71 -23
View File
@@ -4,6 +4,7 @@ import Link from "@/components/routing/app-link";
import { useCallback, useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@kandev/ui/dialog";
import { Button } from "@kandev/ui/button";
import { Checkbox } from "@kandev/ui/checkbox";
import { IconAlertTriangle, IconStethoscope, IconCheck } from "@tabler/icons-react";
import { useToast } from "@/components/toast-provider";
@@ -15,6 +16,12 @@ import { fetchSystemHealth } from "@/lib/api/domains/health-api";
import type { Task } from "@/lib/types/http";
import { buildImproveKandevDescription } from "./improve-kandev-dialog-helpers";
import { CreateModeView, type BootstrapState } from "./improve-kandev-dialog-create";
import {
initialImproveKandevMode,
getImproveKandevBrowserStorage,
readImproveKandevSkipIntro,
writeImproveKandevSkipIntro,
} from "./improve-kandev-dialog-model";
type ImproveKandevDialogProps = {
open: boolean;
@@ -32,7 +39,8 @@ type AuthState =
export function ImproveKandevDialog(props: ImproveKandevDialogProps) {
const { open, onOpenChange, workspaceId, onSuccess } = props;
const [mode, setMode] = useState<Mode>("intro");
const [mode, setMode] = useState<Mode>(() => initialImproveKandevMode(readSkipIntro()));
const [skipIntro, setSkipIntro] = useState(() => readSkipIntro());
const [auth, setAuth] = useState<AuthState>({ kind: "checking" });
const [bootstrap, setBootstrap] = useState<BootstrapState>({ kind: "idle" });
const [captureLogs, setCaptureLogs] = useState(true);
@@ -43,7 +51,8 @@ export function ImproveKandevDialog(props: ImproveKandevDialogProps) {
useEffect(() => {
if (open) return;
/* eslint-disable react-hooks/set-state-in-effect */
setMode("intro");
setMode(initialImproveKandevMode(readSkipIntro()));
setSkipIntro(readSkipIntro());
setAuth({ kind: "checking" });
setBootstrap({ kind: "idle" });
setCaptureLogs(true);
@@ -51,7 +60,13 @@ export function ImproveKandevDialog(props: ImproveKandevDialogProps) {
}, [open]);
useGitHubAuthCheck(open, workspaceId, setAuth);
useBootstrapKandev(mode, workspaceId, setBootstrap);
useBootstrapKandev(open, mode, workspaceId, setBootstrap);
useEffect(() => {
if (!open || mode !== "create" || auth.kind !== "missing") return;
// A saved intro preference must not bypass the GitHub-auth recovery UI.
// eslint-disable-next-line react-hooks/set-state-in-effect
setMode("intro");
}, [auth.kind, mode, open]);
const handleSuccess = useCallback(
(task: Task) => {
@@ -80,13 +95,14 @@ export function ImproveKandevDialog(props: ImproveKandevDialogProps) {
setCaptureLogs={setCaptureLogs}
transformDescription={transformDescription}
onTaskCreated={handleSuccess}
externalBlockedReason={auth.kind === "checking" ? "Checking GitHub authentication…" : null}
/>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconStethoscope className="h-5 w-5" />
@@ -95,6 +111,11 @@ export function ImproveKandevDialog(props: ImproveKandevDialogProps) {
</DialogHeader>
<IntroBody
auth={auth}
skipIntro={skipIntro}
onSkipIntroChange={(checked) => {
setSkipIntro(checked);
writeImproveKandevSkipIntro(getImproveKandevBrowserStorage(), checked);
}}
onCancel={() => onOpenChange(false)}
onProceed={() => setMode("create")}
/>
@@ -137,6 +158,7 @@ function useGitHubAuthCheck(
}
function useBootstrapKandev(
open: boolean,
mode: Mode,
workspaceId: string | null,
setBootstrap: (s: BootstrapState) => void,
@@ -144,36 +166,28 @@ function useBootstrapKandev(
const { toast } = useToast();
const setRepositories = useAppStore((state) => state.setRepositories);
useEffect(() => {
if (mode !== "create" || !workspaceId) return;
if (!open || mode !== "create" || !workspaceId) return;
let cancelled = false;
setBootstrap({ kind: "loading" });
(async () => {
try {
const data = await bootstrapImproveKandev(workspaceId);
// Surface the EMU fork-restriction case before any further setup so
// the user sees a clear error and can't submit a contribution that
// would only fail at the PR step.
if (data.fork_status === "blocked_emu") {
if (cancelled) return;
const message = data.fork_message || "Your account cannot fork kdlbs/kandev.";
setBootstrap({ kind: "blocked", message });
toast({
title: "Cannot contribute from this account",
description: message,
variant: "error",
});
return;
}
// Refresh the workspace repository list so the newly-created kandev
// repo is in the store; otherwise the locked repo dropdown can't
// resolve a label for the bootstrapped repository_id.
const [stepsRes, reposRes] = await Promise.all([
const [stepsRes, issueStepsRes, reposRes] = await Promise.all([
listWorkflowSteps(data.workflow_id),
listWorkflowSteps(data.issue_workflow_id),
listRepositories(workspaceId, undefined, { cache: "no-store" }),
]);
if (cancelled) return;
setRepositories(workspaceId, reposRes.repositories);
setBootstrap({ kind: "ready", data, steps: stepsRes.steps });
setBootstrap({
kind: "ready",
data,
steps: stepsRes.steps,
issueSteps: issueStepsRes.steps,
});
} catch (err) {
if (cancelled) return;
const message = err instanceof Error ? err.message : "Bootstrap failed";
@@ -188,22 +202,34 @@ function useBootstrapKandev(
return () => {
cancelled = true;
};
}, [mode, workspaceId, setBootstrap, setRepositories, toast]);
}, [open, mode, workspaceId, setBootstrap, setRepositories, toast]);
}
function IntroBody({
auth,
skipIntro,
onSkipIntroChange,
onCancel,
onProceed,
}: {
auth: AuthState;
skipIntro: boolean;
onSkipIntroChange: (checked: boolean) => void;
onCancel: () => void;
onProceed: () => void;
}) {
if (auth.kind === "missing") {
return <GhAuthMissing auth={auth} onCancel={onCancel} />;
}
return <IntroExplanation auth={auth} onCancel={onCancel} onProceed={onProceed} />;
return (
<IntroExplanation
auth={auth}
skipIntro={skipIntro}
onSkipIntroChange={onSkipIntroChange}
onCancel={onCancel}
onProceed={onProceed}
/>
);
}
function GhAuthMissing({
@@ -241,10 +267,14 @@ function GhAuthMissing({
function IntroExplanation({
auth,
skipIntro,
onSkipIntroChange,
onCancel,
onProceed,
}: {
auth: AuthState;
skipIntro: boolean;
onSkipIntroChange: (checked: boolean) => void;
onCancel: () => void;
onProceed: () => void;
}) {
@@ -274,6 +304,16 @@ function IntroExplanation({
account and opens a PR from your fork, credited to you
</IntroBullet>
</ul>
<label
className="flex min-h-12 cursor-pointer items-center gap-2 text-sm text-muted-foreground"
data-testid="improve-kandev-skip-intro"
>
<Checkbox
checked={skipIntro}
onCheckedChange={(checked) => onSkipIntroChange(checked === true)}
/>
Do not show this again
</label>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onCancel} className="cursor-pointer">
Cancel
@@ -299,3 +339,11 @@ function IntroBullet({ children }: { children: React.ReactNode }) {
</li>
);
}
function readSkipIntro(): boolean {
try {
return readImproveKandevSkipIntro(globalThis.localStorage);
} catch {
return false;
}
}
@@ -1,6 +1,5 @@
"use client";
import { type ReactNode, type RefObject, useRef } from "react";
import { type ReactNode, type RefObject, useRef, useState } from "react";
import { useRouter } from "@/lib/routing/client-router";
import Link from "@/components/routing/app-link";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet";
@@ -15,6 +14,7 @@ import {
IconLayoutKanban,
IconList,
IconSettings,
IconStethoscope,
IconTimeline,
} from "@tabler/icons-react";
import { AppSidebarWorkspacePicker } from "@/components/app-sidebar/app-sidebar-workspace-picker";
@@ -25,13 +25,13 @@ import {
type TasksListDisplayOptions,
} from "./mobile-menu-task-list-options";
import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings";
import { linkToTasks } from "@/lib/links";
import { linkToTask, linkToTasks } from "@/lib/links";
import { cn } from "@/lib/utils";
import type { Repository } from "@/lib/types/http";
import type { Repository, Task } from "@/lib/types/http";
import type { WorkflowsState } from "@/lib/state/slices";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { useAppStatusDrawer } from "@/components/app-status-bar/app-status-surface-provider";
import { ImproveKandevDialog } from "@/components/improve-kandev-dialog";
type MobileMenuSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -281,10 +281,12 @@ function MobileViewSection({
function MobileUtilityActions({
showHealthIndicator,
onOpenHealthDialog,
onOpenImproveKandev,
onOpenChange,
}: {
showHealthIndicator: boolean;
onOpenHealthDialog: () => void;
onOpenImproveKandev: () => void;
onOpenChange: (open: boolean) => void;
}) {
const { enabled: statusDrawerEnabled, openStatusDrawer } = useAppStatusDrawer();
@@ -323,6 +325,16 @@ function MobileUtilityActions({
Settings
</Link>
</Button>
<Button
type="button"
variant="outline"
className="h-11 w-full cursor-pointer justify-start gap-3 px-3 text-sm"
onClick={onOpenImproveKandev}
data-testid="mobile-improve-kandev-button"
>
<IconStethoscope className={mobileControlIconClass} />
Improve Kandev
</Button>
{showHealthIndicator && (
<Button
type="button"
@@ -407,6 +419,7 @@ function MobileMenuContent({
displayOptions,
showHealthIndicator,
onOpenHealthDialog,
onOpenImproveKandev,
}: Pick<
MobileMenuSheetProps,
| "searchQuery"
@@ -420,6 +433,7 @@ function MobileMenuContent({
onViewChange: (value: string) => void;
showPipeline: boolean;
displayOptions: MobileDisplayOptionsProps;
onOpenImproveKandev: () => void;
}) {
return (
<div className="flex min-h-full flex-col gap-6 p-4">
@@ -439,6 +453,7 @@ function MobileMenuContent({
<MobileUtilityActions
showHealthIndicator={showHealthIndicator}
onOpenHealthDialog={onOpenHealthDialog}
onOpenImproveKandev={onOpenImproveKandev}
onOpenChange={onOpenChange}
/>
</div>
@@ -459,6 +474,7 @@ export function MobileMenuSheet({
}: MobileMenuSheetProps) {
const contentRef = useRef<HTMLDivElement | null>(null);
const router = useRouter();
const [improveOpen, setImproveOpen] = useState(false);
const { isMobile } = useResponsiveBreakpoint();
const {
workflows,
@@ -514,27 +530,71 @@ export function MobileMenuSheet({
event.preventDefault();
contentRef.current?.focus({ preventScroll: true });
};
const openImproveKandev = () => {
onOpenChange(false);
requestAnimationFrame(() => setImproveOpen(true));
};
return (
<ResponsiveMenuSurface
<MobileMenuRender
isMobile={isMobile}
open={open}
onOpenChange={onOpenChange}
contentRef={contentRef}
onOpenAutoFocus={focusMenu}
>
<MobileMenuContent
searchQuery={searchQuery}
onSearchChange={onSearchChange}
isSearchLoading={isSearchLoading}
onOpenChange={onOpenChange}
viewValue={viewValue}
onViewChange={handleViewChange}
showPipeline={!isMobile}
displayOptions={displayOptions}
showHealthIndicator={showHealthIndicator}
onOpenHealthDialog={onOpenHealthDialog}
/>
</ResponsiveMenuSurface>
searchQuery={searchQuery}
onSearchChange={onSearchChange}
isSearchLoading={isSearchLoading}
viewValue={viewValue}
onViewChange={handleViewChange}
displayOptions={displayOptions}
showHealthIndicator={showHealthIndicator}
onOpenHealthDialog={onOpenHealthDialog}
onOpenImproveKandev={openImproveKandev}
improveOpen={improveOpen}
onImproveOpenChange={setImproveOpen}
workspaceId={workspaceId ?? null}
onTaskCreated={(task) => router.push(linkToTask(task.id))}
/>
);
}
function MobileMenuRender(
props: Pick<
MobileMenuSheetProps,
| "open"
| "onOpenChange"
| "searchQuery"
| "onSearchChange"
| "isSearchLoading"
| "showHealthIndicator"
| "onOpenHealthDialog"
> & {
isMobile: boolean;
contentRef: RefObject<HTMLDivElement | null>;
onOpenAutoFocus: (event: Event) => void;
viewValue: string;
onViewChange: (value: string) => void;
displayOptions: MobileDisplayOptionsProps;
onOpenImproveKandev: () => void;
improveOpen: boolean;
onImproveOpenChange: (open: boolean) => void;
workspaceId: string | null;
onTaskCreated: (task: Task) => void;
},
) {
const { isMobile, improveOpen, onImproveOpenChange, workspaceId, onTaskCreated } = props;
return (
<>
<ResponsiveMenuSurface {...props} isMobile={isMobile}>
<MobileMenuContent {...props} showPipeline={!isMobile} />
</ResponsiveMenuSurface>
<ImproveKandevDialog
open={improveOpen}
onOpenChange={onImproveOpenChange}
workspaceId={workspaceId}
onSuccess={onTaskCreated}
/>
</>
);
}
+93 -13
View File
@@ -19,6 +19,7 @@ type BootstrapOverrides = {
has_write_access?: boolean;
fork_status?: ForkStatus;
fork_message?: string;
issueWorkflowId?: string;
/**
* When provided, the bootstrap route handler awaits this promise before
* fulfilling the response. Tests use it to keep the dialog in its
@@ -57,6 +58,7 @@ async function mockImproveKandevApis(
body: JSON.stringify({
repository_id: seed.repositoryId,
workflow_id: seed.workflowId,
issue_workflow_id: overrides.issueWorkflowId ?? seed.workflowId,
branch: "main",
bundle_dir: bundleDir,
bundle_files: {
@@ -82,6 +84,88 @@ async function mockImproveKandevApis(
}
test.describe("Improve Kandev dialog", () => {
test("dismissed intro is persisted and later opens the create dialog directly", async ({
testPage,
seedData,
}) => {
await mockImproveKandevApis(testPage, seedData);
await testPage.goto("/");
await testPage.getByTestId("sidebar-improve-kandev-button").click();
const introDialog = testPage.getByRole("dialog", { name: "Improve Kandev" });
const dismissPreference = introDialog.getByTestId("improve-kandev-skip-intro");
await expect(dismissPreference).toBeVisible();
await dismissPreference.click();
await expect
.poll(() =>
testPage.evaluate(() => window.localStorage.getItem("kandev.improveKandev.skipIntro")),
)
.toBe("true");
const contribute = testPage.getByTestId("improve-kandev-proceed");
await expect(contribute).toBeEnabled({ timeout: 10_000 });
await contribute.click();
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible({ timeout: 10_000 });
await testPage.keyboard.press("Escape");
await expect(testPage.getByTestId("create-task-dialog")).toBeHidden();
await testPage.getByTestId("sidebar-improve-kandev-button").click();
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible({ timeout: 10_000 });
await expect(testPage.getByText("Preparing kandev repository…")).toHaveCount(0);
await expect(testPage.getByText(/Kandev is open source/)).toHaveCount(0);
});
test("Open issue creates a task in the report-only workflow", async ({
testPage,
apiClient,
seedData,
}) => {
const issueWorkflow = await apiClient.createWorkflow(
seedData.workspaceId,
"Report Kandev issue",
"simple",
);
const issueSteps = await apiClient.listWorkflowSteps(issueWorkflow.id);
const issueStartStep =
issueSteps.steps.find((step) => step.is_start_step) ?? issueSteps.steps[0];
await mockImproveKandevApis(testPage, seedData, { issueWorkflowId: issueWorkflow.id });
await testPage.goto("/");
await testPage.getByTestId("sidebar-improve-kandev-button").click();
const contribute = testPage.getByTestId("improve-kandev-proceed");
await expect(contribute).toBeEnabled({ timeout: 10_000 });
await contribute.click();
const createDialog = testPage.getByTestId("create-task-dialog");
await expect(createDialog).toBeVisible({ timeout: 10_000 });
const issueTab = createDialog.getByRole("tab", { name: "Open issue" });
await expect(issueTab).toBeVisible();
await issueTab.click();
await expect(
createDialog.getByText(/only create a GitHub issue.*will not implement/i),
).toBeVisible();
const title = "Document task ownership in parallel sessions";
await createDialog.getByTestId("task-title-input").fill(title);
await createDialog
.getByTestId("task-description-input")
.fill("Users cannot tell which task owns a worktree when several agents run.");
const submit = createDialog.getByTestId("submit-start-agent");
await expect(submit).toBeEnabled({ timeout: 10_000 });
await submit.click();
await expect(createDialog).toBeHidden({ timeout: 10_000 });
await expect
.poll(async () => (await apiClient.listTasks(seedData.workspaceId)).tasks)
.toContainEqual(
expect.objectContaining({
title,
workflow_step_id: issueStartStep.id,
}),
);
});
test("intro → create flow shows workflow preview, useful info, and fork banner", async ({
testPage,
seedData,
@@ -160,7 +244,7 @@ test.describe("Improve Kandev dialog", () => {
).toBeVisible();
});
test("blocks contribution when account is detected as Enterprise Managed User", async ({
test("blocks implementation but allows issue reporting for an Enterprise Managed User", async ({
testPage,
seedData,
}) => {
@@ -184,19 +268,15 @@ test.describe("Improve Kandev dialog", () => {
await expect(contribute).toBeEnabled({ timeout: 10_000 });
await contribute.click();
// Blocked dialog mounts in place of the task-create form. The message
// also fires as a toast, so scope the assertion to the dialog body.
const blockedDialog = testPage.getByRole("dialog", { name: "Improve Kandev" });
await expect(blockedDialog).toBeVisible({ timeout: 10_000 });
await expect(blockedDialog.getByText(blockedMessage)).toBeVisible();
const createDialog = testPage.getByTestId("create-task-dialog");
await expect(createDialog).toBeVisible({ timeout: 10_000 });
await expect(createDialog.getByText(blockedMessage)).toBeVisible();
await createDialog.getByTestId("task-title-input").fill("EMU contribution");
await createDialog.getByTestId("task-description-input").fill("Describe the problem");
await expect(createDialog.getByTestId("submit-start-agent")).toBeDisabled();
// The task-create form must NOT mount: there is nothing to submit and
// no fork should be attempted.
await expect(testPage.getByTestId("create-task-dialog")).toHaveCount(0);
// Close button is the only action; clicking it dismisses the dialog.
await testPage.getByTestId("improve-kandev-blocked-close").click();
await expect(blockedDialog).toBeHidden();
await createDialog.getByRole("tab", { name: "Open issue" }).click();
await expect(createDialog.getByTestId("submit-start-agent")).toBeEnabled({ timeout: 10_000 });
});
test("locked mode hides workflow picker and source-mode switch (URL / None)", async ({
@@ -0,0 +1,65 @@
import { test, expect } from "../fixtures/test-base";
const BOOTSTRAP_URL = "**/api/v1/system/improve-kandev/bootstrap";
const HEALTH_URL = "**/api/v1/system/health";
test.describe("Improve Kandev on mobile", () => {
test("dismisses the intro and reaches the issue-only task option without overflow", async ({
testPage,
seedData,
}) => {
await testPage.route(HEALTH_URL, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ healthy: true, issues: [] }),
}),
);
await testPage.route(BOOTSTRAP_URL, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
repository_id: seedData.repositoryId,
workflow_id: seedData.workflowId,
issue_workflow_id: seedData.workflowId,
branch: "main",
bundle_dir: "/tmp/kandev-improve-mobile-e2e",
bundle_files: {
metadata: "/tmp/kandev-improve-mobile-e2e/metadata.json",
backend_log: "/tmp/kandev-improve-mobile-e2e/backend.log",
frontend_log: "/tmp/kandev-improve-mobile-e2e/frontend.log",
},
github_login: "octocat",
has_write_access: false,
fork_status: "unknown",
}),
}),
);
await testPage.goto("/");
await testPage.getByRole("button", { name: "Open menu" }).tap();
const improveButton = testPage.getByTestId("mobile-improve-kandev-button");
await expect(improveButton).toBeVisible();
await improveButton.tap();
const preference = testPage.getByTestId("improve-kandev-skip-intro");
await expect(preference).toBeVisible();
const preferenceBox = await preference.boundingBox();
expect(preferenceBox?.height).toBeGreaterThanOrEqual(44);
await preference.tap();
await testPage.getByTestId("improve-kandev-proceed").tap();
const createDialog = testPage.getByTestId("create-task-dialog");
await expect(createDialog).toBeVisible({ timeout: 10_000 });
await createDialog.getByRole("tab", { name: "Open issue" }).tap();
await expect(
createDialog.getByText(/only create a GitHub issue.*will not implement/i),
).toBeVisible();
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
).toBe(true);
});
});
@@ -13,6 +13,7 @@ export type ForkStatus = "writable" | "ready" | "blocked_emu" | "unknown";
export type ImproveKandevBootstrapResponse = {
repository_id: string;
workflow_id: string;
issue_workflow_id: string;
branch: string;
bundle_dir: string;
bundle_files: {
+208
View File
@@ -0,0 +1,208 @@
---
spec: docs/specs/improve-kandev/spec.md
created: 2026-07-27
status: done
---
# Implementation Plan: Improve Kandev Dialog and Issue Reporting
## Overview
Extend the existing Improve Kandev bootstrap so it provisions both the
implementation workflow and a one-step issue-reporting workflow, then let the
shared task-create surface select the correct hidden workflow. Add a
browser-local intro dismissal, preserve GitHub-auth and fork safeguards, and
expose the same flow from the existing mobile home menu. The work is partially
implemented in the current uncommitted worktree; tasks below identify exactly
what is complete and what must be continued.
Relevant workflow decision:
[Single-Session Model Switching](../../decisions/2026-07-26-single-session-model-switching.md).
## Planning Guardrail
The session learning that triggered this plan is already encoded in:
- `AGENTS.md`
- `.agents/skills/spec-driven-development/SKILL.md`
- `apps/backend/config/workflows/improve-kandev.yml`
These files now state that workflow-generated implementation envelopes do not
skip spec/plan/task creation. See
[Task 01](task-01-harness-planning-gate.md).
---
## Backend
### Hidden issue-reporting workflow
- Add `apps/backend/config/workflows/report-kandev-issue.yml` with template ID
`report-kandev-issue`.
- Keep it hidden and give it one auto-starting **Open issue** step.
- The prompt must read the current repository issue forms, classify bug versus
feature, gather every required field through `ask_user_question_kandev`,
reject public security disclosure, check duplicates, preview the complete
public draft, obtain publication approval, and run `gh issue create`.
- Extend `apps/backend/config/workflows/loader_test.go` to prove the template is
present, hidden, single-step, auto-starting, and contains the required
reporting safeguards.
### Improve Kandev bootstrap
- Extend `BootstrapResponse` in
`apps/backend/internal/improvekandev/handler.go` with
`IssueWorkflowID string 'json:"issue_workflow_id"'`.
- Generalize `ensureWorkflow` so bootstrap idempotently resolves or creates
both `improve-kandev` and `report-kandev-issue`, healing the hidden flag for
either stale workspace instance.
- Return both workflow IDs in the same bootstrap response; preserve repository
reuse/cloning, bundle generation, GitHub login, write access, and fork-status
behavior.
- Add a handler integration test in
`apps/backend/internal/improvekandev/handler_test.go` using a real test
SQLite task/workflow repository plus fake cloner and GitHub probe. POST the
bootstrap route twice and assert stable, distinct workflow IDs, hidden
workspace workflows, and the issue workflow's start step.
---
## Frontend
### API and dialog model
- Add `issue_workflow_id` to
`ImproveKandevBootstrapResponse` in
`apps/web/lib/api/domains/improve-kandev-api.ts`.
- Load steps for both workflow IDs during bootstrap.
- Extract the non-rendering decisions currently accumulating in
`improve-kandev-dialog.tsx` and
`improve-kandev-dialog-create.tsx` into a focused pure model/helper with
Vitest coverage:
- read/write `kandev.improveKandev.skipIntro` safely when local storage is
unavailable;
- choose intro versus create mode;
- choose implementation versus issue workflow and start step;
- block implementation kinds, but not **Open issue**, for `blocked_emu`.
- Keep GitHub-auth recovery authoritative: a saved intro dismissal must not
bypass a missing-auth message.
### Intro and task kind selection
- Add a controlled **Do not show this again** checkbox to the existing intro.
The labeled touch target is at least 44px high.
- Add **Open issue** beside **Bug fix** and **Feature request**.
- When selected, switch the locked workflow/default step to
`issue_workflow_id`, use issue-specific placeholder copy, default log capture
off, show an explicit report-only notice, and show only the one-step workflow
preview.
- The notice states that the agent follows the repository issue template, asks
for missing information and approval, creates an issue, and does not
implement code or open a pull request.
- Retain the existing contributor banner for implementation kinds. For issue
reporting, explain that a fork and push access are not required.
### Mobile design contract
- **Desktop outcome:** the app-sidebar footer action opens the intro or direct
create dialog.
- **Mobile entry point:** add a 44px-or-larger **Improve Kandev** row to the
existing `MobileMenuSheet` Utilities section.
- **Nearest exemplar:** `apps/web/components/kanban/mobile-menu-sheet.tsx`;
reuse its inset full-height drawer, fixed header, internal scrolling body,
safe-area padding, and utility-row geometry.
- **Hierarchy and primary action:** tapping the utility row closes the menu,
then opens the shared Improve Kandev dialog. The task-create dialog remains
the single focal surface; no stacked drawer/dialog.
- **Scroll and state:** the mobile menu remains the drawer's single scroll
owner, the intro dialog is viewport-contained with internal vertical
scrolling, and the dialog state/business logic is shared across viewports.
- Refactor `MobileMenuSheet` and `CreateModeView` so touched functions remain
within the repository's line, complexity, and no-nested-ternary limits.
---
## Tests
- **What:** embedded issue workflow contract.
**File:** `apps/backend/config/workflows/loader_test.go`.
**How:** load embedded templates and assert ID, hidden flag, one step,
auto-start event, and prompt safeguards.
- **What:** bootstrap creates/reuses both hidden workflow instances.
**File:** `apps/backend/internal/improvekandev/handler_test.go`.
**How:** Gin handler integration test with real SQLite repositories and fake
external boundaries; call twice and inspect response plus persisted workflow
state.
- **What:** local-storage preference, workflow selection, and fork-block
decisions.
**File:** a focused `*.test.ts` beside the extracted dialog model/helper.
**How:** table-driven Vitest cases using an in-memory Storage-compatible fake.
- **What:** description log bundle remains compatible with the extended
bootstrap response.
**File:** `apps/web/components/improve-kandev-dialog-helpers.test.ts`.
**How:** existing Vitest cases with both workflow IDs in the fixture.
---
## E2E Tests
- **Scenario:** selecting the intro dismissal persists through reload and later
opens task creation directly.
**File:** `apps/web/e2e/tests/improve-kandev.spec.ts`.
**Verify:** local-storage value, reload, direct create dialog, and absent
intro copy.
- **Scenario:** selecting **Open issue** creates a task in the issue workflow.
**File:** `apps/web/e2e/tests/improve-kandev.spec.ts`.
**Verify:** report-only notice, submitted task title, and issue workflow start
step.
- **Scenario:** an EMU-shaped account cannot submit implementation work but can
submit an issue report.
**File:** `apps/web/e2e/tests/improve-kandev.spec.ts`.
**Verify:** disabled submit for Bug fix/Feature request and enabled submit
after selecting Open issue.
- **Scenario:** a phone user opens Improve Kandev from the home menu and reaches
the issue-only option.
**File:** `apps/web/e2e/tests/mobile-improve-kandev.spec.ts`.
**Verify:** visible 44px utility/preference targets, menu dismissal, issue
notice, and no document horizontal overflow.
---
## Completion Snapshot
The implementation, focused tests, desktop/mobile E2E coverage, repository
verification, review fixes, and merge-conflict resolution are complete. The PR
is the authoritative record for the final commit and CI results.
---
## Implementation Waves And Parallel Candidates
The default is sequential execution in this primary conversation.
Planning:
- [x] [Task 01: Harness planning gate](task-01-harness-planning-gate.md)
Wave 1:
- [x] [Task 02: Backend issue workflow and bootstrap](task-02-backend-issue-workflow.md)
— complete; loader, bootstrap wiring, and real SQLite integration coverage pass.
Wave 2:
- [x] [Task 03: Dialog persistence, workflow selection, and mobile entry](task-03-frontend-dialog-and-mobile.md)
— complete; model tests, responsive entry, and lint/typecheck pass.
Wave 3:
- [x] [Task 04: Desktop and mobile E2E coverage](task-04-e2e-coverage.md)
— complete; rebuilt desktop and mobile Playwright suites pass.
Wave 4:
- [x] [Task 05: Required verification and commit](task-05-verification-and-commit.md)
— verification complete; changes are ready for the final commit.
No tasks are marked parallel-safe: Tasks 02–04 share the bootstrap contract and
Improve Kandev test fixtures, and Task 05 depends on all of them.
@@ -0,0 +1,66 @@
---
id: "01-harness-planning-gate"
title: "Harness planning gate"
status: done
wave: 0
depends_on: []
plan: "plan.md"
spec: "../../specs/improve-kandev/spec.md"
---
# Task 01: Harness planning gate
## Acceptance
- Repo-wide guidance states that workflow-generated implementation envelopes
do not opt features or behavior-changing fixes out of spec-driven
development.
- The spec-driven-development skill requires approved artifacts or an explicit
user instruction to skip planning before production/permanent test edits.
- The embedded Improve Kandev prompt carries the same planning gate before its
implementation checklist.
## Verification
```bash
git diff --check -- AGENTS.md .agents/skills/spec-driven-development/SKILL.md apps/backend/config/workflows/improve-kandev.yml
rg -n "Workflow-generated phase text|Prompt-Precedence Gate|PLANNING GATE" \
AGENTS.md .agents/skills/spec-driven-development/SKILL.md \
apps/backend/config/workflows/improve-kandev.yml
cd apps/backend && go test ./config/workflows -run TestLoadTemplates_AllValid -count=1
```
## Files likely touched
- `AGENTS.md`
- `.agents/skills/spec-driven-development/SKILL.md`
- `apps/backend/config/workflows/improve-kandev.yml`
## Dependencies
None.
## Parallelism
Sequential; completed during the planning session as an explicit harness
improvement.
## Inputs
- Root **Single-Session Model Workflow** guidance.
- `.agents/skills/harness-improvement/SKILL.md`.
- `.agents/skills/spec-driven-development/SKILL.md`.
## Output contract
Report the learning recorded, exact files changed, validation commands/results,
and any remaining ambiguity in what counts as an explicit planning opt-out.
## Recorded result
- Learning recorded: workflow-generated implementation language is Phase 5
guidance and is not an explicit opt-out from feature planning.
- Markdown/YAML diff checks passed.
- All three guardrail markers were found in their intended files.
- `go test ./config/workflows -run TestLoadTemplates_AllValid -count=1`
passed.
@@ -0,0 +1,82 @@
---
id: "02-backend-issue-workflow"
title: "Backend issue workflow and bootstrap"
status: done
wave: 1
depends_on: ["01-harness-planning-gate"]
plan: "plan.md"
spec: "../../specs/improve-kandev/spec.md"
---
# Task 02: Backend issue workflow and bootstrap
## Acceptance
- `report-kandev-issue` is a hidden, single-step, auto-starting system workflow
whose prompt gathers and confirms every required repository-template field
before publishing an issue without changing code.
- Improve Kandev bootstrap idempotently returns stable `workflow_id` and
`issue_workflow_id` values for distinct hidden workspace workflows.
- A real-handler integration test proves the response and persisted workflow
state across two bootstrap calls.
## Verification
```bash
cd apps/backend && go test ./config/workflows -run 'TestLoadTemplates_(ExpectedTemplateIDs|HiddenFlag|ReportKandevIssuePromptContract)' -count=1
cd apps/backend && go test ./internal/integration -run TestImproveKandevBootstrapCreatesBothHiddenWorkflowsIdempotently -count=1
```
## Files likely touched
- `apps/backend/config/workflows/report-kandev-issue.yml`
- `apps/backend/config/workflows/loader_test.go`
- `apps/backend/internal/improvekandev/handler.go`
- `apps/backend/internal/integration/improve_kandev_test.go`
## Dependencies
Task 01.
## Parallelism
Sequential. The response contract is consumed by Tasks 03 and 04.
## Inputs
- Spec: **What**, **API surface**, **Failure modes**, and issue-reporting
scenarios.
- Plan: **Backend** and **Continuation Snapshot**.
- Pattern: existing `improve-kandev.yml`, `ensureWorkflow`, and integration test
repository setup in `apps/backend/internal/integration/test_server_test.go`.
## Existing partial work to resume
- The new YAML template, generalized bootstrap workflow creation, response
field, and loader contract tests are already present.
- The targeted workflow/config and improvekandev package tests passed.
- The missing real-handler integration assertion was added in the integration
package so it can reuse the existing SQLite test server and workflow service.
It covers two bootstrap calls, stable IDs, hidden persisted workflows, and
the one-step issue workflow.
## Risks
- Test setup must use real task and workflow repositories on the same SQLite
database so template steps are materialized exactly as production does.
- Preserve existing EMU/fork metadata; frontend decides which report kinds it
blocks.
## Output contract
The bootstrap response contains stable, distinct implementation and issue
workflow IDs; both persisted workflows are hidden and the issue workflow has a
single auto-starting step.
## Recorded verification
- `cd apps/backend && go test ./config/workflows -run 'TestLoadTemplates_(ExpectedTemplateIDs|HiddenFlag|ReportKandevIssuePromptContract)' -count=1` — passed (3 tests).
- `cd apps/backend && go test ./internal/improvekandev ./config/workflows -count=1` — passed (43 tests).
- `cd apps/backend && go test ./internal/integration -run TestImproveKandevBootstrapCreatesBothHiddenWorkflowsIdempotently -count=1` — passed.
Task 02 is complete. Continue with Task 03.
@@ -0,0 +1,106 @@
---
id: "03-frontend-dialog-and-mobile"
title: "Dialog persistence, workflow selection, and mobile entry"
status: done
wave: 2
depends_on: ["02-backend-issue-workflow"]
plan: "plan.md"
spec: "../../specs/improve-kandev/spec.md"
---
# Task 03: Dialog persistence, workflow selection, and mobile entry
## Acceptance
- Selecting **Do not show this again** safely persists the browser-local
preference and future opens go directly to task creation unless GitHub-auth
recovery must be shown.
- **Open issue** selects `issue_workflow_id`, renders report-only copy and the
one-step preview, while fork-blocked users remain blocked only on Bug
fix/Feature request.
- Phone users reach the shared flow from a 44px-or-larger Utilities row in the
mobile home menu, without stacked overlays; touched files pass frontend
complexity/length/nested-ternary rules.
## Verification
```bash
cd apps && pnpm --filter @kandev/web test -- --run components/improve-kandev-dialog-model.test.ts components/improve-kandev-dialog-helpers.test.ts
cd apps/web && pnpm run typecheck
cd apps/web && pnpm exec eslint components/improve-kandev-dialog.tsx components/improve-kandev-dialog-create.tsx components/improve-kandev-dialog-model.ts components/kanban/mobile-menu-sheet.tsx
```
## Files likely touched
- `apps/web/lib/api/domains/improve-kandev-api.ts`
- `apps/web/components/improve-kandev-dialog.tsx`
- `apps/web/components/improve-kandev-dialog-create.tsx`
- `apps/web/components/improve-kandev-dialog-model.ts`
- `apps/web/components/improve-kandev-dialog-model.test.ts`
- `apps/web/components/improve-kandev-dialog-helpers.test.ts`
- `apps/web/components/kanban/mobile-menu-sheet.tsx`
## Dependencies
Task 02.
## Parallelism
Sequential. This task consumes the bootstrap contract and owns the UI used by
Task 04.
## Inputs
- Spec: **What**, **Persistence guarantees**, **Failure modes**, and mobile
scenario.
- Plan: **Frontend**, **Mobile design contract**, and **Continuation Snapshot**.
- Mobile exemplar:
`apps/web/components/kanban/mobile-menu-sheet.tsx`.
## Completed implementation notes
- The response type, dual step loading, intro checkbox/local-storage behavior,
issue tab/notice, workflow switching, EMU scoping, and mobile utility entry
are already present.
- Frontend typecheck and existing description-helper tests passed before the
final mobile-menu edit.
- Pure model tests were added before extraction, and the known ESLint
complexity, nested-ternary, and line-length warnings were resolved without
suppressions.
## Recorded implementation and verification
- Added `improve-kandev-dialog-model.ts` with safe local-storage persistence,
intro-mode selection, workflow/start-step selection, and EMU fork-block
decisions.
- Added five table-driven model tests before extraction; the initial run failed
because the model module was absent, then passed after implementation.
- Refactored the dialog to consume the pure model and extracted the report-only
bottom slot/contributor message decisions.
- Added the mobile utility entry and extracted its render surface so the
touched component passes repository line/complexity/nested-ternary rules.
- `cd apps && pnpm --filter @kandev/web test -- --run components/improve-kandev-dialog-model.test.ts components/improve-kandev-dialog-helpers.test.ts` — passed (10 tests).
- `cd apps/web && pnpm run typecheck` — passed.
- `cd apps/web && pnpm exec eslint components/improve-kandev-dialog.tsx components/improve-kandev-dialog-create.tsx components/improve-kandev-dialog-model.ts components/kanban/mobile-menu-sheet.tsx` — passed with no warnings.
Task 03 is complete. Continue with Task 04 for rebuilt desktop and mobile
browser coverage.
## Risks
- `TaskCreateDialog` resets form state when its locked `workflowId` prop
changes. Confirm report-kind switching before data entry or preserve entered
title/description deliberately; do not silently lose user text.
- Closing the mobile drawer before opening the dialog must preserve predictable
focus/dismiss behavior.
- Local-storage access can throw in restricted browser contexts.
## Output contract
The shared dialog persists the intro preference safely, switches between the
implementation and report-only workflows, and is reachable through the native
mobile menu without stacked overlays.
Update this task and `plan.md`, list the helper behavior proven test-first,
record exact unit/typecheck/lint results, and note any remaining rendered
behavior for Task 04.
@@ -0,0 +1,85 @@
---
id: "04-e2e-coverage"
title: "Desktop and mobile E2E coverage"
status: done
wave: 3
depends_on: ["02-backend-issue-workflow", "03-frontend-dialog-and-mobile"]
plan: "plan.md"
spec: "../../specs/improve-kandev/spec.md"
---
# Task 04: Desktop and mobile E2E coverage
## Acceptance
- Desktop Playwright proves intro dismissal persistence, direct reopen,
report-only task creation, and EMU implementation-versus-issue gating.
- Mobile Playwright opens the feature through **Open menu** → **Improve
Kandev**, proves 44px touch targets, reaches **Open issue**, and detects no
document horizontal overflow.
- Both specs pass against the production Vite build served by the Go backend.
## Verification
```bash
cd apps/web && pnpm e2e:run tests/improve-kandev.spec.ts
cd apps/web && pnpm e2e:run --no-build tests/mobile-improve-kandev.spec.ts -- --project=mobile-chrome
```
## Files likely touched
- `apps/web/e2e/tests/improve-kandev.spec.ts`
- `apps/web/e2e/tests/mobile-improve-kandev.spec.ts`
## Dependencies
Tasks 02 and 03.
## Parallelism
Sequential. These specs share the Improve Kandev route mocks and production
build.
## Inputs
- All spec scenarios for dismissal, issue reporting, fork restrictions, auth,
and mobile access.
- Plan: **E2E Tests** and **Continuation Snapshot**.
- Existing fixture APIs in `apps/web/e2e/helpers/api-client.ts`.
## Completed implementation notes
- Desktop tests for persistence, issue task workflow, and revised EMU behavior
are already drafted. The focused persistence and issue-submission tests
passed individually.
- The mobile spec is drafted. Its initial sidebar-based path failed because the
desktop footer is hidden on phones; the test now uses the mobile menu.
- The mobile utility entry was rebuilt and verified after the initial sidebar
path was corrected to use the native mobile menu.
## Recorded verification
- `cd apps/web && pnpm e2e:run tests/improve-kandev.spec.ts` — passed (7 desktop tests).
- `cd apps/web && pnpm e2e:run --no-build tests/mobile-improve-kandev.spec.ts -- --project=mobile-chrome` — passed (1 mobile test).
- The first rebuilt mobile run caught a 43px preference target; the intro
checkbox label was increased to `min-h-12`, then the rebuilt mobile run
passed with the required touch target and no horizontal overflow.
Task 04 is complete. Continue with Task 05 for repository verification and
commit.
## Risks
- Route mocks must return both workflow IDs, and the submitted issue workflow
must exist in the E2E backend before asserting its start step.
- Do not increase Playwright timeouts; inspect error context if the mobile
drawer-to-dialog handoff races.
## Output contract
The desktop and mobile Playwright suites prove the shared flow, touch-target
requirements, report-only workflow, and mobile containment behavior.
Update this task and `plan.md`, report both exact commands and outcomes, include
fresh failure-artifact paths if any, and state whether rendered mobile
containment/touch behavior is proven.
@@ -0,0 +1,85 @@
---
id: "05-verification-and-commit"
title: "Required verification and commit"
status: done
wave: 4
depends_on: ["02-backend-issue-workflow", "03-frontend-dialog-and-mobile", "04-e2e-coverage"]
plan: "plan.md"
spec: "../../specs/improve-kandev/spec.md"
---
# Task 05: Required verification and commit
## Acceptance
- The user-requested repository formatting, typecheck, test, and lint commands
pass after all feature files and planning statuses are final.
- The spec remains `building`, every implementation task and plan checkbox is
updated accurately, and `git diff --check` is clean.
- All scoped changes are committed with a Conventional Commit and normal active
pre-commit/commit-msg hooks.
## Verification
```bash
make fmt
make typecheck test lint
git diff --check
```
Then follow `.agents/skills/commit/SKILL.md` with explicit `git add` paths and a
message such as:
```text
feat: add issue-only Improve Kandev workflow
```
## Files likely touched
- Files owned by Tasks 01–04.
- `docs/specs/improve-kandev/spec.md`
- `docs/plans/improve-kandev/plan.md`
- `docs/plans/improve-kandev/task-*.md`
## Dependencies
Tasks 02, 03, and 04.
## Parallelism
Sequential final gate.
## Inputs
- Original Improve phase requirement to run `make fmt` before
`make typecheck test lint`.
- `.agents/skills/commit/SKILL.md`.
- Completed task verification receipts.
## Risks
- The environment was nearly out of disk space during the planning session.
Check `df -h .` before dependency installation or a broad build, and clean
only recoverable generated/cache artifacts if needed.
- Do not bypass hooks. If formatting hooks modify files, review, restage, and
create a new commit attempt without amending.
## Output contract
Mark every task and plan checkbox accurately, provide the commit hook receipt,
record exact verification results, and stop so the user can move the Kandev
task to the next workflow step.
## Recorded verification
- `make fmt` — passed; Go and frontend formatters completed.
- Initial `make typecheck test lint` was stopped by the RTK-injected indexed
`GIT_CONFIG_*` environment in the unrelated
`TestCollectAgentEnvPreservesParentIndexedGitConfig` test. With those
environment variables unset, the exact `make typecheck test lint` command
passed: all backend tests, 922 web test files (7,037 passing and 4 skipped),
30 CLI test files, script tests, backend/web/harness lint, and typechecks.
- `git diff --check` — passed.
All verification is complete and the scoped changes are ready for the final
Conventional Commit.
+1 -1
View File
@@ -157,7 +157,7 @@ System pages (Radarr/Sonarr-style) for status, disk usage, database maintenance,
| [auth](auth/spec.md) | building |
| [create-local-repository](create-local-repository/spec.md) | shipped |
| [workflow-cycle-guardrails](workflow-cycle-guardrails/spec.md) | building |
| [improve-kandev](improve-kandev/spec.md) | draft |
| [improve-kandev](improve-kandev/spec.md) | building |
| [homebrew-core](homebrew-core/spec.md) | draft |
| [native-kandev-cli](native-kandev-cli/spec.md) | draft |
| [desktop-tauri-app](desktop-tauri-app/spec.md) | shipped |
+77 -9
View File
@@ -1,5 +1,5 @@
---
status: draft
status: building
created: 2026-04-29
owner: Carlos Florencio
---
@@ -15,17 +15,26 @@ the user's own agent picks up immediately — turning every report into a contri
## What
- An icon-only button in the kanban top bar (next to the GitHub and Stats
controls) opens a task-creation dialog that is pre-configured for the
kandev codebase: repository locked to `https://github.com/kdlbs/kandev`,
base branch `main`, workflow set to a hidden `improve-kandev` workflow,
description seeded with a starter template. The button has a tooltip
("Improve kandev") explaining its purpose.
- The **Improve Kandev** action in the desktop app-sidebar footer opens a
task-creation dialog that is pre-configured for the kandev codebase:
repository locked to `https://github.com/kdlbs/kandev`, base branch `main`,
workflow selected from the hidden Improve Kandev workflows, and description
seeded with a starter template. On phones the same action is a 44px-or-larger
row in the existing mobile home menu's **Utilities** section.
- The dialog reuses the existing task-create UI, including prompt enhancement,
image paste, and file attachments.
- The dialog explains the flow up front: the agent will implement the change,
the user will test it, then the agent opens a PR. Brief copy positions this
as the user contributing to kandev's future.
- The explanation includes a "Do not show this again" preference. Once selected,
later uses of **Improve Kandev** skip the explanation and open the
pre-configured task-creation dialog directly. The preference is local to the
current browser profile and can be cleared with other local UI state.
- The task-creation dialog offers three report kinds: **Bug fix**, **Feature
request**, and **Open issue**. Bug fixes and feature requests use the existing
implementation workflow. Open issue uses a separate hidden, one-step workflow
and visibly explains that the agent only publishes a GitHub issue; it does not
implement the change or open a pull request.
- An "Include recent logs" toggle (default on) attaches a context bundle to the
task: recent backend logs, frontend logs, and a metadata snapshot. The bundle
lives in a temporary folder and is referenced by file path in the task
@@ -42,8 +51,50 @@ the user's own agent picks up immediately — turning every report into a contri
- The `improve-kandev` workflow is hidden from the workflow management page in
workspace settings and from the workflow picker in the standard task-create
dialog. It is reachable only through the Improve Kandev entry point.
- The `report-kandev-issue` workflow is also hidden and reachable only through
the **Open issue** option. Its agent reads the repository's current bug-report
or feature-request issue form, gathers every required field from the user,
checks for sensitive data and likely duplicates, then publishes the issue to
`kdlbs/kandev` with the matching template and reports the issue URL. The agent
must ask follow-up questions instead of inventing missing required details.
- A pre-flight check surfaces `gh auth` status from `/api/v1/system/health` and
prevents submission with a clear error when GitHub auth is missing.
- An account that cannot fork `kdlbs/kandev` is blocked from the implementation
workflows but may still use the issue-only workflow.
## API surface
- `POST /api/v1/system/improve-kandev/bootstrap` continues to accept
`{ "workspace_id": string }`. Its success response includes the existing
repository, branch, context-bundle, GitHub-login, write-access, and
fork-status fields plus:
- `workflow_id: string` — the workspace instance of `improve-kandev`.
- `issue_workflow_id: string` — the workspace instance of
`report-kandev-issue`.
- Both workflow IDs refer to hidden, workspace-scoped workflow instances and
are safe to request repeatedly.
## Persistence guarantees
- The intro dismissal is stored as
`kandev.improveKandev.skipIntro = "true"` in browser local storage. It
survives reloads and Kandev restarts for that browser profile, but is not
synchronized between browsers or users.
- The two hidden workflow instances are workspace-scoped and remain idempotent:
opening the dialog again reuses the existing workflow for each template.
## Failure modes
- If browser local storage is unavailable, the preference read/write fails
open and the intro remains available; opening Improve Kandev still works.
- If the saved preference skips the intro but GitHub authentication is missing,
the GitHub-auth recovery explanation takes precedence over the direct-open
preference.
- If bootstrap cannot create or resolve either hidden workflow, the task form
remains blocked and surfaces the bootstrap error.
- A fork restriction blocks only **Bug fix** and **Feature request** submission.
Switching to **Open issue** clears that restriction because publishing an
issue requires neither a fork nor push access.
## Scenarios
@@ -63,8 +114,25 @@ the user's own agent picks up immediately — turning every report into a contri
against `main` in `kdlbs/kandev`.
- **GIVEN** the standard task-create dialog or the workspace workflows settings
page is open, **WHEN** the page lists workflows, **THEN** `improve-kandev`
does not appear.
page is open, **WHEN** the page lists workflows, **THEN** neither
`improve-kandev` nor `report-kandev-issue` appears.
- **GIVEN** the intro explanation is visible, **WHEN** the user selects "Do not
show this again" and later reopens **Improve Kandev**, **THEN** the
task-creation dialog opens directly and the intro explanation is skipped.
- **GIVEN** the user opens the mobile home menu, **WHEN** they tap **Improve
Kandev**, **THEN** the menu closes and the same intro or direct task-creation
flow opens without horizontal document overflow.
- **GIVEN** the user selects **Open issue**, **WHEN** they create the task,
**THEN** the task starts in the issue-only workflow and the agent gathers all
required fields from the matching repository issue form before publishing a
GitHub issue.
- **GIVEN** GitHub reports that the user cannot fork `kdlbs/kandev`, **WHEN**
they select **Open issue**, **THEN** they can create the report task because
that workflow does not require a fork.
- **GIVEN** the user has not configured `gh auth`, **WHEN** they open the
Improve Kandev dialog, **THEN** the dialog shows a blocking error referencing