@cleocode/contracts 2026.5.114 → 2026.5.121

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 (49) hide show
  1. package/dist/__tests__/changesets.test.d.ts +8 -0
  2. package/dist/__tests__/changesets.test.d.ts.map +1 -0
  3. package/dist/__tests__/changesets.test.js +146 -0
  4. package/dist/__tests__/changesets.test.js.map +1 -0
  5. package/dist/__tests__/evidence-atom-schema.test.js +75 -1
  6. package/dist/__tests__/evidence-atom-schema.test.js.map +1 -1
  7. package/dist/__tests__/meta-tokens.test.d.ts +16 -0
  8. package/dist/__tests__/meta-tokens.test.d.ts.map +1 -0
  9. package/dist/__tests__/meta-tokens.test.js +99 -0
  10. package/dist/__tests__/meta-tokens.test.js.map +1 -0
  11. package/dist/boundary.js +3 -3
  12. package/dist/boundary.js.map +1 -1
  13. package/dist/changesets.d.ts +91 -0
  14. package/dist/changesets.d.ts.map +1 -1
  15. package/dist/changesets.js +72 -2
  16. package/dist/changesets.js.map +1 -1
  17. package/dist/dispatch/operations-registry.d.ts.map +1 -1
  18. package/dist/dispatch/operations-registry.js +148 -0
  19. package/dist/dispatch/operations-registry.js.map +1 -1
  20. package/dist/evidence-atom-schema.d.ts +52 -8
  21. package/dist/evidence-atom-schema.d.ts.map +1 -1
  22. package/dist/evidence-atom-schema.js +96 -0
  23. package/dist/evidence-atom-schema.js.map +1 -1
  24. package/dist/index.d.ts +2 -2
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +1 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/operations/docs.d.ts +166 -8
  29. package/dist/operations/docs.d.ts.map +1 -1
  30. package/dist/operations/docs.js +9 -7
  31. package/dist/operations/docs.js.map +1 -1
  32. package/dist/operations/tasks.d.ts +39 -0
  33. package/dist/operations/tasks.d.ts.map +1 -1
  34. package/dist/operations/tasks.js.map +1 -1
  35. package/dist/task-record.d.ts +7 -0
  36. package/dist/task-record.d.ts.map +1 -1
  37. package/package.json +2 -2
  38. package/src/__tests__/__snapshots__/operations-registry.test.ts.snap +1 -1
  39. package/src/__tests__/changesets.test.ts +176 -0
  40. package/src/__tests__/evidence-atom-schema.test.ts +88 -0
  41. package/src/__tests__/meta-tokens.test.ts +107 -0
  42. package/src/boundary.ts +3 -3
  43. package/src/changesets.ts +93 -2
  44. package/src/dispatch/operations-registry.ts +155 -0
  45. package/src/evidence-atom-schema.ts +131 -7
  46. package/src/index.ts +2 -0
  47. package/src/operations/docs.ts +187 -9
  48. package/src/operations/tasks.ts +39 -0
  49. package/src/task-record.ts +7 -0
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Tests for CLEO changeset contracts and parser compatibility.
3
+ *
4
+ * @task T10480
5
+ * @epic T9759
6
+ */
7
+
8
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
12
+ import { ChangesetEntrySchema } from '../changesets.js';
13
+
14
+ type ParseChangesetFile = (path: string) => unknown;
15
+
16
+ let tempDir: string;
17
+
18
+ beforeEach(() => {
19
+ tempDir = mkdtempSync(join(tmpdir(), 'cleo-changesets-contract-'));
20
+ });
21
+
22
+ afterEach(() => {
23
+ rmSync(tempDir, { recursive: true, force: true });
24
+ });
25
+
26
+ function writeChangeset(filename: string, content: string): string {
27
+ const path = join(tempDir, filename);
28
+ writeFileSync(path, content, 'utf8');
29
+ return path;
30
+ }
31
+
32
+ async function loadParseChangesetFile(): Promise<ParseChangesetFile> {
33
+ const parserUrl = new URL('../../../core/src/changesets/parser.ts', import.meta.url).href;
34
+ const parser = (await import(parserUrl)) as { parseChangesetFile: ParseChangesetFile };
35
+ return parser.parseChangesetFile;
36
+ }
37
+
38
+ describe('ChangesetEntrySchema release-note metadata', () => {
39
+ it('accepts legacy changesets without releaseNotes unchanged', () => {
40
+ const legacy = {
41
+ id: 'T10480',
42
+ tasks: ['T10480'],
43
+ kind: 'fix',
44
+ summary: 'Legacy changesets still parse.',
45
+ };
46
+
47
+ const parsed = ChangesetEntrySchema.parse(legacy);
48
+
49
+ expect(parsed).toEqual(legacy);
50
+ expect(parsed.releaseNotes).toBeUndefined();
51
+ });
52
+
53
+ it('accepts deterministic zero-token release-note metadata', () => {
54
+ const entry = ChangesetEntrySchema.parse({
55
+ id: 't10480-release-notes',
56
+ tasks: ['T10480', 'T9759'],
57
+ kind: 'feat',
58
+ summary: 'Add structured release-note metadata.',
59
+ releaseNotes: {
60
+ section: 'added',
61
+ audience: ['operators', 'developers'],
62
+ scope: 'package',
63
+ targets: ['@cleocode/contracts', '@cleocode/core'],
64
+ impact: 'Release tooling can render richer notes from author-provided facts.',
65
+ migration: 'No migration required for legacy changesets.',
66
+ operatorNotes: 'No LLM, API token, or network call is required.',
67
+ includeInChangelog: true,
68
+ },
69
+ });
70
+
71
+ expect(entry.releaseNotes).toEqual({
72
+ section: 'added',
73
+ audience: ['operators', 'developers'],
74
+ scope: 'package',
75
+ targets: ['@cleocode/contracts', '@cleocode/core'],
76
+ impact: 'Release tooling can render richer notes from author-provided facts.',
77
+ migration: 'No migration required for legacy changesets.',
78
+ operatorNotes: 'No LLM, API token, or network call is required.',
79
+ includeInChangelog: true,
80
+ });
81
+ });
82
+
83
+ it('rejects malformed release-note metadata loudly', () => {
84
+ const result = ChangesetEntrySchema.safeParse({
85
+ id: 't10480-bad-release-notes',
86
+ tasks: ['T10480'],
87
+ kind: 'feat',
88
+ summary: 'Bad metadata.',
89
+ releaseNotes: {
90
+ section: 'feature',
91
+ },
92
+ });
93
+
94
+ expect(result.success).toBe(false);
95
+ if (result.success) return;
96
+ expect(result.error.issues[0]?.path).toEqual(['releaseNotes', 'section']);
97
+ });
98
+ });
99
+
100
+ describe('parseChangesetFile release-note metadata compatibility', () => {
101
+ it('parses legacy markdown changesets unchanged', async () => {
102
+ const parseChangesetFile = await loadParseChangesetFile();
103
+ const file = writeChangeset(
104
+ 'T10480.md',
105
+ `---
106
+ id: T10480
107
+ tasks: [T10480]
108
+ kind: fix
109
+ summary: Legacy markdown changesets still parse unchanged.
110
+ ---
111
+
112
+ Body notes survive.
113
+ `,
114
+ );
115
+
116
+ expect(parseChangesetFile(file)).toEqual({
117
+ id: 'T10480',
118
+ tasks: ['T10480'],
119
+ kind: 'fix',
120
+ summary: 'Legacy markdown changesets still parse unchanged.',
121
+ notes: 'Body notes survive.',
122
+ });
123
+ });
124
+
125
+ it('parses new release-note metadata from markdown frontmatter', async () => {
126
+ const parseChangesetFile = await loadParseChangesetFile();
127
+ const file = writeChangeset(
128
+ 't10480-new-metadata.md',
129
+ `---
130
+ id: t10480-new-metadata
131
+ tasks: [T10480]
132
+ kind: feat
133
+ summary: Parse release-note metadata.
134
+ releaseNotes:
135
+ section: changed
136
+ audience: [operators]
137
+ scope: package
138
+ targets: ['@cleocode/core']
139
+ impact: Deterministic release notes get structured context.
140
+ includeInChangelog: true
141
+ ---
142
+ `,
143
+ );
144
+
145
+ expect(parseChangesetFile(file)).toMatchObject({
146
+ releaseNotes: {
147
+ section: 'changed',
148
+ audience: ['operators'],
149
+ scope: 'package',
150
+ targets: ['@cleocode/core'],
151
+ impact: 'Deterministic release notes get structured context.',
152
+ includeInChangelog: true,
153
+ },
154
+ });
155
+ });
156
+
157
+ it('fails malformed release-note metadata with file and line context', async () => {
158
+ const parseChangesetFile = await loadParseChangesetFile();
159
+ const file = writeChangeset(
160
+ 't10480-bad-metadata.md',
161
+ `---
162
+ id: t10480-bad-metadata
163
+ tasks: [T10480]
164
+ kind: feat
165
+ summary: Bad release-note metadata.
166
+ releaseNotes:
167
+ section: feature
168
+ ---
169
+ `,
170
+ );
171
+
172
+ expect(() => parseChangesetFile(file)).toThrow(
173
+ new RegExp(`${file.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:7[\\s\\S]*releaseNotes\\.section`),
174
+ );
175
+ });
176
+ });
@@ -19,6 +19,7 @@ import {
19
19
  EvidenceAtomSchema,
20
20
  EvidenceParseError,
21
21
  formatGateRequirement,
22
+ formatGateRequirementHint,
22
23
  GATE_EVIDENCE_REQUIREMENTS,
23
24
  parseEvidenceString,
24
25
  validateEvidenceForGate,
@@ -497,3 +498,90 @@ describe('formatGateRequirement (T10337)', () => {
497
498
  expect(formatGateRequirement('cleanupDone')).toBe('[note]');
498
499
  });
499
500
  });
