add pql subsystem — daemon-side client + IPC handlers

PqlClient wraps pql CLI per D-003 (shell-out, never duplicate).
IPC verbs: pql.files, pql.meta, pql.backlinks, pql.outlinks,
pql.tags, pql.schema, pql.query, pql.doctor, pql.decisions.*,
pql.tickets.*, pql.plan.status. 15 new core tests.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-04-22 14:02:47 +02:00
co-authored by Claude
parent e43438d369
commit 93ab6ea3bd
5 changed files with 534 additions and 0 deletions
+227
View File
@@ -0,0 +1,227 @@
/// Registers `pql.*` command handlers on the daemon dispatcher.
///
/// Thin pass-through to [PqlClient] — the daemon is a JSON relay
/// between the IPC socket and the pql CLI. Per D-003, clide wraps
/// pql; it never re-implements.
library;
import '../ipc/envelope.dart';
import '../ipc/schema_v1.dart';
import '../pql/client.dart';
import 'dispatcher.dart';
void registerPqlCommands(DaemonDispatcher d, PqlClient pql) {
d.register('pql.files', (req) async {
try {
final glob = req.args['glob'] as String?;
final limit = (req.args['limit'] as num?)?.toInt();
final files = await pql.files(glob: glob, limit: limit);
return IpcResponse.ok(id: req.id, data: {'files': files});
} on PqlException catch (e) {
return _pqlError(req.id, e);
}
});
d.register('pql.meta', (req) async {
final path = req.args['path'] as String?;
if (path == null || path.isEmpty) {
return _userError(req.id, 'pql.meta requires a path');
}
try {
final meta = await pql.meta(path);
return IpcResponse.ok(id: req.id, data: meta);
} on PqlException catch (e) {
return _pqlError(req.id, e);
}
});
d.register('pql.backlinks', (req) async {
final path = req.args['path'] as String?;
if (path == null || path.isEmpty) {
return _userError(req.id, 'pql.backlinks requires a path');
}
try {
final links = await pql.backlinks(path);
return IpcResponse.ok(id: req.id, data: {'links': links});
} on PqlException catch (e) {
return _pqlError(req.id, e);
}
});
d.register('pql.outlinks', (req) async {
final path = req.args['path'] as String?;
if (path == null || path.isEmpty) {
return _userError(req.id, 'pql.outlinks requires a path');
}
try {
final links = await pql.outlinks(path);
return IpcResponse.ok(id: req.id, data: {'links': links});
} on PqlException catch (e) {
return _pqlError(req.id, e);
}
});
d.register('pql.tags', (req) async {
try {
final limit = (req.args['limit'] as num?)?.toInt();
final tags = await pql.tags(limit: limit);
return IpcResponse.ok(id: req.id, data: {'tags': tags});
} on PqlException catch (e) {
return _pqlError(req.id, e);
}
});
d.register('pql.schema', (req) async {
try {
final schema = await pql.schema();
return IpcResponse.ok(id: req.id, data: {'schema': schema});
} on PqlException catch (e) {
return _pqlError(req.id, e);
}
});
d.register('pql.query', (req) async {
final dsl = req.args['query'] as String?;
if (dsl == null || dsl.isEmpty) {
return _userError(req.id, 'pql.query requires a query string');
}
try {
final limit = (req.args['limit'] as num?)?.toInt();
final results = await pql.query(dsl, limit: limit);
return IpcResponse.ok(id: req.id, data: {'results': results});
} on PqlException catch (e) {
return _pqlError(req.id, e);
}
});
d.register('pql.doctor', (req) async {
try {
final report = await pql.doctor();
return IpcResponse.ok(id: req.id, data: report);
} on PqlException catch (e) {
return _pqlError(req.id, e);
}
});
d.register('pql.decisions.sync', (req) async {
try {
final result = await pql.decisionSync();
return IpcResponse.ok(id: req.id, data: result);
} on PqlException catch (e) {
return _pqlError(req.id, e);
}
});
d.register('pql.decisions.list', (req) async {
try {
final results = await pql.decisionList(
type: req.args['type'] as String?,
domain: req.args['domain'] as String?,
status: req.args['status'] as String?,
);
return IpcResponse.ok(id: req.id, data: {'decisions': results});
} on PqlException catch (e) {
return _pqlError(req.id, e);
}
});
d.register('pql.decisions.show', (req) async {
final id = req.args['id'] as String?;
if (id == null || id.isEmpty) {
return _userError(req.id, 'pql.decisions.show requires an id');
}
try {
final result = await pql.decisionShow(
id,
withRefs: req.args['withRefs'] as bool? ?? false,
withTickets: req.args['withTickets'] as bool? ?? false,
);
return IpcResponse.ok(id: req.id, data: result);
} on PqlException catch (e) {
return _pqlError(req.id, e);
}
});
d.register('pql.decisions.coverage', (req) async {
try {
final gaps = await pql.decisionCoverage();
return IpcResponse.ok(id: req.id, data: {'gaps': gaps});
} on PqlException catch (e) {
return _pqlError(req.id, e);
}
});
d.register('pql.tickets.list', (req) async {
try {
final results = await pql.ticketList(
status: req.args['status'] as String?,
team: req.args['team'] as String?,
assigned: req.args['assigned'] as String?,
decision: req.args['decision'] as String?,
);
return IpcResponse.ok(id: req.id, data: {'tickets': results});
} on PqlException catch (e) {
return _pqlError(req.id, e);
}
});
d.register('pql.tickets.show', (req) async {
final id = req.args['id'] as String?;
if (id == null || id.isEmpty) {
return _userError(req.id, 'pql.tickets.show requires an id');
}
try {
final result = await pql.ticketShow(
id,
withDecision: req.args['withDecision'] as bool? ?? false,
withBlockers: req.args['withBlockers'] as bool? ?? false,
);
return IpcResponse.ok(id: req.id, data: result);
} on PqlException catch (e) {
return _pqlError(req.id, e);
}
});
d.register('pql.tickets.board', (req) async {
try {
final board = await pql.ticketBoard(
team: req.args['team'] as String?,
);
return IpcResponse.ok(id: req.id, data: {'columns': board});
} on PqlException catch (e) {
return _pqlError(req.id, e);
}
});
d.register('pql.plan.status', (req) async {
try {
final status = await pql.planStatus();
return IpcResponse.ok(id: req.id, data: status);
} on PqlException catch (e) {
return _pqlError(req.id, e);
}
});
}
IpcResponse _userError(String id, String message) {
return IpcResponse.err(
id: id,
error: IpcError(
code: IpcExitCode.userError,
kind: IpcErrorKind.userError,
message: message,
),
);
}
IpcResponse _pqlError(String id, PqlException e) {
return IpcResponse.err(
id: id,
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: e.message,
hint: e.stderr.isNotEmpty ? e.stderr : null,
),
);
}
+172
View File
@@ -0,0 +1,172 @@
/// pql CLI wrapper — shells out per D-003 (wrap, don't duplicate).
///
/// Every function runs `pql <subcommand>` as a subprocess, parses
/// the JSON stdout, and returns typed Dart maps. Errors surface as
/// [PqlException] with the stderr diagnostics attached.
library;
import 'dart:convert';
import 'dart:io';
class PqlException implements Exception {
const PqlException(this.message, {this.exitCode = 1, this.stderr = ''});
final String message;
final int exitCode;
final String stderr;
@override
String toString() => 'PqlException($exitCode): $message';
}
class PqlClient {
PqlClient({required this.workDir, this.pqlBinary = 'pql'});
final Directory workDir;
final String pqlBinary;
Future<List<Map<String, Object?>>> files({String? glob, int? limit}) async {
final args = ['files'];
if (glob != null) args.add(glob);
if (limit != null) args.addAll(['--limit', '$limit']);
return _runList(args);
}
Future<Map<String, Object?>> meta(String path) async {
return _runObject(['meta', path]);
}
Future<List<Map<String, Object?>>> backlinks(String path) async {
return _runList(['backlinks', path]);
}
Future<List<Map<String, Object?>>> outlinks(String path) async {
return _runList(['outlinks', path]);
}
Future<List<Map<String, Object?>>> tags({int? limit}) async {
final args = ['tags'];
if (limit != null) args.addAll(['--limit', '$limit']);
return _runList(args);
}
Future<List<Map<String, Object?>>> schema() async {
return _runList(['schema']);
}
Future<List<Map<String, Object?>>> query(String dsl, {int? limit}) async {
final args = ['query', dsl];
if (limit != null) args.addAll(['--limit', '$limit']);
return _runList(args);
}
Future<Map<String, Object?>> doctor() async {
return _runObject(['doctor']);
}
Future<Map<String, Object?>> decisionSync() async {
return _runObject(['decisions', 'sync']);
}
Future<Object?> decisionValidate() async {
return _runObject(['decisions', 'validate']);
}
Future<List<Map<String, Object?>>> decisionList({
String? type,
String? domain,
String? status,
}) async {
final args = ['decisions', 'list'];
if (type != null) args.addAll(['--type', type]);
if (domain != null) args.addAll(['--domain', domain]);
if (status != null) args.addAll(['--status', status]);
return _runList(args);
}
Future<Map<String, Object?>> decisionShow(
String id, {
bool withRefs = false,
bool withTickets = false,
}) async {
final args = ['decisions', 'show', id];
if (withRefs) args.add('--with-refs');
if (withTickets) args.add('--with-tickets');
return _runObject(args);
}
Future<List<Map<String, Object?>>> decisionCoverage() async {
return _runList(['decisions', 'coverage']);
}
Future<List<Map<String, Object?>>> ticketList({
String? status,
String? team,
String? assigned,
String? decision,
}) async {
final args = ['ticket', 'list'];
if (status != null) args.addAll(['--status', status]);
if (team != null) args.addAll(['--team', team]);
if (assigned != null) args.addAll(['--assigned', assigned]);
if (decision != null) args.addAll(['--decision', decision]);
return _runList(args);
}
Future<Map<String, Object?>> ticketShow(
String id, {
bool withDecision = false,
bool withBlockers = false,
}) async {
final args = ['ticket', 'show', id];
if (withDecision) args.add('--with-decision');
if (withBlockers) args.add('--with-blockers');
return _runObject(args);
}
Future<List<Map<String, Object?>>> ticketBoard({String? team}) async {
final args = ['ticket', 'board'];
if (team != null) args.addAll(['--team', team]);
return _runList(args);
}
Future<Map<String, Object?>> planStatus() async {
return _runObject(['plan', 'status']);
}
// -------------------------------------------------------------------
Future<List<Map<String, Object?>>> _runList(List<String> args) async {
final result = await _run(args);
if (result == null) return const [];
if (result is List) {
return [for (final e in result) (e as Map).cast<String, Object?>()];
}
return const [];
}
Future<Map<String, Object?>> _runObject(List<String> args) async {
final result = await _run(args);
if (result is Map) return result.cast<String, Object?>();
return const {};
}
Future<Object?> _run(List<String> args) async {
final r = await Process.run(
pqlBinary,
args,
workingDirectory: workDir.path,
);
final stderr = (r.stderr as String).trim();
// Exit 2 = zero matches — valid empty result, not an error.
if (r.exitCode != 0 && r.exitCode != 2) {
throw PqlException(
'pql ${args.first} failed',
exitCode: r.exitCode,
stderr: stderr,
);
}
final stdout = (r.stdout as String).trim();
if (stdout.isEmpty) return null;
return jsonDecode(stdout);
}
}