savepoint 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +4 -1
- package/.savepoint/Design.md +22 -17
- package/.savepoint/audit/v1/E01/proposals.md +168 -0
- package/.savepoint/audit/v1/E01/snapshot.md +78 -0
- package/.savepoint/audit/{E01-go-setup → v1/E01-go-setup}/proposals.md +7 -7
- package/.savepoint/audit/{E01-go-setup → v1/E01-go-setup}/snapshot.md +2 -2
- package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/AGENTS.md +5 -5
- package/.savepoint/audit/{E02-data-readers → v1/E02-data-readers}/proposals.md +20 -20
- package/.savepoint/audit/{E02-data-readers → v1/E02-data-readers}/snapshot.md +1 -1
- package/.savepoint/audit/{E03-board-tui-core → v1/E03-board-tui-core}/proposals.md +11 -11
- package/.savepoint/audit/{E03-board-tui-core → v1/E03-board-tui-core}/snapshot.md +1 -1
- package/.savepoint/audit/{E04-board-components → v1/E04-board-components}/proposals.md +14 -14
- package/.savepoint/audit/{E04-board-components → v1/E04-board-components}/snapshot.md +1 -1
- package/.savepoint/audit/{E05-init-command → v1/E05-init-command}/snapshot.md +1 -1
- package/.savepoint/audit/{E05-phase-transitions → v1/E05-phase-transitions}/proposals.md +4 -4
- package/.savepoint/audit/{E05-phase-transitions → v1/E05-phase-transitions}/snapshot.md +1 -1
- package/.savepoint/audit/{E06-atari-noir-layout → v1/E06-atari-noir-layout}/proposals.md +2 -2
- package/.savepoint/audit/{E07-audit-pipeline → v1/E07-audit-pipeline}/snapshot.md +6 -6
- package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/proposals.md +114 -0
- package/.savepoint/audit/v1.1/E02-cross-platform-compatibility/snapshot.md +41 -0
- package/.savepoint/audit/v1.1/E04-epic-navigation/proposals.md +156 -0
- package/.savepoint/audit/v1.1/E04-epic-navigation/snapshot.md +48 -0
- package/.savepoint/releases/v1/epics/E01-go-setup/tasks/T001-init-module.md +1 -1
- package/.savepoint/releases/v1/epics/E03-board-tui-core/tasks/T005-layout.md +1 -1
- package/.savepoint/releases/v1/epics/E04-board-components/tasks/T002-card.md +1 -1
- package/.savepoint/releases/v1/epics/E04-board-components/tasks/T006-help-overlay.md +1 -1
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/{Design.md → E06-Detail.md} +5 -3
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T002-header-and-dividers.md +1 -1
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T003-footer-status-bar.md +1 -1
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T004-component-refinement.md +1 -1
- package/.savepoint/releases/v1/epics/E06-atari-noir-layout/tasks/T010-auto-refresh-watcher.md +2 -0
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/{Design.md → E01-Detail.md} +9 -1
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/{T007-next-activity-header.md → T001-next-activity-header.md} +13 -12
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T002-rename-epic-design-files.md +9 -9
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T003-rename-release-prd.md +2 -2
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T004-update-instruction-files.md +13 -12
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T005-update-cross-references.md +14 -13
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T006-column-and-detail-scrolling.md +25 -15
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T007-column-focus-border-stability.md +57 -0
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/{Design.md → E02-Detail.md} +12 -3
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T001-fix-makefile.md +11 -8
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T002-linux-build-target.md +12 -7
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T003-macos-build-target.md +9 -5
- package/.savepoint/releases/v1.1/epics/E02-cross-platform-compatibility/tasks/T004-smoke-tests-and-artifacts.md +30 -9
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/E03-Detail.md +32 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T001-border-resize-fix.md +40 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T002-next-activity-below-header.md +64 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T003-checkbox-rendering-fix.md +56 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T005-unify-status-glyphs.md +65 -0
- package/.savepoint/releases/v1.1/epics/E03-ui-visual-refinement/tasks/T006-forced-256-color-profile.md +36 -0
- package/.savepoint/releases/v1.1/epics/E04-epic-navigation/E04-Detail.md +51 -0
- package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T001-sidebar-focusable-navigation.md +65 -0
- package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T002-epic-detail-overlay.md +73 -0
- package/.savepoint/releases/v1.1/epics/E04-epic-navigation/tasks/T003-epic-status-glyphs.md +73 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/E05-Detail.md +45 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T001-update-agents-md.md +34 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T002-update-router-md.md +30 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T003-update-design-md.md +33 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T004-implement-m-hotkey.md +88 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T005-update-help-overlay.md +30 -0
- package/.savepoint/releases/v1.1/epics/E05-tasking-permissions/tasks/T006-tests-and-quality-gates.md +46 -0
- package/.savepoint/releases/v1.1/v1.1-PRD.md +79 -0
- package/.savepoint/router.md +33 -105
- package/AGENTS.md +56 -113
- package/Makefile +19 -3
- package/README.md +6 -5
- package/agent-skills/savepoint-audit/SKILL.md +6 -6
- package/agent-skills/savepoint-build-task/SKILL.md +2 -2
- package/agent-skills/savepoint-create-plan/SKILL.md +3 -3
- package/agent-skills/savepoint-create-task/SKILL.md +2 -2
- package/agent-skills/savepoint-draft-prd/SKILL.md +1 -1
- package/agent-skills/savepoint-system-design/SKILL.md +1 -1
- package/internal/board/board.go +43 -27
- package/internal/board/board_test.go +71 -0
- package/internal/board/card.go +34 -3
- package/internal/board/card_test.go +105 -12
- package/internal/board/column.go +40 -5
- package/internal/board/column_test.go +60 -13
- package/internal/board/detail.go +107 -25
- package/internal/board/detail_test.go +117 -26
- package/internal/board/epic_panel.go +105 -8
- package/internal/board/epic_panel_test.go +343 -5
- package/internal/board/layout.go +12 -2
- package/internal/board/layout_test.go +17 -0
- package/internal/board/model.go +141 -24
- package/internal/board/render_policy_test.go +77 -0
- package/internal/board/status.go +23 -0
- package/internal/board/update.go +276 -8
- package/internal/board/update_test.go +166 -0
- package/internal/board/view.go +131 -17
- package/internal/board/view_test.go +159 -1
- package/internal/board/watch.go +24 -6
- package/internal/buildtool/main.go +219 -0
- package/internal/data/parser.go +8 -0
- package/internal/data/parser_test.go +35 -0
- package/internal/data/task.go +10 -0
- package/internal/styles/palette.go +3 -3
- package/internal/styles/styles.go +39 -12
- package/main.go +9 -0
- package/package.json +1 -1
- package/savepoint +0 -0
- package/savepoint.exe +0 -0
- package/templates/project/.savepoint/router.md +6 -5
- package/templates/project/AGENTS.md +47 -101
- package/templates/prompts/audit-reconciliation.prompt.md +6 -6
- package/templates/prompts/epic-design.prompt.md +3 -3
- package/templates/prompts/task-breakdown.prompt.md +1 -1
- package/templates/prompts/task-building.prompt.md +1 -1
- package/templates/prompts/task-planning.prompt.md +1 -1
- package/.savepoint/releases/v1.1/epics/E01-tui-optimisation/tasks/T001-border-resize-fix.md +0 -36
- package/main.exe +0 -0
- /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/Design.md +0 -0
- /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/epic-Design.md +0 -0
- /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/proposals/quality-review.md +0 -0
- /package/.savepoint/audit/{E01-scaffolding → v1/E01-scaffolding}/snapshot.md +0 -0
- /package/.savepoint/audit/{E02-data-model → v1/E02-data-model}/snapshot.md +0 -0
- /package/.savepoint/audit/{E03-cli-foundation → v1/E03-cli-foundation}/snapshot.md +0 -0
- /package/.savepoint/audit/{E04-templates-and-prompts → v1/E04-templates-and-prompts}/snapshot.md +0 -0
- /package/.savepoint/audit/{E06-atari-noir-layout → v1/E06-atari-noir-layout}/snapshot.md +0 -0
- /package/.savepoint/audit/{E06-tui-board → v1/E06-tui-board}/snapshot.md +0 -0
- /package/.savepoint/audit/{E08-board-workflow-cleanup → v1/E08-board-workflow-cleanup}/snapshot.md +0 -0
- /package/.savepoint/releases/v1/epics/E01-go-setup/{Design.md → E01-Detail.md} +0 -0
- /package/.savepoint/releases/v1/epics/E02-data-readers/{Design.md → E02-Detail.md} +0 -0
- /package/.savepoint/releases/v1/epics/E03-board-tui-core/{Design.md → E03-Detail.md} +0 -0
- /package/.savepoint/releases/v1/epics/E04-board-components/{Design.md → E04-Detail.md} +0 -0
- /package/.savepoint/releases/v1/epics/E05-phase-transitions/{Design.md → E05-Detail.md} +0 -0
- /package/.savepoint/releases/v1/{PRD.md → v1-PRD.md} +0 -0
package/README.md
CHANGED
|
@@ -34,11 +34,11 @@ Savepoint turns your project into a series of hard gates, enforced by **six bund
|
|
|
34
34
|
|
|
35
35
|
Small scopes. Small context windows. No wandering.
|
|
36
36
|
|
|
37
|
-
- **The PRD Gate (`draft-prd`):** The agent interviews you to ensure your idea is crisp enough for a V1 before writing any architecture.
|
|
38
|
-
- **The Design Gate (`system-design`):** Write down what you actually want. If you can't explain it simply in a markdown file, the AI is going to make a mess of it.
|
|
39
|
-
- **The Plan Gate (`create-plan` & `create-task`):** Break the idea down into small, manageable steps. No giant leaps. This becomes the checklist the AI _must_ follow.
|
|
40
|
-
- **The Build Gate (`build-task`):** The AI writes code for one small step at a time. The scope stays tight so it doesn't wander off into the weeds. It logs "Drift Notes" instead of cowboy-coding architectural changes.
|
|
41
|
-
- **The Audit Gate (`audit`):** Before moving on, we stop and check. Does the code match the plan? We don't advance until the map matches the territory.
|
|
37
|
+
- **The PRD Gate (`savepoint-draft-prd`):** The agent interviews you to ensure your idea is crisp enough for a V1 before writing any architecture.
|
|
38
|
+
- **The Design Gate (`savepoint-system-design`):** Write down what you actually want. If you can't explain it simply in a markdown file, the AI is going to make a mess of it.
|
|
39
|
+
- **The Plan Gate (`savepoint-create-plan` & `savepoint-create-task`):** Break the idea down into small, manageable steps. No giant leaps. This becomes the checklist the AI _must_ follow.
|
|
40
|
+
- **The Build Gate (`savepoint-build-task`):** The AI writes code for one small step at a time. The scope stays tight so it doesn't wander off into the weeds. It logs "Drift Notes" instead of cowboy-coding architectural changes.
|
|
41
|
+
- **The Audit Gate (`savepoint-audit`):** Before moving on, we stop and check. Does the code match the plan? We don't advance until the map matches the territory.
|
|
42
42
|
|
|
43
43
|
> 🔒 **The Audit Loop** — When the last task in an epic moves to `done`, the next epic stays _locked_ until your docs (`Design.md`, `AGENTS.md`, and the epic's own design) are reconciled with the actual code via the audit agent. **No existing markdown-first task tool has this gate.**
|
|
44
44
|
|
|
@@ -76,3 +76,4 @@ I’m sharing it to prove a point: The real power of AI isn't just the size of t
|
|
|
76
76
|
|
|
77
77
|
**License:** MIT
|
|
78
78
|
**Status:** Recursive Construction (v1 MVP in progress)
|
|
79
|
+
us:** Recursive Construction (v1 MVP in progress)
|
|
@@ -10,8 +10,8 @@ The Audit Gate is Savepoint's wedge. It prevents projects from degrading into ch
|
|
|
10
10
|
This skill is activated when the `.savepoint/router.md` state is `audit-pending`.
|
|
11
11
|
|
|
12
12
|
## Input
|
|
13
|
-
- `.savepoint/audit/{E##-slug}/snapshot.md` (what changed).
|
|
14
|
-
- `.savepoint/releases/
|
|
13
|
+
- `.savepoint/audit/{release}/{E##-slug}/snapshot.md` (what changed).
|
|
14
|
+
- `.savepoint/releases/{release}/epics/{E##-slug}/E##-Detail.md` (the epic design).
|
|
15
15
|
- `.savepoint/Design.md` (project architecture).
|
|
16
16
|
- `AGENTS.md` (agent guide and codebase map).
|
|
17
17
|
- The source and test files modified during the Epic.
|
|
@@ -21,15 +21,15 @@ This skill is activated when the `.savepoint/router.md` state is `audit-pending`
|
|
|
21
21
|
1. **Fresh Eyes Check:** If you are the exact same agent session that just built the `build-task` code for this Epic, you MUST STOP. Tell the user: "Epic complete. Start a new agent session for the audit."
|
|
22
22
|
2. **Verify ACs:** Review the completed tasks for the Epic. Ensure the Acceptance Criteria were actually met by the committed code.
|
|
23
23
|
3. **Process Drift Notes (Reconciliation):** Read every task file in the Epic and look for `## Drift Notes`.
|
|
24
|
-
4. **Draft Proposals:** Based on the code changes and the Drift Notes, write exactly ONE file: `.savepoint/audit/{E##-slug}/proposals.md`. It must contain:
|
|
24
|
+
4. **Draft Proposals:** Based on the code changes and the Drift Notes, write exactly ONE file: `.savepoint/audit/{release}/{E##-slug}/proposals.md`. It must contain:
|
|
25
25
|
* **Design.md section:** Propose updates to merge the epic's architectural changes into the project-level `Design.md`.
|
|
26
26
|
* **AGENTS.md section:** Propose updates to refresh the Codebase Map table with new or changed modules.
|
|
27
|
-
* **Epic-
|
|
27
|
+
* **Epic-E##-Detail.md section:** Add "Implemented as:" notes showing where reality deviated from the original plan.
|
|
28
28
|
* **Quality Review section:** List any minor code-style infractions that must be fixed before the next Epic.
|
|
29
29
|
5. **Review Format:** Use `## Target File`, `## Replace`, and `## With` formatting in the proposals document so it is easy for a human (or an agent) to apply later.
|
|
30
|
-
6. **Handoff:** Do not apply the proposals yourself. Do not mark the epic audited. Stop and prompt the user to review `.savepoint/audit/{E##-slug}/proposals.md` (often via the TUI). Once the user approves, the proposals are applied, and the router moves to the next Epic.
|
|
30
|
+
6. **Handoff:** Do not apply the proposals yourself. Do not mark the epic audited. Stop and prompt the user to review `.savepoint/audit/{release}/{E##-slug}/proposals.md` (often via the TUI). Once the user approves, the proposals are applied, and the router moves to the next Epic.
|
|
31
31
|
|
|
32
32
|
## Constraints
|
|
33
33
|
- **Do not write product code.** You are an auditor.
|
|
34
34
|
- **Do not apply the changes immediately.** Write the proposals document first.
|
|
35
|
-
- **One proposals file.** Do not create multiple proposal files.
|
|
35
|
+
- **One proposals file.** Do not create multiple proposal files.
|
|
@@ -7,11 +7,11 @@ Act as a disciplined coding agent that strictly follows Savepoint's implementati
|
|
|
7
7
|
The `build-task` skill is the execution engine. It reads the detailed task plan, writes the code, and proves that the Acceptance Criteria (ACs) have been met. It is strictly constrained to the scope of the single active task. It does not rewrite architecture, and it does not fix unrelated bugs.
|
|
8
8
|
|
|
9
9
|
## Trigger
|
|
10
|
-
This skill is activated when the `.savepoint/router.md` state is `
|
|
10
|
+
This skill is activated when the `.savepoint/router.md` state is `task-building` and points to a specific task file.
|
|
11
11
|
|
|
12
12
|
## Input
|
|
13
13
|
- `.savepoint/router.md` (Current state).
|
|
14
|
-
- The active epic
|
|
14
|
+
- The active epic E##-Detail.md: `.savepoint/releases/v1/epics/{E##-epic}/E##-Detail.md`.
|
|
15
15
|
- The active task file: `.savepoint/releases/v1/epics/{E##-epic}/tasks/{T###}-*.md`.
|
|
16
16
|
- Directly touched source/test files.
|
|
17
17
|
|
|
@@ -7,18 +7,18 @@ Turn the Product Requirements Document (PRD) and the architectural `Design.md` i
|
|
|
7
7
|
Savepoint enforces small scopes to prevent AI agents from "wandering." The `create-plan` skill acts as the Technical Project Manager. It takes the grand vision and the architectural blueprint and slices it into a sequence of achievable, testable milestones (Epics).
|
|
8
8
|
|
|
9
9
|
## Trigger
|
|
10
|
-
This skill is activated when the `.savepoint/router.md` state is `
|
|
10
|
+
This skill is activated when the `.savepoint/router.md` state is `pre-implementation`.
|
|
11
11
|
|
|
12
12
|
## Input
|
|
13
13
|
- `.savepoint/PRD.md` (Vision and constraints)
|
|
14
14
|
- `.savepoint/Design.md` (Architecture and layout)
|
|
15
|
-
- `.savepoint/releases/v1/PRD.md` (Optional: Release-scoped PRD)
|
|
15
|
+
- `.savepoint/releases/v1/v1-PRD.md` (Optional: Release-scoped PRD)
|
|
16
16
|
|
|
17
17
|
## Workflow
|
|
18
18
|
|
|
19
19
|
1. **Read the Context:** Consume the PRD and Design documents to understand the scope and technical constraints.
|
|
20
20
|
2. **Define Epics:** Group the work into high-level features or milestones. Name them clearly (e.g., `E01-scaffolding`, `E02-database`, `E03-auth`). Each Epic must represent a deliverable slice of value.
|
|
21
|
-
3. **Draft Epic Designs:** For each Epic, create a shell `
|
|
21
|
+
3. **Draft Epic Designs:** For each Epic, create a shell `E##-Detail.md` inside `.savepoint/releases/v1/epics/{E##-epic-name}/E##-Detail.md`. This file should describe the *delta* (what this specific epic adds to the overall architecture).
|
|
22
22
|
4. **Breakdown Tasks (High Level):** Inside each Epic folder, list out the high-level tasks required to complete it. Do not write full implementation plans yet—just identify the discrete chunks of work (e.g., `T001-setup-repo.md`, `T002-init-db.md`).
|
|
23
23
|
5. **Order and Dependency:** Ensure the Epics and tasks are ordered logically. You cannot build the frontend auth UI (E03) before the database models (E02) exist.
|
|
24
24
|
6. **Handoff:** Update `.savepoint/router.md` to `state: task-breakdown` and point it at the first Epic. Prompt the user to review the Epic list.
|
|
@@ -7,10 +7,10 @@ Take high-level tasks identified during the planning phase and build detailed, a
|
|
|
7
7
|
A task plan is a contract between the planner and the builder. If the task plan is vague, the resulting code will be buggy. The `create-task` skill acts as a Senior Engineer writing tickets for a Junior Developer (the `build-task` agent). It must define exactly *what* constitutes success and *how* to achieve it, without actually writing the code.
|
|
8
8
|
|
|
9
9
|
## Trigger
|
|
10
|
-
This skill is activated when the `.savepoint/router.md` state is `task-breakdown` and the router points to a specific
|
|
10
|
+
This skill is activated when the `.savepoint/router.md` state is `epic-task-breakdown` and the router points to a specific epic.
|
|
11
11
|
|
|
12
12
|
## Input
|
|
13
|
-
- `.savepoint/releases/v1/epics/{E##-epic}/
|
|
13
|
+
- `.savepoint/releases/v1/epics/{E##-epic}/E##-Detail.md` (The active Epic's design).
|
|
14
14
|
- The high-level task markdown file (e.g., `.savepoint/releases/v1/epics/{E##-epic}/tasks/T001-slug.md`).
|
|
15
15
|
|
|
16
16
|
## Workflow
|
|
@@ -7,7 +7,7 @@ Help the user write a structured, sufficiently detailed Product Requirements Doc
|
|
|
7
7
|
In the Savepoint workflow, the PRD is the absolute source of truth. If the PRD is a vague brain-dump, the resulting architecture and code will be a mess. Your job as the `draft-prd` agent is to act as a strict Product Manager. You do not write code. You interrogate the user's idea until it is crisp enough to build a V1.
|
|
8
8
|
|
|
9
9
|
## Trigger
|
|
10
|
-
This skill is activated when the `.savepoint/router.md` state is `
|
|
10
|
+
This skill is activated when the `.savepoint/router.md` state is `pre-implementation` or when the user explicitly asks you to help them write or refine their PRD.
|
|
11
11
|
|
|
12
12
|
## Input
|
|
13
13
|
- The user's initial idea, brain-dump, or `.txt` file outline.
|
|
@@ -7,7 +7,7 @@ Translate the Product Requirements Document (PRD) into the initial architectural
|
|
|
7
7
|
Before any tasks can be planned, the project needs a high-level technical direction. The `system-design` skill acts as the Staff Engineer. It reads the vision and constraints from the PRD and makes authoritative technical decisions regarding architecture, directory layout, dependency strategies, and workflow rules.
|
|
8
8
|
|
|
9
9
|
## Trigger
|
|
10
|
-
This skill is activated when the `.savepoint/router.md` state is `design` or when the user explicitly asks to design the system based on the PRD.
|
|
10
|
+
This skill is activated when the `.savepoint/router.md` state is `epic-design` or when the user explicitly asks to design the system based on the PRD.
|
|
11
11
|
|
|
12
12
|
## Input
|
|
13
13
|
- `.savepoint/PRD.md` (The source of truth for "what" and "why").
|
package/internal/board/board.go
CHANGED
|
@@ -6,10 +6,14 @@ import (
|
|
|
6
6
|
"path/filepath"
|
|
7
7
|
|
|
8
8
|
tea "github.com/charmbracelet/bubbletea"
|
|
9
|
+
"github.com/charmbracelet/lipgloss"
|
|
10
|
+
"github.com/muesli/termenv"
|
|
9
11
|
"github.com/opencode/savepoint/internal/data"
|
|
10
12
|
)
|
|
11
13
|
|
|
12
14
|
func Run() error {
|
|
15
|
+
lipgloss.SetColorProfile(termenv.ANSI256)
|
|
16
|
+
|
|
13
17
|
model, err := newProjectModel(".")
|
|
14
18
|
if err != nil {
|
|
15
19
|
return err
|
|
@@ -39,26 +43,7 @@ func newProjectModel(start string) (Model, error) {
|
|
|
39
43
|
return Model{}, err
|
|
40
44
|
}
|
|
41
45
|
|
|
42
|
-
|
|
43
|
-
if err != nil {
|
|
44
|
-
return Model{}, err
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
releaseIDs := make([]string, 0, len(releases))
|
|
48
|
-
releaseEpics := make(map[string][]string, len(releases))
|
|
49
|
-
|
|
50
|
-
for _, release := range releases {
|
|
51
|
-
releaseIDs = append(releaseIDs, release.ID)
|
|
52
|
-
epics, err := d.ListEpics(root, release.ID)
|
|
53
|
-
if err != nil {
|
|
54
|
-
return Model{}, err
|
|
55
|
-
}
|
|
56
|
-
for _, epic := range epics {
|
|
57
|
-
releaseEpics[release.ID] = append(releaseEpics[release.ID], epic.ID)
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
tasks, err := loadAllTasks(root)
|
|
46
|
+
tasks, releaseIDs, releaseEpics, epicStatuses, err := loadBoardData(root)
|
|
62
47
|
if err != nil {
|
|
63
48
|
return Model{}, err
|
|
64
49
|
}
|
|
@@ -69,40 +54,66 @@ func newProjectModel(start string) (Model, error) {
|
|
|
69
54
|
model := NewModel(tasks, release, epic)
|
|
70
55
|
model.Root = root
|
|
71
56
|
model.RouterTask = routerState.Task
|
|
57
|
+
model.RouterState = routerState
|
|
72
58
|
model.Releases = releaseIDs
|
|
73
59
|
model.ReleaseEpics = releaseEpics
|
|
60
|
+
model.EpicStatus = epicStatuses
|
|
74
61
|
model.refreshEpicsForRelease()
|
|
75
62
|
model.refreshTasks()
|
|
76
63
|
|
|
77
64
|
watcher, err := newWatcher(root)
|
|
78
|
-
if err
|
|
79
|
-
|
|
65
|
+
if err != nil {
|
|
66
|
+
return Model{}, err
|
|
80
67
|
}
|
|
68
|
+
model.Watcher = watcher
|
|
81
69
|
|
|
82
70
|
return model, nil
|
|
83
71
|
}
|
|
84
72
|
|
|
85
|
-
func
|
|
73
|
+
func loadBoardData(root string) ([]data.Task, []string, map[string][]string, map[string]string, error) {
|
|
86
74
|
d := data.NewDiscover()
|
|
87
75
|
releases, err := d.ListReleases(root)
|
|
88
76
|
if err != nil {
|
|
89
|
-
return nil, err
|
|
77
|
+
return nil, nil, nil, nil, err
|
|
90
78
|
}
|
|
79
|
+
|
|
80
|
+
releaseIDs := make([]string, 0, len(releases))
|
|
81
|
+
releaseEpics := make(map[string][]string, len(releases))
|
|
91
82
|
var tasks []data.Task
|
|
83
|
+
epicStatuses := make(map[string]string)
|
|
84
|
+
|
|
92
85
|
for _, release := range releases {
|
|
86
|
+
releaseIDs = append(releaseIDs, release.ID)
|
|
93
87
|
epics, err := d.ListEpics(root, release.ID)
|
|
94
88
|
if err != nil {
|
|
95
|
-
return nil, err
|
|
89
|
+
return nil, nil, nil, nil, err
|
|
96
90
|
}
|
|
97
91
|
for _, epic := range epics {
|
|
92
|
+
releaseEpics[release.ID] = append(releaseEpics[release.ID], epic.ID)
|
|
98
93
|
epicTasks, err := loadEpicTasks(d, root, release.ID, epic.ID)
|
|
99
94
|
if err != nil {
|
|
100
|
-
return nil, err
|
|
95
|
+
return nil, nil, nil, nil, err
|
|
101
96
|
}
|
|
102
97
|
tasks = append(tasks, epicTasks...)
|
|
98
|
+
|
|
99
|
+
detailPath := filepath.Join(epic.Path, shortID(epic.ID)+"-Detail.md")
|
|
100
|
+
if raw, err := os.ReadFile(detailPath); err == nil {
|
|
101
|
+
parser := data.NewParser()
|
|
102
|
+
if fm, err := parser.ParseFrontmatter(string(raw)); err == nil {
|
|
103
|
+
if status, ok := fm["status"].(string); ok {
|
|
104
|
+
epicStatuses[epic.ID] = status
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
103
108
|
}
|
|
104
109
|
}
|
|
105
|
-
|
|
110
|
+
|
|
111
|
+
return tasks, releaseIDs, releaseEpics, epicStatuses, nil
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func loadAllTasks(root string) ([]data.Task, error) {
|
|
115
|
+
tasks, _, _, _, err := loadBoardData(root)
|
|
116
|
+
return tasks, err
|
|
106
117
|
}
|
|
107
118
|
|
|
108
119
|
func readRouterState(root string) (*data.RouterState, error) {
|
|
@@ -150,6 +161,11 @@ func firstKnown(preferred string, values []string) string {
|
|
|
150
161
|
return preferred
|
|
151
162
|
}
|
|
152
163
|
}
|
|
164
|
+
for _, value := range values {
|
|
165
|
+
if shortID(value) == shortID(preferred) {
|
|
166
|
+
return value
|
|
167
|
+
}
|
|
168
|
+
}
|
|
153
169
|
if len(values) == 0 {
|
|
154
170
|
return ""
|
|
155
171
|
}
|
|
@@ -66,6 +66,10 @@ next_action: "test"
|
|
|
66
66
|
if len(tasks) != 1 || tasks[0].ID != "E03-live/T001-live" {
|
|
67
67
|
t.Errorf("visible in-progress tasks = %v, want E03-live/T001-live", tasks)
|
|
68
68
|
}
|
|
69
|
+
if model.Watcher == nil {
|
|
70
|
+
t.Fatal("Watcher is nil, want auto-refresh watcher")
|
|
71
|
+
}
|
|
72
|
+
t.Cleanup(func() { model.Watcher.Close() })
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
func TestNewProjectModelUsesPathReleaseForTaskWithoutReleaseFrontmatter(t *testing.T) {
|
|
@@ -100,6 +104,73 @@ next_action: "test"
|
|
|
100
104
|
if tasks[0].Release != "v1.1" {
|
|
101
105
|
t.Errorf("Task.Release = %q, want v1.1", tasks[0].Release)
|
|
102
106
|
}
|
|
107
|
+
if model.Watcher != nil {
|
|
108
|
+
t.Cleanup(func() { model.Watcher.Close() })
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
func TestNewProjectModelResolvesShortRouterEpicToFullEpicID(t *testing.T) {
|
|
113
|
+
projectRoot := t.TempDir()
|
|
114
|
+
savepointRoot := filepath.Join(projectRoot, ".savepoint")
|
|
115
|
+
writeFile(t, filepath.Join(savepointRoot, "router.md"), `# Agent State Machine
|
|
116
|
+
|
|
117
|
+
## Current state
|
|
118
|
+
|
|
119
|
+
`+"```"+`yaml
|
|
120
|
+
state: task-building
|
|
121
|
+
release: v1.1
|
|
122
|
+
epic: E03
|
|
123
|
+
task: T001
|
|
124
|
+
next_action: "Build v1.1 E03/T001"
|
|
125
|
+
`+"```"+`
|
|
126
|
+
`)
|
|
127
|
+
writeTask(t, savepointRoot, "v1.1", "E01-tui-optimisation", "T007-column-focus-border-stability", data.ColumnInProgress)
|
|
128
|
+
writeTask(t, savepointRoot, "v1.1", "E03-ui-visual-refinement", "T001-border-resize-fix", data.ColumnInProgress)
|
|
129
|
+
|
|
130
|
+
model, err := newProjectModel(projectRoot)
|
|
131
|
+
if err != nil {
|
|
132
|
+
t.Fatalf("newProjectModel() error = %v", err)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if model.SelectedEpic != "E03-ui-visual-refinement" {
|
|
136
|
+
t.Errorf("SelectedEpic = %q, want E03-ui-visual-refinement", model.SelectedEpic)
|
|
137
|
+
}
|
|
138
|
+
tasks := model.Tasks[data.ColumnInProgress]
|
|
139
|
+
if len(tasks) != 1 || tasks[0].ID != "E03-ui-visual-refinement/T001-border-resize-fix" {
|
|
140
|
+
t.Errorf("visible in-progress tasks = %v, want E03-ui-visual-refinement/T001-border-resize-fix", tasks)
|
|
141
|
+
}
|
|
142
|
+
if model.Watcher != nil {
|
|
143
|
+
t.Cleanup(func() { model.Watcher.Close() })
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func TestUpdateReloadMsgRefreshesReleaseEpicIndex(t *testing.T) {
|
|
148
|
+
m := NewModel(nil, "v1", "E01-old")
|
|
149
|
+
m.Releases = []string{"v1"}
|
|
150
|
+
m.ReleaseEpics = map[string][]string{"v1": []string{"E01-old"}}
|
|
151
|
+
|
|
152
|
+
task := data.Task{
|
|
153
|
+
ID: "E02-new/T001-new",
|
|
154
|
+
Release: "v1",
|
|
155
|
+
Epic: "E02-new",
|
|
156
|
+
Column: data.ColumnPlanned,
|
|
157
|
+
}
|
|
158
|
+
got, _ := m.Update(reloadMsg{
|
|
159
|
+
tasks: []data.Task{task},
|
|
160
|
+
releases: []string{"v1"},
|
|
161
|
+
releaseEpics: map[string][]string{"v1": []string{"E02-new"}},
|
|
162
|
+
})
|
|
163
|
+
updated := requireModel(t, got)
|
|
164
|
+
|
|
165
|
+
if updated.SelectedEpic != "E02-new" {
|
|
166
|
+
t.Errorf("SelectedEpic = %q, want E02-new", updated.SelectedEpic)
|
|
167
|
+
}
|
|
168
|
+
if len(updated.Epics) != 1 || updated.Epics[0] != "E02-new" {
|
|
169
|
+
t.Errorf("Epics = %v, want [E02-new]", updated.Epics)
|
|
170
|
+
}
|
|
171
|
+
if len(updated.Tasks[data.ColumnPlanned]) != 1 {
|
|
172
|
+
t.Errorf("planned tasks = %v, want reloaded task visible", updated.Tasks[data.ColumnPlanned])
|
|
173
|
+
}
|
|
103
174
|
}
|
|
104
175
|
|
|
105
176
|
func writeTask(t *testing.T, root, release, epic, task string, column data.ColumnType) {
|
package/internal/board/card.go
CHANGED
|
@@ -17,15 +17,19 @@ const (
|
|
|
17
17
|
)
|
|
18
18
|
|
|
19
19
|
// RenderCard renders a task card with phase glyph, truncated ID+title, and focus styling.
|
|
20
|
-
// When
|
|
21
|
-
func RenderCard(t data.Task, width int, focused bool,
|
|
20
|
+
// When router state matches t's release/epic/task, a green priority glyph replaces the phase glyph.
|
|
21
|
+
func RenderCard(t data.Task, width int, focused bool, routerState *data.RouterState) string {
|
|
22
22
|
inner := width - cardOverhead
|
|
23
23
|
if inner < 2 {
|
|
24
24
|
inner = 2
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
var glyph string
|
|
28
|
-
if
|
|
28
|
+
if t.Status != "" {
|
|
29
|
+
glyph = statusGlyph(t.Status)
|
|
30
|
+
} else if t.Column == data.ColumnDone {
|
|
31
|
+
glyph = styles.GlyphBuild.Render(glyphBuild)
|
|
32
|
+
} else if isRouterPriority(t, routerState) {
|
|
29
33
|
glyph = styles.TagDone.Render(glyphBuild)
|
|
30
34
|
} else {
|
|
31
35
|
glyph = phaseGlyphStyled(t.Stage)
|
|
@@ -53,6 +57,33 @@ func phaseGlyphStyled(stage data.ProgressStage) string {
|
|
|
53
57
|
}
|
|
54
58
|
}
|
|
55
59
|
|
|
60
|
+
func isRouterPriority(t data.Task, state *data.RouterState) bool {
|
|
61
|
+
if state == nil || state.Task == "" {
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
if shortID(t.ID) != shortID(state.Task) {
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
if state.Release != "" && t.Release != "" && t.Release != state.Release {
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
routerEpic := state.Epic
|
|
71
|
+
if routerEpic == "" {
|
|
72
|
+
routerEpic = taskEpic(state.Task)
|
|
73
|
+
}
|
|
74
|
+
if routerEpic != "" && t.Epic != "" && shortID(t.Epic) != shortID(routerEpic) {
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func taskEpic(taskID string) string {
|
|
81
|
+
if idx := strings.LastIndex(taskID, "/"); idx >= 0 {
|
|
82
|
+
return taskID[:idx]
|
|
83
|
+
}
|
|
84
|
+
return ""
|
|
85
|
+
}
|
|
86
|
+
|
|
56
87
|
// shortID strips the epic prefix and slug from a task ID.
|
|
57
88
|
// "E06-atari-noir-layout/T004-component-refinement" → "T004"
|
|
58
89
|
func shortID(id string) string {
|
|
@@ -5,11 +5,12 @@ import (
|
|
|
5
5
|
"testing"
|
|
6
6
|
|
|
7
7
|
"github.com/opencode/savepoint/internal/data"
|
|
8
|
+
"github.com/opencode/savepoint/internal/styles"
|
|
8
9
|
)
|
|
9
10
|
|
|
10
11
|
func TestRenderCard_containsID(t *testing.T) {
|
|
11
12
|
task := data.Task{ID: "E04/T002", Title: "Build card", Stage: data.StageBuild}
|
|
12
|
-
got := RenderCard(task, 30, false,
|
|
13
|
+
got := RenderCard(task, 30, false, nil)
|
|
13
14
|
if !strings.Contains(got, "T002") {
|
|
14
15
|
t.Error("RenderCard missing short task ID")
|
|
15
16
|
}
|
|
@@ -17,7 +18,7 @@ func TestRenderCard_containsID(t *testing.T) {
|
|
|
17
18
|
|
|
18
19
|
func TestRenderCard_containsTitle(t *testing.T) {
|
|
19
20
|
task := data.Task{ID: "T1", Title: "My title", Stage: data.StageBuild}
|
|
20
|
-
got := RenderCard(task, 30, false,
|
|
21
|
+
got := RenderCard(task, 30, false, nil)
|
|
21
22
|
if !strings.Contains(got, "My title") {
|
|
22
23
|
t.Error("RenderCard missing task title")
|
|
23
24
|
}
|
|
@@ -25,7 +26,7 @@ func TestRenderCard_containsTitle(t *testing.T) {
|
|
|
25
26
|
|
|
26
27
|
func TestRenderCard_containsBuildGlyph(t *testing.T) {
|
|
27
28
|
task := data.Task{ID: "T1", Stage: data.StageBuild}
|
|
28
|
-
got := RenderCard(task, 30, false,
|
|
29
|
+
got := RenderCard(task, 30, false, nil)
|
|
29
30
|
if !strings.Contains(got, glyphBuild) {
|
|
30
31
|
t.Errorf("RenderCard missing build glyph %q", glyphBuild)
|
|
31
32
|
}
|
|
@@ -33,7 +34,7 @@ func TestRenderCard_containsBuildGlyph(t *testing.T) {
|
|
|
33
34
|
|
|
34
35
|
func TestRenderCard_containsTestGlyph(t *testing.T) {
|
|
35
36
|
task := data.Task{ID: "T1", Stage: data.StageTest}
|
|
36
|
-
got := RenderCard(task, 30, false,
|
|
37
|
+
got := RenderCard(task, 30, false, nil)
|
|
37
38
|
if !strings.Contains(got, glyphTest) {
|
|
38
39
|
t.Errorf("RenderCard missing test glyph %q", glyphTest)
|
|
39
40
|
}
|
|
@@ -41,7 +42,7 @@ func TestRenderCard_containsTestGlyph(t *testing.T) {
|
|
|
41
42
|
|
|
42
43
|
func TestRenderCard_containsAuditGlyph(t *testing.T) {
|
|
43
44
|
task := data.Task{ID: "T1", Stage: data.StageAudit}
|
|
44
|
-
got := RenderCard(task, 30, false,
|
|
45
|
+
got := RenderCard(task, 30, false, nil)
|
|
45
46
|
if !strings.Contains(got, glyphAudit) {
|
|
46
47
|
t.Errorf("RenderCard missing audit glyph %q", glyphAudit)
|
|
47
48
|
}
|
|
@@ -49,7 +50,7 @@ func TestRenderCard_containsAuditGlyph(t *testing.T) {
|
|
|
49
50
|
|
|
50
51
|
func TestRenderCard_focusedDoesNotPanic(t *testing.T) {
|
|
51
52
|
task := data.Task{ID: "T1", Title: "hello", Stage: data.StageBuild}
|
|
52
|
-
got := RenderCard(task, 30, true,
|
|
53
|
+
got := RenderCard(task, 30, true, nil)
|
|
53
54
|
if got == "" {
|
|
54
55
|
t.Error("RenderCard focused returned empty string")
|
|
55
56
|
}
|
|
@@ -58,7 +59,7 @@ func TestRenderCard_focusedDoesNotPanic(t *testing.T) {
|
|
|
58
59
|
func TestRenderCard_titleWraps(t *testing.T) {
|
|
59
60
|
long := "This is a very long title that should be wrapped for sure"
|
|
60
61
|
task := data.Task{ID: "T1", Title: long, Stage: data.StageBuild}
|
|
61
|
-
got := RenderCard(task, 20, false,
|
|
62
|
+
got := RenderCard(task, 20, false, nil)
|
|
62
63
|
// full title as one line does not fit; it must be broken up
|
|
63
64
|
if strings.Contains(got, long) {
|
|
64
65
|
t.Error("RenderCard should wrap long title, not render it as one line")
|
|
@@ -72,7 +73,7 @@ func TestRenderCard_titleWraps(t *testing.T) {
|
|
|
72
73
|
func TestRenderCard_idTruncated(t *testing.T) {
|
|
73
74
|
long := "E04-board-components/T999-very-long-id"
|
|
74
75
|
task := data.Task{ID: long, Stage: data.StageBuild}
|
|
75
|
-
got := RenderCard(task, 20, false,
|
|
76
|
+
got := RenderCard(task, 20, false, nil)
|
|
76
77
|
if strings.Contains(got, long) {
|
|
77
78
|
t.Error("RenderCard should truncate long ID")
|
|
78
79
|
}
|
|
@@ -106,20 +107,112 @@ func TestTruncate_maxOne(t *testing.T) {
|
|
|
106
107
|
|
|
107
108
|
func TestRenderCard_defaultStageUsesBuildGlyph(t *testing.T) {
|
|
108
109
|
task := data.Task{ID: "T1", Stage: ""}
|
|
109
|
-
got := RenderCard(task, 30, false,
|
|
110
|
+
got := RenderCard(task, 30, false, nil)
|
|
110
111
|
if !strings.Contains(got, glyphBuild) {
|
|
111
112
|
t.Error("RenderCard with empty stage should use build glyph")
|
|
112
113
|
}
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
func TestRenderCard_routerPriorityUsesGreenGlyph(t *testing.T) {
|
|
116
|
-
task := data.Task{ID: "E06/T009", Stage: data.StageTest}
|
|
117
|
-
|
|
117
|
+
task := data.Task{ID: "E06/T009", Release: "v1", Epic: "E06", Stage: data.StageTest}
|
|
118
|
+
router := &data.RouterState{Release: "v1", Epic: "E06", Task: "E06/T009"}
|
|
119
|
+
got := RenderCard(task, 30, false, router)
|
|
120
|
+
if !isRouterPriority(task, router) {
|
|
121
|
+
t.Error("router priority should match release, epic, and task")
|
|
122
|
+
}
|
|
118
123
|
if !strings.Contains(got, glyphBuild) {
|
|
119
124
|
t.Error("router priority card should use build glyph")
|
|
120
125
|
}
|
|
121
|
-
nonPriority := RenderCard(task, 30, false,
|
|
126
|
+
nonPriority := RenderCard(task, 30, false, nil)
|
|
122
127
|
if !strings.Contains(nonPriority, glyphTest) {
|
|
123
128
|
t.Error("non-priority test card should use test glyph")
|
|
124
129
|
}
|
|
125
130
|
}
|
|
131
|
+
|
|
132
|
+
func TestRenderCard_noBackgroundFillEscapes(t *testing.T) {
|
|
133
|
+
task := data.Task{ID: "E06/T009", Title: "Router priority", Release: "v1", Epic: "E06", Stage: data.StageTest}
|
|
134
|
+
router := &data.RouterState{Release: "v1", Epic: "E06", Task: "E06/T009"}
|
|
135
|
+
got := RenderCard(task, 30, false, router)
|
|
136
|
+
if strings.Contains(got, "\x1b[48;") || strings.Contains(got, "\x1b[40m") {
|
|
137
|
+
t.Fatalf("RenderCard should not emit background fills; got %q", got)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
func TestRenderCard_routerPriorityMatchesShortID(t *testing.T) {
|
|
142
|
+
// Router stores short IDs ("T009"); task ID is full slug — must still match.
|
|
143
|
+
task := data.Task{ID: "E06-atari-noir-layout/T009-router-priority", Release: "v1", Epic: "E06-atari-noir-layout", Stage: data.StageTest}
|
|
144
|
+
router := &data.RouterState{Release: "v1", Epic: "E06", Task: "T009"}
|
|
145
|
+
got := RenderCard(task, 30, false, router)
|
|
146
|
+
if !isRouterPriority(task, router) {
|
|
147
|
+
t.Error("short router task ID should match full task ID slug")
|
|
148
|
+
}
|
|
149
|
+
if !strings.Contains(got, glyphBuild) {
|
|
150
|
+
t.Error("router priority card should use build glyph")
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func TestRenderCard_staleRouterTaskNoMatch(t *testing.T) {
|
|
155
|
+
// Task moved to a new epic; router still has old epic path — should NOT match a different task number.
|
|
156
|
+
task := data.Task{ID: "E03-header-activity/T001-border-resize-fix", Release: "v1", Epic: "E03-header-activity", Stage: data.StageBuild}
|
|
157
|
+
router := &data.RouterState{Release: "v1", Epic: "E03", Task: "T002"}
|
|
158
|
+
got := RenderCard(task, 30, false, router)
|
|
159
|
+
if isRouterPriority(task, router) {
|
|
160
|
+
t.Error("stale router pointing to different task number should not show green glyph")
|
|
161
|
+
}
|
|
162
|
+
if !strings.Contains(got, styles.GlyphBuild.Render(glyphBuild)) {
|
|
163
|
+
t.Error("non-priority build task should use orange build glyph")
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
func TestRenderCard_routerSameTaskNumberDifferentEpicNoMatch(t *testing.T) {
|
|
168
|
+
task := data.Task{ID: "E03-header-activity/T001-border-resize-fix", Release: "v1", Epic: "E03-header-activity", Stage: data.StageTest}
|
|
169
|
+
router := &data.RouterState{Release: "v1", Epic: "E01", Task: "T001"}
|
|
170
|
+
got := RenderCard(task, 30, false, router)
|
|
171
|
+
if isRouterPriority(task, router) {
|
|
172
|
+
t.Error("router priority should not match same task number in a different epic")
|
|
173
|
+
}
|
|
174
|
+
if !strings.Contains(got, styles.GlyphTest.Render(glyphTest)) {
|
|
175
|
+
t.Error("non-priority test task should keep test glyph")
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
func TestRenderCard_doneTaskUsesOrangeBuildGlyph(t *testing.T) {
|
|
180
|
+
task := data.Task{ID: "E03/T001", Release: "v1", Epic: "E03", Column: data.ColumnDone, Stage: data.StageTest}
|
|
181
|
+
router := &data.RouterState{Release: "v1", Epic: "E03", Task: "T001"}
|
|
182
|
+
got := RenderCard(task, 30, false, router)
|
|
183
|
+
if !isRouterPriority(task, router) {
|
|
184
|
+
t.Error("router state should still identify the matching done task")
|
|
185
|
+
}
|
|
186
|
+
if !strings.Contains(got, styles.GlyphBuild.Render(glyphBuild)) {
|
|
187
|
+
t.Error("done task should use orange build glyph")
|
|
188
|
+
}
|
|
189
|
+
if strings.Contains(got, glyphTest) {
|
|
190
|
+
t.Error("done task should not use test glyph")
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
func TestRenderCard_explicitStatusUsesUnifiedGlyph(t *testing.T) {
|
|
195
|
+
tests := []struct {
|
|
196
|
+
name string
|
|
197
|
+
status data.TaskStatus
|
|
198
|
+
glyph string
|
|
199
|
+
}{
|
|
200
|
+
{"planned", data.StatusPlanned, "○"},
|
|
201
|
+
{"in progress", data.StatusInProgress, "▶"},
|
|
202
|
+
{"done", data.StatusDone, "◉"},
|
|
203
|
+
{"audited", data.StatusAudited, "✓"},
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for _, tt := range tests {
|
|
207
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
208
|
+
task := data.Task{ID: "T1", Status: string(tt.status), Stage: data.StageAudit}
|
|
209
|
+
got := RenderCard(task, 30, false, nil)
|
|
210
|
+
if !strings.Contains(got, tt.glyph) {
|
|
211
|
+
t.Errorf("RenderCard with status %q missing glyph %q", tt.status, tt.glyph)
|
|
212
|
+
}
|
|
213
|
+
if strings.Contains(got, glyphAudit) {
|
|
214
|
+
t.Errorf("RenderCard with status %q should not fall back to audit glyph", tt.status)
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
}
|
package/internal/board/column.go
CHANGED
|
@@ -8,12 +8,13 @@ import (
|
|
|
8
8
|
"github.com/opencode/savepoint/internal/styles"
|
|
9
9
|
)
|
|
10
10
|
|
|
11
|
-
// RenderColumn renders a board column: header with label+count, task
|
|
12
|
-
func RenderColumn(tasks []data.Task, col data.ColumnType, width, focusedTask int, focused bool,
|
|
11
|
+
// RenderColumn renders a board column: header with label+count, task viewport, bordered container.
|
|
12
|
+
func RenderColumn(tasks []data.Task, col data.ColumnType, width, maxHeight, offset, focusedTask int, focused bool, routerState *data.RouterState) string {
|
|
13
13
|
inner := width - colOverhead
|
|
14
14
|
if inner < minColWidth {
|
|
15
15
|
inner = minColWidth
|
|
16
16
|
}
|
|
17
|
+
offset = clampViewportOffset(offset, len(tasks))
|
|
17
18
|
|
|
18
19
|
title := columnTitle(col)
|
|
19
20
|
header := fmt.Sprintf("%s (%d)", title, len(tasks))
|
|
@@ -27,19 +28,53 @@ func RenderColumn(tasks []data.Task, col data.ColumnType, width, focusedTask int
|
|
|
27
28
|
if len(tasks) == 0 {
|
|
28
29
|
lines = append(lines, styles.TaskItem.Render("(empty)"))
|
|
29
30
|
} else {
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
limit := visibleColumnTaskLimit(maxHeight)
|
|
32
|
+
end := min(offset+limit, len(tasks))
|
|
33
|
+
if offset > 0 {
|
|
34
|
+
lines = append(lines, renderScrollIndicator("↑", offset, "above"))
|
|
35
|
+
}
|
|
36
|
+
for i, t := range tasks[offset:end] {
|
|
37
|
+
taskIndex := offset + i
|
|
38
|
+
lines = append(lines, RenderCard(t, inner, focused && taskIndex == focusedTask, routerState))
|
|
39
|
+
}
|
|
40
|
+
if end < len(tasks) {
|
|
41
|
+
lines = append(lines, renderScrollIndicator("↓", len(tasks)-end, "more"))
|
|
32
42
|
}
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
content := strings.Join(lines, "\n")
|
|
36
|
-
st := styles.
|
|
46
|
+
st := styles.ColumnUnfocused.Width(width)
|
|
37
47
|
if focused {
|
|
38
48
|
st = styles.ColumnFocused.Width(width)
|
|
39
49
|
}
|
|
40
50
|
return st.Render(content)
|
|
41
51
|
}
|
|
42
52
|
|
|
53
|
+
func visibleColumnTaskLimit(maxHeight int) int {
|
|
54
|
+
if maxHeight <= 0 {
|
|
55
|
+
return 999999
|
|
56
|
+
}
|
|
57
|
+
limit := (maxHeight - 2) / 3
|
|
58
|
+
if limit < 1 {
|
|
59
|
+
return 1
|
|
60
|
+
}
|
|
61
|
+
return limit
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func clampViewportOffset(offset, total int) int {
|
|
65
|
+
if offset < 0 || total <= 0 {
|
|
66
|
+
return 0
|
|
67
|
+
}
|
|
68
|
+
if offset >= total {
|
|
69
|
+
return total - 1
|
|
70
|
+
}
|
|
71
|
+
return offset
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func renderScrollIndicator(arrow string, count int, suffix string) string {
|
|
75
|
+
return styles.ScrollIndicator.Render(fmt.Sprintf("%s %d %s", arrow, count, suffix))
|
|
76
|
+
}
|
|
77
|
+
|
|
43
78
|
func columnTitle(col data.ColumnType) string {
|
|
44
79
|
switch col {
|
|
45
80
|
case data.ColumnPlanned:
|