@cleocode/core 2026.6.14 → 2026.6.17

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 (152) hide show
  1. package/dist/check/pr-gate.d.ts +6 -2
  2. package/dist/check/pr-gate.d.ts.map +1 -1
  3. package/dist/check/pr-gate.js +14 -32
  4. package/dist/check/pr-gate.js.map +1 -1
  5. package/dist/config/config-repair.d.ts +70 -0
  6. package/dist/config/config-repair.d.ts.map +1 -0
  7. package/dist/config/config-repair.js +248 -0
  8. package/dist/config/config-repair.js.map +1 -0
  9. package/dist/config/registry.d.ts.map +1 -1
  10. package/dist/config/registry.js +4 -4
  11. package/dist/config/registry.js.map +1 -1
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/config.js +11 -10
  14. package/dist/config.js.map +1 -1
  15. package/dist/docs/export-document.js +58 -16
  16. package/dist/docs/export-document.js.map +2 -2
  17. package/dist/gc/cleanup.d.ts +23 -8
  18. package/dist/gc/cleanup.d.ts.map +1 -1
  19. package/dist/gc/cleanup.js +287 -22
  20. package/dist/gc/cleanup.js.map +1 -1
  21. package/dist/gc/janitor.d.ts +149 -0
  22. package/dist/gc/janitor.d.ts.map +1 -0
  23. package/dist/gc/janitor.js +760 -0
  24. package/dist/gc/janitor.js.map +1 -0
  25. package/dist/internal.d.ts +1 -0
  26. package/dist/internal.d.ts.map +1 -1
  27. package/dist/internal.js +3 -0
  28. package/dist/internal.js.map +1 -1
  29. package/dist/llm/cli-ops.d.ts +31 -4
  30. package/dist/llm/cli-ops.d.ts.map +1 -1
  31. package/dist/llm/cli-ops.js +110 -26
  32. package/dist/llm/cli-ops.js.map +1 -1
  33. package/dist/llm/credential-pool.d.ts +62 -0
  34. package/dist/llm/credential-pool.d.ts.map +1 -1
  35. package/dist/llm/credential-pool.js +161 -0
  36. package/dist/llm/credential-pool.js.map +1 -1
  37. package/dist/llm/cross-provider-selector.d.ts +254 -0
  38. package/dist/llm/cross-provider-selector.d.ts.map +1 -0
  39. package/dist/llm/cross-provider-selector.js +635 -0
  40. package/dist/llm/cross-provider-selector.js.map +1 -0
  41. package/dist/llm/index.d.ts +2 -0
  42. package/dist/llm/index.d.ts.map +1 -1
  43. package/dist/llm/index.js +1 -0
  44. package/dist/llm/index.js.map +1 -1
  45. package/dist/llm/local-model-fit.d.ts +260 -0
  46. package/dist/llm/local-model-fit.d.ts.map +1 -0
  47. package/dist/llm/local-model-fit.js +563 -0
  48. package/dist/llm/local-model-fit.js.map +1 -0
  49. package/dist/llm/plugin-facade.js +883 -231
  50. package/dist/llm/plugin-facade.js.map +3 -3
  51. package/dist/llm/provider-registry/builtin/ollama.d.ts +18 -0
  52. package/dist/llm/provider-registry/builtin/ollama.d.ts.map +1 -1
  53. package/dist/llm/provider-registry/builtin/ollama.js +20 -2
  54. package/dist/llm/provider-registry/builtin/ollama.js.map +1 -1
  55. package/dist/llm/role-resolver.d.ts.map +1 -1
  56. package/dist/llm/role-resolver.js +45 -10
  57. package/dist/llm/role-resolver.js.map +1 -1
  58. package/dist/llm/transports/codex-responses.d.ts +111 -88
  59. package/dist/llm/transports/codex-responses.d.ts.map +1 -1
  60. package/dist/llm/transports/codex-responses.js +456 -258
  61. package/dist/llm/transports/codex-responses.js.map +1 -1
  62. package/dist/release/index.d.ts +2 -2
  63. package/dist/release/index.d.ts.map +1 -1
  64. package/dist/release/index.js +1 -1
  65. package/dist/release/index.js.map +1 -1
  66. package/dist/release/reconcile.d.ts +97 -0
  67. package/dist/release/reconcile.d.ts.map +1 -1
  68. package/dist/release/reconcile.js +412 -5
  69. package/dist/release/reconcile.js.map +1 -1
  70. package/dist/resources/backend.d.ts +155 -0
  71. package/dist/resources/backend.d.ts.map +1 -0
  72. package/dist/resources/backend.js +23 -0
  73. package/dist/resources/backend.js.map +1 -0
  74. package/dist/resources/linux-backend.d.ts +167 -0
  75. package/dist/resources/linux-backend.d.ts.map +1 -0
  76. package/dist/resources/linux-backend.js +259 -0
  77. package/dist/resources/linux-backend.js.map +1 -0
  78. package/dist/resources/monitor.d.ts +230 -0
  79. package/dist/resources/monitor.d.ts.map +1 -0
  80. package/dist/resources/monitor.js +255 -0
  81. package/dist/resources/monitor.js.map +1 -0
  82. package/dist/resources/spawn-wrapper.d.ts +265 -0
  83. package/dist/resources/spawn-wrapper.d.ts.map +1 -0
  84. package/dist/resources/spawn-wrapper.js +304 -0
  85. package/dist/resources/spawn-wrapper.js.map +1 -0
  86. package/dist/selfimprove/budget.d.ts +11 -2
  87. package/dist/selfimprove/budget.d.ts.map +1 -1
  88. package/dist/selfimprove/budget.js +11 -2
  89. package/dist/selfimprove/budget.js.map +1 -1
  90. package/dist/selfimprove/fix-gen-context.d.ts +172 -0
  91. package/dist/selfimprove/fix-gen-context.d.ts.map +1 -0
  92. package/dist/selfimprove/fix-gen-context.js +218 -0
  93. package/dist/selfimprove/fix-gen-context.js.map +1 -0
  94. package/dist/selfimprove/fix-gen.d.ts +285 -0
  95. package/dist/selfimprove/fix-gen.d.ts.map +1 -0
  96. package/dist/selfimprove/fix-gen.js +346 -0
  97. package/dist/selfimprove/fix-gen.js.map +1 -0
  98. package/dist/selfimprove/op-source-map.d.ts +92 -0
  99. package/dist/selfimprove/op-source-map.d.ts.map +1 -0
  100. package/dist/selfimprove/op-source-map.js +187 -0
  101. package/dist/selfimprove/op-source-map.js.map +1 -0
  102. package/dist/selfimprove/probe-helper.d.ts +61 -0
  103. package/dist/selfimprove/probe-helper.d.ts.map +1 -0
  104. package/dist/selfimprove/probe-helper.js +64 -0
  105. package/dist/selfimprove/probe-helper.js.map +1 -0
  106. package/dist/selfimprove/run-loop.d.ts +44 -4
  107. package/dist/selfimprove/run-loop.d.ts.map +1 -1
  108. package/dist/selfimprove/run-loop.js +73 -6
  109. package/dist/selfimprove/run-loop.js.map +1 -1
  110. package/dist/selfimprove/scenarios/seeded-code-regression/golden.json +18 -0
  111. package/dist/selfimprove/scenarios/seeded-code-regression/scenario.json +12 -0
  112. package/dist/sentient/cross-project-hygiene.d.ts +11 -2
  113. package/dist/sentient/cross-project-hygiene.d.ts.map +1 -1
  114. package/dist/sentient/cross-project-hygiene.js +73 -10
  115. package/dist/sentient/cross-project-hygiene.js.map +1 -1
  116. package/dist/sentient/daemon.d.ts.map +1 -1
  117. package/dist/sentient/daemon.js +7 -2
  118. package/dist/sentient/daemon.js.map +1 -1
  119. package/dist/sentient/worktree-dispatch.d.ts.map +1 -1
  120. package/dist/sentient/worktree-dispatch.js +8 -1
  121. package/dist/sentient/worktree-dispatch.js.map +1 -1
  122. package/dist/setup/index.d.ts +1 -0
  123. package/dist/setup/index.d.ts.map +1 -1
  124. package/dist/setup/index.js +1 -0
  125. package/dist/setup/index.js.map +1 -1
  126. package/dist/setup/sections/models-roles.d.ts +32 -1
  127. package/dist/setup/sections/models-roles.d.ts.map +1 -1
  128. package/dist/setup/sections/models-roles.js +118 -19
  129. package/dist/setup/sections/models-roles.js.map +1 -1
  130. package/dist/setup/whoami-summary.d.ts +34 -0
  131. package/dist/setup/whoami-summary.d.ts.map +1 -0
  132. package/dist/setup/whoami-summary.js +106 -0
  133. package/dist/setup/whoami-summary.js.map +1 -0
  134. package/dist/spawn/branch-lock.d.ts.map +1 -1
  135. package/dist/spawn/branch-lock.js +7 -1
  136. package/dist/spawn/branch-lock.js.map +1 -1
  137. package/dist/store/attachment-repair.d.ts +110 -0
  138. package/dist/store/attachment-repair.d.ts.map +1 -0
  139. package/dist/store/attachment-repair.js +326 -0
  140. package/dist/store/attachment-repair.js.map +1 -0
  141. package/dist/store/attachment-store.d.ts.map +1 -1
  142. package/dist/store/attachment-store.js +24 -7
  143. package/dist/store/attachment-store.js.map +1 -1
  144. package/dist/tasks/evidence.d.ts +34 -2
  145. package/dist/tasks/evidence.d.ts.map +1 -1
  146. package/dist/tasks/evidence.js +293 -35
  147. package/dist/tasks/evidence.js.map +1 -1
  148. package/dist/verification/verify-tools.d.ts.map +1 -1
  149. package/dist/verification/verify-tools.js +5 -6
  150. package/dist/verification/verify-tools.js.map +1 -1
  151. package/package.json +22 -12
  152. package/templates/workflows/release-prepare.yml.tmpl +48 -2