501
+
502
+ // ─── formatGateRequirementHint — rich CLI `fix:` surface (T9949) ────────────
503
+
504
+ describe('formatGateRequirementHint (T9949)', () => {
505
+ it('renders implemented as multi-line list of copy-pasteable verify commands', () => {
506
+ const h = formatGateRequirementHint('implemented');
507
+ // Header line names the gate and uses "ONE of" framing.
508
+ expect(h).toMatch(/^Gate 'implemented' requires programmatic evidence\. Use ONE of:/);
509
+ // Each combination renders as a complete `cleo verify ... --evidence '<atoms>'` line.
510
+ expect(h).toContain("--evidence 'commit:<sha>;files:path/a.ts,path/b.ts'");
511
+ expect(h).toContain("--evidence 'commit:<sha>;note:<short description>'");
512
+ expect(h).toContain("--evidence 'decision:D-arch-001;files:path/a.ts,path/b.ts'");
513
+ expect(h).toContain("--evidence 'decision:D-arch-001;note:<short description>'");
514
+ expect(h).toContain("--evidence 'pr:357'");
515
+ // Routes a `note:`-only caller at the partner atom they need.
516
+ expect(h).toContain("'note:' alone is NOT accepted for this gate");
517
+ expect(h).toContain("'commit:'");
518
+ expect(h).toContain("'decision:'");
519
+ });
520
+
521
+ it('routes note-only callers for testsPassed at the right alternatives', () => {
522
+ // testsPassed accepts test-run, tool, or pr — none of which involve note.
523
+ const h = formatGateRequirementHint('testsPassed');
524
+ expect(h).toContain("--evidence 'test-run:/tmp/vitest-out.json'");
525
+ expect(h).toContain("--evidence 'tool:test'");
526
+ expect(h).toContain("--evidence 'pr:357'");
527
+ // When no combination includes note at all, the helper falls through to a
528
+ // generic "not accepted, see ADR-051" clarifier rather than fabricating a
529
+ // partner suggestion.
530
+ expect(h).toContain("'note:' is NOT accepted for this gate");
531
+ expect(h).toContain('ADR-051');
532
+ });
533
+
534
+ it('confirms note-only acceptance for cleanupDone', () => {
535
+ const h = formatGateRequirementHint('cleanupDone');
536
+ expect(h).toContain("--evidence 'note:<short description>'");
537
+ expect(h).toContain("'note:' alone IS accepted for this gate");
538
+ });
539
+
540
+ it('confirms note-only acceptance for securityPassed and nexusImpact', () => {
541
+ for (const gate of ['securityPassed', 'nexusImpact'] as const) {
542
+ const h = formatGateRequirementHint(gate);
543
+ expect(h, `${gate}: should confirm note alone is accepted`).toContain(
544
+ "'note:' alone IS accepted for this gate",
545
+ );
546
+ }
547
+ });
548
+
549
+ it('renders documented with files and url, and points away from note', () => {
550
+ const h = formatGateRequirementHint('documented');
551
+ expect(h).toContain("--evidence 'files:path/a.ts,path/b.ts'");
552
+ expect(h).toContain("--evidence 'url:https://example.com/docs'");
553
+ // documented has no note pairings — generic clarifier path.
554
+ expect(h).toContain("'note:' is NOT accepted for this gate");
555
+ });
556
+ });
557
+
558
+ // ─── validateEvidenceForGate — failure carries `hint` (T9949) ───────────────
559
+
560
+ describe('validateEvidenceForGate failure surface (T9949)', () => {
561
+ it('populates a `hint` field alongside the legacy `message`', () => {
562
+ const r = validateEvidenceForGate('implemented', [
563
+ { kind: 'note', note: 'just a note' },
564
+ ] as EvidenceAtomInput[]);
565
+ expect(r.ok).toBe(false);
566
+ if (!r.ok) {
567
+ // Legacy single-line message preserved byte-for-byte.
568
+ expect(r.message).toBe(
569
+ "Gate 'implemented' requires evidence: [commit AND files] OR [commit AND note] OR [decision AND files] OR [decision AND note] OR [pr]",
570
+ );
571
+ // New rich hint includes the same content as formatGateRequirementHint.
572
+ expect(r.hint).toBe(formatGateRequirementHint('implemented'));
573
+ }
574
+ });
575
+
576
+ it('notes-only against testsPassed surfaces the right alternatives', () => {
577
+ const r = validateEvidenceForGate('testsPassed', [
578
+ { kind: 'note', note: 'I ran the tests by hand' },
579
+ ] as EvidenceAtomInput[]);
580
+ expect(r.ok).toBe(false);
581
+ if (!r.ok) {
582
+ expect(r.hint).toContain("--evidence 'tool:test'");
583
+ expect(r.hint).toContain("--evidence 'pr:357'");
584
+ expect(r.hint).toContain("'note:' is NOT accepted for this gate");
585
+ }
586
+ });
587
+ });
@@ -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
  },
