@cleocode/contracts 2026.5.114 → 2026.5.120

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,107 @@
1
+ /**
2
+ * Type-shape contract test for the envelope-wide `meta.tokens` field
3
+ * promoted in T9923 (Saga T9855 / E8.4).
4
+ *
5
+ * `CliMeta.tokens` is the first-class structured token-cost annotation
6
+ * attached to every CLI envelope (formerly the underscore-prefixed
7
+ * `meta._tokenEstimate`). The legacy field is retained for ONE release
8
+ * (removeAt v2026.7.0) so existing consumers can migrate without a
9
+ * flag-day.
10
+ *
11
+ * @epic T9919
12
+ * @task T9923
13
+ * @saga T9855
14
+ */
15
+
16
+ import type { CliEnvelope, CliMeta, CliMetaTokens } from '@cleocode/lafs';
17
+ import { describe, expect, expectTypeOf, it } from 'vitest';
18
+
19
+ describe('CliMeta.tokens type shape (T9923)', () => {
20
+ it('accepts the full first-class CliMetaTokens shape', () => {
21
+ const meta: CliMeta = {
22
+ operation: 'tasks.show',
23
+ requestId: '00000000-0000-0000-0000-000000000000',
24
+ duration_ms: 0,
25
+ timestamp: '2026-05-24T00:00:00.000Z',
26
+ tokens: {
27
+ estimate: 1234,
28
+ model: 'cl100k',
29
+ calculatedAt: '2026-05-24T00:00:00.000Z',
30
+ },
31
+ };
32
+ expect(meta.tokens?.estimate).toBe(1234);
33
+ expect(meta.tokens?.model).toBe('cl100k');
34
+ expect(meta.tokens?.calculatedAt).toBe('2026-05-24T00:00:00.000Z');
35
+ });
36
+
37
+ it('requires `estimate` to be a number when `tokens` is present', () => {
38
+ expectTypeOf<NonNullable<CliMeta['tokens']>['estimate']>().toEqualTypeOf<number>();
39
+ expectTypeOf<NonNullable<CliMeta['tokens']>['model']>().toEqualTypeOf<string>();
40
+ expectTypeOf<NonNullable<CliMeta['tokens']>['calculatedAt']>().toEqualTypeOf<string>();
41
+ });
42
+
43
+ it('is optional — omitting `tokens` leaves a valid CliMeta', () => {
44
+ const meta: CliMeta = {
45
+ operation: 'tasks.list',
46
+ requestId: '00000000-0000-0000-0000-000000000001',
47
+ duration_ms: 0,
48
+ timestamp: '2026-05-24T00:00:00.000Z',
49
+ };
50
+ expect(meta.tokens).toBeUndefined();
51
+ });
52
+
53
+ it('accepts the legacy `_tokenEstimate` field during the deprecation window', () => {
54
+ const meta: CliMeta = {
55
+ operation: 'tasks.show',
56
+ requestId: '00000000-0000-0000-0000-000000000002',
57
+ duration_ms: 0,
58
+ timestamp: '2026-05-24T00:00:00.000Z',
59
+ _tokenEstimate: { estimate: 999 },
60
+ };
61
+ expect(meta._tokenEstimate?.estimate).toBe(999);
62
+ });
63
+
64
+ it('allows BOTH `tokens` and `_tokenEstimate` for backwards-compat dual-write', () => {
65
+ const meta: CliMeta = {
66
+ operation: 'tasks.show',
67
+ requestId: '00000000-0000-0000-0000-000000000003',
68
+ duration_ms: 0,
69
+ timestamp: '2026-05-24T00:00:00.000Z',
70
+ tokens: {
71
+ estimate: 4321,
72
+ model: 'o200k',
73
+ calculatedAt: '2026-05-24T00:00:00.000Z',
74
+ },
75
+ _tokenEstimate: { estimate: 4321 },
76
+ };
77
+ expect(meta.tokens?.estimate).toBe(meta._tokenEstimate?.estimate);
78
+ });
79
+
80
+ it('flows through CliEnvelope<T>.meta verbatim', () => {
81
+ const envelope: CliEnvelope<{ taskId: string }> = {
82
+ success: true,
83
+ data: { taskId: 'T9923' },
84
+ meta: {
85
+ operation: 'tasks.show',
86
+ requestId: '00000000-0000-0000-0000-000000000004',
87
+ duration_ms: 1,
88
+ timestamp: '2026-05-24T00:00:00.000Z',
89
+ tokens: {
90
+ estimate: 512,
91
+ model: 'cl100k',
92
+ calculatedAt: '2026-05-24T00:00:00.000Z',
93
+ },
94
+ },
95
+ };
96
+ expect(envelope.meta.tokens?.estimate).toBe(512);
97
+ });
98
+
99
+ it('CliMetaTokens is structurally compatible with manual construction', () => {
100
+ const t: CliMetaTokens = {
101
+ estimate: 100,
102
+ model: 'approx',
103
+ calculatedAt: '2026-05-24T00:00:00.000Z',
104
+ };
105
+ expect(t.estimate).toBe(100);
106
+ });
107
+ });
package/src/boundary.ts CHANGED
@@ -492,7 +492,7 @@ export const BOUNDARY_REGISTRY: readonly BoundaryEntry[] = [
492
492
  panic_unwind: 'allowed-with-recovery',
493
493
  root_escape: 'forbidden',
494
494
  },
