@cleocode/core 2026.4.11 → 2026.4.12

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 (169) hide show
  1. package/dist/codebase-map/analyzers/architecture.d.ts.map +1 -1
  2. package/dist/codebase-map/analyzers/architecture.js +0 -1
  3. package/dist/codebase-map/analyzers/architecture.js.map +1 -1
  4. package/dist/conduit/local-transport.d.ts +18 -8
  5. package/dist/conduit/local-transport.d.ts.map +1 -1
  6. package/dist/conduit/local-transport.js +23 -13
  7. package/dist/conduit/local-transport.js.map +1 -1
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +0 -1
  10. package/dist/config.js.map +1 -1
  11. package/dist/errors.d.ts +19 -0
  12. package/dist/errors.d.ts.map +1 -1
  13. package/dist/errors.js +6 -0
  14. package/dist/errors.js.map +1 -1
  15. package/dist/index.js +175 -68950
  16. package/dist/index.js.map +1 -7
  17. package/dist/init.d.ts +1 -2
  18. package/dist/init.d.ts.map +1 -1
  19. package/dist/init.js +1 -2
  20. package/dist/init.js.map +1 -1
  21. package/dist/internal.d.ts +8 -3
  22. package/dist/internal.d.ts.map +1 -1
  23. package/dist/internal.js +13 -6
  24. package/dist/internal.js.map +1 -1
  25. package/dist/memory/learnings.d.ts +2 -2
  26. package/dist/memory/patterns.d.ts +6 -6
  27. package/dist/output.d.ts +32 -11
  28. package/dist/output.d.ts.map +1 -1
  29. package/dist/output.js +67 -67
  30. package/dist/output.js.map +1 -1
  31. package/dist/paths.js +80 -14
  32. package/dist/paths.js.map +1 -1
  33. package/dist/skills/dynamic-skill-generator.d.ts +0 -2
  34. package/dist/skills/dynamic-skill-generator.d.ts.map +1 -1
  35. package/dist/skills/dynamic-skill-generator.js.map +1 -1
  36. package/dist/store/agent-registry-accessor.d.ts +203 -12
  37. package/dist/store/agent-registry-accessor.d.ts.map +1 -1
  38. package/dist/store/agent-registry-accessor.js +618 -100
  39. package/dist/store/agent-registry-accessor.js.map +1 -1
  40. package/dist/store/api-key-kdf.d.ts +73 -0
  41. package/dist/store/api-key-kdf.d.ts.map +1 -0
  42. package/dist/store/api-key-kdf.js +84 -0
  43. package/dist/store/api-key-kdf.js.map +1 -0
  44. package/dist/store/cleanup-legacy.js +171 -0
  45. package/dist/store/cleanup-legacy.js.map +1 -0
  46. package/dist/store/conduit-sqlite.d.ts +184 -0
  47. package/dist/store/conduit-sqlite.d.ts.map +1 -0
  48. package/dist/store/conduit-sqlite.js +570 -0
  49. package/dist/store/conduit-sqlite.js.map +1 -0
  50. package/dist/store/global-salt.d.ts +78 -0
  51. package/dist/store/global-salt.d.ts.map +1 -0
  52. package/dist/store/global-salt.js +147 -0
  53. package/dist/store/global-salt.js.map +1 -0
  54. package/dist/store/migrate-signaldock-to-conduit.d.ts +81 -0
  55. package/dist/store/migrate-signaldock-to-conduit.d.ts.map +1 -0
  56. package/dist/store/migrate-signaldock-to-conduit.js +555 -0
  57. package/dist/store/migrate-signaldock-to-conduit.js.map +1 -0
  58. package/dist/store/nexus-sqlite.js +28 -3
  59. package/dist/store/nexus-sqlite.js.map +1 -1
  60. package/dist/store/signaldock-sqlite.d.ts +122 -19
  61. package/dist/store/signaldock-sqlite.d.ts.map +1 -1
  62. package/dist/store/signaldock-sqlite.js +401 -251
  63. package/dist/store/signaldock-sqlite.js.map +1 -1
  64. package/dist/store/sqlite-backup.js +122 -4
  65. package/dist/store/sqlite-backup.js.map +1 -1
  66. package/dist/system/backup.d.ts +0 -26
  67. package/dist/system/backup.d.ts.map +1 -1
  68. package/dist/system/runtime.d.ts +0 -2
  69. package/dist/system/runtime.d.ts.map +1 -1
  70. package/dist/system/runtime.js +3 -3
  71. package/dist/system/runtime.js.map +1 -1
  72. package/dist/tasks/add.d.ts +1 -1
  73. package/dist/tasks/add.d.ts.map +1 -1
  74. package/dist/tasks/add.js +98 -23
  75. package/dist/tasks/add.js.map +1 -1
  76. package/dist/tasks/complete.d.ts.map +1 -1
  77. package/dist/tasks/complete.js +4 -1
  78. package/dist/tasks/complete.js.map +1 -1
  79. package/dist/tasks/find.d.ts.map +1 -1
  80. package/dist/tasks/find.js +4 -1
  81. package/dist/tasks/find.js.map +1 -1
  82. package/dist/tasks/labels.d.ts.map +1 -1
  83. package/dist/tasks/labels.js +4 -1
  84. package/dist/tasks/labels.js.map +1 -1
  85. package/dist/tasks/relates.d.ts.map +1 -1
  86. package/dist/tasks/relates.js +16 -4
  87. package/dist/tasks/relates.js.map +1 -1
  88. package/dist/tasks/show.d.ts.map +1 -1
  89. package/dist/tasks/show.js +4 -1
  90. package/dist/tasks/show.js.map +1 -1
  91. package/dist/tasks/update.d.ts.map +1 -1
  92. package/dist/tasks/update.js +32 -6
  93. package/dist/tasks/update.js.map +1 -1
  94. package/dist/validation/engine.d.ts.map +1 -1
  95. package/dist/validation/engine.js +16 -4
  96. package/dist/validation/engine.js.map +1 -1
  97. package/dist/validation/param-utils.d.ts +5 -3
  98. package/dist/validation/param-utils.d.ts.map +1 -1
  99. package/dist/validation/param-utils.js +8 -6
  100. package/dist/validation/param-utils.js.map +1 -1
  101. package/dist/validation/protocols/_shared.d.ts.map +1 -1
  102. package/dist/validation/protocols/_shared.js +13 -6
  103. package/dist/validation/protocols/_shared.js.map +1 -1
  104. package/package.json +7 -7
  105. package/src/adapters/__tests__/manager.test.ts +0 -1
  106. package/src/codebase-map/analyzers/architecture.ts +0 -1
  107. package/src/conduit/__tests__/local-credential-flow.test.ts +20 -18
  108. package/src/conduit/__tests__/local-transport.test.ts +14 -12
  109. package/src/conduit/local-transport.ts +23 -13
  110. package/src/config.ts +0 -1
  111. package/src/errors.ts +24 -0
  112. package/src/hooks/handlers/__tests__/hook-automation-e2e.test.ts +2 -5
  113. package/src/init.ts +1 -2
  114. package/src/internal.ts +49 -2
  115. package/src/lifecycle/cant/lifecycle-rcasd.cant +133 -0
  116. package/src/memory/__tests__/engine-compat.test.ts +2 -2
  117. package/src/memory/__tests__/pipeline-manifest-sqlite.test.ts +4 -4
  118. package/src/observability/__tests__/index.test.ts +4 -4
  119. package/src/observability/__tests__/log-filter.test.ts +4 -4
  120. package/src/output.ts +73 -75
  121. package/src/sessions/__tests__/session-grade.integration.test.ts +1 -1
  122. package/src/sessions/__tests__/session-grade.test.ts +2 -2
  123. package/src/skills/__tests__/dynamic-skill-generator.test.ts +0 -2
  124. package/src/skills/dynamic-skill-generator.ts +0 -2
  125. package/src/store/__tests__/agent-registry-accessor.test.ts +807 -0
  126. package/src/store/__tests__/api-key-kdf.test.ts +113 -0
  127. package/src/store/__tests__/conduit-sqlite.test.ts +413 -0
  128. package/src/store/__tests__/global-salt.test.ts +195 -0
  129. package/src/store/__tests__/migrate-signaldock-to-conduit.test.ts +715 -0
  130. package/src/store/__tests__/signaldock-sqlite.test.ts +652 -0
  131. package/src/store/__tests__/sqlite-backup-global.test.ts +307 -3
  132. package/src/store/__tests__/sqlite-backup.test.ts +5 -1
  133. package/src/store/__tests__/t310-integration.test.ts +1150 -0
  134. package/src/store/agent-registry-accessor.ts +847 -140
  135. package/src/store/api-key-kdf.ts +104 -0
  136. package/src/store/conduit-sqlite.ts +655 -0
  137. package/src/store/global-salt.ts +175 -0
  138. package/src/store/migrate-signaldock-to-conduit.ts +669 -0
  139. package/src/store/signaldock-sqlite.ts +431 -254
  140. package/src/store/sqlite-backup.ts +185 -10
  141. package/src/system/backup.ts +2 -62
  142. package/src/system/runtime.ts +4 -6
  143. package/src/tasks/__tests__/error-hints.test.ts +256 -0
  144. package/src/tasks/add.ts +99 -9
  145. package/src/tasks/complete.ts +4 -1
  146. package/src/tasks/find.ts +4 -1
  147. package/src/tasks/labels.ts +4 -1
  148. package/src/tasks/relates.ts +16 -4
  149. package/src/tasks/show.ts +4 -1
  150. package/src/tasks/update.ts +32 -3
  151. package/src/validation/__tests__/error-hints.test.ts +97 -0
  152. package/src/validation/engine.ts +16 -1
  153. package/src/validation/param-utils.ts +10 -7
  154. package/src/validation/protocols/_shared.ts +14 -6
  155. package/src/validation/protocols/cant/architecture-decision.cant +80 -0
  156. package/src/validation/protocols/cant/artifact-publish.cant +95 -0
  157. package/src/validation/protocols/cant/consensus.cant +74 -0
  158. package/src/validation/protocols/cant/contribution.cant +82 -0
  159. package/src/validation/protocols/cant/decomposition.cant +92 -0
  160. package/src/validation/protocols/cant/implementation.cant +67 -0
  161. package/src/validation/protocols/cant/provenance.cant +88 -0
  162. package/src/validation/protocols/cant/release.cant +96 -0
  163. package/src/validation/protocols/cant/research.cant +66 -0
  164. package/src/validation/protocols/cant/specification.cant +67 -0
  165. package/src/validation/protocols/cant/testing.cant +88 -0
  166. package/src/validation/protocols/cant/validation.cant +65 -0
  167. package/src/validation/protocols/protocols-markdown/decomposition.md +0 -4
  168. package/templates/config.template.json +0 -1
  169. package/templates/global-config.template.json +0 -1
