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"
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"

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.
///
/// 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('"', '\\"')}"';
}
}

View file

@ -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<Ticket?> 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<Ticket>> list() async {
@ -43,11 +44,15 @@ class TicketStore {
final pattern = RegExp(r'^' + RegExp.escape(prefix) + r'-\d{4}\.md$');
final tickets = <Ticket>[];
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<Ticket> 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<Ticket> 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<void> 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<int> _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 (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');
}

View file

@ -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');