495
- amendments: ['adr-077-worktreeinclude'],
495
+ amendments: ['adr-087-worktree-ffi-topology'],
496
496
  rationale:
497
497
  'napi binding shim (515 LOC) for worktrunk-core, used by packages/core spawn-pipeline tests, packages/core/src/scaffold/ensure-config.ts, and shipped via packages/worktree-napi-* per-platform npm packages. Internal-only.',
498
498
  },
@@ -510,7 +510,7 @@ export const BOUNDARY_REGISTRY: readonly BoundaryEntry[] = [
510
510
  panic_unwind: 'forbidden',
511
511
  root_escape: 'forbidden',
512
512
  },
513
- amendments: ['adr-077-worktreeinclude', 'adr-078-boundary-registry'],
513
+ amendments: ['adr-077-worktreeinclude-canonical-location', 'adr-078-boundary-registry'],
514
514
  rationale:
515
515
  'Refactored SoC per ADR-078 amendment 2026-05-23. Worktree primitives core (1,352 LOC) vendored from /mnt/projects/worktrunk per D010, consumed by worktree-napi. Reference implementation of the boundary-registry pattern. Full SDK surface documented in crates/worktrunk-core/README.md (T10223). Internal-only today; publish=true is a future option if external worktree-tooling emerges.',
516
516
  },
@@ -868,7 +868,7 @@ export const BOUNDARY_REGISTRY: readonly BoundaryEntry[] = [
868
868
  root_escape: 'forbidden',
869
869
  fs_writes_outside_root: 'audited',
870
870
  },
871
- amendments: ['adr-077-worktreeinclude', 'adr-078-boundary-registry'],
871
+ amendments: ['adr-087-worktree-ffi-topology', 'adr-078-boundary-registry'],
872
872
  rationale:
873
873
  'Worktree SSoT primitive layer (1,972 LOC) — canonical create/destroy/list/prune/include/copy-on-write bound by @cleocode/paths. Post-PR-#487 the responsibilities are distinct from packages/core/src/worktree/ (SDK-level enrichment). Consumes worktrunk-core via worktree-napi.',
874
874
  },
@@ -2335,6 +2335,41 @@ export const OPERATIONS: OperationDef[] = [
2335
2335
  },
2336
2336
  ] satisfies ParamDef[],
2337
2337
  },
2338
+ {
2339
+ gateway: 'mutate',
2340
+ domain: 'tasks',
2341
+ operation: 'relates.remove',
2342
+ description:
2343
+ 'tasks.relates.remove (mutate) — delete a relation between two tasks. Pass `type` to scope deletion to a single relation kind; omit to remove any type.',
2344
+ tier: 1,
2345
+ idempotent: true,
2346
+ sessionRequired: false,
2347
+ requiredParams: ['taskId', 'relatedId'],
2348
+ params: [
2349
+ {
2350
+ name: 'taskId',
2351
+ type: 'string',
2352
+ required: true,
2353
+ description: 'Source task ID',
2354
+ cli: { positional: true },
2355
+ },
2356
+ {
2357
+ name: 'relatedId',
2358
+ type: 'string',
2359
+ required: true,
2360
+ description: 'Target task ID to remove the relation to',
2361
+ cli: { positional: true },
2362
+ },
2363
+ {
2364
+ name: 'type',
2365
+ type: 'string',
2366
+ required: false,
2367
+ description:
2368
+ 'Optional relation type (blocks|related|duplicates|absorbs|fixes|extends|supersedes). Omit to remove any type.',
2369
+ cli: { flag: 'type' },
2370
+ },
2371
+ ] satisfies ParamDef[],
2372
+ },
2338
2373
  // === saga sub-domain (ADR-073 — above-epic grouping tier) ===
