trellis 1.0.8 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +533 -82
  3. package/bin/trellis.mjs +2 -0
  4. package/dist/cli/index.js +4718 -0
  5. package/dist/core/index.js +12 -0
  6. package/dist/decisions/index.js +19 -0
  7. package/dist/embeddings/index.js +43 -0
  8. package/dist/index-1j1anhmr.js +4038 -0
  9. package/dist/index-3s0eak0p.js +1556 -0
  10. package/dist/index-8pce39mh.js +272 -0
  11. package/dist/index-a76rekgs.js +67 -0
  12. package/dist/index-cy9k1g6v.js +684 -0
  13. package/dist/index-fd4e26s4.js +69 -0
  14. package/dist/{store/eav-store.js → index-gkvhzm9f.js} +4 -6
  15. package/dist/index-gnw8d7d6.js +51 -0
  16. package/dist/index-vkpkfwhq.js +817 -0
  17. package/dist/index.js +118 -2876
  18. package/dist/links/index.js +55 -0
  19. package/dist/transformers-m9je15kg.js +32491 -0
  20. package/dist/vcs/index.js +110 -0
  21. package/logo.png +0 -0
  22. package/logo.svg +9 -0
  23. package/package.json +79 -76
  24. package/src/cli/index.ts +2340 -0
  25. package/src/core/index.ts +35 -0
  26. package/src/core/kernel/middleware.ts +44 -0
  27. package/src/core/persist/backend.ts +64 -0
  28. package/src/core/store/eav-store.ts +467 -0
  29. package/src/decisions/auto-capture.ts +136 -0
  30. package/src/decisions/hooks.ts +163 -0
  31. package/src/decisions/index.ts +261 -0
  32. package/src/decisions/types.ts +103 -0
  33. package/src/embeddings/chunker.ts +327 -0
  34. package/src/embeddings/index.ts +41 -0
  35. package/src/embeddings/model.ts +95 -0
  36. package/src/embeddings/search.ts +305 -0
  37. package/src/embeddings/store.ts +313 -0
  38. package/src/embeddings/types.ts +85 -0
  39. package/src/engine.ts +1083 -0
  40. package/src/garden/cluster.ts +330 -0
  41. package/src/garden/garden.ts +306 -0
  42. package/src/garden/index.ts +29 -0
  43. package/src/git/git-exporter.ts +286 -0
  44. package/src/git/git-importer.ts +329 -0
  45. package/src/git/git-reader.ts +189 -0
  46. package/src/git/index.ts +22 -0
  47. package/src/identity/governance.ts +211 -0
  48. package/src/identity/identity.ts +224 -0
  49. package/src/identity/index.ts +30 -0
  50. package/src/identity/signing-middleware.ts +97 -0
  51. package/src/index.ts +20 -0
  52. package/src/links/index.ts +49 -0
  53. package/src/links/lifecycle.ts +400 -0
  54. package/src/links/parser.ts +484 -0
  55. package/src/links/ref-index.ts +186 -0
  56. package/src/links/resolver.ts +314 -0
  57. package/src/links/types.ts +108 -0
  58. package/src/mcp/index.ts +22 -0
  59. package/src/mcp/server.ts +1278 -0
  60. package/src/semantic/csharp-parser.ts +493 -0
  61. package/src/semantic/go-parser.ts +585 -0
  62. package/src/semantic/index.ts +34 -0
  63. package/src/semantic/java-parser.ts +456 -0
  64. package/src/semantic/python-parser.ts +659 -0
  65. package/src/semantic/ruby-parser.ts +446 -0
  66. package/src/semantic/rust-parser.ts +784 -0
  67. package/src/semantic/semantic-merge.ts +210 -0
  68. package/src/semantic/ts-parser.ts +681 -0
  69. package/src/semantic/types.ts +175 -0
  70. package/src/sync/index.ts +32 -0
  71. package/src/sync/memory-transport.ts +66 -0
  72. package/src/sync/reconciler.ts +237 -0
  73. package/src/sync/sync-engine.ts +258 -0
  74. package/src/sync/types.ts +104 -0
  75. package/src/vcs/blob-store.ts +124 -0
  76. package/src/vcs/branch.ts +150 -0
  77. package/src/vcs/checkpoint.ts +64 -0
  78. package/src/vcs/decompose.ts +469 -0
  79. package/src/vcs/diff.ts +409 -0
  80. package/src/vcs/engine-context.ts +26 -0
  81. package/src/vcs/index.ts +23 -0
  82. package/src/vcs/issue.ts +800 -0
  83. package/src/vcs/merge.ts +425 -0
  84. package/src/vcs/milestone.ts +124 -0
  85. package/src/vcs/ops.ts +59 -0
  86. package/src/vcs/types.ts +213 -0
  87. package/src/vcs/vcs-middleware.ts +81 -0
  88. package/src/watcher/fs-watcher.ts +217 -0
  89. package/src/watcher/index.ts +9 -0
  90. package/src/watcher/ingestion.ts +116 -0
  91. package/dist/ai/index.js +0 -688
  92. package/dist/cli/server.js +0 -3321
  93. package/dist/cli/tql.js +0 -5282
  94. package/dist/client/tql-client.js +0 -108
  95. package/dist/graph/index.js +0 -2248
  96. package/dist/kernel/logic-middleware.js +0 -179
  97. package/dist/kernel/middleware.js +0 -0
  98. package/dist/kernel/operations.js +0 -32
  99. package/dist/kernel/schema-middleware.js +0 -34
  100. package/dist/kernel/security-middleware.js +0 -53
  101. package/dist/kernel/trellis-kernel.js +0 -2239
  102. package/dist/kernel/workspace.js +0 -91
  103. package/dist/persist/backend.js +0 -0
  104. package/dist/persist/sqlite-backend.js +0 -123
  105. package/dist/query/index.js +0 -1643
  106. package/dist/server/index.js +0 -3309
  107. package/dist/workflows/index.js +0 -3160
