Storage refactor: tickets in column subdirs, drop redundant column frontmatter

- TicketStore rewritten: tickets live in .project/kanban/<column>/<id>.md
- _findTicketFile() searches all column subdirs (one level deep)
- update() moves the file when column changes
- delete() cleans up attachments/<id>/ 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>
This commit is contained in:
Chris Hendrickson 2026-04-23 19:42:21 -04:00
parent 08f4d5c7cf
commit 951f0d8bc8
19 changed files with 223 additions and 54 deletions

View file

@ -16,11 +16,11 @@ dew:
- id: "spike" - id: "spike"
name: "Spike" name: "Spike"
columns: columns:
- id: "todo" - id: "backlog"
name: "To Do" name: "Backlog"
color: "blue" color: "blue"
- id: "in-progress" - id: "doing"
name: "In Progress" name: "Doing"
color: "yellow" color: "yellow"
- id: "done" - id: "done"
name: "Done" name: "Done"

View file

@ -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.

View file

@ -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.

View file

@ -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<String> in ticket frontmatter.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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/<ticket-id>/ (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/<id>/ if present, create writes into correct column dir.

View file

@ -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.

View file

@ -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.

View file

@ -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<String> and labels: List<String> 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.

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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 <id>` that moves a ticket to .project/kanban/archive/ (and its attachments stay at attachments/<id>/). Archived tickets excluded from list/search by default; add --include-archived flag to opt in. Depends on storage refactor.

View file

@ -71,6 +71,9 @@ class Ticket {
/// Serialises the ticket to markdown with YAML frontmatter. /// 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: /// Format:
/// ``` /// ```
/// --- /// ---
@ -89,9 +92,8 @@ class Ticket {
final buf = StringBuffer(); final buf = StringBuffer();
buf.writeln('---'); buf.writeln('---');
buf.writeln('id: $id'); buf.writeln('id: $id');
buf.writeln('title: $title'); buf.writeln('title: ${_yamlQuote(title)}');
buf.writeln('type: $type'); buf.writeln('type: $type');
buf.writeln('column: $column');
buf.writeln('created: ${created.toUtc().toIso8601String()}'); buf.writeln('created: ${created.toUtc().toIso8601String()}');
if (links.isNotEmpty) { if (links.isNotEmpty) {
buf.writeln('links:'); buf.writeln('links:');
@ -114,7 +116,9 @@ class Ticket {
return buf.toString(); 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')) { if (!content.startsWith('---\n')) {
throw FormatException('Ticket file $id does not start with ---'); throw FormatException('Ticket file $id does not start with ---');
} }
@ -146,12 +150,25 @@ class Ticket {
id: id, id: id,
title: fm['title'] as String, title: fm['title'] as String,
type: fm['type'] as String, type: fm['type'] as String,
column: fm['column'] as String, column: column,
created: DateTime.parse(fm['created'] as String), created: DateTime.parse(fm['created'] as String),
body: sections.isNotEmpty ? sections[0] : '', body: sections.isNotEmpty ? sections[0] : '',
comments: sections.length > 1 ? sections.sublist(1) : const [], comments: sections.length > 1 ? sections.sublist(1) : const [],
links: links, 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('"', '\\"')}"';
}
} }

View file