2339
2374
  {
2340
2375
  gateway: 'mutate',
@@ -6771,6 +6806,40 @@ export const OPERATIONS: OperationDef[] = [
6771
6806
  },
6772
6807
  ],
6773
6808
  },
6809
+ // ── docs.supersede (T10162) ──────────────────────────────────────────────
6810
+ {
6811
+ gateway: 'mutate',
6812
+ domain: 'docs',
6813
+ operation: 'supersede',
6814
+ description:
6815
+ 'docs.supersede (mutate) — atomically flip an older doc to `superseded` and link both rows via the supersedes/superseded_by self-FK pointers (T10162 · Saga T9855)',
6816
+ tier: 1,
6817
+ idempotent: true,
6818
+ sessionRequired: false,
6819
+ requiredParams: ['oldSlug', 'newSlug'],
6820
+ params: [
6821
+ {
6822
+ name: 'oldSlug',
6823
+ type: 'string' as const,
6824
+ required: true,
6825
+ description: 'Slug of the doc being replaced',
6826
+ cli: { positional: true },
6827
+ },
6828
+ {
6829
+ name: 'newSlug',
6830
+ type: 'string' as const,
6831
+ required: true,
6832
+ description: 'Slug of the doc that replaces oldSlug',
6833
+ cli: { positional: true },
6834
+ },
6835
+ {
6836
+ name: 'reason',
6837
+ type: 'string' as const,
6838
+ required: false,
6839
+ description: 'Optional human-readable reason carried back on the response',
6840
+ },
6841
+ ],
6842
+ },
6774
6843
  // ── docs.generate (T798) ─────────────────────────────────────────────────
6775
6844
  {
6776
6845
  gateway: 'query',
@@ -6797,6 +6866,61 @@ export const OPERATIONS: OperationDef[] = [
6797
6866
  },
6798
6867
  ],
6799
6868
  },
6869
+ // ── docs.update (T10161 — Epic T10157 / Saga T9855) ──────────────────────
6870
+ {
6871
+ gateway: 'mutate',
6872
+ domain: 'docs',
6873
+ operation: 'update',
6874
+ description:
6875
+ 'docs.update (mutate) — replace blob content for an existing slug while preserving the slug. ' +
6876
+ 'New sha256 + new attachment row; the old row stays reachable by ID for history. ' +
6877
+ 'NO supersession edge (use `cleo docs supersede` for that). ' +
6878
+ 'Audit log entry appended to .cleo/audit/docs-versioning.jsonl (squashed within a 5-min window).',
6879
+ tier: 1,
6880
+ idempotent: false,
6881
+ sessionRequired: false,
6882
+ requiredParams: ['slug'],
6883
+ params: [
6884
+ {
6885
+ name: 'slug',
6886
+ type: 'string' as const,
6887
+ required: true,
6888
+ description: 'Slug of the existing attachment to update',
6889
+ },
6890
+ {
6891
+ name: 'file',
6892
+ type: 'string' as const,
6893
+ required: false,
6894
+ description: 'Path to a local file containing the new content',
6895
+ },
6896
+ {
6897
+ name: 'content',
6898
+ type: 'string' as const,
6899
+ required: false,
6900
+ description: 'Inline UTF-8 content (mutually exclusive with file)',
6901
+ },
6902
+ {
6903
+ name: 'message',
6904
+ type: 'string' as const,
6905
+ required: false,
6906
+ description: 'One-line summary describing the change (recorded in the audit log)',
6907
+ },
6908
+ {
6909
+ name: 'status',
6910
+ type: 'string' as const,
6911
+ required: false,
6912
+ description:
6913
+ 'Override the new lifecycle status. Defaults to "draft" on every update. ' +
6914
+ 'Valid: draft|proposed|accepted|superseded|archived|deprecated.',
6915
+ },
6916
+ {
6917
+ name: 'attachedBy',
6918
+ type: 'string' as const,
6919
+ required: false,
6920
+ description: 'Agent identity that performed the update (defaults to "human")',
6921
+ },
6922
+ ],
6923
+ },
6800
6924
  // ── playbook.* HITL CLI surface (T935) ───────────────────────────────────