package/src/tasks/add.ts CHANGED
@@ -39,7 +39,7 @@ import { resolveDefaultPipelineStage, validatePipelineStage } from './pipeline-s
39
39
  */
40
40
  export interface AddTaskOptions {
41
41
  title: string;
42
- description: string;
42
+ description?: string;
43
43
  status?: TaskStatus;
44
44
  priority?: TaskPriority;
45
45
  type?: TaskType;
@@ -93,10 +93,16 @@ export function buildDefaultVerification(initializedAt: string): TaskVerificatio
93
93
  */
94
94
  export function validateTitle(title: string): void {
95
95
  if (!title || title.trim().length === 0) {
96
- throw new CleoError(ExitCode.INVALID_INPUT, 'Task title is required');
96
+ throw new CleoError(ExitCode.INVALID_INPUT, 'Task title is required', {
97
+ fix: 'Provide a title: cleo add "<title>"',
98
+ details: { field: 'title' },
99
+ });
97
100
  }
98
101
  if (title.length > 200) {
99
- throw new CleoError(ExitCode.VALIDATION_ERROR, 'Task title must be 200 characters or less');
102
+ throw new CleoError(ExitCode.VALIDATION_ERROR, 'Task title must be 200 characters or less', {
103
+ fix: 'Shorten title to 200 characters or fewer',
104
+ details: { field: 'title', expected: 200, actual: title.length },
105
+ });
100
106
  }
101
107
  }
102
108
 
@@ -109,6 +115,10 @@ export function validateStatus(status: string): asserts status is TaskStatus {
109
115
  throw new CleoError(
110
116
  ExitCode.VALIDATION_ERROR,
111
117
  `Invalid status: ${status} (must be ${TASK_STATUSES.join('|')})`,
118
+ {
119
+ fix: `cleo add ... --status <${TASK_STATUSES.join('|')}>`,
120
+ details: { field: 'status', expected: TASK_STATUSES, actual: status },
121
+ },
112
122
  );
113
123
  }
114
124
  }
