@cleocode/contracts 2026.5.104 → 2026.5.106

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 (64) hide show
  1. package/dist/boundary.js +5 -5
  2. package/dist/boundary.js.map +1 -1
  3. package/dist/errors.d.ts +61 -0
  4. package/dist/errors.d.ts.map +1 -1
  5. package/dist/errors.js +46 -0
  6. package/dist/errors.js.map +1 -1
  7. package/dist/exit-codes.d.ts +13 -0
  8. package/dist/exit-codes.d.ts.map +1 -1
  9. package/dist/exit-codes.js +13 -0
  10. package/dist/exit-codes.js.map +1 -1
  11. package/dist/index.d.ts +5 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +5 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/release/ship-e2e-smoke.d.ts +97 -0
  16. package/dist/release/ship-e2e-smoke.d.ts.map +1 -0
  17. package/dist/release/ship-e2e-smoke.js +20 -0
  18. package/dist/release/ship-e2e-smoke.js.map +1 -0
  19. package/dist/render/__tests__/envelope.test.d.ts +14 -0
  20. package/dist/render/__tests__/envelope.test.d.ts.map +1 -0
  21. package/dist/render/__tests__/envelope.test.js +178 -0
  22. package/dist/render/__tests__/envelope.test.js.map +1 -0
  23. package/dist/render/__tests__/icon.test.d.ts +2 -0
  24. package/dist/render/__tests__/icon.test.d.ts.map +1 -0
  25. package/dist/render/__tests__/icon.test.js +124 -0
  26. package/dist/render/__tests__/icon.test.js.map +1 -0
  27. package/dist/render/envelope.d.ts +103 -0
  28. package/dist/render/envelope.d.ts.map +1 -0
  29. package/dist/render/envelope.js +53 -0
  30. package/dist/render/envelope.js.map +1 -0
  31. package/dist/render/icon.d.ts +120 -0
  32. package/dist/render/icon.d.ts.map +1 -0
  33. package/dist/render/icon.js +163 -0
  34. package/dist/render/icon.js.map +1 -0
  35. package/dist/render/index.d.ts +16 -0
  36. package/dist/render/index.d.ts.map +1 -0
  37. package/dist/render/index.js +12 -0
  38. package/dist/render/index.js.map +1 -0
  39. package/dist/render/list.d.ts +77 -0
  40. package/dist/render/list.d.ts.map +1 -0
  41. package/dist/render/list.js +39 -0
  42. package/dist/render/list.js.map +1 -0
  43. package/dist/render/table.d.ts +73 -0
  44. package/dist/render/table.d.ts.map +1 -0
  45. package/dist/render/table.js +32 -0
  46. package/dist/render/table.js.map +1 -0
  47. package/dist/render/tree.d.ts +94 -0
  48. package/dist/render/tree.d.ts.map +1 -0
  49. package/dist/render/tree.js +30 -0
  50. package/dist/render/tree.js.map +1 -0
  51. package/package.json +2 -2
  52. package/src/boundary.ts +5 -5
  53. package/src/errors.ts +71 -0
  54. package/src/exit-codes.ts +13 -0
  55. package/src/index.ts +15 -1
  56. package/src/release/ship-e2e-smoke.ts +113 -0
  57. package/src/render/__tests__/envelope.test.ts +194 -0
  58. package/src/render/__tests__/icon.test.ts +138 -0
  59. package/src/render/envelope.ts +112 -0
  60. package/src/render/icon.ts +182 -0
  61. package/src/render/index.ts +40 -0
  62. package/src/render/list.ts +90 -0
  63. package/src/render/table.ts +85 -0
  64. package/src/render/tree.ts +114 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tree.d.ts","sourceRoot":"","sources":["../../src/render/tree.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH;;;;GAIG;AACH,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;AAEhE;;;;GAIG;AACH,MAAM,MAAM,cAAc,GACtB,SAAS,GACT,aAAa,GACb,MAAM,GACN,SAAS,GACT,WAAW,GACX,UAAU,CAAC;AAEf;;;;;;;;GAQG;AACH,MAAM,WAAW,YAAY,CAAC,CAAC;IAC7B,4EAA4E;IAC5E,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,4DAA4D;IAC5D,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,mDAAmD;IACnD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,mDAAmD;IACnD,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAC5B,wBAAwB;IACxB,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;IAChC,+DAA+D;IAC/D,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,uCAAuC;IACvC,QAAQ,CAAC,QAAQ,EAAE,CAAC,CAAC;CACtB;AAED;;;;GAIG;AACH,MAAM,WAAW,YAAY,CAAC,CAAC;IAC7B,sDAAsD;IACtD,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,CAAC;IAC9C,0FAA0F;IAC1F,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sDAAsD;IACtD,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,sEAAsE;IACtE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;GAKG;AACH,MAAM,WAAW,iBAAiB;IAChC,sCAAsC;IACtC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,wCAAwC;IACxC,QAAQ,CAAC,KAAK,CAAC,EAAE,aAAa,CAAC,YAAY,CAAC,CAAC;IAC7C,uEAAuE;IACvE,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC;IACzB,qEAAqE;IACrE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,oEAAoE;IACpE,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC5B,kEAAkE;IAClE,QAAQ,CAAC,YAAY,CAAC,EAAE,OAAO,CAAC;CACjC;AAED;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,YAAY,CAAC,CAAC,CAAC,CAS1E"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Typed render contracts — tree (flattened hierarchy).