@@ -39,13 +39,351 @@ import { mkdir } from 'node:fs/promises';
39
39
  import { join, resolve } from 'node:path';
40
40
  import { promisify } from 'node:util';
41
41
  import { safeParseReleasePlan } from '@cleocode/contracts';
42
- import { eq } from 'drizzle-orm';
42
+ import { eq, inArray } from 'drizzle-orm';
43
43
  import { engineError, engineSuccess } from '../engine-result.js';
44
44
  import { getLogger } from '../logger.js';
45
45
  import { generateProjectHash } from '../nexus/hash.js';
46
46
  import { getProjectRoot } from '../paths.js';
47
47
  import { getDb, getNativeDb } from '../store/sqlite.js';
48
48
  import * as schema from '../store/tasks-schema.js';
49
+ // ─── Tag-reconcile plan synthesis (T11977 · DHQ-080) ─────────────────────────
50
+ /** Provenance origin label written into plan.meta when synthesis fires. */
51
+ const TAG_RECONCILE_ORIGIN = 'tag-reconcile-synthesized';
52
+ /** T#### regex re-used across synthesis helpers. */
53
+ const SYNTH_TASK_TOKEN_RE = /\bT\d{1,5}\b/g;
54
+ /** Conventional-commit prefix detector (mirrors backfill.ts equivalent). */
55
+ const SYNTH_CC_RE = /^(feat|fix|chore|docs|refactor|test|perf|build|ci|revert|breaking|style)(?:\(([^)]+)\))?!?:\s/i;
56
+ /**
57
+ * Infer the previous git tag by listing all tags sorted by creator-date
58
+ * (annotated + lightweight, oldest-first) and returning the entry
59
+ * immediately before `version`. Returns `null` when `version` is the
60
+ * oldest tag or no tags exist.
61
+ */
62
+ function inferPrevTag(version, projectRoot) {
63
+ try {
64
+ const raw = execFileSync('git', ['tag', '--list', '--sort=creatordate'], {
65
+ cwd: projectRoot,
66
+ encoding: 'utf-8',
67
+ stdio: ['ignore', 'pipe', 'pipe'],
68
+ timeout: SUBPROCESS_TIMEOUT_MS,
69
+ }).trim();
70
+ const tags = raw
71
+ .split('\n')
72
+ .map((t) => t.trim())
73
+ .filter((t) => t.length > 0);
74
+ const idx = tags.indexOf(version);
75
+ if (idx <= 0)
76
+ return null;
77
+ return tags[idx - 1] ?? null;
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ /**
84
+ * Parse the CHANGELOG.md section for `version` and extract:
85
+ * - Every T#### token mentioned in the section body.
86
+ * - The section text (for task summary extraction).
87
+ *
88
+ * Canonical header format (ADR-028 §2.5): `## [VERSION]` (no `v` prefix).
89
+ * We strip the leading `v` before matching so both forms are accepted.
90
+ *
91
+ * Returns `{ found: false }` when CHANGELOG.md is missing or has no matching
92
+ * section — synthesis continues without CHANGELOG data (non-fatal).
93
+ */
94
+ function parseChangelogSection(version, projectRoot) {
95
+ const changelogPath = join(projectRoot, 'CHANGELOG.md');
96
+ if (!existsSync(changelogPath))
97
+ return { found: false };
98
+ let contents;
99
+ try {
100
+ contents = readFileSync(changelogPath, 'utf-8');
101
+ }
102
+ catch {
103
+ return { found: false };
104
+ }
105
+ // Normalise version to no-v form (ADR-028 §2.5).
106
+ const normalized = version.startsWith('v') ? version.slice(1) : version;
107
+ const headerPattern = `## [${normalized}]`;
108
+ const startIdx = contents.indexOf(headerPattern);
109
+ if (startIdx < 0)
110
+ return { found: false };
111
+ // Slice from the header line to the next `## [` section header (exclusive).
112
+ const bodyStart = startIdx + headerPattern.length;
113
+ const nextSectionIdx = contents.indexOf('\n## [', bodyStart);
114
+ const sectionText = nextSectionIdx >= 0 ? contents.slice(bodyStart, nextSectionIdx) : contents.slice(bodyStart);
115
+ // Extract all T#### tokens from the section body.
116
+ SYNTH_TASK_TOKEN_RE.lastIndex = 0;
117
+ const tokens = new Set();
118
+ let m = SYNTH_TASK_TOKEN_RE.exec(sectionText);
119
+ while (m !== null) {
120
+ tokens.add(m[0]);
121
+ m = SYNTH_TASK_TOKEN_RE.exec(sectionText);
122
+ }
123
+ return { found: true, taskIds: Array.from(tokens), sectionText };
124
+ }
125
+ /**
126
+ * Extract unique T#### tokens from the `prevTag..tag` git log (subjects +
127
+ * bodies), plus PR numbers from merge-commit subjects.
128
+ *
129
+ * Returns empty arrays on git failure (non-fatal — synthesis continues).
130
+ */
131
+ function extractTokensFromGitLog(prevTag, tag, projectRoot) {
132
+ try {
133
+ const range = prevTag ? `${prevTag}..${tag}` : tag;
134
+ const logOut = execFileSync('git', ['log', '--pretty=format:%s%n%b%n---COMMIT---', range], {
135
+ cwd: projectRoot,
136
+ encoding: 'utf-8',
137
+ stdio: ['ignore', 'pipe', 'pipe'],
138
+ timeout: SUBPROCESS_TIMEOUT_MS,
139
+ maxBuffer: 64 * 1024 * 1024,
140
+ }).trim();
141
+ SYNTH_TASK_TOKEN_RE.lastIndex = 0;
142
+ const taskIds = new Set();
143
+ let m = SYNTH_TASK_TOKEN_RE.exec(logOut);
144
+ while (m !== null) {
145
+ taskIds.add(m[0]);
146
+ m = SYNTH_TASK_TOKEN_RE.exec(logOut);
147
+ }
148
+ // Extract PR numbers from merge-commit subjects: "Merge pull request #NNN"
149
+ const prNumbers = new Set();
150
+ const PR_RE = /Merge pull request #(\d+)/g;
151
+ let pm = PR_RE.exec(logOut);
152
+ while (pm !== null) {
153
+ const n = Number.parseInt(pm[1], 10);
154
+ if (Number.isFinite(n) && n > 0)
155
+ prNumbers.add(n);
156
+ pm = PR_RE.exec(logOut);
157
+ }
158
+ return { taskIds: Array.from(taskIds), prNumbers: Array.from(prNumbers) };
159
+ }
160
+ catch {
161
+ return { taskIds: [], prNumbers: [] };
162
+ }
163
+ }
164
+ /**
165
+ * Parse the CC type from a commit subject and map to a task kind.
166
+ * Mirrors the equivalent in `backfill.ts`.
167
+ *
168
+ * @internal
169
+ */
170
+ function synthCcToTaskKind(subject) {
171
+ const m = subject.match(SYNTH_CC_RE);
172
+ const raw = m?.[1]?.toLowerCase() ?? '';
173
+ switch (raw) {
174
+ case 'feat':
175
+ return 'feat';
176
+ case 'fix':
177
+ return 'fix';
178
+ case 'docs':
179
+ return 'docs';
180
+ case 'refactor':
181
+ return 'refactor';
182
+ case 'test':
183
+ return 'test';
184
+ case 'perf':
185
+ return 'perf';
186
+ case 'revert':
187
+ return 'revert';
188
+ case 'breaking':
189
+ return 'breaking';
190
+ default:
191
+ return 'chore';
192
+ }
193
+ }
194
+ /**
195
+ * Synthesise a minimal {@link ReleasePlan} for a tag-driven release that has
196
+ * no `.cleo/release/<version>.plan.json` on disk (DHQ-080 · T11977).
197
+ *
198
+ * Derivation order (highest-fidelity source wins):
199
+ * (a) CHANGELOG.md section for `version` — task-ID tokens + summaries.
200
+ * (b) Merged PRs between prevTag..tag (`git log --merges`) — T#### from titles.
201
+ * (c) All commits between prevTag..tag — T#### from subjects/bodies.
202
+ *
203
+ * The synthesised plan carries `meta.origin = 'tag-reconcile-synthesized'`
204
+ * so honest plans (from `cleo release plan`) remain distinguishable. Fields
205
+ * that cannot be derived without a prior `cleo release plan` run (evidenceAtoms,
206
+ * gates, prUrl, mergeCommitSha, workflowRunUrl) are left empty/null.
207
+ *
208
+ * Does NOT write to disk — the caller decides whether to persist or dry-run.
209
+ *
210
+ * @param version — Version tag to synthesise a plan for (e.g. `v2026.6.14`).
211
+ * @param projectRoot — Absolute project root.
212
+ * @returns `SynthesizedPlanReport` with the in-memory plan + derivation metadata.
213
+ *
214
+ * @task T11977
215
+ */
216
+ export async function synthesizePlanForReconcile(version, projectRoot) {
217
+ const prevTag = inferPrevTag(version, projectRoot);
218
+ // (a) CHANGELOG.md section
219
+ const changelogResult = parseChangelogSection(version, projectRoot);
220
+ const changelogTaskIds = changelogResult.found ? changelogResult.taskIds : [];
221
+ // (b+c) Git log tokens + PR numbers
222
+ const { taskIds: commitTaskIds, prNumbers: discoveredPrNumbers } = extractTokensFromGitLog(prevTag, version, projectRoot);
223
+ // Union the sources; changelog wins position (highest fidelity).
224
+ const allTokens = new Set([...changelogTaskIds, ...commitTaskIds]);
225
+ // Intersect with valid task IDs in the runtime store (do not fabricate rows).
226
+ // Use inArray to avoid a full table scan (DHQ-065 anti-pattern guard).
227
+ const db = await getDb(projectRoot);
228
+ const validIds = new Set();
229
+ if (allTokens.size > 0) {
230
+ const tokenList = Array.from(allTokens);
231
+ const rows = await db
232
+ .select({ id: schema.tasks.id })
233
+ .from(schema.tasks)
234
+ .where(inArray(schema.tasks.id, tokenList))
235
+ .all();
236
+ for (const r of rows) {
237
+ if (typeof r.id === 'string') {
238
+ validIds.add(r.id);
239
+ }
240
+ }
241
+ }
242
+ // Derive task kind heuristic from the CHANGELOG section or first git log subject.
243
+ let seedKind = 'chore';
244
+ if (changelogResult.found) {
245
+ // Scan changelog section for a first `###` heading to derive kind.
246
+ const firstHeading = changelogResult.sectionText.split('\n').find((l) => l.startsWith('### '));
247
+ if (firstHeading?.toLowerCase().includes('added'))
248
+ seedKind = 'feat';
249
+ else if (firstHeading?.toLowerCase().includes('fix'))
250
+ seedKind = 'fix';
251
+ }
252
+ else {
253
+ // Fall back to first commit subject CC type.
254
+ try {
255
+ const range = prevTag ? `${prevTag}..${version}` : version;
256
+ const firstSubject = execFileSync('git', ['log', '--pretty=format:%s', '--reverse', range], {
257
+ cwd: projectRoot,
258
+ encoding: 'utf-8',
259
+ stdio: ['ignore', 'pipe', 'pipe'],
260
+ timeout: SUBPROCESS_TIMEOUT_MS,
261
+ })
262
+ .trim()
263
+ .split('\n')[0];
264
+ if (firstSubject)
265
+ seedKind = synthCcToTaskKind(firstSubject);
266
+ }
267
+ catch {
268
+ // non-fatal
269
+ }
270
+ }
271
+ const epicId = validIds.size > 0
272
+ ? Array.from(validIds)[0]
273
+ : `SYNTH-${version.replace(/[^A-Z0-9]/gi, '')}`;
274
+ // Build per-task entries. Each task gets a summary from the CHANGELOG section
275
+ // when available; otherwise a generic backfill note.
276
+ const taskEntries = validIds.size > 0
277
+ ? Array.from(validIds).map((id) => {
278
+ // Try to extract a summary line for this task from the CHANGELOG section.
279
+ let summary = `${id} — synthesized from ${version}`;
280
+ if (changelogResult.found) {
281
+ // Look for a bullet line containing the task ID.
282
+ const summaryLine = changelogResult.sectionText
283
+ .split('\n')
284
+ .find((l) => l.includes(`(${id}`) || l.includes(`${id}/`) || l.includes(` ${id} `));
285
+ if (summaryLine) {
286
+ // Strip leading `-` or `*` and trailing refs like `_(T#/PR#)_`.
287
+ summary = summaryLine
288
+ .replace(/^\s*[-*]\s*/, '')
289
+ .replace(/\s*_\(.*\)_\s*$/, '')
290
+ .trim();
291
+ }
292
+ }
293
+ return {
294
+ id,
295
+ kind: seedKind,
296
+ impact: 'patch',
297
+ userFacingSummary: summary,
298
+ evidenceAtoms: [`note:synthesized-from-tag-${version}`],
299
+ epicAncestor: epicId,
300
+ };
301
+ })
302
+ : [
303
+ {
304
+ id: `SYNTH-${version.replace(/[^A-Z0-9]/gi, '')}-PLACEHOLDER`,
305
+ kind: 'chore',
306
+ impact: 'patch',
307
+ userFacingSummary: `Tag-driven release ${version} (no task tokens resolved)`,
308
+ evidenceAtoms: [`note:synthesized-from-tag-${version}`],
309
+ epicAncestor: epicId,
310
+ },
311
+ ];
312
+ // Bucket task IDs into changelog sections.
313
+ const changelog = {
314
+ features: [],
315
+ fixes: [],
316
+ chores: [],
317
+ breaking: [],
318
+ };
319
+ for (const t of taskEntries) {
320
+ if (t.kind === 'feat')
321
+ changelog.features.push(t.id);
322
+ else if (t.kind === 'fix' || t.kind === 'hotfix')
323
+ changelog.fixes.push(t.id);
324
+ else if (t.kind === 'breaking' || t.kind === 'revert')
325
+ changelog.breaking.push(t.id);
326
+ else
327
+ changelog.chores.push(t.id);
328
+ }
329
+ const nowIso = new Date().toISOString();
330
+ const plan = {
331
+ $schema: 'https://cleocode.io/schemas/release-plan/v1.json',
332
+ version,
333
+ resolvedVersion: version,
334
+ suffixApplied: false,
335
+ scheme: 'calver',
336
+ channel: 'latest',
337
+ epicId,
338
+ releaseKind: 'regular',
339
+ createdAt: nowIso,
340
+ createdBy: TAG_RECONCILE_ORIGIN,
341
+ previousVersion: prevTag,
342
+ previousTag: prevTag,
343
+ previousShippedAt: null,
344
+ tasks: taskEntries,
345
+ changelog,
346
+ gates: [],
347
+ platformMatrix: [
348
+ {
349
+ platform: 'any',
350
+ publisher: 'npm',
351
+ package: '@cleocode/cleo',
352
+ smoke: false,
353
+ },
354
+ ],
355
+ preflightSummary: {
356
+ esbuildExternalsDrift: false,
357
+ lockfileDrift: false,
358
+ epicCompletenessClean: true,
359
+ doubleListingClean: true,
360
+ preflightWarnings: [TAG_RECONCILE_ORIGIN],
361
+ },
362
+ workflowRunUrl: null,
363
+ prUrl: null,
364
+ mergeCommitSha: null,
365
+ status: 'published',
366
+ meta: {
367
+ firstEverRelease: prevTag === null,
368
+ origin: TAG_RECONCILE_ORIGIN,
369
+ synthesizedAt: nowIso,
370
+ changelogSectionFound: changelogResult.found,
371
+ discoveredPrCount: discoveredPrNumbers.length,
372
+ },
373
+ };
374
+ const prTaskIds = [];
375
+ // PR task IDs are derived at reconcile time from the actual fetched PRs.
376
+ // We surface discoveredPrNumbers in the report so dry-run can show them.
377
+ return {
378
+ plan,
379
+ prevTag,
380
+ changelogTaskIds,
381
+ prTaskIds,
382
+ commitTaskIds: Array.from(allTokens),
383
+ discoveredPrNumbers,
384
+ changelogSectionFound: changelogResult.found,
385
+ };
386
+ }
49
387
  const log = getLogger('release:reconcile-v2');
50
388
  const execFileAsync = promisify(execFile);
51
389
  /** Default subprocess timeout for git/gh calls (60s per task rules). */
@@ -676,10 +1014,65 @@ export async function releaseReconcileV2(version, opts = {}) {
676
1014
  const startedAt = Date.now();
677
1015
  const projectRoot = getProjectRoot(opts.projectRoot);
678
1016
  // ── 1. Pre-conditions (R-080 .. R-083) ──
1017
+ //
1018
+ // T11977 / DHQ-080: tag-driven path (git tag + push → GHA publishes to npm,
1019
+ // no `cleo release plan` ever run) leaves no plan file. When the plan is
1020
+ // absent BUT the git tag exists, synthesise a minimal plan at reconcile time
1021
+ // from the git log + CHANGELOG.md + merged PRs. The synthesised plan carries
1022
+ // `meta.origin = 'tag-reconcile-synthesized'` so honest plans are
1023
+ // distinguishable. Evidence-staleness validation (R-313) is skipped for
1024
+ // synthesised plans (there is no evidence to validate).
1025
+ //
1026
+ // When `opts.dryRun = true`, return the derivation report WITHOUT touching
1027
+ // the DB or disk.
1028
+ /** Populated when synthesis fires on the tag-driven path. */
1029
+ let synthReport = null;
679
1030
  const planRes = loadPlan(version, projectRoot);
680
- if (!planRes.success)
681
- return planRes;
682
- const plan = planRes.data;
1031
+ let plan;
1032
+ if (!planRes.success) {
1033
+ // Only attempt synthesis for E_PLAN_NOT_FOUND — other errors (e.g.
1034
+ // E_PLAN_INVALID) are returned immediately (the file exists but is corrupt).
1035
+ if (planRes.error.code !== 'E_PLAN_NOT_FOUND')
1036
+ return planRes;
1037
+ // Check whether the tag exists before committing to synthesis.
1038
+ const earlyTagRes = assertTagExists(version, projectRoot);
1039
+ if (!earlyTagRes.success) {
1040
+ // Neither a plan file nor a git tag — original E_PLAN_NOT_FOUND is
1041
+ // the most useful error to surface.
1042
+ return planRes;
1043
+ }
1044
+ log.info({ version }, 'E_PLAN_NOT_FOUND on tag-driven path — synthesising minimal plan (DHQ-080 · T11977)');
1045
+ synthReport = await synthesizePlanForReconcile(version, projectRoot);
1046
+ plan = synthReport.plan;
1047
+ // Dry-run: return derivation metadata without any DB writes.
1048
+ if (opts.dryRun) {
1049
+ const durationMs = Date.now() - startedAt;
1050
+ return engineSuccess({
1051
+ version,
1052
+ tag: version,
1053
+ tagSha: earlyTagRes.data,
1054
+ commitCount: 0,
1055
+ taskCount: plan.tasks.length,
1056
+ changeCount: 0,
1057
+ artifactCount: 0,
1058
+ brainLinkCount: 0,
1059
+ orphanCommits: [],
1060
+ dryRun: true,
1061
+ synthesized: {
1062
+ changelogTaskCount: synthReport.changelogTaskIds.length,
1063
+ commitTaskCount: synthReport.commitTaskIds.length,
1064
+ discoveredPrNumbers: synthReport.discoveredPrNumbers,
1065
+ prevTag: synthReport.prevTag,
1066
+ changelogSectionFound: synthReport.changelogSectionFound,
1067
+ },
1068
+ durationMs,
1069
+ txSize: 0,
1070
+ });
1071
+ }
1072
+ }
1073
+ else {
1074
+ plan = planRes.data;
1075
+ }
683
1076
  const tagRes = assertTagExists(version, projectRoot);
684
1077
  if (!tagRes.success)
685
1078
  return tagRes;
@@ -689,8 +1082,9 @@ export async function releaseReconcileV2(version, opts = {}) {
689
1082
  return releaseRes;
690
1083
  // T9528: backfill walks historical tags whose evidence atoms may reference
691
1084
  // long-deleted files or rebased commits — skip the R-313 staleness gate.
1085
+ // T11977: synthesised plans have no evidence atoms to validate — also skip.
692
1086
  // Production publish flows still pass through this check.
693
- if (!opts.backfill) {
1087
+ if (!opts.backfill && synthReport === null) {
694
1088
  const stalenessRes = revalidateEvidenceStaleness(plan, version, projectRoot);
695
1089
  if (!stalenessRes.success)
696
1090
  return stalenessRes;
@@ -1310,6 +1704,19 @@ export async function releaseReconcileV2(version, opts = {}) {
1310
1704
  ...(unknownTokens.size > 0 ? { unknownTokens: Array.from(unknownTokens) } : {}),
1311
1705
  ...(skippedTaskRefs.size > 0 ? { skippedTaskRefs: Array.from(skippedTaskRefs) } : {}),
1312
1706
  ...(reReconciled ? { reReconciled: true } : {}),
1707
+ // T11977 / DHQ-080: include synthesis metadata when the plan was derived at
1708
+ // reconcile time (tag-driven path with no prior `cleo release plan`).
1709
+ ...(synthReport !== null
1710
+ ? {
1711
+ synthesized: {
1712
+ changelogTaskCount: synthReport.changelogTaskIds.length,
1713
+ commitTaskCount: synthReport.commitTaskIds.length,
1714
+ discoveredPrNumbers: synthReport.discoveredPrNumbers,
1715
+ prevTag: synthReport.prevTag,
1716
+ changelogSectionFound: synthReport.changelogSectionFound,
1717
+ },
1718
+ }
1719
+ : {}),
1313
1720
  durationMs,
1314
1721
  txSize,
1315
1722
  };