@@ -152,6 +162,10 @@ export function normalizePriority(priority: string | number): TaskPriority {
152
162
  throw new CleoError(
153
163
  ExitCode.VALIDATION_ERROR,
154
164
  `Invalid numeric priority: ${priority} (must be 1-9)`,
165
+ {
166
+ fix: `Use a numeric priority 1-9 or one of: ${VALID_PRIORITIES.join('|')}`,
167
+ details: { field: 'priority', expected: '1-9', actual: priority },
168
+ },
155
169
  );
156
170
  }
157
171
  return mapped;
@@ -172,6 +186,10 @@ export function normalizePriority(priority: string | number): TaskPriority {
172
186
  throw new CleoError(
173
187
  ExitCode.VALIDATION_ERROR,
174
188
  `Invalid priority: ${priority} (must be ${VALID_PRIORITIES.join('|')} or numeric 1-9)`,
189
+ {
190
+ fix: `cleo add ... --priority <${VALID_PRIORITIES.join('|')}>`,
191
+ details: { field: 'priority', expected: VALID_PRIORITIES, actual: priority },
192
+ },
175
193
  );
176
194
  }
177
195
 
@@ -194,6 +212,10 @@ export function validateTaskType(type: string): asserts type is TaskType {
194
212
  throw new CleoError(
195
213
  ExitCode.VALIDATION_ERROR,
196
214
  `Invalid task type: ${type} (must be ${valid.join('|')})`,
215
+ {
216
+ fix: `cleo add ... --type <${valid.join('|')}>`,
217
+ details: { field: 'type', expected: valid, actual: type },
218
+ },
197
219
  );
198
220
  }
199
221
  }
@@ -208,6 +230,10 @@ export function validateSize(size: string): asserts size is TaskSize {
208
230
  throw new CleoError(
209
231
  ExitCode.VALIDATION_ERROR,
210
232
  `Invalid size: ${size} (must be ${valid.join('|')})`,
233
+ {
234
+ fix: `cleo add ... --size <${valid.join('|')}>`,
235
+ details: { field: 'size', expected: valid, actual: size },
236
+ },
211
237
  );
212
238
  }
213
239
  }
@@ -223,6 +249,10 @@ export function validateLabels(labels: string[]): void {
223
249
  throw new CleoError(
224
250
  ExitCode.VALIDATION_ERROR,
225
251
  `Invalid label format: '${trimmed}' (must be lowercase alphanumeric with hyphens/periods)`,
252
+ {
253
+ fix: `Labels must match pattern ^[a-z][a-z0-9.-]*$ (e.g. my-label, v1.0)`,
254
+ details: { field: 'labels', expected: '^[a-z][a-z0-9.-]*$', actual: trimmed },
255
+ },
226
256
  );
227
257
  }
228
258
  }
@@ -237,6 +267,10 @@ export function validatePhaseFormat(phase: string): void {
237
267
  throw new CleoError(
238
268
  ExitCode.VALIDATION_ERROR,
239
269
  `Invalid phase format: ${phase} (must be lowercase alphanumeric with hyphens)`,
270
+ {
271
+ fix: `Phase slugs must match pattern ^[a-z][a-z0-9-]*$ (e.g. dev-phase-1)`,
272
+ details: { field: 'phase', expected: '^[a-z][a-z0-9-]*$', actual: phase },
273
+ },
240
274
  );
241
275
  }
