release-skill 0.6.2 → 0.6.3

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 (73) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codebuddy-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +2 -2
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +23 -0
  7. package/CONTRIBUTING.md +1 -1
  8. package/INSTALL.md +47 -2
  9. package/INSTALL.zh-CN.md +29 -2
  10. package/README.md +126 -9
  11. package/README.zh-CN.md +108 -9
  12. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  13. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  14. package/adapters/claude/bin/release-skill.bundle.mjs +6408 -1654
  15. package/adapters/claude/schemas/.render-manifest.json +10 -6
  16. package/adapters/claude/schemas/postpublish-approval-record.schema.json +47 -0
  17. package/adapters/claude/schemas/release-plan.schema.json +65 -1
  18. package/adapters/claude/schemas/release-project.schema.json +73 -1
  19. package/adapters/claude/schemas/release-run.schema.json +11 -6
  20. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  21. package/adapters/codex/bin/release-skill.bundle.mjs +6408 -1654
  22. package/adapters/codex/schemas/.render-manifest.json +10 -6
  23. package/adapters/codex/schemas/postpublish-approval-record.schema.json +47 -0
  24. package/adapters/codex/schemas/release-plan.schema.json +65 -1
  25. package/adapters/codex/schemas/release-project.schema.json +73 -1
  26. package/adapters/codex/schemas/release-run.schema.json +11 -6
  27. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  28. package/adapters/kimi/bin/release-skill.bundle.mjs +6408 -1654
  29. package/adapters/kimi/schemas/.render-manifest.json +10 -6
  30. package/adapters/kimi/schemas/postpublish-approval-record.schema.json +47 -0
  31. package/adapters/kimi/schemas/release-plan.schema.json +65 -1
  32. package/adapters/kimi/schemas/release-project.schema.json +73 -1
  33. package/adapters/kimi/schemas/release-run.schema.json +11 -6
  34. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  35. package/adapters/workbuddy/bin/release-skill.bundle.mjs +6408 -1654
  36. package/adapters/workbuddy/schemas/.render-manifest.json +10 -6
  37. package/adapters/workbuddy/schemas/postpublish-approval-record.schema.json +47 -0
  38. package/adapters/workbuddy/schemas/release-plan.schema.json +65 -1
  39. package/adapters/workbuddy/schemas/release-project.schema.json +73 -1
  40. package/adapters/workbuddy/schemas/release-run.schema.json +11 -6
  41. package/bin/release-skill-cli.mjs +181 -3
  42. package/bin/release-skill.bundle.mjs +6408 -1654
  43. package/package.json +2 -1
  44. package/platform-manifest.json +4 -4
  45. package/references/.render-manifest.json +5 -5
  46. package/references/01-state-machine.md +22 -2
  47. package/schemas/.render-manifest.json +10 -6
  48. package/schemas/postpublish-approval-record.schema.json +47 -0
  49. package/schemas/release-plan.schema.json +65 -1
  50. package/schemas/release-project.schema.json +73 -1
  51. package/schemas/release-run.schema.json +11 -6
  52. package/src/commands/approve.mjs +167 -1
  53. package/src/commands/distribute.mjs +411 -33
  54. package/src/commands/postverify.mjs +734 -0
  55. package/src/commands/prepare.mjs +280 -42
  56. package/src/commands/setup.mjs +715 -0
  57. package/src/commands/ship.mjs +152 -5
  58. package/src/commands/verify.mjs +92 -15
  59. package/src/core/approval.mjs +93 -68
  60. package/src/core/bounded-output.mjs +46 -0
  61. package/src/core/derived-artifact-gates.mjs +258 -0
  62. package/src/core/docs-refresh-preset.mjs +167 -0
  63. package/src/core/errors.mjs +4 -0
  64. package/src/core/hooks.mjs +28 -0
  65. package/src/core/marketplace-registry-entry.mjs +174 -0
  66. package/src/core/notify-handoff.mjs +76 -0
  67. package/src/core/postpublish-approval.mjs +110 -0
  68. package/src/core/postpublish.mjs +424 -7
  69. package/src/core/preset-executor.mjs +156 -0
  70. package/src/core/preset-gitwrite.mjs +463 -0
  71. package/src/core/presets.mjs +706 -0
  72. package/src/core/proposal-inbox.mjs +630 -0
  73. package/src/core/run.mjs +91 -6