6801
6925
  {
6802
6926
  gateway: 'mutate',
@@ -1,15 +1,17 @@
1
1
  /**
2
- * Docs Domain Operations Contract (5 operations)
2
+ * Docs Domain Operations Contract (7 operations)
3
3
  *
4
4
  * Query operations: 3
5
- * Mutate operations: 2
5
+ * Mutate operations: 4
6
6
  *
7
7
  * Docs domain handles attachment management and document generation via `cleo docs`:
8
- * - add — attach a local file or URL to a CLEO owner entity (task, session, observation)
9
- * - list — list attachments for an owner entity
10
- * - generate — generate llms.txt summary for a task/epic, optionally attach as blob
11
- * - fetch — retrieve attachment bytes and metadata by ID or SHA-256
12
- * - remove — remove an attachment ref; purges blob when refCount hits zero
8
+ * - add — attach a local file or URL to a CLEO owner entity (task, session, observation)
9
+ * - list — list attachments for an owner entity
10
+ * - generate — generate llms.txt summary for a task/epic, optionally attach as blob
11
+ * - fetch — retrieve attachment bytes and metadata by ID or SHA-256
12
+ * - remove — remove an attachment ref; purges blob when refCount hits zero
13
+ * - update — UPDATE-in-place via slug (T10161)
14
+ * - supersede — atomically flip an older doc to `superseded` and link it to its successor (T10162)
13
15
  *
14
16
  * Owner type is auto-detected from the owner ID prefix:
15
17
  * T### → 'task'
@@ -398,6 +400,15 @@ export interface DocsAddParams {
398
400
  * @task T9636 (T-DOCS-SLUG-1)
399
401
  */
400
402
  slug?: string;
403
+ /**
404
+ * Optional human-readable title — used to derive the kebab-slug tail when
405
+ * auto-allocating an ADR slug. REQUIRED when `type === 'adr'` AND `slug`
406
+ * is omitted; ignored otherwise. The allocator slugifies the title via
407
+ * the shared kebabize helper.
408
+ *
409
+ * @task T10360 (closes T10153)
410
+ */
411
+ title?: string;
401
412
  /**
402
413
  * Optional taxonomy classification. Must match one of
403
414
  * {@link DOCS_TYPE_VALUES}; an invalid value returns `E_INVALID_TYPE`.
@@ -447,6 +458,13 @@ export interface DocsAddResult {
447
458
  slug?: string;
448
459
  /** Type classification recorded for this attachment, when provided (T9637). */
449
460
  type?: DocsType;
461
+ /**
462
+ * Auto-allocated ADR number, present iff the slug was minted by
463
+ * {@link allocateAdrSlug} (i.e. `--type adr` without `--slug`).
464
+ *
465
+ * @task T10360
466
+ */
467
+ adrNumber?: number;
450
468
  }
451
469
 
452
470
  // --------------------------------------------------------------------------
@@ -481,6 +499,157 @@ export interface DocsRemoveResult {
481
499
  attachmentBackend?: AttachmentBackend;
482
500
  }
483
501
 
