/** * `cleo release plan` — Phase 1 verb of the new release pipeline. * * Builds the canonical Release Plan envelope from `tasks.db` + git log + * previous-release state. Emits a LAFS-compliant `EngineResult`, * writes the plan file atomically to `.cleo/release/.plan.json`, * and UPSERTs one row into the `releases` table with `status='planned'`. * * This verb is **read-mostly** — it performs NO git mutations, NO `gh` calls, * NO network calls. Writes are limited to `.cleo/release/.plan.json` * and the `releases` table (R-032). * * @task T9525 * @epic T9492 * @spec .cleo/rcasd/T9345/research/SPEC-T9345-release-pipeline-v2.md §4.2 */ import type { EvidenceAtom, Task } from '@cleocode/contracts'; import { type EngineResult, type ReleaseGate, type ReleaseGateExecutionStatus, type ReleaseGateName, type ReleaseKind, type ReleasePlan, type ReleasePlanChannel, type ReleasePlatformMatrixEntry, type ReleaseScheme, type ReleaseTaskKind } from '@cleocode/contracts'; /** * Options accepted by {@link releasePlan}. * * @task T9525 */ export interface ReleasePlanOptions { /** The candidate version (e.g. `v2026.6.0`). Leading `v` optional — added if absent. */ version: string; /** * Epic task ID whose children are candidates for inclusion (required per * R-021 UNLESS `sagaId` is set). Mutually exclusive with `sagaId`. * * Per ADR-073 the leaf-Epic-as-PR pattern is canonical: an Epic with zero * non-cancelled child tasks is valid input. In that case the Epic itself * is treated as the singleton task for evidence aggregation. * * @task T9525 * @task T9838 — leaf-Epic-as-Task fallback */ epicId?: string; /** * Saga task ID — when supplied, the plan aggregates the member Epics linked * to the Saga via `task_relations.relation_type='groups'` (ADR-073 I3). * Member Epics are aggregated recursively the same way `--epic` does today: * union of children of each member epic, OR for leaf-epics, the epic itself. * * Mutually exclusive with `epicId`. The resulting plan's `plan.epicId` is * set to the Saga ID for traceability. * * @task T9838 */ sagaId?: string; /** * Explicit task-list release scope. When set, the plan includes exactly these * non-cancelled/non-archived task rows in caller order after de-duplication. * Mutually exclusive with `epicId` and `sagaId`. * * @task T10088 */ taskIds?: string[]; /** Versioning scheme. Default: inferred from `.cleo/release-config.json`. */ scheme?: ReleaseScheme; /** Release channel. Default: `latest`. */ channel?: ReleasePlanChannel; /** When true, the release is marked `release_kind='hotfix'`. */ hotfix?: boolean; /** Dry-run flag — equivalent to `CLEO_DRY_RUN=1`. Reads only; no writes. */ dryRun?: boolean; /** * Project root override. Defaults to the canonical project root resolved * via {@link getProjectRoot} (walks up from `process.cwd()` for monorepo * subdir invocations; honours `CLEO_ROOT` / `CLEO_PROJECT_ROOT`). * * @task T9583 */ projectRoot?: string; /** Creator identity for `plan.createdBy`. Defaults to `process.env.USER` or `cleo-agent`. */ createdBy?: string; /** * When true (default), `cleo release plan` also writes the aggregated * release notes into `CHANGELOG.md` under a `## [] ()` block. * Idempotent on re-run via section-replace. Set to `false` to opt out and * keep the plan envelope as the sole sink for release notes. * * @task T9838 — auto-write CHANGELOG.md (gap 3 of v5.93 manual-ship saga) */ writeChangelog?: boolean; } /** * Data payload returned by {@link releasePlan} on success. * * @task T9525 */ export interface ReleasePlanResult { /** The requested version (matches input). */ version: string; /** The resolved version after suffix application. */ resolvedVersion: string; /** True iff a `calver-suffix` was applied to disambiguate same-day hotfixes. */ suffixApplied: boolean; /** Release channel. */ channel: ReleasePlanChannel; /** Epic ID. */ epicId: string; /** Absolute path to the written plan file. */ planPath: string; /** Number of tasks rolled into the plan. */ taskCount: number; /** True iff every task has at least one evidence atom (R-301). */ evidenceComplete: boolean; /** Non-fatal preflight warnings (e.g. unresolved tools). */ preflightWarnings: string[]; /** Per-gate verification status summary. */ gateSummary: Record; /** * Number of CLEO-native changeset entries (`.changeset/*.md`) rolled into * this release. Zero when `.changeset/` is absent or empty. * * @task T9753 */ changesetEntryCount: number; /** * True iff `cleo release plan` wrote (or replaced) the `## []` * section in `CHANGELOG.md`. False when `writeChangelog=false`, when the * aggregated notes are empty, or when the file write was skipped under * `dryRun`. * * @task T9838 */ changelogWritten: boolean; /** * Absolute path to the CHANGELOG.md the plan flow targeted. Set even when * `changelogWritten=false` so consumers know where the file would have been * written. * * @task T9838 */ changelogPath: string; } /** * Normalize a version string to include the leading `v` (e.g. `2026.6.0` → `v2026.6.0`). * * @internal */ declare function normalizeVersion(version: string): string; /** * Map the new channel taxonomy (latest|beta|alpha|rc) onto the DB channel * enum (latest|beta|dev|hotfix). The DB schema does not yet model `rc` so * we collapse to `beta` for now per SPEC §6.1 footnote. * * @internal */ declare function mapPlanChannelToDbChannel(channel: ReleasePlanChannel, releaseKind: ReleaseKind): 'latest' | 'beta' | 'dev' | 'hotfix'; /** * Validate a channel + scheme pair per R-022. * * @internal */ declare function validateChannelScheme(channel: ReleasePlanChannel, scheme: ReleaseScheme, version: string): { ok: true; } | { ok: false; reason: string; }; /** * Verify the working tree is clean for the configured version-file patterns * (R-020). Returns the list of offending paths so callers can populate * `error.details.dirtyPaths` for triage. * * @internal */ declare function validateCleanTree(projectRoot: string): { dirty: string[]; }; /** * Resolution outcome of {@link resolveEpicTasks}. * * `isLeafEpic=true` is set when the Epic exists but has no non-cancelled * child tasks. Per ADR-073 the leaf-Epic-as-PR pattern is canonical — the * caller can fall back to the Epic itself as the singleton task list for * evidence + changeset aggregation when this flag is true. * * @internal * @task T9838 */ interface EpicResolution { /** Non-cancelled, non-archived descendants of the Epic (excludes the Epic itself). */ tasks: Task[]; /** True iff the Epic ID resolves to a row in `tasks`. */ epicExists: boolean; /** * The Epic row itself (when `epicExists=true`). Carried out so callers can * extract `verification.evidence` for the leaf-Epic-as-Task path without a * second DB round-trip. */ epic: Task | null; /** * True iff `epicExists=true` AND `tasks.length === 0` after status filtering. * Signals to the caller that the Epic should be treated as the singleton * task list per ADR-073 leaf-Epic semantics. */ isLeafEpic: boolean; } /** * Walk children of `epicId` recursively (depth-first), excluding cancelled * and archived tasks. Returns deduplicated tasks ordered by parent-then-position. * * Per ADR-073 (T9838): when the Epic exists but has zero non-cancelled * children, `isLeafEpic=true` is set and `tasks=[]`. Callers MUST decide * whether to treat the leaf Epic as a singleton (release-as-PR pattern) or * surface `E_EPIC_EMPTY` (Saga case where zero member Epics is an error). * * @internal */ declare function resolveEpicTasks(epicId: string, projectRoot: string): Promise; /** * Resolution outcome of {@link resolveSagaTasks}. * * @internal * @task T9838 */ interface SagaResolution { /** Aggregated tasks across all member Epics (excludes cancelled/archived). */ tasks: Task[]; /** True iff the Saga ID resolves to a task. */ sagaExists: boolean; /** True iff the resolved task is a saga (type='saga'). */ isSaga: boolean; /** IDs of member Epics linked to the Saga via parent_id containment. */ memberEpicIds: string[]; /** * Per-member resolution outcomes — useful to surface a leaf-Epic member's * evidence atoms during aggregation. Indexed by `memberEpicIds`. */ memberResolutions: Map; } /** * Resolve a Saga (ADR-073 type='saga') and aggregate its member Epics via * parent_id containment into a single task list for `cleo release plan --saga`. * * After T10638, Saga membership uses `parent_id` containment rather than * `task_relations.type='groups'`. This helper: * * 1. Loads the saga task and verifies `type='saga'`. * 2. Resolves member Epics via `parentId = sagaId`. * 3. For each member Epic, runs the same {@link resolveEpicTasks} walk and * unions the result. Leaf-Epic members contribute the Epic itself. * * Returns `{ sagaExists: false }` when the ID doesn't resolve, `{ isSaga: false }` * when the task exists but `type !== 'saga'`. The caller maps these into * `E_NOT_FOUND` / `E_INVALID_INPUT` respectively. * * @internal * @task T9838 * @task T10638 — E10.W5 switch to parent_id containment */ declare function resolveSagaTasks(sagaId: string, projectRoot: string): Promise; /** * Extract ADR-051 evidence atoms from a task's `verification` blob and * serialize them into the `kind:value` string form persisted in `plan.tasks[*].evidenceAtoms`. * * @internal */ declare function collectEvidenceAtomsForTask(task: Task): string[]; /** * Serialize an ADR-051 {@link EvidenceAtom} into its `kind:value` string form * (e.g. `commit:abc123`, `tool:test`, `files:a.ts,b.ts`). * * @internal */ declare function serializeAtom(atom: EvidenceAtom): string | null; /** * Best-effort mapping from a {@link Task} (axes: `kind`, `scope`, `labels`) * into the conventional-commit-aligned {@link ReleaseTaskKind} used by the * plan changelog buckets. * * Heuristic: * 1. `task.kind === 'bug'` → `'fix'` * 2. label hit on `'breaking'` / `'docs'` / `'chore'` / `'refactor'` * / `'perf'` / `'test'` / `'hotfix'` / `'revert'` → that kind * 3. otherwise default to `'feat'`. * * @internal */ declare function inferTaskKind(task: Task): ReleaseTaskKind; /** * Map a {@link ReleaseTaskKind} onto a SemVer-style impact level. * * @internal */ declare function inferImpact(kind: ReleaseTaskKind): 'major' | 'minor' | 'patch'; /** * Resolve the six canonical release gates via the ADR-061 tool resolver. * * Per R-312 this verb does NOT execute the resolved commands; it only records * the resolved name + provenance into `plan.gates[]`. Status is initialized to * `unresolved` for tools the resolver could not produce a command for. * * @internal */ declare function resolveGatesViaToolResolver(projectRoot: string, nowIso: string): { gates: ReleaseGate[]; unresolved: string[]; }; /** * Resolve the publish matrix for the release. * * Reads `.cleo/release-config.json` for an optional `platformMatrix[]` override; * else returns a single-entry default per R-371 (single platform-agnostic artifact). * * @internal */ declare function enumeratePlatformMatrix(projectRoot: string): ReleasePlatformMatrixEntry[]; /** * Result of {@link resolvePreviousVersion}. * * @internal */ interface PreviousReleaseInfo { previousVersion: string | null; previousTag: string | null; previousShippedAt: string | null; firstEverRelease: boolean; } /** * Query the `releases` table for the most recent shipped row on the same * channel. Returns `{ firstEverRelease: true }` when no row matches (R-023). * * @internal */ declare function resolvePreviousVersion(channel: 'latest' | 'beta' | 'dev' | 'hotfix', projectRoot: string): Promise; /** * Atomic write to `.cleo/release/.plan.json` (R-030). * * Registered as a system-managed writer (T10368) — see * `WriterRegistry.listSystemManaged()` entry `release.plan-json`. Emits a * routing log line when `CLEO_QUIET` is unset so downstream auditors can * confirm the bypass was registered (vs an undocumented `writeFileSync`). * * @internal * @task T9525, T10368 */ declare function writePlanFile(plan: ReleasePlan, projectRoot: string): string; /** * Idempotently write (or replace) the `## [] ()` section in * `CHANGELOG.md` with the rendered release notes from * {@link aggregateChangesetsForRelease}. * * Semantics: * - If `CHANGELOG.md` already contains a `## [] (...)` header, the * block up to the next `## [` heading (or end of file) is replaced in * place. Re-running `cleo release plan v` is therefore a no-op when * the aggregated notes haven't changed. * - Otherwise the new section is inserted as the FIRST `## []` * block — right after the document title `# ...` if present, else at the * very top. * - When the file doesn't exist, a minimal `# Changelog\n\n` header is * seeded before the new section. * - Writes are atomic (`atomicWrite` → tmp-then-rename). * * Returns the absolute path to the file and a `written` flag that is `false` * iff the resulting contents matched what was already on disk (no-op case). * * @internal * @task T9838 */ declare function writeChangelogSection(args: { changelogPath: string; version: string; date: string; notesMarkdown: string; }): Promise<{ changelogPath: string; written: boolean; }>; /** * Pure-string transform used by {@link writeChangelogSection}. Extracted so * it can be exercised in isolation by the test suite. * * @internal * @task T9838 */ declare function replaceOrInsertChangelogSection(existing: string, version: string, newSection: string): string; /** * UPSERT the row into the `releases` table (R-031). * * Uses `INSERT … ON CONFLICT(version) DO UPDATE` so re-running the verb with * identical inputs is a no-op modulo timestamps (R-042). * * ## FK safety (DHQ-051 · T11818 — plan/open slice of the reconcile fix T11659) * * `releases.epic_id` is a nullable FK → `tasks.id` (`ON DELETE SET NULL`). On a * consolidated dual-scope `cleo.db` (post-T11578 cutover) the bare `tasks` * table is empty — runtime task data lives in the prefixed `tasks_tasks` table. * For the `--tasks` scope `plan.epicId` is the first task's `parentId` (a real * `tasks.id`, see {@link releasePlan}); for `--epic` / `--saga` it is the epic / * saga id. Any of those exist only in `tasks_tasks`, so inserting them straight * into `epic_id` violates the FK at INSERT time and aborts the whole plan. * * Post-cutover (T11883 · E3) `releases` binds the PREFIXED `tasks_releases` * table, whose `epic_id` is plain text (no cross-domain FK), so the epic id is * written directly — the DHQ-051 FK-parent shim and its NULL-on-unresolvable * fallback are retired. * * @internal */ declare function upsertReleasesRow(plan: ReleasePlan, dbChannel: 'latest' | 'beta' | 'dev' | 'hotfix', projectRoot: string): Promise; /** * Build a Release Plan envelope per SPEC-T9345 §4.2. * * @example * ```ts * const result = await releasePlan({ * version: 'v2026.6.0', * epicId: 'T9492', * channel: 'latest', * scheme: 'calver', * }); * if (result.success) { * console.log(`Plan written to ${result.data.planPath}`); * } * ``` * * @task T9525 */ export declare function releasePlan(opts: ReleasePlanOptions): Promise>; /** * Internal helpers exported for unit testing. Not part of the public API. * * @internal */ export declare const __test__: { collectEvidenceAtomsForTask: typeof collectEvidenceAtomsForTask; enumeratePlatformMatrix: typeof enumeratePlatformMatrix; inferImpact: typeof inferImpact; inferTaskKind: typeof inferTaskKind; mapPlanChannelToDbChannel: typeof mapPlanChannelToDbChannel; normalizeVersion: typeof normalizeVersion; replaceOrInsertChangelogSection: typeof replaceOrInsertChangelogSection; resolveEpicTasks: typeof resolveEpicTasks; resolveGatesViaToolResolver: typeof resolveGatesViaToolResolver; resolvePreviousVersion: typeof resolvePreviousVersion; resolveSagaTasks: typeof resolveSagaTasks; serializeAtom: typeof serializeAtom; upsertReleasesRow: typeof upsertReleasesRow; validateChannelScheme: typeof validateChannelScheme; validateCleanTree: typeof validateCleanTree; writeChangelogSection: typeof writeChangelogSection; writePlanFile: typeof writePlanFile; }; export {}; //# sourceMappingURL=plan.d.ts.map