@@ -56,6 +56,7 @@ import {
56
56
  } from '../snapshot/frozen.mjs';
57
57
  import { ReleaseError, GATE_FAILED, CONFIG_INVALID, CONFIG_MISSING, FORBIDDEN_CONTENT_DETECTED, RELEASE_DOCS_STALE, DIRTY_SOURCE_INPUT, BUNDLE_STALE } from '../core/errors.mjs';
58
58
  import { assertBundleFreshness } from '../core/bundle-freshness.mjs';
59
+ import { assertAdapterFreshness, assertSelfBootstrapFacts } from '../core/derived-artifact-gates.mjs';
59
60
  import { PKG_ROOT } from '../core/pkg-root.mjs';
60
61
  import { writeFrozenMarker, FROZEN_MARKER_FILENAME } from '../core/frozen-marker.mjs';
61
62
  import {
@@ -71,7 +72,12 @@ import { createProductionPrepareRunDir } from '../core/run.mjs';
71
72
  import { PLATFORMS } from '../platforms/registry.mjs';
72
73
  import { validateMarketplaceSourceSelection, MARKETPLACE_SOURCE_TYPES, resolvePluginManifestFromMarketplaceEntrySource, resolveMarketplaceRoot } from '../adapters/plugin-marketplace.mjs';
73
74
  import { buildInstallationContract, computeInstallationContractDigest, INSTALLATION_CONTRACT_ALGORITHM_VERSION } from '../core/installation-contract.mjs';
74
- import { validatePostPublishDeclaration, PAYLOAD_SOURCE_TAG_WORKTREE } from '../core/postpublish.mjs';
75
+ import {
76
+ validatePostPublishDeclaration,
77
+ normalizePostPublishDeclaration,
78
+ orderNormalizedHooks,
79
+ PAYLOAD_SOURCE_TAG_WORKTREE,
80
+ } from '../core/postpublish.mjs';
75
81
 
76
82
  // ---------------------------------------------------------------------------
77
83
  // 安装契约常量
@@ -228,39 +234,15 @@ export async function resolveAllUnitVersions(units, root, explicitVersion, evide
228
234
  // Hooks execution
229
235
  // ---------------------------------------------------------------------------
230
236
 
231
- /** Maximum number of output lines preserved in a hook-failure tail. */
232
- const HOOK_OUTPUT_TAIL_MAX_LINES = 50;
233
- /** Maximum bytes preserved in a hook-failure tail. */
234
- const HOOK_OUTPUT_TAIL_MAX_BYTES = 8 * 1024;
235
-
236
- /**
237
- * Bound a captured child-output stream to the tail that matters for triage:
238
- * the last 50 lines, further capped at 8 KB — whichever is smaller.
239
- *
240
- * @param {string} [text] - Captured stdout/stderr text.
241
- * @returns {string} The bounded tail ('' for empty/absent input).
242
- */
243
- export function boundedOutputTail(text) {
244
- if (typeof text !== 'string' || text.length === 0) return '';
245
- let lines = text.split('\n');
246
- // A trailing newline produces an empty final element; drop it so the line
247
- // budget counts real output lines.
248
- if (lines.length > 1 && lines[lines.length - 1] === '') {
249
- lines = lines.slice(0, -1);
250
- }
251
- let tail = lines.slice(-HOOK_OUTPUT_TAIL_MAX_LINES);
252
- let joined = tail.join('\n');
253
- while (tail.length > 1 && Buffer.byteLength(joined, 'utf8') > HOOK_OUTPUT_TAIL_MAX_BYTES) {
254
- tail = tail.slice(1);
255
- joined = tail.join('\n');
256
- }
257
- if (Buffer.byteLength(joined, 'utf8') > HOOK_OUTPUT_TAIL_MAX_BYTES) {
258
- // A single line exceeds the byte cap: keep the trailing bytes.
259
- const buf = Buffer.from(joined, 'utf8');
260
- joined = buf.subarray(buf.length - HOOK_OUTPUT_TAIL_MAX_BYTES).toString('utf8');
261
- }
262
- return joined;
263
- }
237
+ // v0.6.3 R1 tail unification: the "last 50 lines / 8 KiB" tail authority now
238
+ // lives in core/bounded-output.mjs, shared by prepare and distribute. The
239
+ // re-export preserves prepare's historical public surface.
240
+ export {
241
+ boundedOutputTail,
242
+ HOOK_OUTPUT_TAIL_MAX_LINES,
243
+ HOOK_OUTPUT_TAIL_MAX_BYTES,
244
+ } from '../core/bounded-output.mjs';
245
+ import { boundedOutputTail } from '../core/bounded-output.mjs';
264
246
 
265
247
  /**
266
248
  * Run all declared project hooks in order: docs, build, test, typecheck.
@@ -908,6 +890,94 @@ function normalizedProductionConfig(unit) {
908
890
  };
909
891
  }
910
892
 
893
+ /**
894
+ * O5 (2026-08-18 release-cycle investigation §3.2): observe how the local
895
+ * workspace HEAD relates to `origin/<defaultBranch>` for an ONLINE production
896
+ * prepare. This is a WARNING-LEVEL, non-blocking pre-publish signal: in the
897
+ * 0.6.1 cycle a workspace 15 commits ahead of origin only surfaced when the
898
+ * publish source-authority gate rejected it. Pushing is a legitimate
899
+ * pre-publish action, so the observation must inform, never block the freeze.
900
+ *
901
+ * Read-only git plumbing only (argv arrays, no shell): rev-parse, ls-remote,
902
+ * cat-file, rev-list. Every failure degrades to a descriptive status object —
903
+ * this function NEVER throws, so it can never break a freeze.
904
+ *
905
+ * @param {object} options
906
+ * @param {string} options.root - Workspace (git repository) root.
907
+ * @param {string} options.defaultBranch - Default branch name on origin.
908
+ * @returns {Promise<{
909
+ * status: 'in-sync' | 'ahead' | 'behind' | 'diverged' | 'no-origin' | 'remote-ref-missing' | 'unknown',
910
+ * localHead?: string,
911
+ * remoteHead?: string,
912
+ * aheadCount?: number,
913
+ * behindCount?: number,
914
+ * error?: string,
915
+ * }>}
916
+ */
917
+ export async function observeOriginAhead({ root, defaultBranch }) {
918
+ const runGit = (gitArgs) => execFile('git', ['-C', root, ...gitArgs], {
919
+ shell: false,
920
+ encoding: 'utf8',
921
+ timeout: 30000,
922
+ });
923
+
924
+ let localHead;
925
+ try {
926
+ ({ stdout: localHead } = await runGit(['rev-parse', 'HEAD']));
927
+ localHead = localHead.trim();
928
+ } catch (err) {
929
+ return { status: 'unknown', error: `rev-parse HEAD failed: ${err.message}` };
930
+ }
931
+
932
+ // Distinguish "no origin remote" from a transient ls-remote failure.
933
+ try {
934
+ await runGit(['remote', 'get-url', 'origin']);
935
+ } catch {
936
+ return { status: 'no-origin', localHead };
937
+ }
938
+
939
+ let remoteHead;
940
+ try {
941
+ const { stdout } = await runGit(['ls-remote', 'origin', `refs/heads/${defaultBranch}`]);
942
+ const firstLine = stdout.trim().split('\n').filter((line) => line.length > 0)[0];
943
+ if (!firstLine) {
944
+ return { status: 'remote-ref-missing', localHead, defaultBranch };
945
+ }
946
+ remoteHead = firstLine.split('\t')[0];
947
+ } catch (err) {
948
+ return { status: 'unknown', localHead, error: `ls-remote origin failed: ${err.message}` };
949
+ }
950
+
951
+ if (remoteHead === localHead) {
952
+ return { status: 'in-sync', localHead, remoteHead };
953
+ }
954
+
955
+ // Ancestry is only computable when the remote head object exists locally
956
+ // (no implicit fetch — this observer is read-only on the network beyond the
957
+ // single ls-remote above). When it does not, report diverged with no counts.
958
+ try {
959
+ await runGit(['cat-file', '-e', `${remoteHead}^{commit}`]);
960
+ } catch {
961
+ return { status: 'diverged', localHead, remoteHead };
962
+ }
963
+
964
+ try {
965
+ const aheadRaw = await runGit(['rev-list', '--count', `${remoteHead}..HEAD`]);
966
+ const behindRaw = await runGit(['rev-list', '--count', `HEAD..${remoteHead}`]);
967
+ const aheadCount = Number.parseInt(aheadRaw.stdout.trim(), 10) || 0;
968
+ const behindCount = Number.parseInt(behindRaw.stdout.trim(), 10) || 0;
969
+ if (aheadCount > 0 && behindCount === 0) {
970
+ return { status: 'ahead', localHead, remoteHead, aheadCount };
971
+ }
972
+ if (behindCount > 0 && aheadCount === 0) {
973
+ return { status: 'behind', localHead, remoteHead, behindCount };
974
+ }
975
+ return { status: 'diverged', localHead, remoteHead, aheadCount, behindCount };
976
+ } catch (err) {
977
+ return { status: 'diverged', localHead, remoteHead, error: err.message };
978
+ }
979
+ }
980
+
911
981
  /**
912
982
  * Derive the deterministic freeze timestamp for planVersion 2 plans (design:
913
983
  * t1-2-digest-decoupling.md §4.2): the baseline headCommit's committer date,
@@ -1211,7 +1281,8 @@ export async function resolveExternalMarketplaceFreezes({
1211
1281
  if (offline) {
1212
1282
  throw new ReleaseError(
1213
1283
  GATE_FAILED,
1214
- `unit "${unit.id}" ${dist.type} external marketplace form requires online production prepare to freeze the marketplace commit sha`,
1284
+ `unit "${unit.id}" ${dist.type} external marketplace form requires online production prepare to freeze the marketplace commit sha. ` +
1285
+ `Remediation: release-skill prepare --production --online`,
1215
1286
  { unitId: unit.id, marketplaceSourceType: dist.marketplaceSourceType },
1216
1287
  );
1217
1288
  }
@@ -1766,7 +1837,7 @@ export function buildExternalActions(unitResults, resolvedVersions, productionAs
1766
1837
  * every declared hook runs in full and the incremental hook cache is neither
1767
1838
  * read nor written.
1768
1839
  *
1769
- * @returns {Promise<{ planPath: string, planDigest: string, evidenceDir: string, warnings: ReadonlyArray<object> }>}
1840
+ * @returns {Promise<{ planPath: string, planDigest: string, evidenceDir: string, warnings: ReadonlyArray<object>, nextSteps: ReadonlyArray<{ code: string, message: string }> }>}
1770
1841
  *
1771
1842
  * @throws {ReleaseError} on any gate failure. No PREPARED plan is written.
1772
1843
  */
@@ -1925,6 +1996,9 @@ export async function prepareRelease(options) {
1925
1996
 
1926
1997
  const { config, configPath, configDigest } = await loadProjectConfig({ root: realRoot });
1927
1998
  const adoptionWarnings = collectExpectedPublicSurfaceAdoptionWarnings(config);
1999
+ // Mutable operator-facing warning list: seeded from the adoption warnings,
2000
+ // appended by later gates (O5 origin-ahead). Returned as `warnings`.
2001
+ const runWarnings = [...adoptionWarnings];
1928
2002
 
1929
2003
  await evidence.append({
1930
2004
  phase: 'config',
@@ -2026,6 +2100,70 @@ export async function prepareRelease(options) {
2026
2100
  reasonTag: 'RELEASE_DOCS_STALE',
2027
2101
  });
2028
2102
 
2103
+ // --- Step 1b-fast: version-sensitive derived-artifact fast pre-gates (O1) ---
2104
+ // 2026-08-18 investigation §3.2: adapter drift and stale self-bootstrap
2105
+ // fact pins used to surface only deep inside the ~80s full test hook.
2106
+ // Promote the two exact canonical checks (build-adapters --check and the
2107
+ // release-docs-self-bootstrap single-file test) to prepare's earliest
2108
+ // stage so the same drift fails closed in seconds, before any hook. Like
2109
+ // the bundle freshness gate this is artifact-integrity class: workflow
2110
+ // trimming never exempts it. Installed layouts record not-applicable.
2111
+ await evidence.append({ phase: 'adapter-freshness', status: 'started' });
2112
+ const adapterFreshnessFn = options.adapterFreshnessFn ?? assertAdapterFreshness;
2113
+ let adapterFreshness;
2114
+ try {
2115
+ adapterFreshness = await adapterFreshnessFn(PKG_ROOT);
2116
+ } catch (err) {
2117
+ await evidence.append({
2118
+ phase: 'adapter-freshness',
2119
+ status: 'blocking',
2120
+ reason: err.details?.reason ?? null,
2121
+ error: { code: err.code, message: err.message },
2122
+ });
2123
+ throw err;
2124
+ }
2125
+ if (adapterFreshness?.applicable === false) {
2126
+ await evidence.append({
2127
+ phase: 'adapter-freshness',
2128
+ status: 'not-applicable',
2129
+ reason: adapterFreshness.reason,
2130
+ });
2131
+ } else {
2132
+ await evidence.append({
2133
+ phase: 'adapter-freshness',
2134
+ status: 'completed',
2135
+ durationMs: adapterFreshness?.durationMs ?? null,
2136
+ });
2137
+ }
2138
+
2139
+ await evidence.append({ phase: 'self-bootstrap-facts', status: 'started' });
2140
+ const selfBootstrapFactsFn = options.selfBootstrapFactsFn ?? assertSelfBootstrapFacts;
2141
+ let selfBootstrapFacts;
2142
+ try {
2143
+ selfBootstrapFacts = await selfBootstrapFactsFn(PKG_ROOT);
2144
+ } catch (err) {
2145
+ await evidence.append({
2146
+ phase: 'self-bootstrap-facts',
2147
+ status: 'blocking',
2148
+ reason: err.details?.reason ?? null,
2149
+ error: { code: err.code, message: err.message },
2150
+ });
2151
+ throw err;
2152
+ }
2153
+ if (selfBootstrapFacts?.applicable === false) {
2154
+ await evidence.append({
2155
+ phase: 'self-bootstrap-facts',
2156
+ status: 'not-applicable',
2157
+ reason: selfBootstrapFacts.reason,
2158
+ });
2159
+ } else {
2160
+ await evidence.append({
2161
+ phase: 'self-bootstrap-facts',
2162
+ status: 'completed',
2163
+ durationMs: selfBootstrapFacts?.durationMs ?? null,
2164
+ });
2165
+ }
2166
+
2029
2167
  // --- Step 1c: postPublish distribution declaration gate (R1/R2) ---
2030
2168
  // The per-unit postPublish block drives the post-publish distribute
2031
2169
  // command. Validate it here, before any hook, baseline, snapshot, remote
@@ -2033,8 +2171,13 @@ export async function prepareRelease(options) {
2033
2171
  // side effects. This is the runtime re-check on top of the config JSON
2034
2172
  // schema: plans frozen by older schema versions must not be able to
2035
2173
  // smuggle shell strings, option-like executables, or secret-ish env
2036
- // keys through. A plan binds exactly one declaration; multiple units
2037
- // declaring postPublish is a hard gate failure.
2174
+ // keys through. R2: preset references resolve against the built-in
2175
+ // preset registry (per-preset config validation + requiresApproval
2176
+ // grading), and targets normalize onto preset hooks — the normalized
2177
+ // table is a deterministic projection of the digest-bound declaration,
2178
+ // so any list change changes the plan digest and voids approvals. A
2179
+ // plan binds exactly one declaration; multiple units declaring
2180
+ // postPublish is a hard gate failure.
2038
2181
  const postPublishDeclarations = configUnits
2039
2182
  .map((unit, index) => ({ unit, index }))
2040
2183
  .filter(({ unit }) => unit.postPublish !== undefined);
@@ -2049,12 +2192,20 @@ export async function prepareRelease(options) {
2049
2192
  if (postPublishDeclarations.length === 1) {
2050
2193
  const { unit, index } = postPublishDeclarations[0];
2051
2194
  validatePostPublishDeclaration(unit.postPublish, { unitId: unit.id });
2195
+ // Normalized hook table (design §2.2): validate the dependency
2196
+ // topology at freeze time too, so a cyclic/dangling declaration can
2197
+ // never be frozen for distribute to trip over.
2198
+ const normalizedDeclaration = normalizePostPublishDeclaration(unit.postPublish);
2199
+ const orderedNormalizedHooks = orderNormalizedHooks(normalizedDeclaration.hooks);
2052
2200
  postPublishDeclaration = { unit, index };
2053
2201
  await evidence.append({
2054
2202
  phase: 'postpublish-declaration',
2055
2203
  status: 'validated',
2056
2204
  unitId: unit.id,
2057
- targetCount: unit.postPublish.targets.length,
2205
+ targetCount: (unit.postPublish.targets ?? []).length,
2206
+ hookCount: (unit.postPublish.hooks ?? []).length,
2207
+ normalizedHookCount: orderedNormalizedHooks.length,
2208
+ preGates: normalizedDeclaration.preGates.map((gate) => gate.gate),
2058
2209
  });
2059
2210
  }
2060
2211
 
@@ -2297,6 +2448,73 @@ export async function prepareRelease(options) {
2297
2448
  inputDigest: sourceInputClosure.digest,
2298
2449
  remoteObservation: offline ? 'unobserved-offline' : 'deferred-to-publish',
2299
2450
  });
2451
+
2452
+ // --- Step 3c-ahead: local vs origin ahead observation (O5) ---
2453
+ // Online production prepare only. WARNING level, never blocking: pushing
2454
+ // is a legitimate pre-publish action, but the operator must know before
2455
+ // approval — the publish source-authority gate compares the frozen
2456
+ // source-input closure against the remote default branch and rejects
2457
+ // unpushed local commits (0.6.1 cycle: 15 commits ahead, discovered only
2458
+ // at publish). Offline prepare keeps the legacy zero-observation form.
2459
+ if (!offline) {
2460
+ if (!configDefaultBranch) {
2461
+ await evidence.append({
2462
+ phase: 'origin-ahead',
2463
+ status: 'skipped',
2464
+ reason: 'no project.defaultBranch configured',
2465
+ });
2466
+ } else {
2467
+ await evidence.append({ phase: 'origin-ahead', status: 'started' });
2468
+ const observeOriginAheadFn = options.observeOriginAheadFn ?? observeOriginAhead;
2469
+ let originObservation;
2470
+ try {
2471
+ originObservation = await observeOriginAheadFn({
2472
+ root: realRoot,
2473
+ defaultBranch: configDefaultBranch,
2474
+ });
2475
+ } catch (err) {
2476
+ originObservation = { status: 'unknown', error: err?.message ?? String(err) };
2477
+ }
2478
+ if (originObservation?.status === 'ahead') {
2479
+ await evidence.append({
2480
+ phase: 'origin-ahead',
2481
+ status: 'warning',
2482
+ defaultBranch: configDefaultBranch,
2483
+ localHead: originObservation.localHead ?? null,
2484
+ remoteHead: originObservation.remoteHead ?? null,
2485
+ aheadCount: originObservation.aheadCount ?? null,
2486
+ guidance: 'push the workspace before publish (git push); the publish source-authority gate compares against the remote default branch',
2487
+ });
2488
+ runWarnings.push({
2489
+ code: 'ORIGIN_AHEAD',
2490
+ defaultBranch: configDefaultBranch,
2491
+ aheadCount: originObservation.aheadCount ?? null,
2492
+ message:
2493
+ `local HEAD is ${originObservation.aheadCount ?? 'an unknown number of'} commit(s) ahead of origin/${configDefaultBranch}; ` +
2494
+ 'the publish source-authority gate compares against the remote default branch — push the workspace (git push) before publish',
2495
+ });
2496
+ } else if (['in-sync', 'behind', 'diverged'].includes(originObservation?.status)) {
2497
+ await evidence.append({
2498
+ phase: 'origin-ahead',
2499
+ status: 'completed',
2500
+ observation: originObservation.status,
2501
+ defaultBranch: configDefaultBranch,
2502
+ localHead: originObservation.localHead ?? null,
2503
+ remoteHead: originObservation.remoteHead ?? null,
2504
+ aheadCount: originObservation.aheadCount ?? null,
2505
+ behindCount: originObservation.behindCount ?? null,
2506
+ });
2507
+ } else {
2508
+ await evidence.append({
2509
+ phase: 'origin-ahead',
2510
+ status: 'unobserved',
2511
+ reason: originObservation?.status ?? 'unknown',
2512
+ defaultBranch: configDefaultBranch,
2513
+ error: originObservation?.error ?? null,
2514
+ });
2515
+ }
2516
+ }
2517
+ }
2300
2518
  } else if (production) {
2301
2519
  await evidence.append({
2302
2520
  phase: 'source-authority',
@@ -2391,7 +2609,8 @@ export async function prepareRelease(options) {
2391
2609
  if (offline) {
2392
2610
  throw new ReleaseError(
2393
2611
  GATE_FAILED,
2394
- `unit "${unit.id}" branch strategy "${branchStrategy}" requires online production prepare`,
2612
+ `unit "${unit.id}" branch strategy "${branchStrategy}" requires online production prepare. ` +
2613
+ `Remediation: release-skill prepare --production --online`,
2395
2614
  { unitId: unit.id, branchStrategy },
2396
2615
  );
2397
2616
  }
@@ -2495,7 +2714,8 @@ export async function prepareRelease(options) {
2495
2714
  throw new ReleaseError(
2496
2715
  GATE_FAILED,
2497
2716
  `unit "${unit.id}" has bound previousPublicBaseline but production prepare uses --offline. ` +
2498
- `Must use --online to observe the previous public baseline before freezing a production plan.`,
2717
+ `Must use --online to observe the previous public baseline before freezing a production plan. ` +
2718
+ `Remediation: release-skill prepare --production --online`,
2499
2719
  { unitId: unit.id, repo: ppbConfig.repo, ref: ppbConfig.ref },
2500
2720
  );
2501
2721
  }
@@ -3494,11 +3714,29 @@ export async function prepareRelease(options) {
3494
3714
  completedAt: (clock ? clock() : new Date().toISOString()),
3495
3715
  });
3496
3716
 
3717
+ // --- O4 (2026-08-18 investigation §3.2): success-time guidance ---
3718
+ // A NON-PRODUCTION plan cannot be used by publish; historically operators
3719
+ // discovered that only after a full prepare+approve round-trip. Surface the
3720
+ // remediation at success time so the loop never starts. Production plans
3721
+ // carry no such warning. The prepare command's own defaults are unchanged —
3722
+ // this only enriches the result, never flips offline/production.
3723
+ const nextSteps = [];
3724
+ if (!production) {
3725
+ nextSteps.push({
3726
+ code: 'NON_PRODUCTION_PLAN_NOT_PUBLISHABLE',
3727
+ message:
3728
+ 'This plan is NON-PRODUCTION and cannot be used by publish. ' +
3729
+ 'To release, re-run: release-skill prepare --production --online — ' +
3730
+ 'or use the guided happy end: release-skill ship --target-version <version>.',
3731
+ });
3732
+ }
3733
+
3497
3734
  return {
3498
3735
  planPath: writtenPath,
3499
3736
  planDigest,
3500
3737
  evidenceDir,
3501
- warnings: adoptionWarnings,
3738
+ warnings: runWarnings,
3739
+ nextSteps,
3502
3740
  };
3503
3741
  } catch (err) {
3504
3742
  // Record failure evidence