@@ -0,0 +1,800 @@
1
+ /**
2
+ * Issue Module
3
+ *
4
+ * Extracted per DESIGN.md pattern (like milestone.ts, branch.ts).
5
+ * Handles issue creation, lifecycle (start/pause/resume/close/reopen),
6
+ * acceptance criteria, and queries.
7
+ */
8
+
9
+ import { exec } from 'child_process';
10
+ import { promisify } from 'util';
11
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
12
+ import { join, dirname } from 'path';
13
+ import { createVcsOp } from './ops.js';
14
+ import type { VcsOp } from './types.js';
15
+ import { issueEntityId, criterionEntityId } from './types.js';
16
+ import type { EngineContext } from './engine-context.js';
17
+
18
+ const execAsync = promisify(exec);
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Types
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export interface IssueInfo {
25
+ id: string;
26
+ title?: string;
27
+ description?: string;
28
+ status?: string;
29
+ priority?: string;
30
+ labels: string[];
31
+ assignee?: string;
32
+ createdAt?: string;
33
+ createdBy?: string;
34
+ startedAt?: string;
35
+ pausedAt?: string;
36
+ pauseNote?: string;
37
+ closedAt?: string;
38
+ parentId?: string;
39
+ branchName?: string;
40
+ blockedBy: string[];
41
+ blocking: string[];
42
+ isBlocked: boolean;
43
+ criteria: CriterionInfo[];
44
+ }
45
+
46
+ export interface CriterionInfo {
47
+ id: string;
48
+ description?: string;
49
+ command?: string;
50
+ status?: string;
51
+ lastRunAt?: string;
52
+ lastOutput?: string;
53
+ }
54
+
55
+ export interface CriterionResult {
56
+ id: string;
57
+ description?: string;
58
+ command?: string;
59
+ status: 'passed' | 'failed' | 'skipped';
60
+ output?: string;
61
+ exitCode?: number;
62
+ }
63
+
64
+ export interface IssueFilters {
65
+ status?: string;
66
+ assignee?: string;
67
+ label?: string;
68
+ parentId?: string;
69
+ blocked?: boolean;
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // ID generation
74
+ // ---------------------------------------------------------------------------
75
+
76
+ function getIssueCounterPath(rootPath: string): string {
77
+ return join(rootPath, '.trellis', 'issue-counter.json');
78
+ }
79
+
80
+ function nextIssueId(rootPath: string): string {
81
+ const counterPath = getIssueCounterPath(rootPath);
82
+ let counter = 0;
83
+ if (existsSync(counterPath)) {
84
+ try {
85
+ counter = JSON.parse(readFileSync(counterPath, 'utf-8')).counter ?? 0;
86
+ } catch {}
87
+ }
88
+ counter++;
89
+ const dir = dirname(counterPath);
90
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
91
+ writeFileSync(counterPath, JSON.stringify({ counter }, null, 2));
92
+ return `TRL-${counter}`;
93
+ }
94
+
95
+ function slugify(text: string): string {
96
+ return text
97
+ .toLowerCase()
98
+ .replace(/[^a-z0-9]+/g, '-')
99
+ .replace(/^-|-$/g, '')
100
+ .slice(0, 40);
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Helpers
105
+ // ---------------------------------------------------------------------------
106
+
107
+ function getIssueFact(
108
+ ctx: EngineContext,
109
+ entityId: string,
110
+ attr: string,
111
+ ): string | undefined {
112
+ const facts = ctx.store.getFactsByEntity(entityId);
113
+ // Return the LAST matching fact — EAV store appends, so latest is authoritative
114
+ const matches = facts.filter((f) => f.a === attr);
115
+ return matches.length > 0
116
+ ? (matches[matches.length - 1].v as string)
117
+ : undefined;
118
+ }
119
+
120
+ function getIssueLinks(
121
+ ctx: EngineContext,
122
+ entityId: string,
123
+ attr: string,
124
+ ): string[] {
125
+ const links = ctx.store.getLinksByEntity(entityId);
126
+ // Only forward links (e1 === entityId), not reverse
127
+ return links
128
+ .filter((l) => l.a === attr && l.e1 === entityId)
129
+ .map((l) => l.e2);
130
+ }
131
+
132
+ function getCriteriaForIssue(
133
+ ctx: EngineContext,
134
+ issueId: string,
135
+ ): CriterionInfo[] {
136
+ const eid = issueEntityId(issueId);
137
+ // Find all criterion entities linked to this issue
138
+ const criterionLinks = ctx.store
139
+ .getLinksByAttribute('criterionOf')
140
+ .filter((l) => l.e2 === eid);
141
+
142
+ return criterionLinks.map((link) => {
143
+ const ceid = link.e1;
144
+ const facts = ctx.store.getFactsByEntity(ceid);
145
+ const getLast = (a: string) => {
146
+ const matches = facts.filter((f) => f.a === a);
147
+ return matches.length > 0
148
+ ? (matches[matches.length - 1].v as string)
149
+ : undefined;
150
+ };
151
+ return {
152
+ id: ceid,
153
+ description: getLast('description'),
154
+ command: getLast('command'),
155
+ status: getLast('status'),
156
+ lastRunAt: getLast('lastRunAt'),
157
+ lastOutput: getLast('lastOutput'),
158
+ };
159
+ });
160
+ }
161
+
162
+ function buildIssueInfo(ctx: EngineContext, entityId: string): IssueInfo {
163
+ const facts = ctx.store.getFactsByEntity(entityId);
164
+ // Use last matching fact for each attribute (latest is authoritative)
165
+ const get = (a: string) => {
166
+ const matches = facts.filter((f) => f.a === a);
167
+ return matches.length > 0
168
+ ? (matches[matches.length - 1].v as string)
169
+ : undefined;
170
+ };
171
+
172
+ const labelsStr = get('labels');
173
+ const labels = labelsStr ? labelsStr.split(',').filter(Boolean) : [];
174
+
175
+ const trackedOnLinks = getIssueLinks(ctx, entityId, 'trackedOn');
176
+ const branchName =
177
+ trackedOnLinks.length > 0
178
+ ? trackedOnLinks[0].replace(/^branch:/, '')
179
+ : undefined;
180
+
181
+ const childOfLinks = getIssueLinks(ctx, entityId, 'childOf');
182
+ const parentId =
183
+ childOfLinks.length > 0
184
+ ? childOfLinks[0].replace(/^issue:/, '')
185
+ : undefined;
186
+
187
+ const bareId = entityId.replace(/^issue:/, '');
188
+
189
+ // Blocking: forward blockedBy links (this issue is blocked by...)
190
+ const blockedByLinks = getIssueLinks(ctx, entityId, 'blockedBy');
191
+ const blockedBy = blockedByLinks.map((e) => e.replace(/^issue:/, ''));
192
+
193
+ // Blocking: reverse blockedBy links (this issue blocks...)
194
+ const allBlockedByLinks = ctx.store.getLinksByAttribute('blockedBy');
195
+ const blocking = allBlockedByLinks
196
+ .filter((l) => l.e2 === entityId)
197
+ .map((l) => l.e1.replace(/^issue:/, ''));
198
+
199
+ // Derived: isBlocked if any blocker is not closed
200
+ const isBlocked = blockedByLinks.some((blockerEid) => {
201
+ const blockerStatus = getIssueFact(ctx, blockerEid, 'status');
202
+ return blockerStatus !== 'closed';
203
+ });
204
+
205
+ return {
206
+ id: bareId,
207
+ title: get('title'),
208
+ description: get('description'),
209
+ status: get('status'),
210
+ priority: get('priority'),
211
+ labels,
212
+ assignee: get('assignee'),
213
+ createdAt: get('createdAt'),
214
+ createdBy: get('createdBy'),
215
+ startedAt: get('startedAt'),
216
+ pausedAt: get('pausedAt'),
217
+ pauseNote: get('pauseNote') || undefined,
218
+ closedAt: get('closedAt'),
219
+ parentId,
220
+ branchName,
221
+ blockedBy,
222
+ blocking,
223
+ isBlocked,
224
+ criteria: getCriteriaForIssue(ctx, bareId),
225
+ };
226
+ }
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Operations
230
+ // ---------------------------------------------------------------------------
231
+
232
+ /**
233
+ * Create a new issue.
234
+ */
235
+ export async function createIssue(
236
+ ctx: EngineContext,
237
+ rootPath: string,
238
+ title: string,
239
+ opts?: {
240
+ priority?: 'critical' | 'high' | 'medium' | 'low';
241
+ labels?: string[];
242
+ assignee?: string;
243
+ parentId?: string;
244
+ description?: string;
245
+ status?: 'backlog' | 'queue';
246
+ criteria?: Array<{ description: string; command?: string }>;
247
+ },
248
+ ): Promise<VcsOp> {
249
+ const id = nextIssueId(rootPath);
250
+
251
+ const op = await createVcsOp('vcs:issueCreate', {
252
+ agentId: ctx.agentId,
253
+ previousHash: ctx.getLastOp()?.hash,
254
+ vcs: {
255
+ issueId: id,
256
+ issueTitle: title,
257
+ issueDescription: opts?.description,
258
+ issueStatus: opts?.status ?? 'backlog',
259
+ issuePriority: opts?.priority ?? 'medium',
260
+ issueLabels: opts?.labels,
261
+ issueAssignee: opts?.assignee,
262
+ parentIssueId: opts?.parentId,
263
+ },
264
+ });
265
+ ctx.applyOp(op);
266
+
267
+ // Add acceptance criteria if provided
268
+ if (opts?.criteria) {
269
+ for (let i = 0; i < opts.criteria.length; i++) {
270
+ await addCriterion(
271
+ ctx,
272
+ id,
273
+ opts.criteria[i].description,
274
+ opts.criteria[i].command,
275
+ );
276
+ }
277
+ }
278
+
279
+ return op;
280
+ }
281
+
282
+ /**
283
+ * Update an issue's metadata.
284
+ */
285
+ export async function updateIssue(
286
+ ctx: EngineContext,
287
+ id: string,
288
+ updates: {
289
+ title?: string;
290
+ description?: string;
291
+ priority?: 'critical' | 'high' | 'medium' | 'low';
292
+ labels?: string[];
293
+ assignee?: string;
294
+ status?: 'backlog' | 'queue' | 'in_progress' | 'paused' | 'closed';
295
+ },
296
+ ): Promise<VcsOp> {
297
+ const op = await createVcsOp('vcs:issueUpdate', {
298
+ agentId: ctx.agentId,
299
+ previousHash: ctx.getLastOp()?.hash,
300
+ vcs: {
301
+ issueId: id,
302
+ issueTitle: updates.title,
303
+ issueDescription: updates.description,
304
+ issueStatus: updates.status,
305
+ issuePriority: updates.priority,
306
+ issueLabels: updates.labels,
307
+ issueAssignee: updates.assignee,
308
+ },
309
+ });
310
+ ctx.applyOp(op);
311
+ return op;
312
+ }
313
+
314
+ /**
315
+ * Start working on an issue: sets in_progress, auto-assigns, creates branch.
316
+ * Returns the issueStart op. The caller (engine) is responsible for
317
+ * actually creating the branch and switching to it.
318
+ */
319
+ export async function startIssue(
320
+ ctx: EngineContext,
321
+ id: string,
322
+ branchName: string,
323
+ ): Promise<VcsOp> {
324
+ const eid = issueEntityId(id);
325
+ const status = getIssueFact(ctx, eid, 'status');
326
+ if (status === 'closed') {
327
+ throw new Error(`Cannot start closed issue ${id}. Reopen it first.`);
328
+ }
329
+ if (status === 'in_progress') {
330
+ throw new Error(`Issue ${id} is already in progress.`);
331
+ }
332
+ // Allow start from backlog, queue, or paused
333
+
334
+ const op = await createVcsOp('vcs:issueStart', {
335
+ agentId: ctx.agentId,
336
+ previousHash: ctx.getLastOp()?.hash,
337
+ vcs: {
338
+ issueId: id,
339
+ issueAssignee: ctx.agentId,
340
+ branchName,
341
+ },
342
+ });
343
+ ctx.applyOp(op);
344
+ return op;
345
+ }
346
+
347
+ /**
348
+ * Pause an in-progress issue.
349
+ */
350
+ export async function pauseIssue(
351
+ ctx: EngineContext,
352
+ id: string,
353
+ note: string,
354
+ ): Promise<VcsOp> {
355
+ if (!note || !note.trim()) {
356
+ throw new Error(
357
+ `A pause note is required. Explain why the issue is paused and what must happen before resuming.`,
358
+ );
359
+ }
360
+
361
+ const eid = issueEntityId(id);
362
+ const status = getIssueFact(ctx, eid, 'status');
363
+ if (status !== 'in_progress') {
364
+ throw new Error(
365
+ `Cannot pause issue ${id} — status is '${status}', expected 'in_progress'.`,
366
+ );
367
+ }
368
+
369
+ const op = await createVcsOp('vcs:issuePause', {
370
+ agentId: ctx.agentId,
371
+ previousHash: ctx.getLastOp()?.hash,
372
+ vcs: { issueId: id, pauseNote: note.trim() },
373
+ });
374
+ ctx.applyOp(op);
375
+ return op;
376
+ }
377
+
378
+ /**
379
+ * Resume a paused issue.
380
+ */
381
+ export async function resumeIssue(
382
+ ctx: EngineContext,
383
+ id: string,
384
+ ): Promise<VcsOp> {
385
+ const eid = issueEntityId(id);
386
+ const status = getIssueFact(ctx, eid, 'status');
387
+ if (status !== 'paused') {
388
+ throw new Error(
389
+ `Cannot resume issue ${id} — status is '${status}', expected 'paused'.`,
390
+ );
391
+ }
392
+
393
+ const op = await createVcsOp('vcs:issueResume', {
394
+ agentId: ctx.agentId,
395
+ previousHash: ctx.getLastOp()?.hash,
396
+ vcs: { issueId: id },
397
+ });
398
+ ctx.applyOp(op);
399
+ return op;
400
+ }
401
+
402
+ /**
403
+ * Close an issue. Requires all criteria to have passed and confirm=true.
404
+ */
405
+ export async function closeIssue(
406
+ ctx: EngineContext,
407
+ id: string,
408
+ opts?: { confirm?: boolean },
409
+ ): Promise<{ op?: VcsOp; criteriaResults: CriterionResult[] }> {
410
+ const eid = issueEntityId(id);
411
+ const status = getIssueFact(ctx, eid, 'status');
412
+ if (status === 'closed') {
413
+ throw new Error(`Issue ${id} is already closed.`);
414
+ }
415
+
416
+ // Check criteria status in the store
417
+ const criteria = getCriteriaForIssue(ctx, id);
418
+ const results: CriterionResult[] = criteria.map((c) => ({
419
+ id: c.id,
420
+ description: c.description,
421
+ command: c.command,
422
+ status: (c.status as 'passed' | 'failed') ?? ('pending' as any),
423
+ }));
424
+
425
+ const allPassed =
426
+ results.length === 0 || results.every((r) => r.status === 'passed');
427
+
428
+ if (!allPassed) {
429
+ const failing = results.filter((r) => r.status !== 'passed');
430
+ throw new Error(
431
+ `Cannot close issue ${id}: ${failing.length} criteria not passing:\n` +
432
+ failing
433
+ .map((f) => ` - ${f.description ?? f.id} (${f.status})`)
434
+ .join('\n'),
435
+ );
436
+ }
437
+
438
+ if (!opts?.confirm) {
439
+ return { criteriaResults: results };
440
+ }
441
+
442
+ // Compute duration from startedAt
443
+ const startedAt = getIssueFact(ctx, eid, 'startedAt');
444
+
445
+ const op = await createVcsOp('vcs:issueClose', {
446
+ agentId: ctx.agentId,
447
+ previousHash: ctx.getLastOp()?.hash,
448
+ vcs: { issueId: id },
449
+ });
450
+ ctx.applyOp(op);
451
+
452
+ // Store duration as a fact if we have startedAt
453
+ if (startedAt) {
454
+ const durationMs = Date.now() - new Date(startedAt).getTime();
455
+ ctx.store.addFacts([{ e: eid, a: 'durationMs', v: durationMs }]);
456
+ }
457
+
458
+ return { op, criteriaResults: results };
459
+ }
460
+
461
+ /**
462
+ * Triage a backlog issue to queue (ready to start).
463
+ */
464
+ export async function triageIssue(
465
+ ctx: EngineContext,
466
+ id: string,
467
+ ): Promise<VcsOp> {
468
+ const eid = issueEntityId(id);
469
+ const status = getIssueFact(ctx, eid, 'status');
470
+ if (status !== 'backlog') {
471
+ throw new Error(
472
+ `Cannot triage issue ${id} — status is '${status}', expected 'backlog'.`,
473
+ );
474
+ }
475
+
476
+ const op = await createVcsOp('vcs:issueUpdate', {
477
+ agentId: ctx.agentId,
478
+ previousHash: ctx.getLastOp()?.hash,
479
+ vcs: {
480
+ issueId: id,
481
+ issueStatus: 'queue',
482
+ },
483
+ });
484
+ ctx.applyOp(op);
485
+ return op;
486
+ }
487
+
488
+ /**
489
+ * Reopen a closed issue.
490
+ */
491
+ export async function reopenIssue(
492
+ ctx: EngineContext,
493
+ id: string,
494
+ ): Promise<VcsOp> {
495
+ const eid = issueEntityId(id);
496
+ const status = getIssueFact(ctx, eid, 'status');
497
+ if (status !== 'closed') {
498
+ throw new Error(
499
+ `Cannot reopen issue ${id} — status is '${status}', expected 'closed'.`,
500
+ );
501
+ }
502
+
503
+ const op = await createVcsOp('vcs:issueReopen', {
504
+ agentId: ctx.agentId,
505
+ previousHash: ctx.getLastOp()?.hash,
506
+ vcs: { issueId: id },
507
+ });
508
+ ctx.applyOp(op);
509
+ return op;
510
+ }
511
+
512
+ /**
513
+ * Assign an issue to an agent.
514
+ */
515
+ export async function assignIssue(
516
+ ctx: EngineContext,
517
+ id: string,
518
+ agentId: string,
519
+ ): Promise<VcsOp> {
520
+ return updateIssue(ctx, id, { assignee: agentId });
521
+ }
522
+
523
+ /**
524
+ * Block an issue by another issue.
525
+ */
526
+ export async function blockIssue(
527
+ ctx: EngineContext,
528
+ id: string,
529
+ blockedById: string,
530
+ ): Promise<VcsOp> {
531
+ const eid = issueEntityId(id);
532
+ const blockerEid = issueEntityId(blockedById);
533
+
534
+ // Validate both issues exist
535
+ if (!getIssueFact(ctx, eid, 'type')) {
536
+ throw new Error(`Issue ${id} not found.`);
537
+ }
538
+ if (!getIssueFact(ctx, blockerEid, 'type')) {
539
+ throw new Error(`Blocking issue ${blockedById} not found.`);
540
+ }
541
+ if (id === blockedById) {
542
+ throw new Error(`Issue cannot block itself.`);
543
+ }
544
+
545
+ const op = await createVcsOp('vcs:issueBlock', {
546
+ agentId: ctx.agentId,
547
+ previousHash: ctx.getLastOp()?.hash,
548
+ vcs: { issueId: id, blockedByIssueId: blockedById },
549
+ });
550
+ ctx.applyOp(op);
551
+ return op;
552
+ }
553
+
554
+ /**
555
+ * Remove a blocking relationship.
556
+ */
557
+ export async function unblockIssue(
558
+ ctx: EngineContext,
559
+ id: string,
560
+ blockedById: string,
561
+ ): Promise<VcsOp> {
562
+ const op = await createVcsOp('vcs:issueUnblock', {
563
+ agentId: ctx.agentId,
564
+ previousHash: ctx.getLastOp()?.hash,
565
+ vcs: { issueId: id, blockedByIssueId: blockedById },
566
+ });
567
+ ctx.applyOp(op);
568
+ return op;
569
+ }
570
+
571
+ /**
572
+ * Add an acceptance criterion to an issue.
573
+ */
574
+ export async function addCriterion(
575
+ ctx: EngineContext,
576
+ issueId: string,
577
+ description: string,
578
+ command?: string,
579
+ ): Promise<VcsOp> {
580
+ // Count existing criteria to determine index
581
+ const existing = getCriteriaForIssue(ctx, issueId);
582
+ const index = existing.length + 1;
583
+ const cid = criterionEntityId(issueId, index);
584
+
585
+ const op = await createVcsOp('vcs:criterionAdd', {
586
+ agentId: ctx.agentId,
587
+ previousHash: ctx.getLastOp()?.hash,
588
+ vcs: {
589
+ issueId,
590
+ criterionId: cid,
591
+ criterionDescription: description,
592
+ criterionCommand: command,
593
+ },
594
+ });
595
+ ctx.applyOp(op);
596
+ return op;
597
+ }
598
+
599
+ /**
600
+ * Manually set a criterion's status (for non-command criteria).
601
+ */
602
+ export async function setCriterionStatus(
603
+ ctx: EngineContext,
604
+ issueId: string,
605
+ criterionIndex: number,
606
+ status: 'passed' | 'failed' | 'pending',
607
+ ): Promise<VcsOp> {
608
+ const criteria = getCriteriaForIssue(ctx, issueId);
609
+ if (criterionIndex < 1 || criterionIndex > criteria.length) {
610
+ throw new Error(
611
+ `Criterion index ${criterionIndex} out of range (1–${criteria.length})`,
612
+ );
613
+ }
614
+ const c = criteria[criterionIndex - 1];
615
+
616
+ const op = await createVcsOp('vcs:criterionUpdate', {
617
+ agentId: ctx.agentId,
618
+ previousHash: ctx.getLastOp()?.hash,
619
+ vcs: {
620
+ issueId,
621
+ criterionId: c.id,
622
+ criterionStatus: status,
623
+ },
624
+ });
625
+ ctx.applyOp(op);
626
+ return op;
627
+ }
628
+
629
+ /**
630
+ * Run all acceptance criteria for an issue. Executes test commands
631
+ * and emits criterionUpdate ops with results.
632
+ */
633
+ export async function runCriteria(
634
+ ctx: EngineContext,
635
+ issueId: string,
636
+ rootPath: string,
637
+ ): Promise<CriterionResult[]> {
638
+ const criteria = getCriteriaForIssue(ctx, issueId);
639
+ const results: CriterionResult[] = [];
640
+
641
+ for (const c of criteria) {
642
+ if (!c.command) {
643
+ // No command — check-only criterion, skip automated run
644
+ results.push({
645
+ id: c.id,
646
+ description: c.description,
647
+ status: (c.status as 'passed' | 'failed') ?? 'skipped',
648
+ });
649
+ continue;
650
+ }
651
+
652
+ let status: 'passed' | 'failed' = 'failed';
653
+ let output = '';
654
+ let exitCode = 1;
655
+
656
+ try {
657
+ const result = await execAsync(c.command, {
658
+ cwd: rootPath,
659
+ timeout: 120_000,
660
+ });
661
+ output = (result.stdout + '\n' + result.stderr).trim();
662
+ exitCode = 0;
663
+ status = 'passed';
664
+ } catch (err: any) {
665
+ output = (err.stdout ?? '') + '\n' + (err.stderr ?? err.message ?? '');
666
+ output = output.trim();
667
+ exitCode = err.code ?? 1;
668
+ status = 'failed';
669
+ }
670
+
671
+ // Emit criterionUpdate op
672
+ const updateOp = await createVcsOp('vcs:criterionUpdate', {
673
+ agentId: ctx.agentId,
674
+ previousHash: ctx.getLastOp()?.hash,
675
+ vcs: {
676
+ criterionId: c.id,
677
+ criterionStatus: status,
678
+ criterionOutput: output.slice(0, 4096),
679
+ },
680
+ });
681
+ ctx.applyOp(updateOp);
682
+
683
+ results.push({
684
+ id: c.id,
685
+ description: c.description,
686
+ command: c.command,
687
+ status,
688
+ output,
689
+ exitCode,
690
+ });
691
+ }
692
+
693
+ return results;
694
+ }
695
+
696
+ // ---------------------------------------------------------------------------
697
+ // Queries
698
+ // ---------------------------------------------------------------------------
699
+
700
+ /**
701
+ * List all issues, optionally filtered.
702
+ */
703
+ export function listIssues(
704
+ ctx: EngineContext,
705
+ filters?: IssueFilters,
706
+ ): IssueInfo[] {
707
+ const issueFacts = ctx.store
708
+ .getFactsByAttribute('type')
709
+ .filter((f) => f.v === 'Issue');
710
+
711
+ let issues = issueFacts.map((f) => buildIssueInfo(ctx, f.e));
712
+
713
+ if (filters?.status) {
714
+ issues = issues.filter((i) => i.status === filters.status);
715
+ }
716
+ if (filters?.assignee) {
717
+ issues = issues.filter((i) => i.assignee === filters.assignee);
718
+ }
719
+ if (filters?.label) {
720
+ issues = issues.filter((i) => i.labels.includes(filters.label!));
721
+ }
722
+ if (filters?.parentId) {
723
+ issues = issues.filter((i) => i.parentId === filters.parentId);
724
+ }
725
+ if (filters?.blocked !== undefined) {
726
+ issues = issues.filter((i) => i.isBlocked === filters.blocked);
727
+ }
728
+
729
+ return issues;
730
+ }
731
+
732
+ /**
733
+ * Get a single issue by ID.
734
+ */
735
+ export function getIssue(ctx: EngineContext, id: string): IssueInfo | null {
736
+ const eid = issueEntityId(id);
737
+ const typeFact = ctx.store
738
+ .getFactsByEntity(eid)
739
+ .find((f) => f.a === 'type' && f.v === 'Issue');
740
+ if (!typeFact) return null;
741
+ return buildIssueInfo(ctx, eid);
742
+ }
743
+
744
+ /**
745
+ * Get all active (in_progress) issues.
746
+ */
747
+ export function getActiveIssues(ctx: EngineContext): IssueInfo[] {
748
+ return listIssues(ctx, { status: 'in_progress' });
749
+ }
750
+
751
+ // ---------------------------------------------------------------------------
752
+ // Completion Readiness
753
+ // ---------------------------------------------------------------------------
754
+
755
+ export interface CompletionReadiness {
756
+ ready: boolean;
757
+ queue: IssueInfo[];
758
+ paused: IssueInfo[];
759
+ inProgress: IssueInfo[];
760
+ summary: string;
761
+ }
762
+
763
+ /**
764
+ * Check whether all work is complete: no issues in queue, paused, or in_progress.
765
+ */
766
+ export function checkCompletionReadiness(
767
+ ctx: EngineContext,
768
+ ): CompletionReadiness {
769
+ const all = listIssues(ctx);
770
+ const queue = all.filter((i) => i.status === 'queue');
771
+ const paused = all.filter((i) => i.status === 'paused');
772
+ const inProgress = all.filter((i) => i.status === 'in_progress');
773
+
774
+ const ready =
775
+ queue.length === 0 && paused.length === 0 && inProgress.length === 0;
776
+
777
+ const parts: string[] = [];
778
+ if (ready) {
779
+ parts.push('✓ All clear — no queue, paused, or in-progress issues.');
780
+ } else {
781
+ parts.push('✗ Not ready for completion:');
782
+ if (queue.length > 0) {
783
+ parts.push(
784
+ ` Queue (${queue.length}): ${queue.map((i) => i.id).join(', ')}`,
785
+ );
786
+ }
787
+ if (inProgress.length > 0) {
788
+ parts.push(
789
+ ` In progress (${inProgress.length}): ${inProgress.map((i) => i.id).join(', ')}`,
790
+ );
791
+ }
792
+ if (paused.length > 0) {
793
+ parts.push(
794
+ ` Paused (${paused.length}): ${paused.map((i) => `${i.id}${i.pauseNote ? ` — ${i.pauseNote}` : ''}`).join(', ')}`,
795
+ );
796
+ }
797
+ }
798
+
799
+ return { ready, queue, paused, inProgress, summary: parts.join('\n') };
800
+ }