package/src/changesets.ts CHANGED
@@ -67,6 +67,91 @@ export const CHANGESET_KINDS = [
67
67
  /** Allowed values of {@link ChangesetEntry.kind}. */
68
68
  export type ChangesetKind = (typeof CHANGESET_KINDS)[number];
69
69
 
70
+ // ─── Release-note metadata ───────────────────────────────────────────────────
71
+
72
+ /**
73
+ * Deterministic release-note section override used by zero-token renderers.
74
+ *
75
+ * These values intentionally mirror Keep-a-Changelog style headings so release
76
+ * tooling can render higher-quality notes without an LLM/API/token dependency.
77
+ */
78
+ export const CHANGESET_RELEASE_NOTE_SECTIONS = [
79
+ 'added',
80
+ 'changed',
81
+ 'fixed',
82
+ 'deprecated',
83
+ 'removed',
84
+ 'security',
85
+ 'breaking',
86
+ ] as const;
87
+
88
+ /** Allowed values of {@link ChangesetReleaseNotesMetadata.section}. */
89
+ export type ChangesetReleaseNoteSection = (typeof CHANGESET_RELEASE_NOTE_SECTIONS)[number];
90
+
91
+ /** Audience labels for deterministic release-note routing. */
92
+ export const CHANGESET_RELEASE_NOTE_AUDIENCES = [
93
+ 'users',
94
+ 'operators',
95
+ 'developers',
96
+ 'maintainers',
97
+ ] as const;
98
+
99
+ /** Allowed values of {@link ChangesetReleaseNotesMetadata.audience}. */
100
+ export type ChangesetReleaseNoteAudience = (typeof CHANGESET_RELEASE_NOTE_AUDIENCES)[number];
101
+
102
+ /** Scope labels for deterministic release-note routing. */
103
+ export const CHANGESET_RELEASE_NOTE_SCOPES = [
104
+ 'project',
105
+ 'package',
106
+ 'component',
107
+ 'docs',
108
+ 'ops',
109
+ 'security',
110
+ ] as const;
111
+
112
+ /** Allowed values of {@link ChangesetReleaseNotesMetadata.scope}. */
113
+ export type ChangesetReleaseNoteScope = (typeof CHANGESET_RELEASE_NOTE_SCOPES)[number];
114
+
115
+ const nonEmptyReleaseNoteText = z.string().min(1, 'release-note metadata text must be non-empty');
116
+ const nonEmptyReleaseNoteList = z
117
+ .array(nonEmptyReleaseNoteText)
118
+ .min(1, 'release-note metadata list must contain at least one item');
119
+
120
+ /**
121
+ * Optional deterministic metadata for release-note rendering.
122
+ *
123
+ * This is deliberately small, explicit, and strict: it gives release tooling
124
+ * structured author-provided facts (section, audience, target, impact, etc.)
125
+ * while preserving zero-token defaults and failing loudly on misspelled fields.
126
+ */
127
+ export const ChangesetReleaseNotesMetadataSchema = z
128
+ .object({
129
+ /** Keep-a-Changelog style section/category override. */
130
+ section: z.enum(CHANGESET_RELEASE_NOTE_SECTIONS).optional(),
131
+ /** Intended readers for the note. */
132
+ audience: z.array(z.enum(CHANGESET_RELEASE_NOTE_AUDIENCES)).min(1).optional(),
133
+ /** Release-note scope. */
134
+ scope: z.enum(CHANGESET_RELEASE_NOTE_SCOPES).optional(),
135
+ /** Package/project/component targets affected by this entry. */
136
+ targets: nonEmptyReleaseNoteList.optional(),
137
+ /** User- or operator-visible impact statement. */
138
+ impact: nonEmptyReleaseNoteText.optional(),
139
+ /** Deterministic migration/action text, separate from breaking changes. */
140
+ migration: nonEmptyReleaseNoteText.optional(),
141
+ /** Deprecation detail for Deprecated sections. */
142
+ deprecation: nonEmptyReleaseNoteText.optional(),
143
+ /** Security detail for Security sections. */
144
+ security: nonEmptyReleaseNoteText.optional(),
145
+ /** Operator-facing rollout/verification note. */
146
+ operatorNotes: nonEmptyReleaseNoteText.optional(),
147
+ /** Explicit inclusion toggle for future release-scope filters. */
148
+ includeInChangelog: z.boolean().optional(),
149
+ })
150
+ .strict();
151
+
152
+ /** Inferred TypeScript type for deterministic release-note metadata. */
153
+ export type ChangesetReleaseNotesMetadata = z.infer<typeof ChangesetReleaseNotesMetadataSchema>;
154
+
70
155
  // ─── Task ID validator ────────────────────────────────────────────────────────
71
156
 
72
157
  /**
@@ -93,14 +178,18 @@ const TASK_ID_RE = /^(T\d+(-[A-Z][A-Za-z0-9]*)?|E-\d+(-[A-Z][A-Za-z0-9]*)?)$/;
93
178
  * - `prs` — linked PR numbers
94
179
  * - `notes` — longer-form markdown body
95
180
  * - `breaking` — required migration note when `kind === 'breaking'`
181
+ * - `releaseNotes` — deterministic zero-token release-note metadata
96
182
  */
97
183
  export const ChangesetEntrySchema = z
98
184
  .object({
99
- /** Filename slug (without the `.md` extension). */
185
+ /** Filename identifier (without the `.md` extension); lowercase kebab-case for new files, legacy uppercase task IDs accepted. */
100
186
  id: z
101
187
  .string()
102
188
  .min(1, 'id must be non-empty')
103
- .regex(/^[a-z0-9][a-z0-9-]*$/, 'id must be kebab-case (lowercase, digits, hyphens)'),
189
+ .regex(
190
+ /^[A-Za-z0-9][A-Za-z0-9-]*$/,
191
+ 'id must contain only ASCII letters, digits, and hyphens',
192
+ ),
104
193
  /** One or more CLEO task IDs this change is anchored to. */
105
194
  tasks: z
106
195
  .array(z.string().regex(TASK_ID_RE, 'task ID must match T#### or E-#### format'))
@@ -115,6 +204,8 @@ export const ChangesetEntrySchema = z
115
204
  notes: z.string().optional(),
116
205
  /** Migration note. Required iff `kind === 'breaking'`. */
117
206
  breaking: z.string().optional(),
207
+ /** Structured author-provided metadata for deterministic release notes. */
208
+ releaseNotes: ChangesetReleaseNotesMetadataSchema.optional(),
118
209
  })
119
210
  .refine((entry) => entry.kind !== 'breaking' || (entry.breaking?.length ?? 0) > 0, {
120
211
  message: 'breaking entries must include a non-empty `breaking` migration note',