3
+ *
4
+ * Part of the Human Render Contract (Epic T10114, ADR-077). The flat-tree shape
5
+ * is the canonical wire format for hierarchical task / saga / epic listings —
6
+ * presenters re-materialise the parent/child structure from `parentId` + `depth`.
7
+ *
8
+ * @epic T10114
9
+ * @task T10138
10
+ */
11
+ /**
12
+ * Runtime type guard for `TreeResponse<T>`.
13
+ *
14
+ * Verifies the envelope shape without inspecting node payloads — the generic
15
+ * `T` cannot be checked at runtime, so callers must layer per-node validation
16
+ * separately if they need stronger guarantees.
17
+ *
18
+ * @param value — candidate value to inspect.
19
+ * @returns `true` iff `value` matches the `TreeResponse<T>` envelope shape.
20
+ */
21
+ export function isTreeResponse(value) {
22
+ if (typeof value !== 'object' || value === null)
23
+ return false;
24
+ const v = value;
25
+ return (Array.isArray(v.tree) &&
26
+ typeof v.root === 'string' &&
27
+ typeof v.totalNodes === 'number' &&
28
+ typeof v.maxDepth === 'number');
29
+ }
30
+ //# sourceMappingURL=tree.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tree.js","sourceRoot":"","sources":["../../src/render/tree.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAqFH;;;;;;;;;GASG;AACH,MAAM,UAAU,cAAc,CAAI,KAAc;IAC9C,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,KAAK,CAAC;IAC9D,MAAM,CAAC,GAAG,KAAgC,CAAC;IAC3C,OAAO,CACL,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC;QACrB,OAAO,CAAC,CAAC,IAAI,KAAK,QAAQ;QAC1B,OAAO,CAAC,CAAC,UAAU,KAAK,QAAQ;QAChC,OAAO,CAAC,CAAC,QAAQ,KAAK,QAAQ,CAC/B,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cleocode/contracts",
3
- "version": "2026.5.104",
3
+ "version": "v2026.5.106",
4
4
  "description": "Domain types, interfaces, and contracts for the CLEO ecosystem",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -138,7 +138,7 @@
138
138
  "dependencies": {
139
139
  "zod": "^4.3.6",
140
140
  "zod-to-json-schema": "^3.25.2",
141
- "@cleocode/lafs": "2026.5.104"
141
+ "@cleocode/lafs": "v2026.5.106"
142
142
  },
