From 951f0d8bc8df2569b77fb98d37c12d913c0a87d3 Mon Sep 17 00:00:00 2001 From: Chris Hendrickson Date: Thu, 23 Apr 2026 19:42:21 -0400 Subject: [PATCH] Storage refactor: tickets in column subdirs, drop redundant column frontmatter - TicketStore rewritten: tickets live in .project/kanban//.md - _findTicketFile() searches all column subdirs (one level deep) - update() moves the file when column changes - delete() cleans up attachments// if present - _nextNumber() scans all subdirs including archive - Ticket.fromFileContent() now takes column as explicit parameter - Ticket.toFileContent() drops column field (derived from path) - _yamlQuote() added to safely quote titles containing ': ' - dew.yaml: columns changed to backlog/doing/done (alphabetical order) - Existing tickets migrated to .project/kanban/backlog/ Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .project/dew.yaml | 8 +- .project/kanban/backlog/DEW-0001.md | 8 ++ .project/kanban/backlog/DEW-0002.md | 8 ++ .project/kanban/backlog/DEW-0003.md | 8 ++ .project/kanban/backlog/DEW-0004.md | 8 ++ .project/kanban/backlog/DEW-0005.md | 8 ++ .project/kanban/backlog/DEW-0006.md | 8 ++ .project/kanban/backlog/DEW-0007.md | 8 ++ .project/kanban/backlog/DEW-0008.md | 8 ++ .project/kanban/backlog/DEW-0009.md | 8 ++ .project/kanban/backlog/DEW-0010.md | 8 ++ .project/kanban/backlog/DEW-0011.md | 8 ++ .project/kanban/backlog/DEW-0012.md | 8 ++ .project/kanban/backlog/DEW-0013.md | 8 ++ .project/kanban/backlog/DEW-0014.md | 8 ++ .project/kanban/backlog/DEW-0015.md | 8 ++ packages/kanban/lib/src/ticket.dart | 25 ++++- packages/kanban/lib/src/ticket_store.dart | 118 ++++++++++++++-------- packages/kanban/test/dew_kanban_test.dart | 6 +- 19 files changed, 223 insertions(+), 54 deletions(-) create mode 100644 .project/kanban/backlog/DEW-0001.md create mode 100644 .project/kanban/backlog/DEW-0002.md create mode 100644 .project/kanban/backlog/DEW-0003.md create mode 100644 .project/kanban/backlog/DEW-0004.md create mode 100644 .project/kanban/backlog/DEW-0005.md create mode 100644 .project/kanban/backlog/DEW-0006.md create mode 100644 .project/kanban/backlog/DEW-0007.md create mode 100644 .project/kanban/backlog/DEW-0008.md create mode 100644 .project/kanban/backlog/DEW-0009.md create mode 100644 .project/kanban/backlog/DEW-0010.md create mode 100644 .project/kanban/backlog/DEW-0011.md create mode 100644 .project/kanban/backlog/DEW-0012.md create mode 100644 .project/kanban/backlog/DEW-0013.md create mode 100644 .project/kanban/backlog/DEW-0014.md create mode 100644 .project/kanban/backlog/DEW-0015.md diff --git a/.project/dew.yaml b/.project/dew.yaml index 7c33451..0ea7af5 100644 --- a/.project/dew.yaml +++ b/.project/dew.yaml @@ -16,11 +16,11 @@ dew: - id: "spike" name: "Spike" columns: - - id: "todo" - name: "To Do" + - id: "backlog" + name: "Backlog" color: "blue" - - id: "in-progress" - name: "In Progress" + - id: "doing" + name: "Doing" color: "yellow" - id: "done" name: "Done" diff --git a/.project/kanban/backlog/DEW-0001.md b/.project/kanban/backlog/DEW-0001.md new file mode 100644 index 0000000..18297db --- /dev/null +++ b/.project/kanban/backlog/DEW-0001.md @@ -0,0 +1,8 @@ +--- +id: DEW-0001 +title: Engineering Quality +type: epic +created: 2026-04-23T23:37:10.461762Z +--- + +Refactor and harden the core infrastructure before layering features on top. Covers config architecture, ticket storage model, and documentation accuracy. diff --git a/.project/kanban/backlog/DEW-0002.md b/.project/kanban/backlog/DEW-0002.md new file mode 100644 index 0000000..b395138 --- /dev/null +++ b/.project/kanban/backlog/DEW-0002.md @@ -0,0 +1,8 @@ +--- +id: DEW-0002 +title: Bootstrapping +type: epic +created: 2026-04-23T23:37:10.465324Z +--- + +Make it easy to initialize a new Dew project. `dew init` scaffolds the .project/ directory and each module creates its own folders via init hooks. diff --git a/.project/kanban/backlog/DEW-0003.md b/.project/kanban/backlog/DEW-0003.md new file mode 100644 index 0000000..6d8d33c --- /dev/null +++ b/.project/kanban/backlog/DEW-0003.md @@ -0,0 +1,8 @@ +--- +id: DEW-0003 +title: Milestones & Labels +type: epic +created: 2026-04-23T23:37:10.468947Z +--- + +Add freeform milestone and label fields to tickets for grouping and filtering. Both are List in ticket frontmatter. diff --git a/.project/kanban/backlog/DEW-0004.md b/.project/kanban/backlog/DEW-0004.md new file mode 100644 index 0000000..eef5c7f --- /dev/null +++ b/.project/kanban/backlog/DEW-0004.md @@ -0,0 +1,8 @@ +--- +id: DEW-0004 +title: Output Polish +type: epic +created: 2026-04-23T23:37:10.472323Z +--- + +Improve human-facing CLI output. Grouped list by column, enhanced search filters, and an ASCII board view. diff --git a/.project/kanban/backlog/DEW-0005.md b/.project/kanban/backlog/DEW-0005.md new file mode 100644 index 0000000..0553af6 --- /dev/null +++ b/.project/kanban/backlog/DEW-0005.md @@ -0,0 +1,8 @@ +--- +id: DEW-0005 +title: Column Transitions +type: epic +created: 2026-04-23T23:37:10.476017Z +--- + +Optional config-driven validation of which column moves are allowed. E.g. prevent moving directly from todo to done. diff --git a/.project/kanban/backlog/DEW-0006.md b/.project/kanban/backlog/DEW-0006.md new file mode 100644 index 0000000..e8a5c64 --- /dev/null +++ b/.project/kanban/backlog/DEW-0006.md @@ -0,0 +1,8 @@ +--- +id: DEW-0006 +title: Archiving +type: epic +created: 2026-04-23T23:37:10.479959Z +--- + +Soft-delete tickets to an archive folder rather than permanently destroying them. Archived tickets are excluded from normal list/search but can be browsed. diff --git a/.project/kanban/backlog/DEW-0007.md b/.project/kanban/backlog/DEW-0007.md new file mode 100644 index 0000000..4e933bd --- /dev/null +++ b/.project/kanban/backlog/DEW-0007.md @@ -0,0 +1,8 @@ +--- +id: DEW-0007 +title: "Config refactor: DewConfig.raw + per-package extensions" +type: story +created: 2026-04-23T23:37:36.822736Z +--- + +DewConfig in core becomes a thin wrapper: `class DewConfig { final YamlMap raw; }`. Each package adds a Dart extension with a typed getter (e.g. KanbanDewConfig, McpDewConfig). KanbanConfig/ColumnConfig/TicketTypeConfig move to dew_kanban; McpConfig moves to dew_mcp. Call sites unchanged: context.config.kanban.prefix etc. diff --git a/.project/kanban/backlog/DEW-0008.md b/.project/kanban/backlog/DEW-0008.md new file mode 100644 index 0000000..32e9cde --- /dev/null +++ b/.project/kanban/backlog/DEW-0008.md @@ -0,0 +1,8 @@ +--- +id: DEW-0008 +title: "Ticket storage refactor: column subdirectories" +type: story +created: 2026-04-23T23:37:36.826965Z +--- + +Move ticket .md files from flat .project/kanban/ into column subdirectories (.project/kanban/todo/DEW-0001.md). Attachments live at .project/kanban/attachments// (stable path, unaffected by moves). TicketStore changes: _filePath needs column, findById searches all column dirs, move physically moves the .md file, delete removes .md + attachments// if present, create writes into correct column dir. diff --git a/.project/kanban/backlog/DEW-0009.md b/.project/kanban/backlog/DEW-0009.md new file mode 100644 index 0000000..4a3932d --- /dev/null +++ b/.project/kanban/backlog/DEW-0009.md @@ -0,0 +1,8 @@ +--- +id: DEW-0009 +title: Update stale documentation +type: task +created: 2026-04-23T23:37:36.830655Z +--- + +docs/mcp.md only lists 5 of 12 tools; update to reflect current tool set. docs/index.md references old interface names. docs/kanban.md is a stub; flesh it out. Update tool list, command reference, and architecture notes. diff --git a/.project/kanban/backlog/DEW-0010.md b/.project/kanban/backlog/DEW-0010.md new file mode 100644 index 0000000..139d9a3 --- /dev/null +++ b/.project/kanban/backlog/DEW-0010.md @@ -0,0 +1,8 @@ +--- +id: DEW-0010 +title: dew init command with module init hooks +type: story +created: 2026-04-23T23:37:36.834286Z +--- + +Add `dew init [path]` command. Core defines DewInitHook interface with onInit(root, options). Options include bool gitkeep (default true). Each package registers a hook. Kanban hook creates: column dirs (from config), archive/, attachments/ under .project/kanban/. --[no-]gitkeep flag adds .gitkeep to empty dirs. Must run after storage refactor so dir structure matches new layout. diff --git a/.project/kanban/backlog/DEW-0011.md b/.project/kanban/backlog/DEW-0011.md new file mode 100644 index 0000000..7cbe361 --- /dev/null +++ b/.project/kanban/backlog/DEW-0011.md @@ -0,0 +1,8 @@ +--- +id: DEW-0011 +title: Add milestones and labels to Ticket model +type: story +created: 2026-04-23T23:37:36.838770Z +--- + +Add milestones: List and labels: List to the Ticket model and YAML frontmatter. Support --milestone and --label flags on create/update. Add --label and --milestone filter flags to list and search commands. Depends on storage refactor. diff --git a/.project/kanban/backlog/DEW-0012.md b/.project/kanban/backlog/DEW-0012.md new file mode 100644 index 0000000..53d166b --- /dev/null +++ b/.project/kanban/backlog/DEW-0012.md @@ -0,0 +1,8 @@ +--- +id: DEW-0012 +title: Grouped list view and ASCII board +type: story +created: 2026-04-23T23:37:36.842362Z +--- + +dew kanban list groups output by column with counts. Add `dew kanban board` subcommand for a full ASCII kanban board view showing all columns and their tickets side by side (or stacked). diff --git a/.project/kanban/backlog/DEW-0013.md b/.project/kanban/backlog/DEW-0013.md new file mode 100644 index 0000000..599f0e6 --- /dev/null +++ b/.project/kanban/backlog/DEW-0013.md @@ -0,0 +1,8 @@ +--- +id: DEW-0013 +title: Enhanced search filters +type: story +created: 2026-04-23T23:37:36.846212Z +--- + +Add --column, --type, --label, --milestone filter flags to `dew kanban search`. Currently only matches on text. Filters should be combinable (AND semantics). diff --git a/.project/kanban/backlog/DEW-0014.md b/.project/kanban/backlog/DEW-0014.md new file mode 100644 index 0000000..e51ae6a --- /dev/null +++ b/.project/kanban/backlog/DEW-0014.md @@ -0,0 +1,8 @@ +--- +id: DEW-0014 +title: Column transition validation +type: story +created: 2026-04-23T23:37:36.849741Z +--- + +Add optional allowed_transitions map to column config in dew.yaml. When configured, MoveCommand validates the requested move is permitted and errors with a helpful message listing allowed next columns. Unconfigured = all moves allowed (current behaviour). diff --git a/.project/kanban/backlog/DEW-0015.md b/.project/kanban/backlog/DEW-0015.md new file mode 100644 index 0000000..992539d --- /dev/null +++ b/.project/kanban/backlog/DEW-0015.md @@ -0,0 +1,8 @@ +--- +id: DEW-0015 +title: "Archive command: soft-delete tickets" +type: story +created: 2026-04-23T23:37:36.853236Z +--- + +Add `dew kanban archive --id ` that moves a ticket to .project/kanban/archive/ (and its attachments stay at attachments//). Archived tickets excluded from list/search by default; add --include-archived flag to opt in. Depends on storage refactor. diff --git a/packages/kanban/lib/src/ticket.dart b/packages/kanban/lib/src/ticket.dart index 13e64cb..b7095a1 100644 --- a/packages/kanban/lib/src/ticket.dart +++ b/packages/kanban/lib/src/ticket.dart @@ -71,6 +71,9 @@ class Ticket { /// Serialises the ticket to markdown with YAML frontmatter. /// + /// The column is intentionally omitted — it is derived from the containing + /// directory name and would be redundant (and stale after a move). + /// /// Format: /// ``` /// --- @@ -89,9 +92,8 @@ class Ticket { final buf = StringBuffer(); buf.writeln('---'); buf.writeln('id: $id'); - buf.writeln('title: $title'); + buf.writeln('title: ${_yamlQuote(title)}'); buf.writeln('type: $type'); - buf.writeln('column: $column'); buf.writeln('created: ${created.toUtc().toIso8601String()}'); if (links.isNotEmpty) { buf.writeln('links:'); @@ -114,7 +116,9 @@ class Ticket { return buf.toString(); } - static Ticket fromFileContent(String id, String content) { + /// Parses a ticket from file content. [column] must be supplied by the + /// caller (derived from the containing directory name). + static Ticket fromFileContent(String id, String content, String column) { if (!content.startsWith('---\n')) { throw FormatException('Ticket file $id does not start with ---'); } @@ -146,12 +150,25 @@ class Ticket { id: id, title: fm['title'] as String, type: fm['type'] as String, - column: fm['column'] as String, + column: column, created: DateTime.parse(fm['created'] as String), body: sections.isNotEmpty ? sections[0] : '', comments: sections.length > 1 ? sections.sublist(1) : const [], links: links, ); } + + /// Wraps [value] in double quotes if it contains characters that would + /// confuse a YAML parser (colon-space, leading/trailing whitespace, etc.). + static String _yamlQuote(String value) { + final needsQuoting = value.contains(': ') || + value.contains(' #') || + value.startsWith('"') || + value.startsWith("'") || + value.startsWith(' ') || + value.endsWith(' '); + if (!needsQuoting) return value; + return '"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"'; + } } diff --git a/packages/kanban/lib/src/ticket_store.dart b/packages/kanban/lib/src/ticket_store.dart index ffdc750..65fcd18 100644 --- a/packages/kanban/lib/src/ticket_store.dart +++ b/packages/kanban/lib/src/ticket_store.dart @@ -16,7 +16,8 @@ class TicketStore { required String column, String body = '', }) async { - await Directory(kanbanDir).create(recursive: true); + final columnDir = Directory(p.join(kanbanDir, column)); + await columnDir.create(recursive: true); final id = _formatId(await _nextNumber()); final ticket = Ticket( id: id, @@ -27,14 +28,14 @@ class TicketStore { body: body, comments: const [], ); - await File(_filePath(id)).writeAsString(ticket.toFileContent()); + await File(p.join(columnDir.path, '$id.md')).writeAsString(ticket.toFileContent()); return ticket; } Future findById(String id) async { - final file = File(_filePath(id)); - if (!await file.exists()) return null; - return Ticket.fromFileContent(id, await file.readAsString()); + final found = await _findTicketFile(id); + if (found == null) return null; + return Ticket.fromFileContent(id, await found.file.readAsString(), found.column); } Future> list() async { @@ -43,11 +44,15 @@ class TicketStore { final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-\d{4}\.md$'); final tickets = []; await for (final entity in dir.list()) { - final name = p.basename(entity.path); - if (pattern.hasMatch(name)) { + if (entity is! Directory) continue; + final col = p.basename(entity.path); + if (col == 'archive' || col == 'attachments') continue; + await for (final file in entity.list()) { + if (file is! File) continue; + final name = p.basename(file.path); + if (!pattern.hasMatch(name)) continue; final id = p.basenameWithoutExtension(name); - final ticket = await findById(id); - if (ticket != null) tickets.add(ticket); + tickets.add(Ticket.fromFileContent(id, await file.readAsString(), col)); } } tickets.sort((a, b) => a.id.compareTo(b.id)); @@ -55,12 +60,11 @@ class TicketStore { } Future addComment(String id, String comment) async { - final ticket = await findById(id); - if (ticket == null) throw ArgumentError('Ticket $id not found.'); - final updated = ticket.copyWith( - comments: [...ticket.comments, comment], - ); - await File(_filePath(id)).writeAsString(updated.toFileContent()); + final found = await _findTicketFile(id); + if (found == null) throw ArgumentError('Ticket $id not found.'); + final ticket = Ticket.fromFileContent(id, await found.file.readAsString(), found.column); + final updated = ticket.copyWith(comments: [...ticket.comments, comment]); + await found.file.writeAsString(updated.toFileContent()); return updated; } @@ -81,7 +85,8 @@ class TicketStore { final updated = ticket.copyWith( links: [...ticket.links, TicketLink(targetId: targetId, type: type)], ); - await File(_filePath(id)).writeAsString(updated.toFileContent()); + final found = (await _findTicketFile(id))!; + await found.file.writeAsString(updated.toFileContent()); } // Inverse link on the target. @@ -90,29 +95,34 @@ class TicketStore { final updatedTarget = target.copyWith( links: [...target.links, TicketLink(targetId: id, type: inverseType)], ); - await File(_filePath(targetId)).writeAsString(updatedTarget.toFileContent()); + final foundTarget = (await _findTicketFile(targetId))!; + await foundTarget.file.writeAsString(updatedTarget.toFileContent()); } return (await findById(id))!; } Future unlinkTickets(String id, String targetId) async { - final ticket = await findById(id); - if (ticket == null) throw ArgumentError('Ticket $id not found.'); - - // Remove forward link. + final found = await _findTicketFile(id); + if (found == null) throw ArgumentError('Ticket $id not found.'); + final ticket = Ticket.fromFileContent(id, await found.file.readAsString(), found.column); final updated = ticket.copyWith( links: ticket.links.where((l) => l.targetId != targetId).toList(), ); - await File(_filePath(id)).writeAsString(updated.toFileContent()); + await found.file.writeAsString(updated.toFileContent()); // Remove inverse link on target (if it exists). - final target = await findById(targetId); - if (target != null) { + final foundTarget = await _findTicketFile(targetId); + if (foundTarget != null) { + final target = Ticket.fromFileContent( + targetId, + await foundTarget.file.readAsString(), + foundTarget.column, + ); final updatedTarget = target.copyWith( links: target.links.where((l) => l.targetId != id).toList(), ); - await File(_filePath(targetId)).writeAsString(updatedTarget.toFileContent()); + await foundTarget.file.writeAsString(updatedTarget.toFileContent()); } return (await findById(id))!; @@ -137,22 +147,43 @@ class TicketStore { String? column, String? body, }) async { - final ticket = await findById(id); - if (ticket == null) throw ArgumentError('Ticket $id not found.'); - final updated = ticket.copyWith( - title: title, - type: type, - column: column, - body: body, - ); - await File(_filePath(id)).writeAsString(updated.toFileContent()); + final found = await _findTicketFile(id); + if (found == null) throw ArgumentError('Ticket $id not found.'); + final ticket = Ticket.fromFileContent(id, await found.file.readAsString(), found.column); + final updated = ticket.copyWith(title: title, type: type, column: column, body: body); + if (column != null && column != ticket.column) { + // Column changed — move the file to the new column directory. + await found.file.delete(); + final newColDir = Directory(p.join(kanbanDir, column)); + await newColDir.create(recursive: true); + await File(p.join(newColDir.path, '$id.md')).writeAsString(updated.toFileContent()); + } else { + await found.file.writeAsString(updated.toFileContent()); + } return updated; } Future delete(String id) async { - final file = File(_filePath(id)); - if (!await file.exists()) throw ArgumentError('Ticket $id not found.'); - await file.delete(); + final found = await _findTicketFile(id); + if (found == null) throw ArgumentError('Ticket $id not found.'); + await found.file.delete(); + // Clean up per-ticket attachment directory if present. + final attachmentsDir = Directory(p.join(kanbanDir, 'attachments', id)); + if (await attachmentsDir.exists()) await attachmentsDir.delete(recursive: true); + } + + /// Searches all column subdirectories (one level deep) for a ticket file. + /// Skips the [attachments] directory. Includes [archive]. + Future<({File file, String column})?> _findTicketFile(String id) async { + final dir = Directory(kanbanDir); + if (!await dir.exists()) return null; + await for (final entity in dir.list()) { + if (entity is! Directory) continue; + if (p.basename(entity.path) == 'attachments') continue; + final file = File(p.join(entity.path, '$id.md')); + if (await file.exists()) return (file: file, column: p.basename(entity.path)); + } + return null; } Future _nextNumber() async { @@ -161,16 +192,17 @@ class TicketStore { final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-(\d+)\.md$'); var max = 0; await for (final entity in dir.list()) { - final match = pattern.firstMatch(p.basename(entity.path)); - if (match != null) { - final n = int.parse(match.group(1)!); - if (n > max) max = n; + if (entity is! Directory || p.basename(entity.path) == 'attachments') continue; + await for (final file in entity.list()) { + final match = pattern.firstMatch(p.basename(file.path)); + if (match != null) { + final n = int.parse(match.group(1)!); + if (n > max) max = n; + } } } return max + 1; } String _formatId(int n) => '$prefix-${n.toString().padLeft(4, '0')}'; - - String _filePath(String id) => p.join(kanbanDir, '$id.md'); } diff --git a/packages/kanban/test/dew_kanban_test.dart b/packages/kanban/test/dew_kanban_test.dart index fb8d323..c819032 100644 --- a/packages/kanban/test/dew_kanban_test.dart +++ b/packages/kanban/test/dew_kanban_test.dart @@ -139,7 +139,7 @@ dew: body: 'Some body.', comments: ['First comment.', 'Second comment.'], ); - final parsed = Ticket.fromFileContent(t.id, t.toFileContent()); + final parsed = Ticket.fromFileContent(t.id, t.toFileContent(), t.column); expect(parsed.id, t.id); expect(parsed.title, t.title); expect(parsed.type, t.type); @@ -159,7 +159,7 @@ dew: body: '', comments: const [], ); - final parsed = Ticket.fromFileContent(t.id, t.toFileContent()); + final parsed = Ticket.fromFileContent(t.id, t.toFileContent(), t.column); expect(parsed.body, ''); expect(parsed.comments, isEmpty); }); @@ -178,7 +178,7 @@ dew: TicketLink(targetId: 'TEST-0002', type: 'relates_to'), ], ); - final parsed = Ticket.fromFileContent(t.id, t.toFileContent()); + final parsed = Ticket.fromFileContent(t.id, t.toFileContent(), t.column); expect(parsed.links, hasLength(2)); expect(parsed.links[0].targetId, 'TEST-0001'); expect(parsed.links[0].type, 'blocks');