feat: add issue-only Improve Kandev workflow (#1994)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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.";
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user