143
143
  "scripts": {
144
144
  "build": "tsc -b --force && node scripts/emit-schemas.mjs",
package/src/boundary.ts CHANGED
@@ -318,9 +318,9 @@ export const BOUNDARY_REGISTRY: readonly BoundaryEntry[] = [
318
318
  'Verified-dead code: 531 LOC Rust crate referenced ONLY by packages/core/src/llm/rust/__tests__/. Production transports (chat-completions/gemini/openai) import StreamingThinkScrubber from the TS think-scrubber.js path. CLEO_USE_RUST gate is never set in any workflow. Scheduled for deletion in T10205; see ADR-078 implementation tasks.',
319
319
  },
320
320
  {
321
- module: 'conduit-core',
321
+ module: 'cleo-conduit-core',
322
322
  intent: 'data-manifest',
323
- rustCore: 'crates/conduit-core',
323
+ rustCore: 'crates/cleo-conduit-core',
324
324
  canonicalHome: 'cleocode',
325
325
  perfBudget: {
326
326
  latency_p50_ms: 10,
@@ -331,7 +331,7 @@ export const BOUNDARY_REGISTRY: readonly BoundaryEntry[] = [
331
331
  },
332
332
  amendments: [],
333
333
  rationale:
334
- 'Conduit serde-types crate (681 LOC) — stable contract surface consumed by cant-core, signaldock-protocol, signaldock-core, integration-tests, AND external /mnt/projects/signaldock-core + /mnt/projects/signaldock/backend via git deps. Mirrors @cleocode/contracts/conduit.ts. Forced KEEP+publish=true: deleting breaks production, and signaldock-protocol publish requires it.',
334
+ 'Conduit serde-types crate (681 LOC) — renamed from conduit-core to cleo-conduit-core (T10185, saga T10180) because conduit-core is squatted on crates.io. Stable contract surface consumed by cant-core, signaldock-protocol, signaldock-core, integration-tests, AND external /mnt/projects/signaldock-core + /mnt/projects/signaldock/backend via git deps. Mirrors @cleocode/contracts/conduit.ts. Forced KEEP+publish=true: deleting breaks production, and signaldock-protocol publish requires it.',
335
335
  },
336
336
  {
337
337
  module: 'integration-tests',
@@ -348,7 +348,7 @@ export const BOUNDARY_REGISTRY: readonly BoundaryEntry[] = [
348
348
  },
349
349
  amendments: [],
350
350
  rationale:
351
- 'Internal Rust test harness (241 LOC) over lafs-core/conduit-core/cant-core/cant-router/signaldock-core. publish=false intentional — test code never ships.',
351
+ 'Internal Rust test harness (241 LOC) over lafs-core/cleo-conduit-core/cant-core/cant-router/signaldock-core. publish=false intentional — test code never ships.',
352
352
  },
353
353
  {
354
354
  module: 'lafs-core',
@@ -366,7 +366,7 @@ export const BOUNDARY_REGISTRY: readonly BoundaryEntry[] = [
366
366
  },
367
367
  amendments: [],
368
368
  rationale:
369
- 'LAFS envelope serialization core (1,432 LOC) consumed by conduit-core, signaldock-protocol, lafs-napi, integration-tests. Mirrors @cleocode/lafs/src/envelope.ts. Flip publish=true to expose canonical envelope spec as a Rust library.',
369
+ 'LAFS envelope serialization core (1,432 LOC) consumed by cleo-conduit-core, signaldock-protocol, lafs-napi, integration-tests. Mirrors @cleocode/lafs/src/envelope.ts. Flip publish=true to expose canonical envelope spec as a Rust library.',
370
370
  },
371
371
  {
372
372
  module: 'lafs-napi',
package/src/errors.ts CHANGED
@@ -519,3 +519,74 @@ export function isErrorResult(result: {
519
519
  }): result is { success: false; error: string } {
520
520
  return !result.success;
521
521
  }
522
+
523
+ /**
524
+ * Structured details payload attached to {@link ChangesetYamlInvalidError}.
525
+ *
526
+ * Carries the offending file path, the 1-based line number where the YAML
527
+ * parser bailed (when determinable), and an optional snippet of the parsed
528
+ * region to surface in CLI output and CI logs.
529
+ *
530
+ * @task T10105
531
+ * @epic E-RELEASE-PLAN-CHANGELOG
532
+ */
533
+ export interface ChangesetYamlInvalidDetails {
534
+ /** Absolute or process-relative path to the offending `.md` file. */
535
+ file: string;
536
+ /** 1-based line number reported by the YAML parser; `null` when unknown. */
537
+ line: number | null;
538
+ /** Optional short excerpt around the failure (≤ 120 chars). */
539
+ snippet?: string;
540
+ /** Underlying parser message, propagated verbatim for diagnostics. */
541
+ parserMessage: string;
542
+ }
543
+
544
+ /**
545
+ * Thrown when `cleo release plan` (or the lint script) encounters a `.changeset/*.md`
546
+ * file whose YAML frontmatter cannot be parsed.
547
+ *
548
+ * Replaces the pre-T10105 silent-skip behaviour where a single malformed
549
+ * file (e.g. an unquoted colon in `summary:`) caused the aggregator to
550
+ * silently drop ALL changeset entries for that release. The v2026.5.100
551
+ * ship lost the v5.100/v5.101/v5.103 CHANGELOG entries to exactly this
552
+ * vector — see Saga T10099 SG-RELEASE-AUDIT-V2.
553
+ *
554
+ * @remarks
555
+ * Carries `exitCode` aligned with {@link ExitCode.VALIDATION_ERROR} (6) so
556
+ * the CLI surfaces a deterministic non-zero exit when invoked from CI.
557
+ *
558
+ * @example
559
+ * ```typescript
560
+ * throw new ChangesetYamlInvalidError({
561
+ * file: '.changeset/bad.md',
562
+ * line: 4,
563
+ * parserMessage: 'Nested mappings are not allowed in compact mappings',
564
+ * });
565
+ * ```
566
+ *
567
+ * @task T10105
568
+ * @epic E-RELEASE-PLAN-CHANGELOG
569
+ * @saga T10099
570
+ */
571
+ export class ChangesetYamlInvalidError extends Error {
572
+ /** Stable LAFS error code string for envelope emission. */
573
+ readonly code = 'E_CHANGESET_YAML_INVALID';
574
+ /** Numeric exit code aligned with {@link ExitCode.VALIDATION_ERROR} (6). */
575
+ readonly exitCode: ExitCode = ExitCode.VALIDATION_ERROR;
576
+ /** Structured payload describing the offending file + location. */
577
+ readonly details: ChangesetYamlInvalidDetails;
578
+
579
+ /**
580
+ * @param details - Structured failure context including file path and
581
+ * line number. The constructor builds a deterministic human-readable
582
+ * message of the form `<file>:<line> invalid YAML frontmatter: <msg>`.
583
+ */
584
+ constructor(details: ChangesetYamlInvalidDetails) {
585
+ const linePart = details.line !== null ? `:${details.line}` : '';
586
+ super(
587
+ `E_CHANGESET_YAML_INVALID: ${details.file}${linePart} invalid YAML frontmatter: ${details.parserMessage}`,
588
+ );
589
+ this.name = 'ChangesetYamlInvalidError';
590
+ this.details = details;
591
+ }
592
+ }
package/src/exit-codes.ts CHANGED
@@ -266,6 +266,19 @@ export const E_EVIDENCE_INSUFFICIENT = 'E_EVIDENCE_INSUFFICIENT' as const;
266
266
  export const E_PLAN_NOT_FOUND = 'E_PLAN_NOT_FOUND' as const;
267
267
  /** Generic release-plan validation failure (e.g. schema). Exit code 6. */
268
268
  export const E_RELEASE_PLAN_INVALID = 'E_RELEASE_PLAN_INVALID' as const;
269
+ /**
270
+ * `cleo release plan` (or the lint script) encountered a `.changeset/*.md`
271
+ * file whose YAML frontmatter cannot be parsed. Exit code 6 (VALIDATION_ERROR).
272
+ *
273
+ * Replaces the pre-T10105 silent-skip that caused the v2026.5.100 ship to
274
+ * lose v5.100/v5.101/v5.103 CHANGELOG entries when a single changeset had
275
+ * an unquoted colon in `summary:`.
276
+ *
277
+ * @task T10105
278
+ * @epic E-RELEASE-PLAN-CHANGELOG
279
+ * @saga T10099
280
+ */
281
+ export const E_CHANGESET_YAML_INVALID = 'E_CHANGESET_YAML_INVALID' as const;
269
282
  /**
270
283
  * Release row exists but is not in the status FSM state required by the
271
284
  * caller (R-051 — `cleo release open` requires status='planned'). Exit code 6.
package/src/index.ts CHANGED
@@ -349,9 +349,10 @@ export {
349
349
  TASK_SEVERITIES,
350
350
  TASK_SIZES,
351
351
  } from './enums.js';
352
- export type { SagaInvariantErrorCode } from './errors.js';
352
+ export type { ChangesetYamlInvalidDetails, SagaInvariantErrorCode } from './errors.js';
353
353
  // === Error Utilities ===
354
354
  export {
355
+ ChangesetYamlInvalidError,
355
356
  ClassifierUnregisteredAgentError,
356
357
  createErrorResult,
357
358
  createSuccessResult,
@@ -397,6 +398,8 @@ export {
397
398
  } from './evidence-record-schema.js';
398
399
  // === Exit Codes ===
399
400
  export {
401
+ // T10105 / Saga T10099 — fail-loud changeset parse
402
+ E_CHANGESET_YAML_INVALID,
400
403
  // SPEC-T9345 release pipeline v2 error code names (T9525)
401
404
  E_CHANNEL_MISMATCH,
402
405
  E_DIRTY_TREE,
@@ -1480,6 +1483,15 @@ export {
1480
1483
  TASK_KIND,
1481
1484
  TaskKindSchema,
1482
1485
  } from './release/plan.js';
1486
+ // === Release ship-e2e-smoke (T10103) ===
1487
+ export type {
1488
+ ShipE2eSmokeFinalState,
1489
+ ShipE2eSmokeParams,
1490
+ ShipE2eSmokeResult,
1491
+ ShipE2eSmokeStep,
1492
+ ShipE2eSmokeStepName,
1493
+ ShipE2eSmokeStepStatus,
1494
+ } from './release/ship-e2e-smoke.js';
1483
1495
  // === Release Version Bump ===
1484
1496
  export type {
1485
1497
  BumpResult,
@@ -1490,6 +1502,8 @@ export type {
1490
1502
  VersionBumpTarget,
1491
1503
  VersionBumpTargetSource,
1492
1504
  } from './release/version-bump.js';
1505
+ // === Human Render Contract (Epic T10114, ADR-077) ===
1506
+ export * from './render/index.js';
1493
1507
  // === Result Types (Dashboard, Stats, Log, Context, Sequence, Analysis, Deps) ===
1494
1508
  export type {
1495
1509
  BottleneckTask,
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Ship E2E Smoke contracts — types for the `cleo release ship-e2e-smoke`
3
+ * one-shot walker that exercises the full release lifecycle end-to-end.
4
+ *
5
+ * The smoke runs `plan` → `open` → wait-for-PR-merge → wait-for-tag →
6
+ * verify-npm-published. Each step records its status, duration, and any
7
+ * side-effect identifier (PR number, tag SHA, npm version) for downstream
8
+ * inspection. The aggregate envelope reports the final lifecycle state
9
+ * reached so failures can be resumed.
10
+ *
11
+ * Dry-run is the default; `--execute` flips the runner to perform real
12
+ * mutations. Both modes return the same envelope shape so consumers can
13
+ * diff a dry-run preview against a real run.
14
+ *
15
+ * @task T10103
16
+ * @epic E-CLEO-RELEASE-VERBS
17
+ * @saga T10099
18
+ */
19
+
20
+ /**
21
+ * The discrete steps the smoke walker executes, in order. The aggregate
22
+ * envelope records one entry per step with status + duration.
23
+ */
24
+ export type ShipE2eSmokeStepName =
25
+ | 'plan'
26
+ | 'open'
27
+ | 'wait-for-pr'
28
+ | 'wait-for-tag'
29
+ | 'verify-npm-published';
30
+
31
+ /** Outcome of a single smoke step. */
32
+ export type ShipE2eSmokeStepStatus =
33
+ /** Step completed successfully (or, in dry-run, would have run). */
34
+ | 'ok'
35
+ /** Step was skipped — e.g. dry-run mode shortcuts post-plan. */
36
+ | 'skipped'
37
+ /** Step failed; the smoke envelope's success flag will be false. */
38
+ | 'failed';
39
+
40
+ /** Per-step result attached to the smoke envelope. */
41
+ export interface ShipE2eSmokeStep {
42
+ /** The step name. */
43
+ name: ShipE2eSmokeStepName;
44
+ /** Outcome. */
45
+ status: ShipE2eSmokeStepStatus;
46
+ /** Wall-clock duration in milliseconds. */
47
+ durationMs: number;
48
+ /**
49
+ * Optional human-readable detail string captured at step completion
50
+ * (e.g. "plan written to .cleo/release/v2026.5.104.plan.json",
51
+ * "PR #524 merged", "tag v2026.5.104 found", "npm @cleocode/cleo@…
52
+ * published").
53
+ */
54
+ detail?: string;
55
+ /**
56
+ * Optional error message when `status === 'failed'`. Single-line so
57
+ * the envelope stays compact for CLI consumers.
58
+ */
59
+ error?: string;
60
+ }
61
+
62
+ /**
63
+ * Final lifecycle state the smoke reached. Mirrors the `releases.status`
64
+ * column values plus a synthetic `npm-published` terminal state captured
65
+ * by the verify step.
66
+ */
67
+ export type ShipE2eSmokeFinalState =
68
+ | 'not-started'
69
+ | 'planned'
70
+ | 'pr-opened'
71
+ | 'pr-merged'
72
+ | 'tag-pushed'
73
+ | 'npm-published';
74
+
75
+ /** Parameters accepted by the smoke walker. */
76
+ export interface ShipE2eSmokeParams {
77
+ /** Candidate release version (e.g. "v2026.6.0" or "2026.6.0"). */
78
+ version: string;
79
+ /** Epic task ID that scopes the release (forwarded to `release plan`). */
80
+ epicId: string;
81
+ /**
82
+ * When false (default), no side effects — every step reports what it
83
+ * WOULD do. When true, each step performs its real mutation.
84
+ */
85
+ execute: boolean;
86
+ /**
87
+ * Total wall-clock budget across all polling waits, in milliseconds.
88
+ * Default 30 minutes when omitted. Per-step waits MUST respect this
89
+ * shared budget so a stuck PR doesn't hang the whole smoke.
90
+ */
91
+ totalTimeoutMs?: number;
92
+ /**
93
+ * Poll interval for wait-* steps, in milliseconds. Default 5_000.
94
+ * Lowered in tests via DI to keep the suite fast.
95
+ */
96
+ pollIntervalMs?: number;
97
+ }
98
+
99
+ /** Top-level envelope returned by the smoke walker. */
100
+ export interface ShipE2eSmokeResult {
101
+ /** True when every step completed (or was intentionally skipped). */
102
+ success: boolean;
103
+ /** Echo of the version under smoke. */
104
+ version: string;
105
+ /** True when --execute was passed; false for dry-run preview. */
106
+ executed: boolean;
107
+ /** Ordered step log. */
108
+ steps: ShipE2eSmokeStep[];
109
+ /** Lifecycle state at smoke termination. */
110
+ finalState: ShipE2eSmokeFinalState;
111
+ /** Total wall-clock duration across all steps, in milliseconds. */
112
+ totalDurationMs: number;
113
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Compile-time + runtime narrowing tests for `RenderableEnvelope<T>` and the
3
+ * per-kind type guards.
4
+ *
5
+ * Each test constructs a valid envelope of a single `kind` and asserts that
6
+ * the matching guard narrows it correctly while every other guard returns
7
+ * `false`. This catches both schema drift and accidental short-circuits in
8
+ * the guards.
9
+ *
10
+ * @epic T10114
11
+ * @task T10141
12
+ */
13
+
14
+ import { describe, expect, it } from 'vitest';
15
+ import {
16
+ isGenericEnvelope,
17
+ isGroupedListEnvelope,
18
+ isListEnvelope,
19
+ isSectionEnvelope,
20
+ isSingleEnvelope,
21
+ isTableEnvelope,
22
+ isTreeEnvelope,
23
+ type RenderableEnvelope,
24
+ } from '../envelope.js';
25
+ import { isGroupedListResponse, isListResponse } from '../list.js';
26
+ import { isTableResponse } from '../table.js';
27
+ import { isTreeResponse } from '../tree.js';
28
+
29
+ interface Sample {
30
+ readonly x: number;
31
+ }
32
+
33
+ describe('RenderableEnvelope<T> discriminated-union narrowing', () => {
34
+ it('narrows the tree variant and rejects all others', () => {
35
+ const env: RenderableEnvelope<Sample> = {
36
+ kind: 'tree',
37
+ data: { tree: [], root: 'T1', totalNodes: 0, maxDepth: 0 },
38
+ };
39
+ expect(isTreeEnvelope(env)).toBe(true);
40
+ expect(isTableEnvelope(env)).toBe(false);
41
+ expect(isListEnvelope(env)).toBe(false);
42
+ expect(isGroupedListEnvelope(env)).toBe(false);
43
+ expect(isSectionEnvelope(env)).toBe(false);
44
+ expect(isSingleEnvelope(env)).toBe(false);
45
+ expect(isGenericEnvelope(env)).toBe(false);
46
+ if (isTreeEnvelope(env)) {
47
+ expect(env.data.root).toBe('T1');
48
+ } else {
49
+ throw new Error('expected tree narrowing');
50
+ }
51
+ });
52
+
53
+ it('narrows the table variant and rejects all others', () => {
54
+ const env: RenderableEnvelope<Sample> = {
55
+ kind: 'table',
56
+ data: {
57
+ rows: [{ x: 1 }],
58
+ schema: { columns: [{ key: 'x', header: 'X' }] },
59
+ total: 1,
60
+ },
61
+ };
62
+ expect(isTableEnvelope(env)).toBe(true);
63
+ expect(isTreeEnvelope(env)).toBe(false);
64
+ if (isTableEnvelope(env)) {
65
+ expect(env.data.rows[0]?.x).toBe(1);
66
+ } else {
67
+ throw new Error('expected table narrowing');
68
+ }
69
+ });
70
+
71
+ it('narrows the flat list variant', () => {
72
+ const env: RenderableEnvelope<Sample> = {
73
+ kind: 'list',
74
+ data: { items: [{ x: 7 }], total: 1, renderItem: 'kv' },
75
+ };
76
+ expect(isListEnvelope(env)).toBe(true);
77
+ expect(isGroupedListEnvelope(env)).toBe(false);
78
+ if (isListEnvelope(env)) {
79
+ expect(env.data.items[0]?.x).toBe(7);
80
+ expect(env.data.renderItem).toBe('kv');
81
+ } else {
82
+ throw new Error('expected list narrowing');
83
+ }
84
+ });
85
+
86
+ it('narrows the grouped-list variant', () => {
87
+ const env: RenderableEnvelope<Sample> = {
88
+ kind: 'grouped-list',
89
+ data: {
90
+ groups: [{ key: 'a', label: 'A', items: [{ x: 2 }] }],
91
+ total: 1,
92
+ },
93
+ };
94
+ expect(isGroupedListEnvelope(env)).toBe(true);
95
+ expect(isListEnvelope(env)).toBe(false);
96
+ if (isGroupedListEnvelope(env)) {
97
+ expect(env.data.groups[0]?.items[0]?.x).toBe(2);
98
+ } else {
99
+ throw new Error('expected grouped-list narrowing');
100
+ }
101
+ });
102
+
103
+ it('narrows the section variant', () => {
104
+ const env: RenderableEnvelope<Sample> = {
105
+ kind: 'section',
106
+ data: { header: 'Summary', icon: 'i', items: ['a', 'b'] },
107
+ };
108
+ expect(isSectionEnvelope(env)).toBe(true);
109
+ if (isSectionEnvelope(env)) {
110
+ expect(env.data.items).toEqual(['a', 'b']);
111
+ } else {
112
+ throw new Error('expected section narrowing');
113
+ }
114
+ });
115
+
116
+ it('narrows the single variant', () => {
117
+ const env: RenderableEnvelope<Sample> = {
118
+ kind: 'single',
119
+ data: { x: 42 },
120
+ };
121
+ expect(isSingleEnvelope(env)).toBe(true);
122
+ if (isSingleEnvelope(env)) {
123
+ expect(env.data.x).toBe(42);
124
+ } else {
125
+ throw new Error('expected single narrowing');
126
+ }
127
+ });
128
+
129
+ it('narrows the generic variant', () => {
130
+ const env: RenderableEnvelope<Sample> = {
131
+ kind: 'generic',
132
+ data: { foo: 'bar', count: 3 },
133
+ };
134
+ expect(isGenericEnvelope(env)).toBe(true);
135
+ if (isGenericEnvelope(env)) {
136
+ expect(env.data.foo).toBe('bar');
137
+ } else {
138
+ throw new Error('expected generic narrowing');
139
+ }
140
+ });
141
+
142
+ it('exhaustively switches on kind without falling through', () => {
143
+ const envelopes: Array<RenderableEnvelope<Sample>> = [
144
+ { kind: 'tree', data: { tree: [], root: 'r', totalNodes: 0, maxDepth: 0 } },
145
+ {
146
+ kind: 'table',
147
+ data: { rows: [], schema: { columns: [] }, total: 0 },
148
+ },
149
+ { kind: 'list', data: { items: [], total: 0 } },
150
+ { kind: 'grouped-list', data: { groups: [], total: 0 } },
151
+ { kind: 'section', data: { header: 'h', items: [] } },
152
+ { kind: 'single', data: { x: 0 } },
153
+ { kind: 'generic', data: {} },
154
+ ];
155
+ const seen = new Set<RenderableEnvelope<Sample>['kind']>();
156
+ for (const env of envelopes) {
157
+ switch (env.kind) {
158
+ case 'tree':
159
+ case 'table':
160
+ case 'list':
161
+ case 'grouped-list':
162
+ case 'section':
163
+ case 'single':
164
+ case 'generic':
165
+ seen.add(env.kind);
166
+ break;
167
+ default: {
168
+ const _exhaustive: never = env;
169
+ throw new Error(`unreachable: ${JSON.stringify(_exhaustive)}`);
170
+ }
171
+ }
172
+ }
173
+ expect(seen.size).toBe(7);
174
+ });
175
+ });
176
+
177
+ describe('per-response type guards', () => {
178
+ it('accepts valid response shapes', () => {
179
+ expect(isTreeResponse({ tree: [], root: 'r', totalNodes: 0, maxDepth: 0 })).toBe(true);
180
+ expect(isTableResponse({ rows: [], schema: { columns: [] }, total: 0 })).toBe(true);
181
+ expect(isListResponse({ items: [], total: 0 })).toBe(true);
182
+ expect(isGroupedListResponse({ groups: [], total: 0 })).toBe(true);
183
+ });
184
+
185
+ it('rejects invalid response shapes', () => {
186
+ expect(isTreeResponse(null)).toBe(false);
187
+ expect(isTreeResponse({})).toBe(false);
188
+ expect(isTreeResponse({ tree: 'nope', root: 'r', totalNodes: 0, maxDepth: 0 })).toBe(false);
189
+ expect(isTableResponse({ rows: [], total: 0 })).toBe(false);
190
+ expect(isTableResponse({ rows: [], schema: 'nope', total: 0 })).toBe(false);
191
+ expect(isListResponse({ items: 'nope', total: 0 })).toBe(false);
192
+ expect(isGroupedListResponse({ groups: 'nope', total: 0 })).toBe(false);
193
+ });
194
+ });
@@ -0,0 +1,138 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+ import { ascii, BadgeIcon, KindIcon, pickIcon, RelationIcon, StatusIcon } from '../icon.js';
3
+
4
+ describe('icon enums', () => {
5
+ it('StatusIcon emoji values match ADR-077', () => {
6
+ expect(StatusIcon.PENDING).toBe('⏳');
7
+ expect(StatusIcon.ACTIVE).toBe('🚧');
8
+ expect(StatusIcon.DONE).toBe('✅');
9
+ expect(StatusIcon.BLOCKED).toBe('🚪');
10
+ expect(StatusIcon.ARCHIVED).toBe('🗄');
11
+ expect(StatusIcon.CANCELLED).toBe('✗');
12
+ });
13
+
14
+ it('KindIcon emoji values match ADR-077', () => {
15
+ expect(KindIcon.SAGA).toBe('🌲');
16
+ expect(KindIcon.EPIC).toBe('📋');
17
+ expect(KindIcon.TASK).toBe('•');
18
+ expect(KindIcon.SUBTASK).toBe('◦');
19
+ expect(KindIcon.RESEARCH).toBe('📖');
20
+ expect(KindIcon.BUG).toBe('🐛');
21
+ expect(KindIcon.RELEASE).toBe('🚀');
22
+ });
23
+
24
+ it('BadgeIcon emoji values match ADR-077 (ORPHAN amended in T10137)', () => {
25
+ expect(BadgeIcon.EMPTY).toBe('🪦');
26
+ // ADR-077 §2 originally specified '🚪' for ORPHAN — identical to
27
+ // StatusIcon.BLOCKED. String enums share runtime values so ascii() cannot
28
+ // disambiguate. Amended to '👻' (abandoned/lonely). T10137 (B12) updates
29
+ // the ADR text to match.
30
+ expect(BadgeIcon.ORPHAN).toBe('👻');
31
+ expect(BadgeIcon.NESTED).toBe('🔁');
32
+ expect(BadgeIcon.CAUTION).toBe('⚠');
33
+ expect(BadgeIcon.NEW).toBe('★');
34
+ });
35
+
36
+ it('RelationIcon emoji values match ADR-077', () => {
37
+ expect(RelationIcon.GROUPS).toBe('⊂');
38
+ expect(RelationIcon.PARENT).toBe('⤴');
39
+ expect(RelationIcon.DEPENDS).toBe('⇨');
40
+ expect(RelationIcon.BLOCKS).toBe('⊘');
41
+ });
42
+
43
+ it('no two enum members share the same runtime string value', () => {
44
+ const all = [
45
+ ...Object.values(StatusIcon),
46
+ ...Object.values(KindIcon),
47
+ ...Object.values(BadgeIcon),
48
+ ...Object.values(RelationIcon),
49
+ ];
50
+ const unique = new Set(all);
51
+ expect(unique.size).toBe(all.length);
52
+ });
53
+ });
54
+
55
+ describe('ascii()', () => {
56
+ it('returns NO_COLOR fallback for every StatusIcon', () => {
57
+ expect(ascii(StatusIcon.PENDING)).toBe('[ ]');
58
+ expect(ascii(StatusIcon.ACTIVE)).toBe('[~]');
59
+ expect(ascii(StatusIcon.DONE)).toBe('[x]');
60
+ expect(ascii(StatusIcon.BLOCKED)).toBe('[!]');
61
+ expect(ascii(StatusIcon.ARCHIVED)).toBe('[#]');
62
+ expect(ascii(StatusIcon.CANCELLED)).toBe('[-]');
63
+ });
64
+
65
+ it('returns NO_COLOR fallback for every KindIcon', () => {
66
+ expect(ascii(KindIcon.SAGA)).toBe('SG');
67
+ expect(ascii(KindIcon.EPIC)).toBe('E');
68
+ expect(ascii(KindIcon.TASK)).toBe('-');
69
+ expect(ascii(KindIcon.SUBTASK)).toBe('.');
70
+ expect(ascii(KindIcon.RESEARCH)).toBe('R');
71
+ expect(ascii(KindIcon.BUG)).toBe('B');
72
+ expect(ascii(KindIcon.RELEASE)).toBe('>');
73
+ });
74
+
75
+ it('returns NO_COLOR fallback for every BadgeIcon', () => {
76
+ expect(ascii(BadgeIcon.EMPTY)).toBe('(empty)');
77
+ expect(ascii(BadgeIcon.ORPHAN)).toBe('(orphan)');
78
+ expect(ascii(BadgeIcon.NESTED)).toBe('(nested)');
79
+ expect(ascii(BadgeIcon.CAUTION)).toBe('(!)');
80
+ expect(ascii(BadgeIcon.NEW)).toBe('(new)');
81
+ });
82
+
83
+ it('returns NO_COLOR fallback for every RelationIcon', () => {
84
+ expect(ascii(RelationIcon.GROUPS)).toBe('in');
85
+ expect(ascii(RelationIcon.PARENT)).toBe('^');
86
+ expect(ascii(RelationIcon.DEPENDS)).toBe('->');
87
+ expect(ascii(RelationIcon.BLOCKS)).toBe('!>');
88
+ });
89
+ });
90
+
91
+ describe('pickIcon()', () => {
92
+ const originalNoColor = process.env['NO_COLOR'];
93
+ const originalTerm = process.env['TERM'];
94
+
95
+ beforeEach(() => {
96
+ delete process.env['NO_COLOR'];
97
+ delete process.env['TERM'];
98
+ });
99
+
100
+ afterEach(() => {
101
+ if (originalNoColor === undefined) {
102
+ delete process.env['NO_COLOR'];
103
+ } else {
104
+ process.env['NO_COLOR'] = originalNoColor;
105
+ }
106
+ if (originalTerm === undefined) {
107
+ delete process.env['TERM'];
108
+ } else {
109
+ process.env['TERM'] = originalTerm;
110
+ }
111
+ });
112
+
113
+ it('returns the emoji when noColor is explicitly false', () => {
114
+ expect(pickIcon(KindIcon.SAGA, { noColor: false })).toBe('🌲');
115
+ expect(pickIcon(StatusIcon.DONE, { noColor: false })).toBe('✅');
116
+ });
117
+
118
+ it('returns the ASCII form when noColor is explicitly true', () => {
119
+ expect(pickIcon(KindIcon.SAGA, { noColor: true })).toBe('SG');
120
+ expect(pickIcon(StatusIcon.DONE, { noColor: true })).toBe('[x]');
121
+ });
122
+
123
+ it('falls back to NO_COLOR=1 from process.env', () => {
124
+ process.env['NO_COLOR'] = '1';
125
+ expect(pickIcon(KindIcon.EPIC)).toBe('E');
126
+ expect(pickIcon(BadgeIcon.ORPHAN)).toBe('(orphan)');
127
+ });
128
+
129
+ it('falls back to TERM=dumb from process.env', () => {
130
+ process.env['TERM'] = 'dumb';
131
+ expect(pickIcon(RelationIcon.GROUPS)).toBe('in');
132
+ });
133
+
134
+ it('returns the emoji when no NO_COLOR signals are present', () => {
135
+ expect(pickIcon(KindIcon.TASK)).toBe('•');
136
+ expect(pickIcon(StatusIcon.ACTIVE)).toBe('🚧');
137
+ });
138
+ });