@ -16,7 +16,8 @@ class TicketStore {
required String column, required String column,
String body = '', String body = '',
}) async { }) 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 id = _formatId(await _nextNumber());
final ticket = Ticket( final ticket = Ticket(
id: id, id: id,
@ -27,14 +28,14 @@ class TicketStore {
body: body, body: body,
comments: const [], comments: const [],
); );
await File(_filePath(id)).writeAsString(ticket.toFileContent()); await File(p.join(columnDir.path, '$id.md')).writeAsString(ticket.toFileContent());
return ticket; return ticket;
} }
Future<Ticket?> findById(String id) async { Future<Ticket?> findById(String id) async {
final file = File(_filePath(id)); final found = await _findTicketFile(id);
if (!await file.exists()) return null; if (found == null) return null;
return Ticket.fromFileContent(id, await file.readAsString()); return Ticket.fromFileContent(id, await found.file.readAsString(), found.column);
} }
Future<List<Ticket>> list() async { Future<List<Ticket>> list() async {
@ -43,11 +44,15 @@ class TicketStore {
final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-\d{4}\.md$'); final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-\d{4}\.md$');
final tickets = <Ticket>[]; final tickets = <Ticket>[];
await for (final entity in dir.list()) { await for (final entity in dir.list()) {
final name = p.basename(entity.path); if (entity is! Directory) continue;
if (pattern.hasMatch(name)) { 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 id = p.basenameWithoutExtension(name);
final ticket = await findById(id); tickets.add(Ticket.fromFileContent(id, await file.readAsString(), col));
if (ticket != null) tickets.add(ticket);
} }
} }
tickets.sort((a, b) => a.id.compareTo(b.id)); tickets.sort((a, b) => a.id.compareTo(b.id));
@ -55,12 +60,11 @@ class TicketStore {
} }
Future<Ticket> addComment(String id, String comment) async { Future<Ticket> addComment(String id, String comment) async {
final ticket = await findById(id); final found = await _findTicketFile(id);
if (ticket == null) throw ArgumentError('Ticket $id not found.'); if (found == null) throw ArgumentError('Ticket $id not found.');
final updated = ticket.copyWith( final ticket = Ticket.fromFileContent(id, await found.file.readAsString(), found.column);
comments: [...ticket.comments, comment], final updated = ticket.copyWith(comments: [...ticket.comments, comment]);
); await found.file.writeAsString(updated.toFileContent());
await File(_filePath(id)).writeAsString(updated.toFileContent());
return updated; return updated;
} }
@ -81,7 +85,8 @@ class TicketStore {
final updated = ticket.copyWith( final updated = ticket.copyWith(
links: [...ticket.links, TicketLink(targetId: targetId, type: type)], 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. // Inverse link on the target.
@ -90,29 +95,34 @@ class TicketStore {
final updatedTarget = target.copyWith( final updatedTarget = target.copyWith(
links: [...target.links, TicketLink(targetId: id, type: inverseType)], 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))!; return (await findById(id))!;
} }
Future<Ticket> unlinkTickets(String id, String targetId) async { Future<Ticket> unlinkTickets(String id, String targetId) async {
final ticket = await findById(id); final found = await _findTicketFile(id);
if (ticket == null) throw ArgumentError('Ticket $id not found.'); if (found == null) throw ArgumentError('Ticket $id not found.');
final ticket = Ticket.fromFileContent(id, await found.file.readAsString(), found.column);
// Remove forward link.
final updated = ticket.copyWith( final updated = ticket.copyWith(
links: ticket.links.where((l) => l.targetId != targetId).toList(), 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). // Remove inverse link on target (if it exists).
final target = await findById(targetId); final foundTarget = await _findTicketFile(targetId);
if (target != null) { if (foundTarget != null) {
final target = Ticket.fromFileContent(
targetId,
await foundTarget.file.readAsString(),
foundTarget.column,
);
final updatedTarget = target.copyWith( final updatedTarget = target.copyWith(
links: target.links.where((l) => l.targetId != id).toList(), 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))!; return (await findById(id))!;
@ -137,22 +147,43 @@ class TicketStore {
String? column, String? column,
String? body, String? body,
}) async { }) async {
final ticket = await findById(id); final found = await _findTicketFile(id);
if (ticket == null) throw ArgumentError('Ticket $id not found.'); if (found == null) throw ArgumentError('Ticket $id not found.');
final updated = ticket.copyWith( final ticket = Ticket.fromFileContent(id, await found.file.readAsString(), found.column);
title: title, final updated = ticket.copyWith(title: title, type: type, column: column, body: body);
type: type, if (column != null && column != ticket.column) {
column: column, // Column changed move the file to the new column directory.
body: body, await found.file.delete();
); final newColDir = Directory(p.join(kanbanDir, column));
await File(_filePath(id)).writeAsString(updated.toFileContent()); 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; return updated;
} }
Future<void> delete(String id) async { Future<void> delete(String id) async {
final file = File(_filePath(id)); final found = await _findTicketFile(id);
if (!await file.exists()) throw ArgumentError('Ticket $id not found.'); if (found == null) throw ArgumentError('Ticket $id not found.');
await file.delete(); 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<int> _nextNumber() async { Future<int> _nextNumber() async {
@ -161,16 +192,17 @@ class TicketStore {
final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-(\d+)\.md$'); final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-(\d+)\.md$');
var max = 0; var max = 0;
await for (final entity in dir.list()) { await for (final entity in dir.list()) {
final match = pattern.firstMatch(p.basename(entity.path)); 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) { if (match != null) {
final n = int.parse(match.group(1)!); final n = int.parse(match.group(1)!);
if (n > max) max = n; if (n > max) max = n;
} }
} }
}
return max + 1; return max + 1;
} }
String _formatId(int n) => '$prefix-${n.toString().padLeft(4, '0')}'; String _formatId(int n) => '$prefix-${n.toString().padLeft(4, '0')}';
String _filePath(String id) => p.join(kanbanDir, '$id.md');
} }

View file

@ -139,7 +139,7 @@ dew:
body: 'Some body.', body: 'Some body.',
comments: ['First comment.', 'Second comment.'], 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.id, t.id);
expect(parsed.title, t.title); expect(parsed.title, t.title);
expect(parsed.type, t.type); expect(parsed.type, t.type);
@ -159,7 +159,7 @@ dew:
body: '', body: '',
comments: const [], 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.body, '');
expect(parsed.comments, isEmpty); expect(parsed.comments, isEmpty);
}); });
@ -178,7 +178,7 @@ dew:
TicketLink(targetId: 'TEST-0002', type: 'relates_to'), 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, hasLength(2));
expect(parsed.links[0].targetId, 'TEST-0001'); expect(parsed.links[0].targetId, 'TEST-0001');
expect(parsed.links[0].type, 'blocks'); expect(parsed.links[0].type, 'blocks');