From ef2c132c929f4fd2401224d4dc32aee45ac82fb3 Mon Sep 17 00:00:00 2001 From: Konstantin Dyachenko Date: Mon, 30 Mar 2026 08:00:05 +0700 Subject: [PATCH] [Add] Base TaskBoard & TaskManager --- .gitignore | 1 + Assets/Editor/Tasks.meta | 8 + Assets/Editor/Tasks/TaskBoardModels.cs | 51 ++ Assets/Editor/Tasks/TaskBoardModels.cs.meta | 2 + Assets/Editor/Tasks/TaskBoardService.cs | 722 +++++++++++++++++++ Assets/Editor/Tasks/TaskBoardService.cs.meta | 2 + Assets/Editor/Tasks/TaskBoardWindow.cs | 677 +++++++++++++++++ Assets/Editor/Tasks/TaskBoardWindow.cs.meta | 2 + docs/tasks/Index.md | 45 ++ docs/tasks/_template.md | 114 +++ 10 files changed, 1624 insertions(+) create mode 100644 Assets/Editor/Tasks.meta create mode 100644 Assets/Editor/Tasks/TaskBoardModels.cs create mode 100644 Assets/Editor/Tasks/TaskBoardModels.cs.meta create mode 100644 Assets/Editor/Tasks/TaskBoardService.cs create mode 100644 Assets/Editor/Tasks/TaskBoardService.cs.meta create mode 100644 Assets/Editor/Tasks/TaskBoardWindow.cs create mode 100644 Assets/Editor/Tasks/TaskBoardWindow.cs.meta create mode 100644 docs/tasks/Index.md create mode 100644 docs/tasks/_template.md diff --git a/.gitignore b/.gitignore index b8a487da..8ccb1c69 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ !/Assets/ !/ProjectSettings/ !/Packages/ +!/docs/ !.gitignore !.gitattributes !LICENSE diff --git a/Assets/Editor/Tasks.meta b/Assets/Editor/Tasks.meta new file mode 100644 index 00000000..c028018d --- /dev/null +++ b/Assets/Editor/Tasks.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 85e740c52ab3e0a488b0109bd39c9e86 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/Tasks/TaskBoardModels.cs b/Assets/Editor/Tasks/TaskBoardModels.cs new file mode 100644 index 00000000..7e884095 --- /dev/null +++ b/Assets/Editor/Tasks/TaskBoardModels.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; + +namespace Project.Tasks.Editor +{ + internal static class TaskBoardConstants + { + public static readonly string[] Statuses = { "proposal", "ready", "in_progress", "blocked", "done" }; + public static readonly string[] Priorities = { "Lowest", "Low", "Medium", "High", "Highest" }; + } + + [Serializable] + internal sealed class TaskRecord + { + public string Id; + public string Title; + public string Status; + public string Priority; + public string Area; + public string Owner; + public string Created; + public string Updated; + public string ExecutionTime; + public string RelativeFilePath; + public string AbsoluteFilePath; + public string Summary; + public string Header; + public string Why; + public string ExpectedOutcome; + public string CurrentContext; + public string AcceptanceCriteria; + public string Verification; + public string Risks; + public string HumanDecisions; + public string DecisionLog; + public string HandoffNotes; + public bool FileExists; + public int IndexLineNumber = -1; + public readonly List ValidationMessages = new List(); + } + + [Serializable] + internal sealed class TaskBoardData + { + public string ProjectRoot; + public string TasksDirectory; + public string IndexPath; + public readonly List Tasks = new List(); + public readonly List Warnings = new List(); + } +} diff --git a/Assets/Editor/Tasks/TaskBoardModels.cs.meta b/Assets/Editor/Tasks/TaskBoardModels.cs.meta new file mode 100644 index 00000000..2773bf57 --- /dev/null +++ b/Assets/Editor/Tasks/TaskBoardModels.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4716897eb71185742ad590f2a59df8e3 \ No newline at end of file diff --git a/Assets/Editor/Tasks/TaskBoardService.cs b/Assets/Editor/Tasks/TaskBoardService.cs new file mode 100644 index 00000000..7ca9fc80 --- /dev/null +++ b/Assets/Editor/Tasks/TaskBoardService.cs @@ -0,0 +1,722 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using UnityEngine; + +namespace Project.Tasks.Editor +{ + internal static class TaskBoardService + { + private static readonly Regex HeadingRegex = new Regex(@"^##\s+(.+)$", RegexOptions.Compiled); + private static readonly Regex H1Regex = new Regex(@"^#\s+(.+)$", RegexOptions.Compiled); + private static readonly Regex JiraTimeRegex = new Regex(@"^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public static string NormalizePriority(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + switch (value.Trim()) + { + case "lowest": + case "Lowest": + return "Lowest"; + case "low": + case "Low": + return "Low"; + case "medium": + case "Medium": + return "Medium"; + case "high": + case "High": + return "High"; + case "highest": + case "Highest": + return "Highest"; + default: + return value.Trim(); + } + } + + public static int GetPrioritySortOrder(string value) + { + switch (NormalizePriority(value)) + { + case "Highest": + return 0; + case "High": + return 1; + case "Medium": + return 2; + case "Low": + return 3; + case "Lowest": + return 4; + default: + return int.MaxValue; + } + } + + public static TaskBoardData Load() + { + var data = new TaskBoardData(); + data.ProjectRoot = GetProjectRoot(); + data.TasksDirectory = NormalizePath(Path.Combine(data.ProjectRoot, "docs", "tasks")); + data.IndexPath = NormalizePath(Path.Combine(data.TasksDirectory, "Index.md")); + + if (!Directory.Exists(data.TasksDirectory)) + { + data.Warnings.Add("Каталог docs/tasks не найден."); + return data; + } + + if (!File.Exists(data.IndexPath)) + { + data.Warnings.Add("Файл docs/tasks/Index.md не найден."); + return data; + } + + string[] lines = File.ReadAllLines(data.IndexPath, Encoding.UTF8); + ParseIndex(data, lines); + + string[] taskFiles = Directory.GetFiles(data.TasksDirectory, "TASK-*.md", SearchOption.TopDirectoryOnly); + var indexedIds = new HashSet(data.Tasks.Select(task => task.Id), StringComparer.OrdinalIgnoreCase); + + foreach (string taskPath in taskFiles) + { + string taskId = Path.GetFileNameWithoutExtension(taskPath); + int dashIndex = taskId.IndexOf('-'); + if (dashIndex >= 0) + { + int secondDash = taskId.IndexOf('-', dashIndex + 1); + if (secondDash > 0) + { + taskId = taskId.Substring(0, secondDash); + } + } + + if (!indexedIds.Contains(taskId)) + { + data.Warnings.Add("Task-файл без записи в реестре: " + NormalizePath(GetRelativePath(data.ProjectRoot, taskPath))); + } + } + + data.Tasks.Sort(CompareTaskIds); + return data; + } + + public static bool Save(TaskBoardData data, TaskRecord updatedTask, out string error) + { + error = null; + + try + { + SaveIndexRow(data.IndexPath, updatedTask); + + if (updatedTask.FileExists && File.Exists(updatedTask.AbsoluteFilePath)) + { + SaveTaskFrontMatter(updatedTask.AbsoluteFilePath, updatedTask); + } + + return true; + } + catch (Exception exception) + { + error = exception.Message; + return false; + } + } + + public static bool CreateTaskFileFromTemplate(TaskBoardData data, TaskRecord task, out string error) + { + error = null; + + try + { + if (data == null) + { + throw new InvalidOperationException("Данные task board не загружены."); + } + + if (task == null) + { + throw new InvalidOperationException("Задача не выбрана."); + } + + if (string.IsNullOrWhiteSpace(task.RelativeFilePath)) + { + throw new InvalidOperationException("В Index.md не указан путь к task-файлу."); + } + + string relativePath = NormalizePath(task.RelativeFilePath); + if (!relativePath.StartsWith("docs/tasks/", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Task-файл можно создавать только внутри docs/tasks."); + } + + string absolutePath = NormalizePath(Path.Combine(data.ProjectRoot, relativePath)); + if (File.Exists(absolutePath)) + { + throw new InvalidOperationException("Task-файл уже существует."); + } + + string templatePath = NormalizePath(Path.Combine(data.TasksDirectory, "_template.md")); + if (!File.Exists(templatePath)) + { + throw new InvalidOperationException("Шаблон docs/tasks/_template.md не найден."); + } + + string template = File.ReadAllText(templatePath, Encoding.UTF8); + string today = DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + string title = string.IsNullOrWhiteSpace(task.Title) ? task.Id : task.Title.Trim(); + string owner = string.IsNullOrWhiteSpace(task.Owner) ? "unassigned" : task.Owner.Trim(); + string created = string.IsNullOrWhiteSpace(task.Created) ? today : task.Created.Trim(); + string area = string.IsNullOrWhiteSpace(task.Area) ? "docs" : task.Area.Trim(); + string priority = NormalizePriority(task.Priority); + if (string.IsNullOrWhiteSpace(priority)) + { + priority = "Medium"; + } + + string executionTime = string.IsNullOrWhiteSpace(task.ExecutionTime) ? "1d" : task.ExecutionTime.Trim(); + + string content = template + .Replace("id: TASK-XXXX", "id: " + task.Id) + .Replace("title: Короткий заголовок", "title: " + title) + .Replace("priority: Medium", "priority: " + priority) + .Replace("area: docs", "area: " + area) + .Replace("owner: unassigned", "owner: " + owner) + .Replace("created: YYYY-MM-DD", "created: " + created) + .Replace("updated: YYYY-MM-DD", "updated: " + today) + .Replace("execution_time: 1d6h30m", "execution_time: " + executionTime) + .Replace("# TASK-XXXX - Короткий заголовок", "# " + task.Id + " - " + title); + + string directory = Path.GetDirectoryName(absolutePath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + File.WriteAllText(absolutePath, content, new UTF8Encoding(false)); + task.AbsoluteFilePath = absolutePath; + task.FileExists = true; + return true; + } + catch (Exception exception) + { + error = exception.Message; + return false; + } + } + + public static void OpenInDefaultApp(string absolutePath) + { + if (string.IsNullOrEmpty(absolutePath) || !File.Exists(absolutePath)) + { + return; + } + + var info = new ProcessStartInfo + { + FileName = absolutePath, + UseShellExecute = true, + }; + + Process.Start(info); + } + + public static bool IsValidExecutionTime(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + Match match = JiraTimeRegex.Match(value.Trim()); + if (!match.Success) + { + return false; + } + + bool hasAnyGroup = false; + for (int i = 1; i < match.Groups.Count; i++) + { + if (match.Groups[i].Success) + { + hasAnyGroup = true; + break; + } + } + + if (!hasAnyGroup) + { + return false; + } + + if (match.Groups[4].Success) + { + int minutes; + if (!int.TryParse(match.Groups[4].Value, NumberStyles.None, CultureInfo.InvariantCulture, out minutes)) + { + return false; + } + + if (minutes % 30 != 0) + { + return false; + } + } + + return true; + } + + public static string GetProjectRoot() + { + DirectoryInfo parent = Directory.GetParent(Application.dataPath); + return parent != null ? NormalizePath(parent.FullName) : NormalizePath(Application.dataPath); + } + + public static string NormalizePath(string path) + { + return string.IsNullOrEmpty(path) ? string.Empty : path.Replace('\\', '/'); + } + + public static string GetRelativePath(string rootPath, string fullPath) + { + Uri rootUri = new Uri(AppendDirectorySeparator(rootPath)); + Uri fullUri = new Uri(fullPath); + return Uri.UnescapeDataString(rootUri.MakeRelativeUri(fullUri).ToString()).Replace('\\', '/'); + } + + private static string AppendDirectorySeparator(string path) + { + if (string.IsNullOrEmpty(path)) + { + return string.Empty; + } + + if (path[path.Length - 1] == Path.DirectorySeparatorChar || path[path.Length - 1] == Path.AltDirectorySeparatorChar) + { + return path; + } + + return path + Path.DirectorySeparatorChar; + } + + private static void ParseIndex(TaskBoardData data, string[] lines) + { + int registryIndex = Array.FindIndex(lines, line => string.Equals(line.Trim(), "## Task Registry", StringComparison.Ordinal)); + if (registryIndex < 0) + { + data.Warnings.Add("В Index.md не найдена секция '## Task Registry'."); + return; + } + + int headerIndex = -1; + int separatorIndex = -1; + for (int i = registryIndex + 1; i < lines.Length; i++) + { + string trimmed = lines[i].Trim(); + if (trimmed.StartsWith("|", StringComparison.Ordinal)) + { + if (headerIndex < 0) + { + headerIndex = i; + } + else + { + separatorIndex = i; + break; + } + } + else if (!string.IsNullOrWhiteSpace(trimmed)) + { + break; + } + } + + if (headerIndex < 0 || separatorIndex < 0) + { + data.Warnings.Add("В Index.md не удалось прочитать таблицу реестра задач."); + return; + } + + for (int i = separatorIndex + 1; i < lines.Length; i++) + { + string line = lines[i]; + string trimmed = line.Trim(); + if (string.IsNullOrWhiteSpace(trimmed)) + { + break; + } + + if (!trimmed.StartsWith("|", StringComparison.Ordinal)) + { + break; + } + + string[] columns = SplitMarkdownRow(trimmed); + if (columns.Length < 7) + { + continue; + } + + var task = new TaskRecord + { + Id = columns[0], + Status = columns[1], + Priority = NormalizePriority(columns[2]), + Area = columns[3], + ExecutionTime = columns[4], + RelativeFilePath = StripTicks(columns[5]), + Summary = columns[6], + IndexLineNumber = i, + }; + + if (!string.IsNullOrEmpty(task.RelativeFilePath)) + { + task.AbsoluteFilePath = NormalizePath(Path.Combine(data.ProjectRoot, task.RelativeFilePath)); + task.FileExists = File.Exists(task.AbsoluteFilePath); + } + + if (!task.FileExists) + { + task.ValidationMessages.Add("Task-файл не найден по пути из реестра."); + } + + PopulateTaskFromFile(task); + ValidateTask(task); + data.Tasks.Add(task); + } + } + + private static void PopulateTaskFromFile(TaskRecord task) + { + if (!task.FileExists || string.IsNullOrEmpty(task.AbsoluteFilePath)) + { + if (string.IsNullOrEmpty(task.Title)) + { + task.Title = task.Id; + } + return; + } + + string[] lines = File.ReadAllLines(task.AbsoluteFilePath, Encoding.UTF8); + int bodyStart = 0; + + if (lines.Length > 0 && string.Equals(lines[0].Trim(), "---", StringComparison.Ordinal)) + { + for (int i = 1; i < lines.Length; i++) + { + if (string.Equals(lines[i].Trim(), "---", StringComparison.Ordinal)) + { + bodyStart = i + 1; + break; + } + + string line = lines[i]; + int separator = line.IndexOf(':'); + if (separator <= 0) + { + continue; + } + + string key = line.Substring(0, separator).Trim(); + string value = line.Substring(separator + 1).Trim(); + ApplyFrontMatterField(task, key, value); + } + } + + var sections = new Dictionary(StringComparer.OrdinalIgnoreCase); + string currentSection = null; + + for (int i = bodyStart; i < lines.Length; i++) + { + string line = lines[i]; + Match h1Match = H1Regex.Match(line); + if (h1Match.Success && string.IsNullOrEmpty(task.Header)) + { + task.Header = h1Match.Groups[1].Value.Trim(); + if (string.IsNullOrEmpty(task.Title)) + { + task.Title = ExtractTitleFromHeader(task.Header, task.Id); + } + continue; + } + + Match headingMatch = HeadingRegex.Match(line); + if (headingMatch.Success) + { + currentSection = headingMatch.Groups[1].Value.Trim(); + if (!sections.ContainsKey(currentSection)) + { + sections[currentSection] = new StringBuilder(); + } + continue; + } + + if (string.IsNullOrEmpty(currentSection)) + { + continue; + } + + if (sections[currentSection].Length > 0) + { + sections[currentSection].AppendLine(); + } + + sections[currentSection].Append(line); + } + + task.Why = GetSection(sections, "Why"); + task.ExpectedOutcome = GetSection(sections, "Expected Outcome"); + task.CurrentContext = GetSection(sections, "Current Context"); + task.AcceptanceCriteria = GetSection(sections, "Acceptance Criteria"); + task.Verification = GetSection(sections, "Verification"); + task.Risks = GetSection(sections, "Risks / Open Questions"); + task.HumanDecisions = GetSection(sections, "Human Decisions Needed"); + task.DecisionLog = GetSection(sections, "Decision Log"); + task.HandoffNotes = GetSection(sections, "Handoff Notes"); + + if (string.IsNullOrEmpty(task.Title)) + { + task.Title = task.Id; + } + } + + private static void ApplyFrontMatterField(TaskRecord task, string key, string value) + { + switch (key) + { + case "id": + if (!string.Equals(task.Id, value, StringComparison.OrdinalIgnoreCase)) + { + task.ValidationMessages.Add("ID в task-файле не совпадает с записью в Index.md."); + } + break; + case "title": + task.Title = value; + break; + case "priority": + string normalizedPriority = NormalizePriority(value); + if (!string.IsNullOrEmpty(normalizedPriority) && !string.Equals(task.Priority, normalizedPriority, StringComparison.OrdinalIgnoreCase)) + { + task.ValidationMessages.Add("Priority в task-файле не совпадает с реестром."); + } + break; + case "area": + if (!string.IsNullOrEmpty(value) && !string.Equals(task.Area, value, StringComparison.OrdinalIgnoreCase)) + { + task.ValidationMessages.Add("Area в task-файле не совпадает с реестром."); + } + break; + case "owner": + task.Owner = value; + break; + case "created": + task.Created = value; + break; + case "updated": + task.Updated = value; + break; + case "execution_time": + if (!string.IsNullOrEmpty(value) && !string.Equals(task.ExecutionTime, value, StringComparison.OrdinalIgnoreCase)) + { + task.ValidationMessages.Add("execution_time в task-файле не совпадает с реестром."); + } + break; + } + } + + private static void ValidateTask(TaskRecord task) + { + if (Array.IndexOf(TaskBoardConstants.Statuses, task.Status) < 0) + { + task.ValidationMessages.Add("Неизвестный статус в Index.md: " + task.Status); + } + + task.Priority = NormalizePriority(task.Priority); + if (Array.IndexOf(TaskBoardConstants.Priorities, task.Priority) < 0) + { + task.ValidationMessages.Add("Неизвестный приоритет в Index.md: " + task.Priority); + } + + if (!IsValidExecutionTime(task.ExecutionTime)) + { + task.ValidationMessages.Add("execution_time не соответствует Jira-формату или не кратен 30 минутам."); + } + + if (string.IsNullOrEmpty(task.RelativeFilePath)) + { + task.ValidationMessages.Add("В реестре не указан путь к task-файлу."); + } + } + + private static void SaveIndexRow(string indexPath, TaskRecord task) + { + string[] lines = File.ReadAllLines(indexPath, Encoding.UTF8); + if (task.IndexLineNumber < 0 || task.IndexLineNumber >= lines.Length) + { + throw new InvalidOperationException("Не удалось найти строку задачи в Index.md."); + } + + lines[task.IndexLineNumber] = FormatIndexRow(task); + File.WriteAllText(indexPath, string.Join("\n", lines), new UTF8Encoding(false)); + } + + private static void SaveTaskFrontMatter(string taskPath, TaskRecord task) + { + var lines = File.ReadAllLines(taskPath, Encoding.UTF8).ToList(); + string today = DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + + if (lines.Count == 0 || !string.Equals(lines[0].Trim(), "---", StringComparison.Ordinal)) + { + var newLines = new List + { + "---", + "id: " + task.Id, + "title: " + (string.IsNullOrEmpty(task.Title) ? task.Id : task.Title), + "priority: " + task.Priority, + "area: " + task.Area, + "owner: " + (string.IsNullOrEmpty(task.Owner) ? "unassigned" : task.Owner), + "created: " + (string.IsNullOrEmpty(task.Created) ? today : task.Created), + "updated: " + today, + "execution_time: " + task.ExecutionTime, + "depends_on: []", + "canonical_docs: []", + "related_files: []", + "---", + string.Empty, + }; + newLines.AddRange(lines); + lines = newLines; + } + + int frontMatterEnd = -1; + for (int i = 1; i < lines.Count; i++) + { + if (string.Equals(lines[i].Trim(), "---", StringComparison.Ordinal)) + { + frontMatterEnd = i; + break; + } + } + + if (frontMatterEnd < 0) + { + throw new InvalidOperationException("Не удалось прочитать front matter task-файла."); + } + + UpsertFrontMatterField(lines, ref frontMatterEnd, "priority", task.Priority); + UpsertFrontMatterField(lines, ref frontMatterEnd, "area", task.Area); + UpsertFrontMatterField(lines, ref frontMatterEnd, "owner", string.IsNullOrEmpty(task.Owner) ? "unassigned" : task.Owner); + UpsertFrontMatterField(lines, ref frontMatterEnd, "created", string.IsNullOrEmpty(task.Created) ? today : task.Created); + UpsertFrontMatterField(lines, ref frontMatterEnd, "updated", today); + UpsertFrontMatterField(lines, ref frontMatterEnd, "execution_time", task.ExecutionTime); + + File.WriteAllText(taskPath, string.Join("\n", lines), new UTF8Encoding(false)); + } + + private static void UpsertFrontMatterField(List lines, ref int frontMatterEnd, string key, string value) + { + string prefix = key + ":"; + for (int i = 1; i < frontMatterEnd; i++) + { + if (lines[i].StartsWith(prefix, StringComparison.Ordinal)) + { + lines[i] = key + ": " + value; + return; + } + } + + lines.Insert(frontMatterEnd, key + ": " + value); + frontMatterEnd++; + } + + private static string FormatIndexRow(TaskRecord task) + { + return string.Format( + CultureInfo.InvariantCulture, + "| {0} | {1} | {2} | {3} | {4} | `{5}` | {6} |", + task.Id, + task.Status, + task.Priority, + task.Area, + task.ExecutionTime, + NormalizePath(task.RelativeFilePath), + (task.Summary ?? string.Empty).Replace("|", "/")); + } + + private static string[] SplitMarkdownRow(string line) + { + string trimmed = line.Trim().Trim('|'); + return trimmed.Split('|').Select(part => part.Trim()).ToArray(); + } + + private static string StripTicks(string value) + { + return string.IsNullOrEmpty(value) ? string.Empty : value.Trim().Trim('`'); + } + + private static string ExtractTitleFromHeader(string header, string id) + { + if (string.IsNullOrEmpty(header)) + { + return id; + } + + string prefix = id + " - "; + if (header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return header.Substring(prefix.Length).Trim(); + } + + return header.Trim(); + } + + private static string GetSection(Dictionary sections, string key) + { + StringBuilder value; + if (!sections.TryGetValue(key, out value)) + { + return string.Empty; + } + + return value.ToString().Trim(); + } + + private static int CompareTaskIds(TaskRecord left, TaskRecord right) + { + return CompareTaskIds(left != null ? left.Id : null, right != null ? right.Id : null); + } + + private static int CompareTaskIds(string left, string right) + { + return ExtractTaskNumber(left).CompareTo(ExtractTaskNumber(right)); + } + + private static int ExtractTaskNumber(string id) + { + if (string.IsNullOrEmpty(id)) + { + return int.MaxValue; + } + + string[] parts = id.Split('-'); + if (parts.Length < 2) + { + return int.MaxValue; + } + + int number; + return int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out number) ? number : int.MaxValue; + } + } +} diff --git a/Assets/Editor/Tasks/TaskBoardService.cs.meta b/Assets/Editor/Tasks/TaskBoardService.cs.meta new file mode 100644 index 00000000..9d496550 --- /dev/null +++ b/Assets/Editor/Tasks/TaskBoardService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: da90adbe6fa0acb429453d3550e18961 \ No newline at end of file diff --git a/Assets/Editor/Tasks/TaskBoardWindow.cs b/Assets/Editor/Tasks/TaskBoardWindow.cs new file mode 100644 index 00000000..b3311d64 --- /dev/null +++ b/Assets/Editor/Tasks/TaskBoardWindow.cs @@ -0,0 +1,677 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace Project.Tasks.Editor +{ + internal sealed class TaskBoardWindow : EditorWindow + { + private const float ColumnWidth = 290f; + private const float DetailsWidth = 420f; + + private TaskBoardData data; + private Vector2 boardScroll; + private Vector2 detailsScroll; + private string searchText = string.Empty; + private string areaFilter = "All"; + private string priorityFilter = "All"; + private bool showDone = true; + private TaskRecord selectedTask; + private string saveMessage = string.Empty; + private MessageType saveMessageType = MessageType.Info; + + private string editStatus; + private string editPriority; + private string editOwner; + private string editExecutionTime; + private string editSummary; + private GUIStyle readOnlyWrappedTextArea; + private GUIStyle priorityBadgeStyle; + private TaskRecord pressedTask; + private Vector2 pressedMousePosition; + + [MenuItem("Tools/Tasks/Kanban Board")] + public static void Open() + { + GetWindow("Task Board"); + } + + private void OnEnable() + { + Reload(); + } + + private void OnGUI() + { + DrawToolbar(); + + if (data == null) + { + EditorGUILayout.HelpBox("Данные задач еще не загружены.", MessageType.Info); + return; + } + + if (data.Warnings.Count > 0) + { + EditorGUILayout.HelpBox(string.Join("\n", data.Warnings), MessageType.Warning); + } + + if (!string.IsNullOrEmpty(saveMessage)) + { + EditorGUILayout.HelpBox(saveMessage, saveMessageType); + } + + EditorGUILayout.BeginHorizontal(); + DrawBoard(); + DrawDetails(); + EditorGUILayout.EndHorizontal(); + } + + private void DrawToolbar() + { + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + + if (GUILayout.Button("Reload", EditorStyles.toolbarButton, GUILayout.Width(60f))) + { + Reload(); + } + + if (GUILayout.Button("Open Index", EditorStyles.toolbarButton, GUILayout.Width(80f))) + { + string indexPath = data != null ? data.IndexPath : TaskBoardService.NormalizePath(System.IO.Path.Combine(TaskBoardService.GetProjectRoot(), "docs", "tasks", "Index.md")); + TaskBoardService.OpenInDefaultApp(indexPath); + } + + GUILayout.Space(8f); + GUILayout.Label("Search", GUILayout.Width(45f)); + searchText = GUILayout.TextField(searchText, GUI.skin.FindStyle("ToolbarSeachTextField") ?? EditorStyles.toolbarTextField, GUILayout.MinWidth(150f)); + + GUILayout.Space(8f); + areaFilter = DrawFilterPopup("Area", areaFilter, BuildAreaOptions(), 120f); + priorityFilter = DrawFilterPopup("Priority", priorityFilter, BuildPriorityOptions(), 110f); + showDone = GUILayout.Toggle(showDone, "Show Done", EditorStyles.toolbarButton, GUILayout.Width(85f)); + + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + } + + private string DrawFilterPopup(string label, string currentValue, string[] options, float width) + { + GUILayout.Label(label, GUILayout.Width(36f)); + int currentIndex = Array.IndexOf(options, currentValue); + if (currentIndex < 0) + { + currentIndex = 0; + } + + int nextIndex = EditorGUILayout.Popup(currentIndex, options, EditorStyles.toolbarPopup, GUILayout.Width(width)); + return options[Mathf.Clamp(nextIndex, 0, options.Length - 1)]; + } + + private void DrawBoard() + { + List filteredTasks = GetFilteredTasks(); + + EditorGUILayout.BeginVertical(GUILayout.ExpandWidth(true)); + boardScroll = EditorGUILayout.BeginScrollView(boardScroll, GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true)); + EditorGUILayout.BeginHorizontal(); + + foreach (string status in TaskBoardConstants.Statuses) + { + if (!showDone && string.Equals(status, "done", StringComparison.Ordinal)) + { + continue; + } + + DrawColumn(status, filteredTasks + .Where(task => string.Equals(task.Status, status, StringComparison.Ordinal)) + .OrderBy(task => TaskBoardService.GetPrioritySortOrder(task.Priority)) + .ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase) + .ToList()); + } + + EditorGUILayout.EndHorizontal(); + EditorGUILayout.EndScrollView(); + EditorGUILayout.EndVertical(); + } + + private void DrawColumn(string status, List tasks) + { + EditorGUILayout.BeginVertical("box", GUILayout.Width(ColumnWidth), GUILayout.ExpandHeight(true)); + EditorGUILayout.LabelField(ObjectNames.NicifyVariableName(status), EditorStyles.boldLabel); + EditorGUILayout.LabelField(tasks.Count + " task(s)", EditorStyles.miniLabel); + EditorGUILayout.Space(6f); + + Rect headerRect = GUILayoutUtility.GetLastRect(); + Rect lastRect = headerRect; + + if (tasks.Count == 0) + { + EditorGUILayout.HelpBox("Нет задач по текущему фильтру.", MessageType.None); + lastRect = GUILayoutUtility.GetLastRect(); + } + + foreach (TaskRecord task in tasks) + { + DrawTaskCard(task); + lastRect = GUILayoutUtility.GetLastRect(); + EditorGUILayout.Space(6f); + } + + GUILayout.FlexibleSpace(); + EditorGUILayout.EndVertical(); + + Rect columnRect = new Rect(headerRect.xMin - 6f, headerRect.yMin - 28f, ColumnWidth, Mathf.Max(110f, lastRect.yMax - headerRect.yMin + 36f)); + HandleColumnDrop(columnRect, status); + } + + private void DrawTaskCard(TaskRecord task) + { + Color previousBackground = GUI.backgroundColor; + GUI.backgroundColor = selectedTask == task ? new Color(0.72f, 0.82f, 1f) : GetStatusColor(task.Status); + + EditorGUILayout.BeginVertical("box"); + GUI.backgroundColor = previousBackground; + + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField(task.Id, EditorStyles.boldLabel); + GUILayout.FlexibleSpace(); + DrawPriorityBadge(task.Priority); + EditorGUILayout.EndHorizontal(); + Rect firstRect = GUILayoutUtility.GetLastRect(); + + EditorGUILayout.LabelField(string.IsNullOrEmpty(task.Title) ? task.Id : task.Title, EditorStyles.wordWrappedLabel); + if (!string.IsNullOrEmpty(task.Summary)) + { + EditorGUILayout.LabelField(task.Summary, EditorStyles.wordWrappedMiniLabel); + } + + EditorGUILayout.Space(4f); + EditorGUILayout.LabelField("Area: " + SafeValue(task.Area) + " Owner: " + SafeValue(task.Owner), EditorStyles.miniLabel); + EditorGUILayout.LabelField("Time: " + SafeValue(task.ExecutionTime), EditorStyles.miniLabel); + + if (task.ValidationMessages.Count > 0) + { + EditorGUILayout.HelpBox(task.ValidationMessages[0], MessageType.Warning); + } + + if (GUILayout.Button("Open Details")) + { + SelectTask(task); + } + + EditorGUILayout.EndVertical(); + + Rect lastRect = GUILayoutUtility.GetLastRect(); + Rect cardRect = new Rect(firstRect.xMin - 6f, firstRect.yMin - 6f, firstRect.width + 12f, Mathf.Max(44f, lastRect.yMax - firstRect.yMin + 12f)); + HandleCardDrag(task, cardRect); + } + + private void DrawDetails() + { + EditorGUILayout.BeginVertical("box", GUILayout.Width(DetailsWidth), GUILayout.ExpandHeight(true)); + EditorGUILayout.LabelField("Task Details", EditorStyles.boldLabel); + + if (selectedTask == null) + { + EditorGUILayout.HelpBox("Выберите карточку, чтобы посмотреть детали задачи и изменить статус.", MessageType.Info); + EditorGUILayout.EndVertical(); + return; + } + + detailsScroll = EditorGUILayout.BeginScrollView(detailsScroll, GUILayout.ExpandHeight(true)); + EditorGUILayout.LabelField(selectedTask.Id, EditorStyles.boldLabel); + EditorGUILayout.LabelField(string.IsNullOrEmpty(selectedTask.Title) ? selectedTask.Id : selectedTask.Title, EditorStyles.wordWrappedLabel); + EditorGUILayout.Space(8f); + + if (selectedTask.ValidationMessages.Count > 0) + { + EditorGUILayout.HelpBox(string.Join("\n", selectedTask.ValidationMessages), MessageType.Warning); + } + + EditorGUILayout.BeginHorizontal(); + using (new EditorGUI.DisabledScope(!selectedTask.FileExists)) + { + if (GUILayout.Button("Open Task File")) + { + TaskBoardService.OpenInDefaultApp(selectedTask.AbsoluteFilePath); + } + } + + using (new EditorGUI.DisabledScope(selectedTask.FileExists)) + { + if (GUILayout.Button("Create From Template")) + { + CreateSelectedTaskFromTemplate(); + } + } + + if (GUILayout.Button("Open Index")) + { + TaskBoardService.OpenInDefaultApp(data.IndexPath); + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(8f); + DrawEditableFields(); + EditorGUILayout.Space(12f); + + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField("Priority", GUILayout.Width(80f)); + DrawPriorityBadge(selectedTask.Priority); + EditorGUILayout.EndHorizontal(); + DrawReadOnlyField("File", selectedTask.RelativeFilePath); + DrawReadOnlyField("Created", selectedTask.Created); + DrawReadOnlyField("Updated", selectedTask.Updated); + DrawReadOnlyField("Area", selectedTask.Area); + + EditorGUILayout.Space(10f); + DrawSection("Why", selectedTask.Why); + DrawSection("Expected Outcome", selectedTask.ExpectedOutcome); + DrawSection("Current Context", selectedTask.CurrentContext); + DrawSection("Acceptance Criteria", selectedTask.AcceptanceCriteria); + DrawSection("Verification", selectedTask.Verification); + DrawSection("Risks / Open Questions", selectedTask.Risks); + DrawSection("Human Decisions Needed", selectedTask.HumanDecisions); + DrawSection("Decision Log", selectedTask.DecisionLog); + DrawSection("Handoff Notes", selectedTask.HandoffNotes); + + EditorGUILayout.EndScrollView(); + EditorGUILayout.EndVertical(); + } + + private void DrawEditableFields() + { + EditorGUILayout.LabelField("Quick Edit", EditorStyles.boldLabel); + + editStatus = DrawStringPopup("Status", editStatus, TaskBoardConstants.Statuses); + editPriority = DrawStringPopup("Priority", editPriority, TaskBoardConstants.Priorities); + editOwner = EditorGUILayout.TextField("Owner", editOwner ?? string.Empty); + editExecutionTime = EditorGUILayout.TextField("Execution Time", editExecutionTime ?? string.Empty); + + EditorGUILayout.LabelField("Summary"); + editSummary = EditorGUILayout.TextArea(editSummary ?? string.Empty, GUILayout.MinHeight(60f)); + + bool hasChanges = HasChanges(); + bool executionTimeValid = TaskBoardService.IsValidExecutionTime(editExecutionTime); + + if (!executionTimeValid) + { + EditorGUILayout.HelpBox("execution_time должен быть в формате Jira, например 1d6h30m, и быть кратным 30 минутам.", MessageType.Warning); + } + + using (new EditorGUI.DisabledScope(!hasChanges || !executionTimeValid)) + { + if (GUILayout.Button("Save Changes")) + { + SaveSelectedTask(); + } + } + } + + private string DrawStringPopup(string label, string currentValue, string[] options) + { + int index = Array.IndexOf(options, currentValue); + if (index < 0) + { + index = 0; + } + + int nextIndex = EditorGUILayout.Popup(label, index, options); + return options[Mathf.Clamp(nextIndex, 0, options.Length - 1)]; + } + + private void DrawReadOnlyField(string label, string value) + { + EditorGUILayout.LabelField(label, string.IsNullOrEmpty(value) ? "-" : value); + } + + private void DrawSection(string title, string content) + { + if (string.IsNullOrWhiteSpace(content)) + { + return; + } + + EditorGUILayout.LabelField(title, EditorStyles.boldLabel); + using (new EditorGUI.DisabledScope(true)) + { + EditorGUILayout.TextArea(content, GetReadOnlyWrappedTextArea(), GUILayout.MinHeight(52f)); + } + EditorGUILayout.Space(6f); + } + + private GUIStyle GetReadOnlyWrappedTextArea() + { + if (readOnlyWrappedTextArea == null) + { + readOnlyWrappedTextArea = new GUIStyle(EditorStyles.textArea) + { + wordWrap = true, + }; + } + + return readOnlyWrappedTextArea; + } + + private GUIStyle GetPriorityBadgeStyle() + { + if (priorityBadgeStyle == null) + { + priorityBadgeStyle = new GUIStyle(EditorStyles.miniBoldLabel) + { + alignment = TextAnchor.MiddleCenter, + padding = new RectOffset(8, 8, 3, 3), + normal = { textColor = Color.white }, + }; + } + + return priorityBadgeStyle; + } + + private void DrawPriorityBadge(string priority) + { + GUIStyle style = GetPriorityBadgeStyle(); + Vector2 contentSize = style.CalcSize(new GUIContent(priority)); + Rect rect = GUILayoutUtility.GetRect(contentSize.x + 18f, 22f, GUILayout.Width(contentSize.x + 18f), GUILayout.Height(22f)); + EditorGUI.DrawRect(rect, GetPriorityColor(priority)); + GUI.Label(rect, priority, style); + } + + private void HandleCardDrag(TaskRecord task, Rect cardRect) + { + Event current = Event.current; + if (current.type == EventType.MouseDown && current.button == 0 && cardRect.Contains(current.mousePosition)) + { + pressedTask = task; + pressedMousePosition = current.mousePosition; + } + + if (pressedTask != task) + { + return; + } + + if (current.type == EventType.MouseDrag && (current.mousePosition - pressedMousePosition).sqrMagnitude > 16f) + { + DragAndDrop.PrepareStartDrag(); + DragAndDrop.objectReferences = Array.Empty(); + DragAndDrop.SetGenericData("TaskBoard.TaskId", task.Id); + DragAndDrop.StartDrag(task.Id); + current.Use(); + } + + if (current.type == EventType.MouseUp) + { + pressedTask = null; + } + } + + private void HandleColumnDrop(Rect columnRect, string status) + { + Event current = Event.current; + if (!columnRect.Contains(current.mousePosition)) + { + return; + } + + if (current.type == EventType.DragUpdated) + { + if (GetDraggedTask() != null) + { + DragAndDrop.visualMode = DragAndDropVisualMode.Move; + current.Use(); + Repaint(); + } + return; + } + + if (current.type == EventType.DragPerform) + { + TaskRecord task = GetDraggedTask(); + if (task == null) + { + return; + } + + DragAndDrop.AcceptDrag(); + current.Use(); + pressedTask = null; + + if (!string.Equals(task.Status, status, StringComparison.Ordinal)) + { + MoveTaskToStatus(task, status); + } + } + } + + private TaskRecord GetDraggedTask() + { + string draggedTaskId = DragAndDrop.GetGenericData("TaskBoard.TaskId") as string; + if (string.IsNullOrEmpty(draggedTaskId) || data == null) + { + return null; + } + + return data.Tasks.FirstOrDefault(task => string.Equals(task.Id, draggedTaskId, StringComparison.OrdinalIgnoreCase)); + } + + private void MoveTaskToStatus(TaskRecord task, string status) + { + string selectedId = task.Id; + task.Status = status; + + string error; + if (!TaskBoardService.Save(data, task, out error)) + { + saveMessage = "Не удалось изменить статус перетаскиванием: " + error; + saveMessageType = MessageType.Error; + return; + } + + saveMessage = task.FileExists + ? "Статус задачи обновлен перетаскиванием." + : "Статус задачи обновлен в Index.md. Task-файл не найден, поэтому его метаданные не обновлялись."; + saveMessageType = MessageType.Info; + Reload(selectedId); + } + + private void SelectTask(TaskRecord task) + { + selectedTask = task; + editStatus = task.Status; + editPriority = task.Priority; + editOwner = task.Owner; + editExecutionTime = task.ExecutionTime; + editSummary = task.Summary; + } + + private void SaveSelectedTask() + { + if (selectedTask == null) + { + return; + } + + string selectedId = selectedTask.Id; + selectedTask.Status = editStatus; + selectedTask.Priority = editPriority; + selectedTask.Owner = string.IsNullOrWhiteSpace(editOwner) ? "unassigned" : editOwner.Trim(); + selectedTask.ExecutionTime = editExecutionTime.Trim(); + selectedTask.Summary = (editSummary ?? string.Empty).Trim(); + + string error; + if (!TaskBoardService.Save(data, selectedTask, out error)) + { + saveMessage = "Не удалось сохранить задачу: " + error; + saveMessageType = MessageType.Error; + return; + } + + saveMessage = selectedTask.FileExists + ? "Изменения сохранены в Index.md и task-файл." + : "Изменения сохранены в Index.md. Task-файл не найден, поэтому его метаданные не обновлялись."; + saveMessageType = MessageType.Info; + Reload(selectedId); + } + + private void CreateSelectedTaskFromTemplate() + { + if (selectedTask == null) + { + return; + } + + string selectedId = selectedTask.Id; + string error; + if (!TaskBoardService.CreateTaskFileFromTemplate(data, selectedTask, out error)) + { + saveMessage = "Не удалось создать task-файл из шаблона: " + error; + saveMessageType = MessageType.Error; + return; + } + + saveMessage = "Task-файл создан по шаблону."; + saveMessageType = MessageType.Info; + Reload(selectedId); + } + + private bool HasChanges() + { + if (selectedTask == null) + { + return false; + } + + return !string.Equals(selectedTask.Status, editStatus, StringComparison.Ordinal) + || !string.Equals(selectedTask.Priority, editPriority, StringComparison.Ordinal) + || !string.Equals(selectedTask.Owner ?? string.Empty, editOwner ?? string.Empty, StringComparison.Ordinal) + || !string.Equals(selectedTask.ExecutionTime ?? string.Empty, editExecutionTime ?? string.Empty, StringComparison.Ordinal) + || !string.Equals(selectedTask.Summary ?? string.Empty, editSummary ?? string.Empty, StringComparison.Ordinal); + } + + private void Reload(string selectedId = null) + { + data = TaskBoardService.Load(); + + if (data == null || data.Tasks.Count == 0) + { + selectedTask = null; + return; + } + + TaskRecord nextSelection = null; + if (!string.IsNullOrEmpty(selectedId)) + { + nextSelection = data.Tasks.FirstOrDefault(task => string.Equals(task.Id, selectedId, StringComparison.OrdinalIgnoreCase)); + } + + if (nextSelection == null && selectedTask != null) + { + nextSelection = data.Tasks.FirstOrDefault(task => string.Equals(task.Id, selectedTask.Id, StringComparison.OrdinalIgnoreCase)); + } + + if (nextSelection == null) + { + nextSelection = data.Tasks[0]; + } + + SelectTask(nextSelection); + } + + private List GetFilteredTasks() + { + IEnumerable query = data.Tasks; + + if (!string.IsNullOrWhiteSpace(searchText)) + { + string search = searchText.Trim(); + query = query.Where(task => + ContainsIgnoreCase(task.Id, search) + || ContainsIgnoreCase(task.Title, search) + || ContainsIgnoreCase(task.Summary, search) + || ContainsIgnoreCase(task.Area, search) + || ContainsIgnoreCase(task.Owner, search)); + } + + if (!string.Equals(areaFilter, "All", StringComparison.Ordinal)) + { + query = query.Where(task => string.Equals(task.Area, areaFilter, StringComparison.OrdinalIgnoreCase)); + } + + if (!string.Equals(priorityFilter, "All", StringComparison.Ordinal)) + { + query = query.Where(task => string.Equals(task.Priority, priorityFilter, StringComparison.OrdinalIgnoreCase)); + } + + return query.ToList(); + } + + private string[] BuildAreaOptions() + { + if (data == null || data.Tasks.Count == 0) + { + return new[] { "All" }; + } + + List options = new List { "All" }; + options.AddRange(data.Tasks.Select(task => task.Area).Where(area => !string.IsNullOrWhiteSpace(area)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(area => area, StringComparer.OrdinalIgnoreCase)); + return options.ToArray(); + } + + private string[] BuildPriorityOptions() + { + return new[] { "All", "Lowest", "Low", "Medium", "High", "Highest" }; + } + + private static bool ContainsIgnoreCase(string source, string value) + { + return !string.IsNullOrEmpty(source) && source.IndexOf(value, StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static string SafeValue(string value) + { + return string.IsNullOrEmpty(value) ? "-" : value; + } + + private static Color GetStatusColor(string status) + { + switch (status) + { + case "proposal": + return new Color(0.94f, 0.94f, 0.94f); + case "ready": + return new Color(0.85f, 0.96f, 0.85f); + case "in_progress": + return new Color(0.84f, 0.91f, 1f); + case "blocked": + return new Color(1f, 0.9f, 0.82f); + case "done": + return new Color(0.88f, 0.88f, 0.88f); + default: + return Color.white; + } + } + + private static Color GetPriorityColor(string priority) + { + switch (TaskBoardService.NormalizePriority(priority)) + { + case "Lowest": + return new Color(0.42f, 0.52f, 0.61f); + case "Low": + return new Color(0.45f, 0.45f, 0.45f); + case "Medium": + return new Color(0.19f, 0.48f, 0.84f); + case "High": + return new Color(0.88f, 0.54f, 0.16f); + case "Highest": + return new Color(0.78f, 0.2f, 0.2f); + default: + return new Color(0.35f, 0.35f, 0.35f); + } + } + } +} diff --git a/Assets/Editor/Tasks/TaskBoardWindow.cs.meta b/Assets/Editor/Tasks/TaskBoardWindow.cs.meta new file mode 100644 index 00000000..dcf38950 --- /dev/null +++ b/Assets/Editor/Tasks/TaskBoardWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5203f967846df5f43baaa0a3ba2e2d5b \ No newline at end of file diff --git a/docs/tasks/Index.md b/docs/tasks/Index.md new file mode 100644 index 00000000..cec6ca7c --- /dev/null +++ b/docs/tasks/Index.md @@ -0,0 +1,45 @@ +# Task Index + +## Purpose + +Эта папка хранит один файл на каждую отложенную или асинхронную единицу работы и единый реестр статусов, чтобы контекст реализации не терялся между чатами. + +Файлы задач должны описывать работу достаточно ясно, чтобы будущий человек или AI-агент мог продолжить ее без восстановления исходного замысла по истории переписки. + +## Rules + +- используйте `docs/tasks/_template.md` для каждой новой задачи +- храните одну задачу в одном файле +- храните все task-файлы плоско в `docs/tasks`, без подпапок по статусам +- не переименовывайте и не перемещайте файл задачи при смене статуса +- статус задачи считается каноническим по записи в этом индексе +- предпочитайте ссылки на канонические документы вместо копирования больших фоновых разделов +- обновляйте статус задачи в этом индексе по мере продвижения работы +- если завершенная задача меняет поведение системы или операционные процессы, отдельно обновляйте каноническую документацию +- указывайте `execution_time` в формате Jira, например `1d6h30m` +- значение `execution_time` должно быть кратно 30 минутам +- используйте приоритеты `Lowest`, `Low`, `Medium`, `High`, `Highest` +- держите реестр отсортированным по `ID`, а не группируйте задачи по статусным разделам + +## Supporting Docs + +- шаблон задачи: `docs/tasks/_template.md` + +## Statuses + +- `proposal` - идея существует, но объем или подход еще не готовы к исполнению +- `ready` - задачу можно брать в работу сейчас +- `in_progress` - по задаче сейчас идет активная работа +- `blocked` - задача ждет решения, зависимости или внешней предпосылки +- `done` - работа завершена; оставьте короткую заметку по итогу и позже при необходимости переместите или переименуйте файл + +## Task Registry + +| ID | Status | Priority | Area | Execution Time | File | Summary | +| --- | --- | --- | --- | --- | --- | --- | +| TASK-0001 | done | Medium | docs | 1d | `docs/tasks/TASK-0001-define-docs-structure-and-migration-plan.md` | Определена целевая структура документации, карта миграции и последовательность работ для переноса docs. | +| TASK-0002 | done | Medium | docs | 1d6h | `docs/tasks/TASK-0002-execute-docs-structure-migration.md` | Выполнен перенос дерева docs в разделы current, runbooks, history, process и tasks, а также обновлены пути входа в документацию. | +| TASK-0003 | in_progress | High | ci_cd | 2d | `docs/tasks/TASK-0003-stabilize-ci-cd-and-validate-pipeline.md` | Требуется итеративно стабилизировать текущий CI/CD путь на GitHub Actions и довести его до подтвержденно рабочего состояния. | +| TASK-0004 | ready | Medium | product | 1d | `docs/tasks/TASK-0004-define-directories-feature-and-implementation-decision.md` | Нужно согласовать и зафиксировать модель фичи directories, чтобы реализация не пошла в неверном направлении. | +| TASK-0005 | blocked | Medium | product | 2d | `docs/tasks/TASK-0005-implement-directories-and-folder-navigation.md` | Реализацию directories нельзя начинать, пока `TASK-0004` не зафиксирует согласованную модель папок и границы выполнения. | +| TASK-0006 | ready | Low | docs | 1d | `docs/tasks/TASK-0006-reposition-readme-as-project-brief.md` | Нужно переписать `README`, чтобы он начинался с идентичности проекта, стека и верхнеуровневого онбординга. | diff --git a/docs/tasks/_template.md b/docs/tasks/_template.md new file mode 100644 index 00000000..b020a816 --- /dev/null +++ b/docs/tasks/_template.md @@ -0,0 +1,114 @@ +--- +id: TASK-XXXX +title: Короткий заголовок +priority: Medium +area: docs +owner: unassigned +created: YYYY-MM-DD +updated: YYYY-MM-DD +execution_time: 1d6h30m +depends_on: [] +canonical_docs: [] +related_files: [] +--- + +# TASK-XXXX - Короткий заголовок + +## Status + +Статус задачи ведется в `docs/tasks/Index.md` и является каноническим там. + +Допустимые значения статуса: + +- `proposal` +- `ready` +- `in_progress` +- `blocked` +- `done` + +## Why + +Объясните, почему эта задача важна и какую проблему она решает. + +## Expected Outcome + +Опишите, какое новое состояние должно существовать после завершения задачи. + +## Current Context + +Держите этот раздел коротким. Ссылайтесь на канонические документы вместо копирования больших фоновых блоков. + +## Source Of Truth + +Перечислите документы или артефакты, которые имеют приоритет, если файл задачи неполон или устарел. + +- канонические документы текущего состояния в `docs/current/...` +- операционные runbook-документы в `docs/runbooks/...` +- проверенный код, тесты и закоммиченные артефакты деплоя +- явные решения человека, принятые после создания этой задачи + +## Read First + +- `README.md` +- `docs/...` +- `src/...` +- `tests/...` + +## Scope In + +- пункт +- пункт + +## Scope Out + +- пункт +- пункт + +## Constraints + +- сохраняйте контракты, уровень безопасности и задокументированную архитектуру, если только человек явно не изменил их +- предпочитайте наименьшее безопасное изменение, которое оставляет после себя более понятную документацию и подтверждение проверки +- указывайте `execution_time` в формате Jira, например `1d6h30m`, и только с шагом в 30 минут +- используйте приоритеты `Lowest`, `Low`, `Medium`, `High`, `Highest` +- не переименовывайте и не перемещайте task-файл при смене статуса; обновляйте запись в `docs/tasks/Index.md` + +## If You Find Drift + +- не считайте этот файл задачи молча источником высшего приоритета +- если текущие канонические документы и исторические документы расходятся, предпочитайте текущие канонические документы +- если код и документация расходятся, определите, является ли код намеренным текущим поведением или это дрейф документации, затем обновите ближайший канонический документ +- если конфликт затрагивает архитектуру, контракты, уровень безопасности, форму деплоя или поведение данных и миграций, остановитесь и спросите человека, если только более новое явное решение уже не сняло вопрос +- фиксируйте важный дрейф или последующие пробелы в файле задачи перед передачей дальше + +## Suggested Approach + +1. Шаг первый. +2. Шаг второй. +3. Шаг третий. + +## Acceptance Criteria + +- измеримый результат +- измеримый результат + +## Verification + +- проверка согласованности документации при вычитке +- точечные шаги сборки, тестирования или ручной проверки, если ожидаются изменения в коде + +## Risks / Open Questions + +- вопрос или риск + +## Human Decisions Needed + +- перечисляйте только решения, которые действительно требуют человека +- пишите `none currently`, когда задачу можно выполнять без дополнительных уточнений + +## Decision Log + +- `YYYY-MM-DD` - фиксируйте значимые решения, принятые при уточнении или выполнении задачи + +## Handoff Notes + +Добавляйте короткие заметки, которые помогут следующему человеку или AI-агенту безопасно продолжить работу.