502
+ // --------------------------------------------------------------------------
503
+ // docs.update — UPDATE-in-place via slug (T10161)
504
+ // --------------------------------------------------------------------------
505
+
506
+ /**
507
+ * Allowed `lifecycle_status` values mirrored from the attachments-table enum
508
+ * (`ATTACHMENT_LIFECYCLE_STATUSES` in
509
+ * `packages/core/src/store/schema/attachments.ts`). Kept inline here so the
510
+ * contract surface stays self-contained — the dispatch handler narrows raw
511
+ * `--status` input against this set before touching the store.
512
+ *
513
+ * @task T10161 (Epic T10157 / Saga T9855)
514
+ */
515
+ export type DocsLifecycleStatus =
516
+ | 'draft'
517
+ | 'proposed'
518
+ | 'accepted'
519
+ | 'superseded'
520
+ | 'archived'
521
+ | 'deprecated';
522
+
523
+ /**
524
+ * Parameters for `docs.update`.
525
+ *
526
+ * Replaces the blob content for an existing slug — the slug is transferred
527
+ * from the old `attachments` row to a new row carrying the new sha256, and
528
+ * the old row remains reachable by attachment-id for history. Unlike
529
+ * {@link DocsSupersedeParams}-style flows, NO supersession edge is created
530
+ * (callers wanting an explicit lineage edge should use `cleo docs supersede`).
531
+ *
532
+ * Exactly one of `file` or `content` MUST be provided.
533
+ *
534
+ * @task T10161 (Epic T10157 / Saga T9855 — E12.C4)
535
+ */
536
+ export interface DocsUpdateParams {
537
+ /** Slug of the existing attachment to update (required). */
538
+ slug: string;
539
+ /** Path to a local file containing the new content. */
540
+ file?: string;
541
+ /** Inline UTF-8 content (mutually exclusive with `file`). */
542
+ content?: string;
543
+ /** Optional one-line summary describing the change (recorded in the audit log). */
544
+ message?: string;
545
+ /**
546
+ * Override the new lifecycle status. Defaults to `'draft'` on every update
547
+ * so an explicit `accepted` doc gets back-pressured to draft on edit. Pass
548
+ * `--status accepted` (or any other valid status) to override.
549
+ */
550
+ status?: DocsLifecycleStatus;
551
+ /** Agent identity that performed the update (default: `'human'`). */
552
+ attachedBy?: string;
553
+ }
554
+
555
+ /**
556
+ * Result of `docs.update`.
557
+ *
558
+ * Identifies the new row (active) and the row it replaced. When the supplied
559
+ * content is byte-identical to the current content the operation is a noop —
560
+ * `previousSha256` will equal `sha256` and `changed` will be `false`.
561
+ *
562
+ * @task T10161
563
+ */
564
+ export interface DocsUpdateResult {
565
+ /** Slug that now points at the new content. */
566
+ slug: string;
567
+ /** Type/kind of the row, preserved from the prior version. */
568
+ type?: DocsType;
569
+ /** New attachment ID now bearing the slug. */
570
+ attachmentId: string;
571
+ /** Previous attachment ID; retains its row + bytes for history. */
572
+ previousAttachmentId: string;
573
+ /** SHA-256 hex of the new content. */
574
+ sha256: string;
575
+ /** SHA-256 hex of the prior content (equals `sha256` on a noop). */
576
+ previousSha256: string;
577
+ /** Whether the bytes actually changed (false ⇒ noop). */
578
+ changed: boolean;
579
+ /** Lifecycle status now stored on the new row. */
580
+ lifecycleStatus: DocsLifecycleStatus;
581
+ /** ISO 8601 timestamp the new row was registered. */
582
+ updatedAt: string;
583
+ /**
584
+ * Best-effort 1-indexed version number for the slug (count of historical
585
+ * rows that ever carried this slug, including the current one). When the
586
+ * underlying store cannot enumerate history this falls back to `2` for the
587
+ * first update and increments by 1 thereafter.
588
+ */
589
+ version: number;
590
+ /**
591
+ * True when this update was squashed into an existing audit entry within
592
+ * the 5-minute squash window (no new audit line was written).
593
+ */
594
+ squashed: boolean;
595
+ }
596
+
597
+ // --------------------------------------------------------------------------
598
+ // docs.supersede — flip an old doc to `superseded` and point at its successor
599
+ // --------------------------------------------------------------------------
600
+
601
+ /**
602
+ * Parameters for `docs.supersede`.
603
+ *
604
+ * Both `oldSlug` and `newSlug` reference rows on `attachments.slug`. The
605
+ * operation is atomic: either both rows update (and the lifecycle flip lands)
606
+ * or neither does.
607
+ *
608
+ * @task T10162 (Saga T9855 · Epic T10157 · ADR-078)
609
+ */
610
+ export interface DocsSupersedeParams {
611
+ /** Slug of the doc being replaced. */
612
+ oldSlug: string;
613
+ /** Slug of the doc that replaces {@link oldSlug}. */
614
+ newSlug: string;
615
+ /**
616
+ * Optional human-readable reason surfaced on the response envelope and
617
+ * preserved by future provenance-graph reads (T10166 `summary` field on
618
+ * the `supersedes` edge). Not persisted to a dedicated column today.
619
+ */
620
+ reason?: string;
621
+ }
622
+
623
+ /**
624
+ * Result of `docs.supersede`.
625
+ *
626
+ * @task T10162 (Saga T9855 · Epic T10157 · ADR-078)
627
+ */
628
+ export interface DocsSupersedeResult {
629
+ /** Slug of the doc that was just superseded. */
630
+ oldSlug: string;
631
+ /** Slug of the new doc that supersedes {@link oldSlug}. */
632
+ newSlug: string;
633
+ /** Attachment ID resolved from {@link oldSlug}. */
634
+ oldAttachmentId: string;
635
+ /** Attachment ID resolved from {@link newSlug}. */
636
+ newAttachmentId: string;
637
+ /** ISO-8601 timestamp the supersession was recorded. */
638
+ supersededAt: string;
639
+ /**
640
+ * Stable identifier for the lineage edge minted by this call.
641
+ *
642
+ * The edge itself is not stored in a dedicated table — it is reconstructed
643
+ * at read time from `attachments.supersedes` / `attachments.superseded_by`
644
+ * by `cleo docs provenance` (T10166). The ID is deterministic
645
+ * (`supersedes:<newAttachmentId>->&<oldAttachmentId>`) so concurrent callers
646
+ * that win the transaction race observe a stable value.
647
+ */
648
+ edgeId: string;
649
+ /** Optional reason carried through from the request. */
650
+ reason?: string;
651
+ }
652
+
484
653
  // ============================================================================