242
276
  }
@@ -253,10 +287,17 @@ export function validateDepends(depends: string[], tasks: Task[]): void {
253
287
  throw new CleoError(
254
288
  ExitCode.VALIDATION_ERROR,
255
289
  `Invalid dependency ID format: '${trimmed}' (must be T### format)`,
290
+ {
291
+ fix: 'Dependency IDs must match T### format (e.g. T123, T4567)',
292
+ details: { field: 'depends', expected: 'T###', actual: trimmed },
293
+ },
256
294
  );
257
295
  }
258
296
  if (!existingIds.has(trimmed)) {
259
- throw new CleoError(ExitCode.NOT_FOUND, `Dependency task not found: ${trimmed}`);
297
+ throw new CleoError(ExitCode.NOT_FOUND, `Dependency task not found: ${trimmed}`, {
298
+ fix: `cleo find "${trimmed}"`,
299
+ details: { field: 'depends', actual: trimmed },
300
+ });
260
301
  }
261
302
  }
262
303
  }
@@ -294,6 +335,10 @@ export function validateParent(
294
335
  throw new CleoError(
295
336
  ExitCode.DEPTH_EXCEEDED,
296
337
  `Cannot add child to ${parentId}: max hierarchy depth (${maxDepth}) would be exceeded`,
338
+ {
339
+ fix: 'Reparent this task under a higher-level epic',
340
+ details: { field: 'parentId', expected: `depth < ${maxDepth}`, actual: depth },
341
+ },
297
342
  );
298
343
  }
299
344
 
@@ -436,6 +481,10 @@ export async function addTask(
436
481
  throw new CleoError(
437
482
  ExitCode.VALIDATION_ERROR,
438
483
  'Title and description must be different (anti-hallucination rule)',
484
+ {
485
+ fix: 'Provide --desc with a description different from the title',
486
+ details: { field: 'description' },
487
+ },
439
488
  );
440
489
  }
441
490
 
@@ -508,11 +557,18 @@ export async function addTask(
508
557
  throw new CleoError(
509
558
  ExitCode.VALIDATION_ERROR,
510
559
  `Invalid dependency ID format: '${trimmed}' (must be T### format)`,
560
+ {
561
+ fix: 'Dependency IDs must match T### format (e.g. T123, T4567)',
562
+ details: { field: 'depends', expected: 'T###', actual: trimmed },
563
+ },
511
564
  );
512
565
  }
513
566
  const exists = await dataAccessor.taskExists(trimmed);
514
567
  if (!exists) {
515
- throw new CleoError(ExitCode.NOT_FOUND, `Dependency task not found: ${trimmed}`);
568
+ throw new CleoError(ExitCode.NOT_FOUND, `Dependency task not found: ${trimmed}`, {
569
+ fix: `cleo find "${trimmed}"`,
570
+ details: { field: 'depends', actual: trimmed },
571
+ });
516
572
  }
517
573
  }
518
574
  }
@@ -532,6 +588,10 @@ export async function addTask(
532
588
  throw new CleoError(
533
589
  ExitCode.NOT_FOUND,
534
590
  `Phase '${phase}' not found. Valid phases: ${validPhases || 'none'}. Use --add-phase to create new.`,
591
+ {
592
+ fix: `cleo add ... --add-phase to create '${phase}', or use one of: ${validPhases || 'none'}`,
593
+ details: { field: 'phase', expected: Object.keys(phases), actual: phase },
594
+ },
535
595
  );
536
596
  }
537
597
  // Create phase
