@cleocode/contracts 2026.5.125 → 2026.5.127

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.
@@ -0,0 +1,634 @@
1
+ /**
2
+ * Canonical Taxonomy Registry (T11186).
3
+ *
4
+ * Single source of truth for classification tags across decisions,
5
+ * documents, tasks, and routing surfaces. Unifies the previously
6
+ * fragmented vocabularies:
7
+ *
8
+ * - `BRAIN_DECISION_TYPES` (memory-schema.ts) — 5 values
9
+ * - `BRAIN_DECISION_CATEGORIES` (memory-schema.ts) — 3 values
10
+ * - `BrainCognitiveType` (brain.ts) — 3 values
11
+ * - `BUILTIN_DOC_KINDS` (docs-taxonomy.ts) — 10 values
12
+ * - `labels_json` (tasks schema) — free-form, 30+ ad-hoc labels
13
+ * - `BrainEdgeKind` (operations/brain.ts) — open-ended
14
+ * - `BRAIN_PATTERN_TYPES` (memory-schema.ts) — 5 values
15
+ * - `BRAIN_OBSERVATION_TYPES` (memory-schema.ts) — 8 values
16
+ *
17
+ * The taxonomy is organized into **axes** — each tag is classified
18
+ * by what dimension of classification it represents. A single tag
19
+ * may belong to multiple axes (e.g., `adr` is both a doc_kind and
20
+ * a decision type).
21
+ *
22
+ * ## Axes
23
+ *
24
+ * | Axis | Purpose |
25
+ * |-------------|----------------------------------------------|
26
+ * | `domain` | Architectural subsystem or component area |
27
+ * | `type` | Kind of artifact, decision, or entry |
28
+ * | `lifecycle` | Phase in the RCASD→IVTR pipeline |
29
+ * | `priority` | Severity, urgency, or importance |
30
+ * | `doc_kind` | Document classification (absorbs docs-taxonomy)|
31
+ *
32
+ * @task T11186
33
+ * @epic T10520
34
+ * @saga T10516
35
+ * @see ADR-073 §1 — Task Hierarchy Charter
36
+ * @see packages/contracts/src/docs-taxonomy.ts — absorbed doc-kind subset
37
+ */
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Tag metadata interface
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /** Allowed tag axis values. */
44
+ export type TaxonomyAxis = 'domain' | 'type' | 'lifecycle' | 'priority' | 'doc_kind';
45
+
46
+ /**
47
+ * Metadata for a single canonical tag in the unified taxonomy.
48
+ */
49
+ export interface CanonicalTagMetadata {
50
+ /** Canonical tag id, lowercase kebab-case. */
51
+ readonly tag: string;
52
+ /** Human label for display in CLI output and UIs. */
53
+ readonly label: string;
54
+ /** One-line description. */
55
+ readonly description: string;
56
+ /** Which classification axes this tag belongs to. */
57
+ readonly axes: ReadonlyArray<TaxonomyAxis>;
58
+ /**
59
+ * Ad-hoc labels from tasks.labels_json that normalize to this tag.
60
+ * Used by the backfill script.
61
+ */
62
+ readonly adhocAliases: ReadonlyArray<string>;
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Built-in canonical tags
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Every canonical tag known to the system.
71
+ *
72
+ * Adding a tag here automatically makes it available for decisions,
73
+ * task labels, doc classification, and routing surfaces. No other
74
+ * file needs to be updated.
75
+ */
76
+ export const BUILTIN_TAXONOMY_TAGS: ReadonlyArray<CanonicalTagMetadata> = [
77
+ // ─── Domain tags ───────────────────────────────────────────────
78
+ {
79
+ tag: 'architecture',
80
+ label: 'Architecture',
81
+ description: 'Cross-cutting architectural decisions, patterns, and subsystem-level concerns',
82
+ axes: ['domain', 'type'],
83
+ adhocAliases: ['architecture'],
84
+ },
85
+ {
86
+ tag: 'cli',
87
+ label: 'CLI',
88
+ description: 'Command-line interface, dispatch, and command infrastructure',
89
+ axes: ['domain'],
90
+ adhocAliases: ['cli'],
91
+ },
92
+ {
93
+ tag: 'core',
94
+ label: 'Core',
95
+ description: 'Core engine, shared utilities, and foundational modules',
96
+ axes: ['domain'],
97
+ adhocAliases: ['core', 'pm-core-v2', 'foundation'],
98
+ },
99
+ {
100
+ tag: 'contracts',
101
+ label: 'Contracts',
102
+ description: 'Type contracts, schemas, and public API surface definitions',
103
+ axes: ['domain'],
104
+ adhocAliases: ['contracts', 'schema'],
105
+ },
106
+ {
107
+ tag: 'caamp',
108
+ label: 'CAAMP',
109
+ description: 'Cross-Agent Adaptive Messaging Protocol — hooks, adapters, providers',
110
+ axes: ['domain'],
111
+ adhocAliases: ['caamp', 'cant', 'pi', 'cant-dsl'],
112
+ },
113
+ {
114
+ tag: 'skills',
115
+ label: 'Skills',
116
+ description: 'Agent skill definitions, injection, dispatch, and guard patterns',
117
+ axes: ['domain'],
118
+ adhocAliases: ['skills'],
119
+ },
120
+ {
121
+ tag: 'brain',
122
+ label: 'BRAIN',
123
+ description: 'Cognitive memory system — decisions, patterns, observations, learnings',
124
+ axes: ['domain'],
125
+ adhocAliases: ['brain'],
126
+ },
127
+ {
128
+ tag: 'nexus',
129
+ label: 'Nexus',
130
+ description: 'Code intelligence surface — project indexing, impact analysis, cross-repo',
131
+ axes: ['domain'],
132
+ adhocAliases: ['nexus'],
133
+ },
134
+ {
135
+ tag: 'orchestration',
136
+ label: 'Orchestration',
137
+ description: 'Multi-agent orchestration, playbooks, LOOM lifecycle, spawn dispatch',
138
+ axes: ['domain'],
139
+ adhocAliases: ['orchestration', 'orchestrate'],
140
+ },
141
+ {
142
+ tag: 'sessions',
143
+ label: 'Sessions',
144
+ description: 'Session lifecycle — handoff, debrief, briefing, snapshot, state',
145
+ axes: ['domain'],
146
+ adhocAliases: ['sessions'],
147
+ },
148
+ {
149
+ tag: 'tasks',
150
+ label: 'Tasks',
151
+ description: 'Task management — CRUD, gates, evidence, acceptance criteria, lifecycle',
152
+ axes: ['domain'],
153
+ adhocAliases: ['tasks'],
154
+ },
155
+ {
156
+ tag: 'docs',
157
+ label: 'Documents',
158
+ description: 'Document storage, retrieval, taxonomy, publish, and provenance',
159
+ axes: ['domain'],
160
+ adhocAliases: ['docs', 'documentation'],
161
+ },
162
+ {
163
+ tag: 'cleoos',
164
+ label: 'CleoOS',
165
+ description: 'CleoOS runtime — gateway, daemon, injection facade, agents',
166
+ axes: ['domain'],
167
+ adhocAliases: ['cleoos', 'facade', 'sentient'],
168
+ },
169
+ {
170
+ tag: 'worktrunk',
171
+ label: 'Worktrunk',
172
+ description: 'Git worktree management — spawn, adopt, prune, lifecycle',
173
+ axes: ['domain'],
174
+ adhocAliases: ['worktrunk', 'worktrunk-ssot'],
175
+ },
176
+ {
177
+ tag: 'agents',
178
+ label: 'Agents',
179
+ description: 'Agent profiles, execution learning, registry, dispatch',
180
+ axes: ['domain'],
181
+ adhocAliases: ['agents'],
182
+ },
183
+ {
184
+ tag: 'routing',
185
+ label: 'Routing',
186
+ description: 'Operation routing, capability matrix, dispatch path resolution',
187
+ axes: ['domain'],
188
+ adhocAliases: ['routing'],
189
+ },
190
+ {
191
+ tag: 'studio',
192
+ label: 'Studio',
193
+ description: 'Web UI, dashboard, and visual interfaces',
194
+ axes: ['domain'],
195
+ adhocAliases: ['studio'],
196
+ },
197
+
198
+ // ─── Type tags ─────────────────────────────────────────────────
199
+ {
200
+ tag: 'architectural',
201
+ label: 'Architectural Decision',
202
+ description: 'Cross-cutting architectural decision with durable rationale',
203
+ axes: ['type'],
204
+ adhocAliases: ['architectural'],
205
+ },
206
+ {
207
+ tag: 'technical',
208
+ label: 'Technical Decision',
209
+ description: 'Implementation-scoped technical decision (library, pattern, algorithm)',
210
+ axes: ['type'],
211
+ adhocAliases: ['technical'],
212
+ },
213
+ {
214
+ tag: 'process',
215
+ label: 'Process Decision',
216
+ description: 'Workflow, methodology, or operational process decision',
217
+ axes: ['type'],
218
+ adhocAliases: ['process'],
219
+ },
220
+ {
221
+ tag: 'strategic',
222
+ label: 'Strategic Decision',
223
+ description: 'Long-horizon direction-setting decision spanning multiple epics',
224
+ axes: ['type'],
225
+ adhocAliases: ['strategic'],
226
+ },
227
+ {
228
+ tag: 'tactical',
229
+ label: 'Tactical Decision',
230
+ description: 'Short-horizon execution-level decision (AGT-* dispatch, within-sprint)',
231
+ axes: ['type'],
232
+ adhocAliases: ['tactical'],
233
+ },
234
+ {
235
+ tag: 'operational',
236
+ label: 'Operational Decision',
237
+ description: 'Infrastructure, deployment, or runtime configuration decision',
238
+ axes: ['type'],
239
+ adhocAliases: ['operational'],
240
+ },
241
+ {
242
+ tag: 'bugfix',
243
+ label: 'Bug Fix',
244
+ description: 'Defect correction or regression fix',
245
+ axes: ['type'],
246
+ adhocAliases: ['bugfix', 'bug-fix', 'hygiene'],
247
+ },
248
+ {
249
+ tag: 'refactor',
250
+ label: 'Refactor',
251
+ description: 'Internal restructuring without functional change',
252
+ axes: ['type'],
253
+ adhocAliases: ['refactor'],
254
+ },
255
+ {
256
+ tag: 'feature',
257
+ label: 'Feature',
258
+ description: 'New capability or user-facing enhancement',
259
+ axes: ['type'],
260
+ adhocAliases: ['feature'],
261
+ },
262
+ {
263
+ tag: 'discovery',
264
+ label: 'Discovery',
265
+ description: 'Exploratory research, investigation, or spike outcome',
266
+ axes: ['type'],
267
+ adhocAliases: ['discovery', 'exploration', 'research-type'],
268
+ },
269
+ {
270
+ tag: 'migration',
271
+ label: 'Migration',
272
+ description: 'Data or schema migration, backfill, or upgrade path',
273
+ axes: ['type'],
274
+ adhocAliases: ['migration', 'migrations'],
275
+ },
276
+ {
277
+ tag: 'unification',
278
+ label: 'Unification',
279
+ description: 'Consolidation of fragmented systems into a single SSoT',
280
+ axes: ['type'],
281
+ adhocAliases: ['unification'],
282
+ },
283
+ {
284
+ tag: 'bootstrap',
285
+ label: 'Bootstrap',
286
+ description: 'Initial setup, scaffolding, or seed infrastructure',
287
+ axes: ['type'],
288
+ adhocAliases: ['bootstrap'],
289
+ },
290
+
291
+ // ─── Lifecycle tags ────────────────────────────────────────────
292
+ {
293
+ tag: 'research',
294
+ label: 'Research',
295
+ description: 'LOOM stage 1: information gathering and domain understanding',
296
+ axes: ['lifecycle'],
297
+ adhocAliases: ['research', 'wave-0'],
298
+ },
299
+ {
300
+ tag: 'consensus',
301
+ label: 'Consensus',
302
+ description: 'LOOM stage 2: multi-agent consensus and validation of findings',
303
+ axes: ['lifecycle'],
304
+ adhocAliases: ['consensus'],
305
+ },
306
+ {
307
+ tag: 'design',
308
+ label: 'Architecture Decision',
309
+ description: 'LOOM stage 3: architecture decision recording and validation',
310
+ axes: ['lifecycle'],
311
+ adhocAliases: ['design', 'architecture_decision'],
312
+ },
313
+ {
314
+ tag: 'specification',
315
+ label: 'Specification',
316
+ description: 'LOOM stage 4: technical specification authoring',
317
+ axes: ['lifecycle'],
318
+ adhocAliases: ['specification', 'rfc'],
319
+ },
320
+ {
321
+ tag: 'decomposition',
322
+ label: 'Decomposition',
323
+ description: 'LOOM stage 5: task decomposition and dependency modeling',
324
+ axes: ['lifecycle'],
325
+ adhocAliases: ['decomposition'],
326
+ },
327
+ {
328
+ tag: 'implementation',
329
+ label: 'Implementation',
330
+ description: 'LOOM stage 6: code authoring and unit-level verification',
331
+ axes: ['lifecycle'],
332
+ adhocAliases: [
333
+ 'implementation',
334
+ 'wave-1',
335
+ 'wave-2',
336
+ 'wave-3',
337
+ 'wave-4',
338
+ 'wave-5',
339
+ 'wave.1',
340
+ 'wave.2',
341
+ 'wave.3',
342
+ ],
343
+ },
344
+ {
345
+ tag: 'validation',
346
+ label: 'Validation',
347
+ description: 'LOOM stage 7: integration testing and gate verification',
348
+ axes: ['lifecycle'],
349
+ adhocAliases: ['validation', 'testing'],
350
+ },
351
+ {
352
+ tag: 'release',
353
+ label: 'Release',
354
+ description: 'LOOM stage 9: versioning, changelog, npm publish, tag push',
355
+ axes: ['lifecycle', 'type'],
356
+ adhocAliases: ['release'],
357
+ },
358
+
359
+ // ─── Priority tags ─────────────────────────────────────────────
360
+ {
361
+ tag: 'p0',
362
+ label: 'P0 — Critical',
363
+ description: 'Drop-everything critical: blocks release, data loss, security incident',
364
+ axes: ['priority'],
365
+ adhocAliases: ['p0', 'critical', 'prime-tier1'],
366
+ },
367
+ {
368
+ tag: 'p1',
369
+ label: 'P1 — High',
370
+ description: 'High priority: blocks dependent work, user-visible degradation',
371
+ axes: ['priority'],
372
+ adhocAliases: ['p1'],
373
+ },
374
+ {
375
+ tag: 'p2',
376
+ label: 'P2 — Medium',
377
+ description: 'Medium priority: should fix, not blocking current wave',
378
+ axes: ['priority'],
379
+ adhocAliases: ['p2'],
380
+ },
381
+ {
382
+ tag: 'p3',
383
+ label: 'P3 — Low',
384
+ description: 'Low priority: nice-to-have, cleanup, future consideration',
385
+ axes: ['priority'],
386
+ adhocAliases: ['p3'],
387
+ },
388
+
389
+ // ─── Doc-kind tags (absorb docs-taxonomy) ──────────────────────
390
+ {
391
+ tag: 'adr',
392
+ label: 'Architecture Decision Record',
393
+ description: 'Formal architecture decision record with rationale and alternatives',
394
+ axes: ['doc_kind'],
395
+ adhocAliases: ['adr'],
396
+ },
397
+ {
398
+ tag: 'spec',
399
+ label: 'Specification',
400
+ description: 'Technical specification for a feature, protocol, or subsystem',
401
+ axes: ['doc_kind'],
402
+ adhocAliases: ['spec'],
403
+ },
404
+ {
405
+ tag: 'research',
406
+ label: 'Research Note',
407
+ description: 'Investigation findings, surveys, and domain analysis',
408
+ axes: ['doc_kind'],
409
+ adhocAliases: ['research'],
410
+ },
411
+ {
412
+ tag: 'handoff',
413
+ label: 'Handoff Note',
414
+ description: 'Cross-session handoff with state, blockers, and next actions',
415
+ axes: ['doc_kind'],
416
+ adhocAliases: ['handoff'],
417
+ },
418
+ {
419
+ tag: 'note',
420
+ label: 'Note',
421
+ description: 'General-purpose note, observation, or free-form documentation',
422
+ axes: ['doc_kind'],
423
+ adhocAliases: ['note'],
424
+ },
425
+ {
426
+ tag: 'llmreadme',
427
+ label: 'LLM README',
428
+ description: 'Agent-consumable project overview, conventions, and context',
429
+ axes: ['doc_kind'],
430
+ adhocAliases: ['llmreadme', 'llm-readme'],
431
+ },
432
+ {
433
+ tag: 'designmd',
434
+ label: 'Design Doc',
435
+ description: 'Google DESIGN.md token spec for agent interface contracts',
436
+ axes: ['doc_kind'],
437
+ adhocAliases: ['designmd', 'design-md'],
438
+ },
439
+ {
440
+ tag: 'changeset',
441
+ label: 'Changeset',
442
+ description: 'User-facing changelog entry for a single change',
443
+ axes: ['doc_kind'],
444
+ adhocAliases: ['changeset'],
445
+ },
446
+ {
447
+ tag: 'changelog',
448
+ label: 'Changelog',
449
+ description: 'Aggregated release changelog for a version',
450
+ axes: ['doc_kind'],
451
+ adhocAliases: ['changelog'],
452
+ },
453
+ ];
454
+
455
+ // ---------------------------------------------------------------------------
456
+ // Derived constants
457
+ // ---------------------------------------------------------------------------
458
+
459
+ /** All canonical tag string values. */
460
+ export const CANONICAL_TAG_VALUES = BUILTIN_TAXONOMY_TAGS.map((t) => t.tag);
461
+
462
+ /** Every tag indexed by its canonical id. */
463
+ const _TAG_BY_ID = new Map<string, CanonicalTagMetadata>(
464
+ BUILTIN_TAXONOMY_TAGS.map((t) => [t.tag, t]),
465
+ );
466
+
467
+ /** Ad-hoc label → canonical tag lookup. */
468
+ const ADHOC_TO_CANONICAL = new Map<string, string>();
469
+ for (const tag of BUILTIN_TAXONOMY_TAGS) {
470
+ for (const alias of tag.adhocAliases) {
471
+ ADHOC_TO_CANONICAL.set(alias, tag.tag);
472
+ }
473
+ }
474
+
475
+ /** Tags grouped by axis. */
476
+ const TAGS_BY_AXIS = new Map<TaxonomyAxis, CanonicalTagMetadata[]>();
477
+ for (const axis of ['domain', 'type', 'lifecycle', 'priority', 'doc_kind'] as TaxonomyAxis[]) {
478
+ TAGS_BY_AXIS.set(
479
+ axis,
480
+ BUILTIN_TAXONOMY_TAGS.filter((t) => t.axes.includes(axis)),
481
+ );
482
+ }
483
+
484
+ /** All canonical tag type values (subset of ALL_TAGS with axis='type'). */
485
+ export const CANONICAL_TYPE_TAGS: ReadonlyArray<string> = TAGS_BY_AXIS.get('type')!.map(
486
+ (t) => t.tag,
487
+ );
488
+
489
+ /** All canonical domain tag values. */
490
+ export const CANONICAL_DOMAIN_TAGS: ReadonlyArray<string> = TAGS_BY_AXIS.get('domain')!.map(
491
+ (t) => t.tag,
492
+ );
493
+
494
+ /** Canonical priority tag values in order. */
495
+ export const CANONICAL_PRIORITY_TAGS = ['p0', 'p1', 'p2', 'p3'] as const;
496
+
497
+ /** Canonical lifecycle tag values in LOOM order. */
498
+ export const CANONICAL_LIFECYCLE_TAGS = [
499
+ 'research',
500
+ 'consensus',
501
+ 'design',
502
+ 'specification',
503
+ 'decomposition',
504
+ 'implementation',
505
+ 'validation',
506
+ 'release',
507
+ ] as const;
508
+
509
+ /** Doc-kind tags (absorbed from docs-taxonomy). */
510
+ export const CANONICAL_DOC_KIND_TAGS: ReadonlyArray<string> = TAGS_BY_AXIS.get('doc_kind')!.map(
511
+ (t) => t.tag,
512
+ );
513
+
514
+ // ---------------------------------------------------------------------------
515
+ // Taxonomy Registry
516
+ // ---------------------------------------------------------------------------
517
+
518
+ /** Error thrown for taxonomy validation failures. */
519
+ export class TaxonomyError extends Error {
520
+ constructor(
521
+ message: string,
522
+ public readonly invalidTags: string[],
523
+ ) {
524
+ super(message);
525
+ this.name = 'TaxonomyError';
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Canonical Taxonomy Registry — runtime accessor and validator.
531
+ *
532
+ * Use {@link TaxonomyRegistry.default} for the built-in singleton,
533
+ * or construct with a custom tag list for testing.
534
+ */
535
+ export class TaxonomyRegistry {
536
+ private readonly byId: Map<string, CanonicalTagMetadata>;
537
+ private readonly adhocMap: Map<string, string>;
538
+
539
+ constructor(tags: ReadonlyArray<CanonicalTagMetadata>) {
540
+ this.byId = new Map(tags.map((t) => [t.tag, t]));
541
+ this.adhocMap = new Map<string, string>();
542
+ for (const tag of tags) {
543
+ for (const alias of tag.adhocAliases) {
544
+ const existingTarget = this.adhocMap.get(alias);
545
+ if (existingTarget !== undefined && existingTarget !== tag.tag) {
546
+ throw new TaxonomyError(
547
+ `Ad-hoc alias '${alias}' maps to both '${existingTarget}' and '${tag.tag}' — aliases must be unique across targets`,
548
+ [alias],
549
+ );
550
+ }
551
+ this.adhocMap.set(alias, tag.tag);
552
+ }
553
+ }
554
+ }
555
+
556
+ /** Default built-in registry singleton. */
557
+ static readonly default = new TaxonomyRegistry(BUILTIN_TAXONOMY_TAGS);
558
+
559
+ /** True when `tag` is a canonical tag. */
560
+ isCanonical(tag: string): boolean {
561
+ return this.byId.has(tag);
562
+ }
563
+
564
+ /** Look up metadata for a canonical tag. */
565
+ get(tag: string): CanonicalTagMetadata | undefined {
566
+ return this.byId.get(tag);
567
+ }
568
+
569
+ /** List all canonical tags. */
570
+ list(): ReadonlyArray<CanonicalTagMetadata> {
571
+ return [...this.byId.values()];
572
+ }
573
+
574
+ /** List tags for a specific axis. */
575
+ listByAxis(axis: TaxonomyAxis): ReadonlyArray<CanonicalTagMetadata> {
576
+ return this.list().filter((t) => t.axes.includes(axis));
577
+ }
578
+
579
+ /**
580
+ * Normalize an ad-hoc label to its canonical form.
581
+ *
582
+ * Returns the canonical tag id, or `undefined` if the label has no
583
+ * known canonical mapping.
584
+ */
585
+ normalize(adhoc: string): string | undefined {
586
+ return this.adhocMap.get(adhoc);
587
+ }
588
+
589
+ /**
590
+ * Validate that every tag in `tags` is canonical.
591
+ *
592
+ * @param tags - Tags to validate.
593
+ * @returns An array of invalid tags. Empty array = all valid.
594
+ */
595
+ validate(tags: ReadonlyArray<string>): string[] {
596
+ return tags.filter((t) => !this.byId.has(t));
597
+ }
598
+
599
+ /**
600
+ * Validate tags and throw on failure.
601
+ *
602
+ * @param tags - Tags to validate.
603
+ * @param context - Human-readable context for the error message.
604
+ * @throws TaxonomyError when any tag is not canonical.
605
+ */
606
+ validateOrThrow(tags: ReadonlyArray<string>, context: string): void {
607
+ const invalid = this.validate(tags);
608
+ if (invalid.length > 0) {
609
+ throw new TaxonomyError(
610
+ `${context}: unknown tags [${invalid.join(', ')}]. ` +
611
+ `Use canonical tags. Run 'cleo taxonomy list' to see all valid tags.`,
612
+ invalid,
613
+ );
614
+ }
615
+ }
616
+
617
+ /**
618
+ * Normalize a set of ad-hoc labels to canonical form.
619
+ *
620
+ * Unknown labels are passed through unchanged (they may be
621
+ * project-specific extensions or task-id references).
622
+ *
623
+ * @param labels - Ad-hoc labels to normalize.
624
+ * @returns Normalized label set (deduplicated).
625
+ */
626
+ normalizeSet(labels: ReadonlyArray<string>): string[] {
627
+ const result = new Set<string>();
628
+ for (const label of labels) {
629
+ const canonical = this.normalize(label);
630
+ result.add(canonical ?? label);
631
+ }
632
+ return [...result];
633
+ }
634
+ }
package/src/workgraph.ts CHANGED
@@ -1058,13 +1058,36 @@ export interface WorkGraphHierarchyValidationOptions {
1058
1058
  readonly throwOnViolation?: boolean;
1059
1059
  }
1060
1060
 
1061
+ const ROOT_TASK_TYPES: ReadonlySet<TaskType> = new Set(['saga', 'epic']);
1062
+
1061
1063
  const PARENT_TYPE_MATRIX: Readonly<Record<TaskType, readonly TaskType[]>> = {
1062
1064
  saga: [],
1063
- epic: [],
1065
+ epic: ['saga'],
1064
1066
  task: ['epic'],
1065
- subtask: ['epic', 'task'],
1067
+ subtask: ['task'],
1066
1068
  };
1067
1069
 
1070
+ /**
1071
+ * Return true when the task type can exist without a parent in the canonical
1072
+ * PM-Core V2 containment tree.
1073
+ *
1074
+ * @param type - Task type to test.
1075
+ */
1076
+ export function canWorkGraphTaskTypeBeRoot(type: TaskType): boolean {
1077
+ return ROOT_TASK_TYPES.has(type);
1078
+ }
1079
+
1080
+ /**
1081
+ * Return true when a parent type can contain a child type in the canonical
1082
+ * PM-Core V2 containment tree.
1083
+ *
1084
+ * @param childType - Child task type.
1085
+ * @param parentType - Candidate parent task type.
1086
+ */
1087
+ export function isAllowedWorkGraphParentType(childType: TaskType, parentType: TaskType): boolean {
1088
+ return PARENT_TYPE_MATRIX[childType].includes(parentType);
1089
+ }
1090
+
1068
1091
  function describeAllowedParents(type: TaskType): string {
1069
1092
  const allowed = PARENT_TYPE_MATRIX[type];
1070
1093
  return allowed.length === 0 ? 'root only' : allowed.join('|');
@@ -1107,7 +1130,7 @@ function makeViolation(
1107
1130
  taskType: node.type,
1108
1131
  parentId,
1109
1132
  parentType,
1110
- message: `tasks.parent_id must follow saga/epic roots, epic->task|subtask, and task->subtask; expected ${node.type} parent ${expected}, got ${actual}`,
1133
+ message: `tasks.parent_id must follow saga->epic, epic->task, and task->subtask; sagas and standalone epics may be roots; expected ${node.type} parent ${expected}, got ${actual}`,
1111
1134
  };
1112
1135
  }
1113
1136
 
@@ -1115,9 +1138,10 @@ function makeViolation(
1115
1138
  * Validate WorkGraph hierarchy rows against CLEO's canonical type/parent matrix.
1116
1139
  *
1117
1140
  * The validator mirrors the SQLite trigger invariant in a storage-agnostic form
1118
- * for core adapters, projections, and API callers: sagas and epics are roots,
1119
- * epics may contain tasks or subtasks, tasks may contain subtasks, and subtasks
1120
- * are leaves. Saga membership remains a `groups` relation, never `parentId`.
1141
+ * for core adapters, projections, and API callers: sagas and standalone epics
1142
+ * may be roots, sagas may contain epics, epics may contain tasks, tasks may
1143
+ * contain subtasks, and subtasks are leaves. Saga membership uses `parentId`
1144
+ * containment, never `task_relations.groups`.
1121
1145
  *
1122
1146
  * @param nodes - Task-like hierarchy rows to validate.
1123
1147
  * @param options - Optional fail-fast behavior for enforcement call sites.
@@ -1136,11 +1160,10 @@ export function validateWorkGraphHierarchy(
1136
1160
  for (const node of nodes) {
1137
1161
  const parentId = node.parentId ?? null;
1138
1162
  const parent = parentId === null ? undefined : byId.get(parentId);
1139
- const allowedParents = PARENT_TYPE_MATRIX[node.type];
1140
1163
  const valid =
1141
1164
  parentId === null
1142
- ? allowedParents.length === 0
1143
- : parent !== undefined && allowedParents.includes(parent.type);
1165
+ ? canWorkGraphTaskTypeBeRoot(node.type)
1166
+ : parent !== undefined && isAllowedWorkGraphParentType(node.type, parent.type);
1144
1167
 
1145
1168
  if (!valid) {
1146
1169
  const violation = makeViolation(node, parent);