485
654
  // Discriminated Union (DocsOps)
486
655
  // ============================================================================
@@ -500,7 +669,9 @@ export type DocsOps =
500
669
  | { op: 'docs.fetch'; params: DocsFetchParams; result: DocsFetchResult }
501
670
  | { op: 'docs.generate'; params: DocsGenerateParams; result: DocsGenerateResult }
502
671
  | { op: 'docs.add'; params: DocsAddParams; result: DocsAddResult }
503
- | { op: 'docs.remove'; params: DocsRemoveParams; result: DocsRemoveResult };
672
+ | { op: 'docs.remove'; params: DocsRemoveParams; result: DocsRemoveResult }
673
+ | { op: 'docs.update'; params: DocsUpdateParams; result: DocsUpdateResult }
674
+ | { op: 'docs.supersede'; params: DocsSupersedeParams; result: DocsSupersedeResult };
504
675
 
505
676
  /**
506
677
  * Enumeration of all docs domain operation names.
@@ -509,4 +680,11 @@ export type DocsOps =
509
680
  * Useful for dynamic operation dispatch, type narrowing, or documentation.
510
681
  * Kept in sync with the `DocsOps` discriminated union above.
511
682
  */
512
- export type DocsOp = 'docs.list' | 'docs.fetch' | 'docs.generate' | 'docs.add' | 'docs.remove';
683
+ export type DocsOp =
684
+ | 'docs.list'
685
+ | 'docs.fetch'
686
+ | 'docs.generate'
687
+ | 'docs.add'
688
+ | 'docs.remove'
689
+ | 'docs.update'
690
+ | 'docs.supersede';
@@ -125,6 +125,19 @@ export interface TasksFindParams {
125
125
  * @task T9072
126
126
  */
127
127
  kind?: string;
128
+ /**
129
+ * Unified urgency surface (T9905).
130
+ *
131
+ * When `true`, the predicate is
132
+ *
133
+ * `priority IN ('critical','high') OR severity IN ('P0','P1')`
134
+ *
135
+ * combining the two orthogonal urgency axes (priority + severity) into a
136
+ * single filter. Composes with other filters via AND.
137
+ *
138
+ * @task T9905
139
+ */
140
+ urgent?: boolean;
128
141
  }
129
142
  export type TasksFindResult = MinimalTask[];
130
143
 
@@ -103,4 +103,11 @@ export interface MinimalTaskRecord {
103
103
  type?: string;
104
104
  /** Scope size estimate — helps agents decide if decomposition is needed. @task T091 */
105
105
  size?: string;
106
+ /**
107
+ * Bug severity axis — P0|P1|P2|P3 (string-widened). Surfaced so the unified
108
+ * urgency surface (`cleo find --urgent`) can identify P0/P1 rows without a
109
+ * follow-up `cleo show` per row.
110
+ * @task T9905
111
+ */
112
+ severity?: string | null;
106
113
  }