@@ -554,12 +614,18 @@ export async function addTask(
554
614
  // Parent hierarchy validation using targeted queries
555
615
  if (parentId) {
556
616
  if (!/^T\d{3,}$/.test(parentId)) {
557
- throw new CleoError(ExitCode.INVALID_INPUT, `Invalid parent ID format: ${parentId}`);
617
+ throw new CleoError(ExitCode.INVALID_INPUT, `Invalid parent ID format: ${parentId}`, {
618
+ fix: 'Parent IDs must match T### format (e.g. T123)',
619
+ details: { field: 'parentId', expected: 'T###', actual: parentId },
620
+ });
558
621
  }
559
622
  // Validate parent exists
560
623
  const parentTask = await dataAccessor.loadSingleTask(parentId);
561
624
  if (!parentTask) {
562
- throw new CleoError(ExitCode.PARENT_NOT_FOUND, `Parent task ${parentId} not found`);
625
+ throw new CleoError(ExitCode.PARENT_NOT_FOUND, `Parent task ${parentId} not found`, {
626
+ fix: `Use 'cleo find "${parentId}"' to search or create as standalone task`,
627
+ details: { field: 'parentId', actual: parentId },
628
+ });
563
629
  }
564
630
 
565
631
  // Read hierarchy limits from config via policy module
@@ -573,6 +639,14 @@ export async function addTask(
573
639
  throw new CleoError(
574
640
  ExitCode.DEPTH_EXCEEDED,
575
641
  `Maximum nesting depth ${policy.maxDepth} would be exceeded`,
642
+ {
643
+ fix: 'Reparent this task under a higher-level epic',
644
+ details: {
645
+ field: 'parentId',
646
+ expected: `depth < ${policy.maxDepth}`,
647
+ actual: parentDepth + 1,
648
+ },
649
+ },
576
650
  );
577
651
  }
578
652
 
@@ -586,6 +660,14 @@ export async function addTask(
586
660
  throw new CleoError(
587
661
  ExitCode.SIBLING_LIMIT,
588
662
  `Parent ${parentId} already has ${counted} children (limit: ${policy.maxSiblings})`,
663
+ {
664
+ fix: 'Create as standalone task or increase hierarchy.maxSiblings in config',
665
+ details: {
666
+ field: 'parentId',
667
+ expected: `<= ${policy.maxSiblings} siblings`,
668
+ actual: counted,
669
+ },
670
+ },
589
671
  );
590
672
  }
591
673
  }
@@ -596,6 +678,14 @@ export async function addTask(
596
678
  throw new CleoError(
597
679
  ExitCode.SIBLING_LIMIT,
598
680
  `Parent ${parentId} already has ${activeCount} active children (maxActiveSiblings=${policy.maxActiveSiblings})`,
681
+ {
682
+ fix: 'Complete or cancel an active sibling before adding a new task here',
683
+ details: {
684
+ field: 'parentId',
685
+ expected: `<= ${policy.maxActiveSiblings} active siblings`,
686
+ actual: activeCount,
687
+ },
688
+ },
599
689
  );
600
690
  }
601
691
 
@@ -676,7 +766,7 @@ export async function addTask(
676
766
  const previewTask: Task = {
677
767
  id: 'T???',
678
768
  title: options.title,
679
- description: options.description,
769
+ description: options.description ?? '',
680
770
  status,
681
771
  priority,
682
772
  type: taskType,
@@ -767,7 +857,7 @@ export async function addTask(
767
857
  const task: Task = {
768
858
  id: taskId,
769
859
  title: options.title,
770
- description: options.description,
860
+ description: options.description ?? '',
771
861
  status,
772
862
  priority,
773
863
  type: taskType,
@@ -115,7 +115,10 @@ export async function completeTask(
115
115
 
116
116
  // Already done
117
117
  if (task.status === 'done') {
118
- throw new CleoError(ExitCode.TASK_COMPLETED, `Task ${options.taskId} is already completed`);
118
+ throw new CleoError(ExitCode.TASK_COMPLETED, `Task ${options.taskId} is already completed`, {
119
+ fix: `To reopen, run cleo update ${options.taskId} --status active`,
120
+ details: { field: 'status', expected: 'not done', actual: 'done' },
121
+ });
119
122
  }
120
123
 
121
124
  // Check if task has incomplete dependencies
package/src/tasks/find.ts CHANGED
@@ -100,7 +100,10 @@ export async function findTasks(
100
100
  accessor?: DataAccessor,
101
101
  ): Promise<FindTasksResult> {
102
102
  if (!options.query && !options.id) {
103
- throw new CleoError(ExitCode.INVALID_INPUT, 'Search query or --id is required');
103
+ throw new CleoError(ExitCode.INVALID_INPUT, 'Search query or --id is required', {
104
+ fix: 'cleo find "<query>"',
105
+ details: { field: 'query' },
106
+ });
104
107
  }
105
108
 
106
109
  const acc = accessor ?? (await getAccessor(cwd));
@@ -45,7 +45,10 @@ export async function showLabelTasks(
45
45
  const { tasks } = await acc.queryTasks({ label });
46
46
 
47
47
  if (tasks.length === 0) {
48
- throw new CleoError(ExitCode.NOT_FOUND, `No tasks found with label '${label}'`);
48
+ throw new CleoError(ExitCode.NOT_FOUND, `No tasks found with label '${label}'`, {
49
+ fix: 'cleo labels list to see available labels',
50
+ details: { field: 'label', actual: label },
51
+ });
49
52
  }
50
53
 
51
54
  return {
@@ -20,7 +20,10 @@ export async function suggestRelated(
20
20
  const { tasks: allTasks } = await acc.queryTasks({});
21
21
  const task = allTasks.find((t) => t.id === taskId);
22
22
  if (!task) {
23
- throw new CleoError(ExitCode.NOT_FOUND, `Task ${taskId} not found`);
23
+ throw new CleoError(ExitCode.NOT_FOUND, `Task ${taskId} not found`, {
24
+ fix: `cleo find "${taskId}"`,
25
+ details: { field: 'taskId', actual: taskId },
26
+ });
24
27
  }
25
28
 
26
29
  const suggestions: Array<Pick<TaskRef, 'id' | 'title'> & { score: number; reason: string }> = [];
@@ -80,12 +83,18 @@ export async function addRelation(
80
83
 
81
84
  const fromExists = await acc.taskExists(from);
82
85
  if (!fromExists) {
83
- throw new CleoError(ExitCode.NOT_FOUND, `Task ${from} not found`);
86
+ throw new CleoError(ExitCode.NOT_FOUND, `Task ${from} not found`, {
87
+ fix: `cleo find "${from}"`,
88
+ details: { field: 'from', actual: from },
89
+ });
84
90
  }
85
91
 
86
92
  const toExists = await acc.taskExists(to);
87
93
  if (!toExists) {
88
- throw new CleoError(ExitCode.NOT_FOUND, `Task ${to} not found`);
94
+ throw new CleoError(ExitCode.NOT_FOUND, `Task ${to} not found`, {
95
+ fix: `cleo find "${to}"`,
96
+ details: { field: 'to', actual: to },
97
+ });
89
98
  }
90
99
 
91
100
  // Persist to task_relations table via accessor (T5168 fix)
@@ -112,7 +121,10 @@ export async function listRelations(
112
121
  const acc = accessor ?? (await getAccessor(cwd));
113
122
  const task = await acc.loadSingleTask(taskId);
114
123
  if (!task) {
115
- throw new CleoError(ExitCode.NOT_FOUND, `Task ${taskId} not found`);
124
+ throw new CleoError(ExitCode.NOT_FOUND, `Task ${taskId} not found`, {
125
+ fix: `cleo find "${taskId}"`,
126
+ details: { field: 'taskId', actual: taskId },
127
+ });
116
128
  }
117
129
 
118
130
  // task.relates is populated from task_relations table by loadSingleTask
package/src/tasks/show.ts CHANGED
@@ -35,7 +35,10 @@ export async function showTask(
35
35
  accessor?: DataAccessor,
36
36
  ): Promise<TaskDetail> {
37
37
  if (!taskId) {
38
- throw new CleoError(ExitCode.INVALID_INPUT, 'Task ID is required');
38
+ throw new CleoError(ExitCode.INVALID_INPUT, 'Task ID is required', {
39
+ fix: 'cleo show T###',
40
+ details: { field: 'taskId' },
41
+ });
39
42
  }
40
43
 
41
44
  const acc = accessor ?? (await getAccessor(cwd));
@@ -153,7 +153,14 @@ export async function updateTask(
153
153
  const { validateStatusTransition } = await import('../validation/validation-rules.js');
154
154
  const transitionViolations = validateStatusTransition(task.status, options.status);
155
155
  if (transitionViolations.length > 0) {
156
- throw new CleoError(ExitCode.VALIDATION_ERROR, transitionViolations[0].message);
156
+ throw new CleoError(ExitCode.VALIDATION_ERROR, transitionViolations[0].message, {
157
+ fix: `Valid transitions from '${task.status}': see cleo update --help`,
158
+ details: {
159
+ field: transitionViolations[0].field ?? 'status',
160
+ expected: `valid transition from ${task.status}`,
161
+ actual: options.status,
162
+ },
163
+ });
157
164
  }
158
165
 
159
166
  const oldStatus = task.status;
@@ -319,12 +326,19 @@ export async function updateTask(
319
326
  // Validate target parent exists
320
327
  const newParent = await acc.loadSingleTask(newParentId);
321
328
  if (!newParent) {
322
- throw new CleoError(ExitCode.PARENT_NOT_FOUND, `Parent task ${newParentId} not found`);
329
+ throw new CleoError(ExitCode.PARENT_NOT_FOUND, `Parent task ${newParentId} not found`, {
330
+ fix: `Use 'cleo find "${newParentId}"' to search or remove --parent flag`,
331
+ details: { field: 'parentId', actual: newParentId },
332
+ });
323
333
  }
324
334
  if (newParent.type === 'subtask') {
325
335
  throw new CleoError(
326
336
  ExitCode.INVALID_PARENT_TYPE,
327
337
  `Cannot parent under subtask '${newParentId}'`,
338
+ {
339
+ fix: `Choose an epic or task as parent instead of subtask '${newParentId}'`,
340
+ details: { field: 'parentId', expected: 'epic or task', actual: 'subtask' },
341
+ },
328
342
  );
329
343
  }
330
344
 
@@ -334,6 +348,10 @@ export async function updateTask(
334
348
  throw new CleoError(
335
349
  ExitCode.CIRCULAR_REFERENCE,
336
350
  `Moving '${options.taskId}' under '${newParentId}' would create a circular reference`,
351
+ {
352
+ fix: `Choose a parent that is not a descendant of ${options.taskId}`,
353
+ details: { field: 'parentId', actual: newParentId },
354
+ },
337
355
  );
338
356
  }
339
357
 
@@ -346,6 +364,14 @@ export async function updateTask(
346
364
  throw new CleoError(
347
365
  ExitCode.DEPTH_EXCEEDED,
348
366
  `Maximum nesting depth ${policy.maxDepth} would be exceeded`,
367
+ {
368
+ fix: 'Choose a parent at a shallower level in the hierarchy',
369
+ details: {
370
+ field: 'parentId',
371
+ expected: `depth < ${policy.maxDepth}`,
372
+ actual: parentDepth + 1,
373
+ },
374
+ },
349
375
  );
350
376
  }
351
377
 
@@ -362,7 +388,10 @@ export async function updateTask(
362
388
  }
363
389
 
364
390
  if (changes.length === 0) {
365
- throw new CleoError(ExitCode.NO_CHANGE, 'No changes specified');
391
+ throw new CleoError(ExitCode.NO_CHANGE, 'No changes specified', {
392
+ fix: `Provide at least one field to update (e.g. cleo update ${options.taskId} --status active)`,
393
+ details: { field: 'options' },
394
+ });
366
395
  }
367
396
 
368
397
  task.updatedAt = now;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Regression tests: CleoError fix hints on validation-layer throws.
3
+ *
4
+ * Each test invokes a validation function with invalid input and asserts that
5
+ * the thrown CleoError carries a non-empty `fix` string and, where applicable,
6
+ * a `details.field` identifying the failing field.
7
+ *
8
+ * @task T341
9
+ * @epic T335
10
+ */
11
+
12
+ import { describe, expect, it } from 'vitest';
13
+ import { CleoError } from '../../errors.js';
14
+ import { sanitizeFilePath } from '../engine.js';
15
+ import { loadManifestEntryByTaskId, loadManifestEntryFromFile } from '../protocols/_shared.js';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Helper
19
+ // ---------------------------------------------------------------------------
20
+
21
+ function assertErrorHints(
22
+ fn: () => unknown,
23
+ opts: { fixIncludes?: string; detailsField?: string },
24
+ ): void {
25
+ let caught: unknown;
26
+ try {
27
+ fn();
28
+ } catch (err) {
29
+ caught = err;
30
+ }
31
+ expect(caught).toBeInstanceOf(CleoError);
32
+ const e = caught as CleoError;
33
+ expect(e.fix).toBeTruthy();
34
+ if (opts.fixIncludes) {
35
+ expect(e.fix).toContain(opts.fixIncludes);
36
+ }
37
+ if (opts.detailsField) {
38
+ expect(e.details).toBeDefined();
39
+ expect(e.details!.field).toBe(opts.detailsField);
40
+ }
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // sanitizeFilePath — engine.ts
45
+ // ---------------------------------------------------------------------------
46
+
47
+ describe('error-hints: sanitizeFilePath', () => {
48
+ it('empty path — fix mentions file path, field=path', () => {
49
+ assertErrorHints(() => sanitizeFilePath(''), {
50
+ detailsField: 'path',
51
+ });
52
+ });
53
+
54
+ it('path with shell metachar — fix mentions shell metacharacters, field=path', () => {
55
+ assertErrorHints(() => sanitizeFilePath('/tmp/file;rm -rf /'), {
56
+ fixIncludes: 'metacharacter',
57
+ detailsField: 'path',
58
+ });
59
+ });
60
+
61
+ it('path ending in backslash — fix mentions backslash, field=path', () => {
62
+ assertErrorHints(() => sanitizeFilePath('/tmp/file\\'), {
63
+ fixIncludes: 'backslash',
64
+ detailsField: 'path',
65
+ });
66
+ });
67
+
68
+ it('path with newline — fix mentions newline, field=path', () => {
69
+ assertErrorHints(() => sanitizeFilePath('/tmp/file\ninjected'), {
70
+ fixIncludes: 'newline',
71
+ detailsField: 'path',
72
+ });
73
+ });
74
+ });
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // loadManifestEntryByTaskId — protocols/_shared.ts
78
+ // ---------------------------------------------------------------------------
79
+
80
+ describe('error-hints: loadManifestEntryByTaskId', () => {
81
+ it('task not in manifest — fix mentions manifest entry, field=taskId', () => {
82
+ // This will always throw NOT_FOUND since no manifest exists in test env
83
+ assertErrorHints(() => loadManifestEntryByTaskId('T9999'), {
84
+ fixIncludes: 'manifest',
85
+ detailsField: 'taskId',
86
+ });
87
+ });
88
+ });
89
+
90
+ describe('error-hints: loadManifestEntryFromFile', () => {
91
+ it('manifest file not found — fix mentions manifest file path, field=manifestFile', () => {
92
+ assertErrorHints(() => loadManifestEntryFromFile('/nonexistent/path/manifest.json'), {
93
+ fixIncludes: 'manifest',
94
+ detailsField: 'manifestFile',
95
+ });
96
+ });
97
+ });
@@ -94,7 +94,10 @@ const SHELL_METACHARACTERS = new Set([
94
94
  */
95
95
  export function sanitizeFilePath(path: string): string {
96
96
  if (!path) {
97
- throw new CleoError(ExitCode.VALIDATION_ERROR, 'Empty path provided');
97
+ throw new CleoError(ExitCode.VALIDATION_ERROR, 'Empty path provided', {
98
+ fix: 'Provide a non-empty file path',
99
+ details: { field: 'path' },
100
+ }); /* internal invariant — no user action */
98
101
  }
99
102
 
100
103
  for (const char of path) {
@@ -102,6 +105,10 @@ export function sanitizeFilePath(path: string): string {
102
105
  throw new CleoError(
103
106
  ExitCode.VALIDATION_ERROR,
104
107
  `Path contains shell metacharacters - potential injection attempt: ${path}`,
108
+ {
109
+ fix: 'Remove shell metacharacters from the file path',
110
+ details: { field: 'path', actual: path },
111
+ },
105
112
  );
106
113
  }
107
114
  }
@@ -110,6 +117,10 @@ export function sanitizeFilePath(path: string): string {
110
117
  throw new CleoError(
111
118
  ExitCode.VALIDATION_ERROR,
112
119
  'Path ends with backslash - potential injection attempt',
120
+ {
121
+ fix: 'Remove trailing backslash from the file path',
122
+ details: { field: 'path', actual: path },
123
+ },
113
124
  );
114
125
  }
115
126
 
@@ -117,6 +128,10 @@ export function sanitizeFilePath(path: string): string {
117
128
  throw new CleoError(
118
129
  ExitCode.VALIDATION_ERROR,
119
130
  'Path contains newline/carriage return - potential injection attempt',
131
+ {
132
+ fix: 'Remove newline characters from the file path',
133
+ details: { field: 'path', actual: path },
134
+ },
120
135
  );
121
136
  }
122
137
 
@@ -56,7 +56,7 @@ interface ParamDef {
56
56
  items?: { type: ParamType };
57
57
  cli?: ParamCliDef;
58
58
  /** Schema config for dispatch adapter. */
59
- mcp?: ParamSchemaDef;
59
+ dispatch?: ParamSchemaDef;
60
60
  }
61
61
 
62
62
  // ---------------------------------------------------------------------------
@@ -79,7 +79,7 @@ export interface JSONSchemaObject {
79
79
  }
80
80
 
81
81
  // ---------------------------------------------------------------------------
82
- // 1. buildMcpInputSchema (name kept for backward compat)
82
+ // 1. buildDispatchInputSchema
83
83
  // ---------------------------------------------------------------------------
84
84
 
85
85
  /**
@@ -87,18 +87,18 @@ export interface JSONSchemaObject {
87
87
  *
88
88
  * Algorithm:
89
89
  * 1. Iterate `def.params`
90
- * 2. Skip params where schema `mcp.hidden === true`
90
+ * 2. Skip params where schema `dispatch.hidden === true`
91
91
  * 3. Map ParamType → JSON Schema type
92
92
  * 4. Collect names where `required === true` into `required[]`
93
93
  * 5. Return { type: 'object', properties, required }
94
94
  */
95
- export function buildMcpInputSchema(def: OperationDef): JSONSchemaObject {
95
+ export function buildDispatchInputSchema(def: OperationDef): JSONSchemaObject {
96
96
  const properties: Record<string, JsonSchemaProperty> = {};
97
97
  const required: string[] = [];
98
98
 
99
99
  for (const param of def.params ?? []) {
100
100
  // Skip CLI-only params from dispatch schema
101
- if (param.mcp?.hidden === true) continue;
101
+ if (param.dispatch?.hidden === true) continue;
102
102
 
103
103
  const prop: JsonSchemaProperty = {
104
104
  type: paramTypeToJsonSchema(param.type),
@@ -109,8 +109,8 @@ export function buildMcpInputSchema(def: OperationDef): JSONSchemaObject {
109
109
  prop.items = { type: 'string' };
110
110
  }
111
111
 
112
- if (param.mcp?.enum) {
113
- prop.enum = param.mcp.enum;
112
+ if (param.dispatch?.enum) {
113
+ prop.enum = param.dispatch.enum;
114
114
  }
115
115
 
116
116
  properties[param.name] = prop;
@@ -123,6 +123,9 @@ export function buildMcpInputSchema(def: OperationDef): JSONSchemaObject {
123
123
  return { type: 'object', properties, required };
124
124
  }
125
125
 
126
+ /** @deprecated Use {@link buildDispatchInputSchema} instead. */
127
+ export const buildMcpInputSchema = buildDispatchInputSchema;
128
+
126
129
  function paramTypeToJsonSchema(t: ParamDef['type']): JsonSchemaType {
127
130
  switch (t) {
128
131
  case 'string':
@@ -46,7 +46,10 @@ export function loadManifestEntryByTaskId(taskId: string): ManifestEntryInput {
46
46
  const manifestPath = getManifestPath();
47
47
  const entry = findManifestEntry(taskId, manifestPath);
48
48
  if (!entry) {
49
- throw new CleoError(ExitCode.NOT_FOUND, `No manifest entry found for task ${taskId}`);
49
+ throw new CleoError(ExitCode.NOT_FOUND, `No manifest entry found for task ${taskId}`, {
50
+ fix: `Ensure the agent wrote a manifest entry for ${taskId} before validation`,
51
+ details: { field: 'taskId', actual: taskId },
52
+ });
50
53
  }
51
54
  return JSON.parse(entry) as ManifestEntryInput;
52
55
  }
@@ -59,7 +62,10 @@ export function loadManifestEntryByTaskId(taskId: string): ManifestEntryInput {
59
62
  */
60
63
  export function loadManifestEntryFromFile(manifestFile: string): ManifestEntryInput {
61
64
  if (!existsSync(manifestFile)) {
62
- throw new CleoError(ExitCode.NOT_FOUND, `Manifest file not found: ${manifestFile}`);
65
+ throw new CleoError(ExitCode.NOT_FOUND, `Manifest file not found: ${manifestFile}`, {
66
+ fix: `Ensure the manifest file exists at: ${manifestFile}`,
67
+ details: { field: 'manifestFile', actual: manifestFile },
68
+ });
63
69
  }
64
70
  return JSON.parse(readFileSync(manifestFile, 'utf-8')) as ManifestEntryInput;
65
71
  }
@@ -78,11 +84,13 @@ export function throwIfStrictFailed(
78
84
  ): void {
79
85
  if (!opts.strict || result.valid) return;
80
86
  const code = PROTOCOL_EXIT_CODES[protocol];
87
+ const errorViolations = result.violations.filter((v) => v.severity === 'error');
81
88
  throw new CleoError(
82
89
  code,
83
- `${protocol} protocol violations for ${taskId}: ${result.violations
84
- .filter((v) => v.severity === 'error')
85
- .map((v) => v.message)
86
- .join('; ')}`,
90
+ `${protocol} protocol violations for ${taskId}: ${errorViolations.map((v) => v.message).join('; ')}`,
91
+ {
92
+ fix: `Review ${protocol} protocol requirements and correct the listed violations`,
93
+ details: { field: 'protocol', actual: protocol },
94
+ },
87
95
  );
88
96
  }