release-skill 0.6.2 → 0.7.0

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 (112) 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 +48 -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 +137 -12
  11. package/README.zh-CN.md +110 -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/error-codes.json +1 -1
  15. package/adapters/claude/bin/kernel-protocol.json +1 -1
  16. package/adapters/claude/bin/license-texts/Apache-2.0.txt +201 -0
  17. package/adapters/claude/bin/license-texts/MIT.txt +21 -0
  18. package/adapters/claude/bin/registry.json +64 -1
  19. package/adapters/claude/bin/release-skill.bundle.mjs +50570 -42208
  20. package/adapters/claude/bin/rules.json +1 -1
  21. package/adapters/claude/schemas/.render-manifest.json +10 -6
  22. package/adapters/claude/schemas/postpublish-approval-record.schema.json +47 -0
  23. package/adapters/claude/schemas/release-plan.schema.json +200 -4
  24. package/adapters/claude/schemas/release-project.schema.json +86 -5
  25. package/adapters/claude/schemas/release-run.schema.json +11 -6
  26. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  27. package/adapters/codex/bin/error-codes.json +1 -1
  28. package/adapters/codex/bin/kernel-protocol.json +1 -1
  29. package/adapters/codex/bin/license-texts/Apache-2.0.txt +201 -0
  30. package/adapters/codex/bin/license-texts/MIT.txt +21 -0
  31. package/adapters/codex/bin/registry.json +64 -1
  32. package/adapters/codex/bin/release-skill.bundle.mjs +50570 -42208
  33. package/adapters/codex/bin/rules.json +1 -1
  34. package/adapters/codex/schemas/.render-manifest.json +10 -6
  35. package/adapters/codex/schemas/postpublish-approval-record.schema.json +47 -0
  36. package/adapters/codex/schemas/release-plan.schema.json +200 -4
  37. package/adapters/codex/schemas/release-project.schema.json +86 -5
  38. package/adapters/codex/schemas/release-run.schema.json +11 -6
  39. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  40. package/adapters/kimi/bin/error-codes.json +1 -1
  41. package/adapters/kimi/bin/kernel-protocol.json +1 -1
  42. package/adapters/kimi/bin/license-texts/Apache-2.0.txt +201 -0
  43. package/adapters/kimi/bin/license-texts/MIT.txt +21 -0
  44. package/adapters/kimi/bin/registry.json +64 -1
  45. package/adapters/kimi/bin/release-skill.bundle.mjs +50570 -42208
  46. package/adapters/kimi/bin/rules.json +1 -1
  47. package/adapters/kimi/schemas/.render-manifest.json +10 -6
  48. package/adapters/kimi/schemas/postpublish-approval-record.schema.json +47 -0
  49. package/adapters/kimi/schemas/release-plan.schema.json +200 -4
  50. package/adapters/kimi/schemas/release-project.schema.json +86 -5
  51. package/adapters/kimi/schemas/release-run.schema.json +11 -6
  52. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  53. package/adapters/workbuddy/bin/error-codes.json +1 -1
  54. package/adapters/workbuddy/bin/kernel-protocol.json +1 -1
  55. package/adapters/workbuddy/bin/license-texts/Apache-2.0.txt +201 -0
  56. package/adapters/workbuddy/bin/license-texts/MIT.txt +21 -0
  57. package/adapters/workbuddy/bin/registry.json +64 -1
  58. package/adapters/workbuddy/bin/release-skill.bundle.mjs +50570 -42208
  59. package/adapters/workbuddy/bin/rules.json +1 -1
  60. package/adapters/workbuddy/schemas/.render-manifest.json +10 -6
  61. package/adapters/workbuddy/schemas/postpublish-approval-record.schema.json +47 -0
  62. package/adapters/workbuddy/schemas/release-plan.schema.json +200 -4
  63. package/adapters/workbuddy/schemas/release-project.schema.json +86 -5
  64. package/adapters/workbuddy/schemas/release-run.schema.json +11 -6
  65. package/bin/error-codes.json +1 -1
  66. package/bin/kernel-protocol.json +1 -1
  67. package/bin/license-texts/Apache-2.0.txt +201 -0
  68. package/bin/license-texts/MIT.txt +21 -0
  69. package/bin/registry.json +64 -1
  70. package/bin/release-skill-cli.mjs +187 -9
  71. package/bin/release-skill.bundle.mjs +50570 -42208
  72. package/bin/rules.json +1 -1
  73. package/package.json +6 -4
  74. package/platform-manifest.json +4 -4
  75. package/references/.render-manifest.json +5 -5
  76. package/references/01-state-machine.md +22 -2
  77. package/references/02-project-config.md +1 -1
  78. package/references/06-adapter-contract.md +6 -3
  79. package/schemas/.render-manifest.json +10 -6
  80. package/schemas/postpublish-approval-record.schema.json +47 -0
  81. package/schemas/release-plan.schema.json +200 -4
  82. package/schemas/release-project.schema.json +86 -5
  83. package/schemas/release-run.schema.json +11 -6
  84. package/src/adapters/distribute-git.mjs +17 -11
  85. package/src/commands/approve.mjs +163 -1
  86. package/src/commands/distribute.mjs +649 -89
  87. package/src/commands/postverify.mjs +808 -0
  88. package/src/commands/prepare.mjs +456 -45
  89. package/src/commands/setup.mjs +715 -0
  90. package/src/commands/ship.mjs +152 -5
  91. package/src/commands/verify.mjs +92 -15
  92. package/src/core/approval.mjs +93 -68
  93. package/src/core/bounded-output.mjs +46 -0
  94. package/src/core/derived-artifact-gates.mjs +258 -0
  95. package/src/core/docs-refresh-preset.mjs +171 -0
  96. package/src/core/errors.mjs +4 -0
  97. package/src/core/evidence.mjs +8 -1
  98. package/src/core/git-url-policy.mjs +214 -0
  99. package/src/core/hooks.mjs +28 -0
  100. package/src/core/marketplace-registry-entry.mjs +175 -0
  101. package/src/core/notify-handoff.mjs +76 -0
  102. package/src/core/postpublish-approval.mjs +241 -0
  103. package/src/core/postpublish-bundle.mjs +342 -0
  104. package/src/core/postpublish-projection.mjs +210 -0
  105. package/src/core/postpublish.mjs +449 -25
  106. package/src/core/preset-executor.mjs +175 -0
  107. package/src/core/preset-gitwrite.mjs +484 -0
  108. package/src/core/presets.mjs +754 -0
  109. package/src/core/proposal-inbox.mjs +646 -0
  110. package/src/core/redact.mjs +16 -3
  111. package/src/core/run.mjs +91 -6
  112. package/src/producers/build-adapters.mjs +28 -14
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Version-sensitive derived-artifact fast pre-gates (O1, 2026-08-18
3
+ * release-cycle investigation §3.2).
4
+ *
5
+ * In the 0.6.1 cycle two prepares burned the full ~80s test hook before
6
+ * surfacing drift that a sub-second check could have caught: adapter trees
7
+ * out of sync with skills-src/, and self-bootstrap fact pins still bound to
8
+ * the previous version. This module promotes those two exact checks to
9
+ * prepare's earliest stage, alongside the bundle freshness gate:
10
+ *
11
+ * - adapter gate: runs `scripts/build-adapters.mjs --check` (drift list,
12
+ * exit 1 on drift — the same supported check the scripts surface offers);
13
+ * - fact-pin gate: runs the version fact pins of
14
+ * `test/release-docs-self-bootstrap.test.mjs` — exactly its hermetic
15
+ * section 1 (`[self-bootstrap 1*]`: byte-level version assertions plus the
16
+ * in-process read-only planner), scoped via `--test-name-pattern`.
17
+ *
18
+ * Both promote the CANONICAL check logic — the gate and the full pipeline
19
+ * can never disagree about what "in sync" means. The facts gate is scoped to
20
+ * the suite's hermetic fact-pin section deliberately: the suite's remaining
21
+ * sections shell out to npm/git and drive fixture prepares, which would make
22
+ * a "fast pre-gate" slow, recursive, and brittle under toolchain-shimming
23
+ * fixtures (a prepare invoked with a shimmed `npm` would crash the gate
24
+ * child and false-report drift). Those sections remain the full test hooks'
25
+ * job — this gate never replaces them. Every failure message therefore
26
+ * states the boundary explicitly: this is a fast pre-gate and does NOT
27
+ * replace the full test hooks(快速前置,不替代全量测试).
28
+ *
29
+ * Spawn hygiene: the child environment drops NODE_TEST_CONTEXT. When prepare
30
+ * itself runs inside a node:test harness, the runner exports that variable,
31
+ * and an inheriting `node --test` child then prints "run() is being called
32
+ * recursively ... skipping running files" and exits 0 — a false pass this
33
+ * gate must never report as fresh. Production prepare runs in a plain shell
34
+ * where the variable is absent, so the sanitization is a pure hardening.
35
+ *
36
+ * Recursion guard: the facts gate spawns the very suite whose fixture
37
+ * prepares call prepareRelease — an unguarded child would re-enter the gate
38
+ * and recurse without bound (every level waits on its own child until the
39
+ * 300s timeouts cascade). Gate children therefore carry
40
+ * RELEASE_SKILL_FACTS_GATE_ACTIVE; a facts gate running under the marker
41
+ * records not-applicable (reason nested-gate-run) and spawns nothing. Only
42
+ * the facts gate needs the guard: the adapter child (build-adapters --check)
43
+ * never calls prepareRelease. The outermost prepare still enforces both
44
+ * gates; only the verification run itself is exempt.
45
+ *
46
+ * Applicability mirrors bundle-freshness: installed distributions ship
47
+ * neither the build scripts nor the test file, so the gates record
48
+ * not-applicable there; a source checkout is always gated.
49
+ *
50
+ * @module core/derived-artifact-gates
51
+ */
52
+
53
+ import { lstat } from 'node:fs/promises';
54
+ import { join } from 'node:path';
55
+ import { execFile as execFileCb } from 'node:child_process';
56
+ import { promisify } from 'node:util';
57
+
58
+ import { ReleaseError, DERIVED_ARTIFACT_STALE } from './errors.mjs';
59
+ import { boundedOutputTail } from './bounded-output.mjs';
60
+
61
+ const defaultExecFile = promisify(execFileCb);
62
+
63
+ /**
64
+ * Exact bilingual note every pre-gate failure carries: the gates are a fast
65
+ * front line and never a substitute for the full test hooks.
66
+ */
67
+ export const DERIVED_ARTIFACT_PREGATE_NOTE =
68
+ 'This is a fast pre-gate and does not replace the full test hooks(快速前置,不替代全量测试).';
69
+
70
+ /** One-click derived-artifact sync suggested by every drift remediation (O2). */
71
+ export const DERIVED_SYNC_COMMAND = 'node scripts/sync-derived-artifacts.mjs';
72
+
73
+ /**
74
+ * Marker every gate child carries. A facts gate running under it is part of
75
+ * the verification run itself and records not-applicable instead of
76
+ * re-spawning the suite (recursion guard, see module docs).
77
+ */
78
+ export const FACTS_GATE_NESTED_ENV = 'RELEASE_SKILL_FACTS_GATE_ACTIVE';
79
+
80
+ /** Gate descriptors: marker file (applicability), argv, remediation text. */
81
+ const GATES = Object.freeze({
82
+ adapters: Object.freeze({
83
+ artifact: 'adapters',
84
+ marker: join('scripts', 'build-adapters.mjs'),
85
+ argv: (pkgRoot) => [join(pkgRoot, 'scripts', 'build-adapters.mjs'), '--check'],
86
+ timeoutMs: 120000,
87
+ remediation:
88
+ 'Rebuild the existing adapters with: node scripts/build-adapters.mjs --apply ' +
89
+ `(or run the one-click derived-artifact sync from the workspace root: ${DERIVED_SYNC_COMMAND}).`,
90
+ }),
91
+ 'self-bootstrap-facts': Object.freeze({
92
+ artifact: 'self-bootstrap-facts',
93
+ marker: join('test', 'release-docs-self-bootstrap.test.mjs'),
94
+ // Hermetic fact-pin section only (see module docs): byte-level version
95
+ // facts + in-process planner — no npm/git, no fixture prepares.
96
+ argv: (pkgRoot) => [
97
+ '--test',
98
+ '--test-name-pattern',
99
+ '\\[self-bootstrap 1',
100
+ join(pkgRoot, 'test', 'release-docs-self-bootstrap.test.mjs'),
101
+ ],
102
+ timeoutMs: 120000,
103
+ remediation:
104
+ 'Refresh the derived documents and version points first ' +
105
+ `(workspace root: ${DERIVED_SYNC_COMMAND}); if the pins still fail, update the fact pins deliberately — ` +
106
+ 'the gate never edits sources or test pins itself.',
107
+ }),
108
+ });
109
+
110
+ async function isFile(path) {
111
+ try {
112
+ return (await lstat(path)).isFile();
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Run one derived-artifact check (pure decision, never throws).
120
+ *
121
+ * @param {'adapters' | 'self-bootstrap-facts'} kind - Gate to run.
122
+ * @param {string} pkgRoot - Absolute package root of the running checkout.
123
+ * @param {object} [options]
124
+ * @param {Function} [options.execFileFn] - execFile seam (tests).
125
+ * @param {number} [options.timeoutMs] - Child timeout override.
126
+ * @returns {Promise<{
127
+ * applicable: boolean,
128
+ * fresh?: boolean,
129
+ * reason?: string,
130
+ * artifact?: string,
131
+ * exitCode?: number | null,
132
+ * stdoutTail?: string,
133
+ * stderrTail?: string,
134
+ * durationMs?: number,
135
+ * }>} stdoutTail/stderrTail are bounded and present on BOTH outcomes —
136
+ * on the fresh path they prove the child really executed (e.g. the facts
137
+ * gate carries the child suite's own `ℹ tests N` summary).
138
+ */
139
+ export async function checkDerivedArtifactGate(kind, pkgRoot, options = {}) {
140
+ const gate = GATES[kind];
141
+ if (!gate) {
142
+ throw new ReleaseError(DERIVED_ARTIFACT_STALE, `unknown derived-artifact gate: ${kind}`, { kind });
143
+ }
144
+ const execFileFn = options.execFileFn ?? defaultExecFile;
145
+
146
+ if (kind === 'self-bootstrap-facts' && process.env[FACTS_GATE_NESTED_ENV]) {
147
+ // Recursion guard: this prepare runs inside a gate child (the suite the
148
+ // facts gate itself spawns). Re-spawning would recurse without bound;
149
+ // the outermost prepare already enforces the gate for this checkout.
150
+ return { applicable: false, reason: 'nested-gate-run', artifact: gate.artifact };
151
+ }
152
+
153
+ if (!(await isFile(join(pkgRoot, gate.marker)))) {
154
+ // Installed distributions ship neither the build scripts nor the test
155
+ // file; drift is a source-checkout concern only (bundle-freshness rule).
156
+ return { applicable: false, reason: 'installed-layout', artifact: gate.artifact };
157
+ }
158
+
159
+ const startedAt = Date.now();
160
+ // Never hand the child a test-runner context: under a nested node:test
161
+ // harness NODE_TEST_CONTEXT makes `node --test` skip all files and exit 0,
162
+ // which this gate must not report as fresh (false pass).
163
+ const childEnv = { ...process.env };
164
+ delete childEnv.NODE_TEST_CONTEXT;
165
+ childEnv[FACTS_GATE_NESTED_ENV] = '1';
166
+ try {
167
+ const { stdout, stderr } = await execFileFn(process.execPath, gate.argv(pkgRoot), {
168
+ cwd: pkgRoot,
169
+ shell: false,
170
+ encoding: 'utf8',
171
+ timeout: options.timeoutMs ?? gate.timeoutMs,
172
+ env: childEnv,
173
+ maxBuffer: 16 * 1024 * 1024,
174
+ });
175
+ return {
176
+ applicable: true,
177
+ fresh: true,
178
+ artifact: gate.artifact,
179
+ stdoutTail: boundedOutputTail(stdout ?? ''),
180
+ stderrTail: boundedOutputTail(stderr ?? ''),
181
+ durationMs: Date.now() - startedAt,
182
+ };
183
+ } catch (err) {
184
+ // Timeout / spawn failure fail closed too: an undecidable gate is drift.
185
+ const stdoutTail = boundedOutputTail(err?.stdout ?? '');
186
+ const stderrTail = boundedOutputTail(err?.stderr ?? err?.message ?? '');
187
+ return {
188
+ applicable: true,
189
+ fresh: false,
190
+ reason: err?.killed || err?.code === 'ETIMEDOUT' ? 'timeout' : 'drift',
191
+ artifact: gate.artifact,
192
+ exitCode: typeof err?.code === 'number' ? err.code : null,
193
+ stdoutTail,
194
+ stderrTail,
195
+ durationMs: Date.now() - startedAt,
196
+ };
197
+ }
198
+ }
199
+
200
+ /** Adapter freshness decision (build-adapters --check). */
201
+ export function checkAdapterFreshness(pkgRoot, options = {}) {
202
+ return checkDerivedArtifactGate('adapters', pkgRoot, options);
203
+ }
204
+
205
+ /** Self-bootstrap fact-pin decision (single-file test). */
206
+ export function checkSelfBootstrapFacts(pkgRoot, options = {}) {
207
+ return checkDerivedArtifactGate('self-bootstrap-facts', pkgRoot, options);
208
+ }
209
+
210
+ async function assertGate(kind, pkgRoot, options = {}) {
211
+ const result = await checkDerivedArtifactGate(kind, pkgRoot, options);
212
+ if (!result.applicable || result.fresh) {
213
+ return result;
214
+ }
215
+ const gate = GATES[kind];
216
+ const subject = kind === 'adapters'
217
+ ? 'adapters/ is out of sync with its sources (build-adapters --check reported drift)'
218
+ : 'the release-docs-self-bootstrap fact pins are stale (the hermetic fact-pin check failed)';
219
+ throw new ReleaseError(
220
+ DERIVED_ARTIFACT_STALE,
221
+ `${subject}. ${DERIVED_ARTIFACT_PREGATE_NOTE} ${gate.remediation}`,
222
+ {
223
+ artifact: result.artifact,
224
+ reason: result.reason,
225
+ exitCode: result.exitCode,
226
+ stdoutTail: result.stdoutTail,
227
+ stderrTail: result.stderrTail,
228
+ durationMs: result.durationMs,
229
+ },
230
+ );
231
+ }
232
+
233
+ /**
234
+ * Fail-closed adapter pre-gate used by prepare's earliest stage.
235
+ * Not-applicable layouts return quietly; drift throws DERIVED_ARTIFACT_STALE.
236
+ *
237
+ * @param {string} pkgRoot - Absolute package root.
238
+ * @param {object} [options] - execFileFn/timeoutMs seams (tests).
239
+ * @returns {Promise<object>} The gate decision.
240
+ * @throws {ReleaseError} DERIVED_ARTIFACT_STALE on drift/timeout.
241
+ */
242
+ export function assertAdapterFreshness(pkgRoot, options = {}) {
243
+ return assertGate('adapters', pkgRoot, options);
244
+ }
245
+
246
+ /**
247
+ * Fail-closed self-bootstrap fact-pin pre-gate used by prepare's earliest
248
+ * stage. Not-applicable layouts return quietly; drift throws
249
+ * DERIVED_ARTIFACT_STALE.
250
+ *
251
+ * @param {string} pkgRoot - Absolute package root.
252
+ * @param {object} [options] - execFileFn/timeoutMs seams (tests).
253
+ * @returns {Promise<object>} The gate decision.
254
+ * @throws {ReleaseError} DERIVED_ARTIFACT_STALE on drift/timeout.
255
+ */
256
+ export function assertSelfBootstrapFacts(pkgRoot, options = {}) {
257
+ return assertGate('self-bootstrap-facts', pkgRoot, options);
258
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * docs-refresh preset: refresh one or more independent docs repositories
3
+ * (v0.6.3 R4, design §2.5).
4
+ *
5
+ * GitHub-Pages-style docs sites live in their own repositories. One
6
+ * declaration refreshes ALL of them (config.repositories array) from the
7
+ * frozen release payload:
8
+ *
9
+ * - config.mappings copies payload files into the docs repository
10
+ * (from = payload-relative source, to = repository-relative destination);
11
+ * an optional per-mapping versionMarker placeholder is replaced with the
12
+ * frozen release version while writing (version-marker replacement);
13
+ * - config.gates (argument arrays, R1 hook runner) run inside each docs
14
+ * repository AFTER the write and BEFORE any commit/push — the docs build
15
+ * gate; a failing gate leaves zero remote side effects;
16
+ * - every repository is committed with the frozen bot identity and pushed
17
+ * (never --force); byte-identical content -> NO_CHANGE (idempotent).
18
+ *
19
+ * Payload requirement: mappings read the materialized payload, which only
20
+ * exists in the distribute phase (§2.3 contexts carry payloadDir there); a
21
+ * postVerify-phase declaration fails closed with a clear message.
22
+ *
23
+ * @module core/docs-refresh-preset
24
+ */
25
+
26
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
27
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
28
+
29
+ import { ReleaseError, GATE_FAILED } from './errors.mjs';
30
+ import { applyDownstreamGitChange } from './preset-gitwrite.mjs';
31
+ import { redactUrlCredentialsIfPresent } from './git-url-policy.mjs';
32
+
33
+ /**
34
+ * Assert `from` stays inside the materialized payload directory (declaration
35
+ * validation guarantees a safe relative shape; this is the execution-time
36
+ * re-check).
37
+ */
38
+ function resolvePayloadSource(payloadDir, from) {
39
+ const sourcePath = resolve(payloadDir, from);
40
+ const rel = relative(payloadDir, sourcePath);
41
+ if (rel === '' || isAbsolute(rel) || rel === '..'
42
+ || rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
43
+ throw new ReleaseError(
44
+ GATE_FAILED,
45
+ `docs-refresh mapping source "${from}" escapes the payload directory`,
46
+ { from },
47
+ );
48
+ }
49
+ return sourcePath;
50
+ }
51
+
52
+ /**
53
+ * Execute one docs-refresh preset hook end-to-end: for every declared docs
54
+ * repository, copy the mapped payload files (version-marker replacement),
55
+ * run the docs build gates, and push. Shared by distribute and postVerify
56
+ * (payload requirement effectively binds it to the distribute phase).
57
+ *
58
+ * @param {object} params
59
+ * @param {object} params.hook - Declared hook entry (config bound).
60
+ * @param {object} params.contextProjection - The §2.3 context projection.
61
+ * @param {object} params.commitIdentity - Frozen commitIdentity.
62
+ * @param {string} params.payloadDir - Materialized payload directory
63
+ * (distribute phase; postVerify contexts never carry it).
64
+ * @param {string} params.releaseWorkspaceRoot - Release workspace root (the
65
+ * real project root; workspace addressing resolution + write exclusion).
66
+ * @param {Function} [params.exec] - Injectable git exec (tests).
67
+ * @param {Function} [params.hookRunner] - Injectable gate runner (tests).
68
+ * @returns {Promise<{ status: string, observation: object,
69
+ * observations: object[], mode: string }>}
70
+ */
71
+ export async function executeDocsRefreshHook(params) {
72
+ const { hook, contextProjection, commitIdentity, payloadDir, releaseWorkspaceRoot, exec, hookRunner } = params ?? {};
73
+ const config = hook?.config;
74
+ const repositories = config?.repositories;
75
+ if (!Array.isArray(repositories) || repositories.length === 0) {
76
+ throw new ReleaseError(GATE_FAILED, 'docs-refresh requires a non-empty config.repositories array');
77
+ }
78
+ const mappings = config?.mappings;
79
+ if (!Array.isArray(mappings) || mappings.length === 0) {
80
+ throw new ReleaseError(GATE_FAILED, 'docs-refresh requires a non-empty config.mappings array');
81
+ }
82
+ if (typeof payloadDir !== 'string' || payloadDir.length === 0) {
83
+ throw new ReleaseError(
84
+ GATE_FAILED,
85
+ 'docs-refresh copies files from the materialized payload, which only exists in the distribute phase; declare phase: distribute (the default) instead of postVerify',
86
+ {},
87
+ );
88
+ }
89
+
90
+ const unitId = contextProjection?.unitId ?? 'unknown';
91
+ const version = contextProjection?.version ?? 'unknown';
92
+ const gates = config?.gates ?? [];
93
+
94
+ // Deterministic per-mapping write. Binary-safe copy; the version-marker
95
+ // replacement (when declared) treats the file as UTF-8 text.
96
+ const mutate = async (worktree) => {
97
+ for (const mapping of mappings) {
98
+ const sourcePath = resolvePayloadSource(payloadDir, mapping.from);
99
+ let content = await readFile(sourcePath).catch(() => null);
100
+ if (content === null) {
101
+ throw new ReleaseError(
102
+ GATE_FAILED,
103
+ `docs-refresh mapping source "${mapping.from}" is missing from the materialized payload`,
104
+ { from: mapping.from },
105
+ );
106
+ }
107
+ if (typeof mapping.versionMarker === 'string' && mapping.versionMarker.length > 0) {
108
+ const text = content.toString('utf8');
109
+ content = Buffer.from(text.split(mapping.versionMarker).join(version), 'utf8');
110
+ }
111
+ const destination = join(worktree, mapping.to);
112
+ await mkdir(dirname(destination), { recursive: true });
113
+ await writeFile(destination, content);
114
+ }
115
+ };
116
+
117
+ const observations = [];
118
+ let anyChange = false;
119
+ for (const [index, target] of repositories.entries()) {
120
+ let result;
121
+ try {
122
+ result = await applyDownstreamGitChange({
123
+ target,
124
+ commitIdentity,
125
+ commitSubject: `release-skill docs-refresh ${unitId} ${version}`,
126
+ mutate,
127
+ gates,
128
+ contextProjection,
129
+ releaseWorkspaceRoot,
130
+ ...(exec !== undefined ? { exec } : {}),
131
+ ...(hookRunner !== undefined ? { hookRunner } : {}),
132
+ });
133
+ } catch (err) {
134
+ throw new ReleaseError(
135
+ err?.code ?? GATE_FAILED,
136
+ `docs-refresh repository ${index + 1} of ${repositories.length} failed: ${err?.message ?? err}`,
137
+ { repositoryIndex: index, ...(err?.details ?? {}) },
138
+ );
139
+ }
140
+ observations.push({
141
+ repositoryIndex: index,
142
+ ...(typeof target.remoteUrl === 'string'
143
+ ? { remoteUrl: redactUrlCredentialsIfPresent(target.remoteUrl) }
144
+ : {}),
145
+ ...(typeof target.workspace === 'string' ? { workspace: target.workspace } : {}),
146
+ branch: target.branch,
147
+ ...(result.observation ?? {}),
148
+ });
149
+ if (result.status === 'EXECUTED') anyChange = true;
150
+ }
151
+
152
+ if (!anyChange) {
153
+ return {
154
+ status: 'NO_CHANGE',
155
+ mode: 'no-change',
156
+ observation: { mode: 'no-change' },
157
+ observations,
158
+ };
159
+ }
160
+ const firstPushed = observations.find((entry) => entry.mode === 'pushed');
161
+ return {
162
+ status: 'EXECUTED',
163
+ mode: 'pushed',
164
+ observation: {
165
+ mode: 'pushed',
166
+ ...(firstPushed?.pushedCommit ? { pushedCommit: firstPushed.pushedCommit } : {}),
167
+ repositoryCount: repositories.length,
168
+ },
169
+ observations,
170
+ };
171
+ }
@@ -95,6 +95,8 @@ const EXIT_CODE_MAP = Object.freeze({
95
95
  CONTENT_MISMATCH: 52,
96
96
  DIRTY_SOURCE_INPUT: 53,
97
97
  BUNDLE_STALE: 54,
98
+ POSTPUBLISH_HOOK_INVALID: 55,
99
+ DERIVED_ARTIFACT_STALE: 56,
98
100
  });
99
101
 
100
102
  // ---- Error code constants ----
@@ -144,6 +146,8 @@ export const NOT_DEFAULT = 'NOT_DEFAULT';
144
146
  export const CONTENT_MISMATCH = 'CONTENT_MISMATCH';
145
147
  export const DIRTY_SOURCE_INPUT = 'DIRTY_SOURCE_INPUT';
146
148
  export const BUNDLE_STALE = 'BUNDLE_STALE';
149
+ export const POSTPUBLISH_HOOK_INVALID = 'POSTPUBLISH_HOOK_INVALID';
150
+ export const DERIVED_ARTIFACT_STALE = 'DERIVED_ARTIFACT_STALE';
147
151
 
148
152
  /**
149
153
  * Typed error for release-skill operations.
@@ -10,6 +10,8 @@
10
10
  import { open, mkdir, writeFile } from 'node:fs/promises';
11
11
  import { basename } from 'node:path';
12
12
 
13
+ import { redactEmbeddedUrlCredentials } from './git-url-policy.mjs';
14
+
13
15
  /** Schema version for evidence events. */
14
16
  const SCHEMA_VERSION = 1;
15
17
 
@@ -66,11 +68,16 @@ export function redact(obj) {
66
68
  }
67
69
 
68
70
  if (typeof obj === 'string') {
71
+ // F-05: strip any embedded URL userinfo before the value reaches disk.
72
+ // Only credential-bearing URL spans are rewritten; ordinary strings and
73
+ // credential-free URLs pass through unchanged.
74
+ const urlRedacted = redactEmbeddedUrlCredentials(obj);
69
75
  for (const { prefix, label } of CREDENTIAL_PREFIXES) {
70
- if (obj.startsWith(prefix)) {
76
+ if (urlRedacted.startsWith(prefix)) {
71
77
  return `[REDACTED:${label}]`;
72
78
  }
73
79
  }
80
+ return urlRedacted;
74
81
  }
75
82
 
76
83
  return obj;
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Release-domain Git remote URL policy (architecture gap F-05, T5).
3
+ *
4
+ * The SINGLE boundary for every postPublish remote-write URL: allowed
5
+ * protocols, the Git path shape, and the remote-write grading. Consumers:
6
+ * core/postpublish.mjs (declaration re-validation), core/presets.mjs (preset
7
+ * registry config), core/preset-gitwrite.mjs + core/proposal-inbox.mjs
8
+ * (write transports), adapters/distribute-git.mjs (legacy target mirror).
9
+ * The root JSON schemas are only the first gate; this runtime policy is the
10
+ * final authority, so plans frozen by older schema versions cannot smuggle
11
+ * unsafe URLs through.
12
+ *
13
+ * Boundary rules (what this module is NOT):
14
+ * - Parsing is delegated to the standard WHATWG URL — this module expresses
15
+ * release-domain POLICY over the parse result; it implements no generic
16
+ * URL parser.
17
+ * - Credential redaction is delegated to the Foundation
18
+ * `redactUrlCredentials` (skill-family-harness-node, FG-2) — this module
19
+ * implements no generic redactor; it only decides WHEN the Foundation
20
+ * redactor applies so non-URL strings are never collapsed into the opaque
21
+ * placeholder.
22
+ *
23
+ * Fail-closed vocabulary: every rejection reason is a stable token, and
24
+ * failure text NEVER carries the original URL — a rejected URL may hold
25
+ * userinfo credentials, and echoing it would defeat the boundary.
26
+ *
27
+ * @module core/git-url-policy
28
+ */
29
+
30
+ import {
31
+ redactUrlCredentials,
32
+ REDACTED_URL_PLACEHOLDER,
33
+ } from 'skill-family-harness-node';
34
+
35
+ /**
36
+ * Protocols allowed for postPublish remote writes: http(s) for real remotes,
37
+ * file: strictly as the test transport.
38
+ */
39
+ export const GIT_REMOTE_PROTOCOLS = Object.freeze(['http:', 'https:', 'file:']);
40
+
41
+ /**
42
+ * Remote-write grading (发布领域远端写分级): the protocol decides the grade —
43
+ * http(s) are real remote writes, file: is the test transport. Central here
44
+ * so no consumer re-derives grading from its own protocol regex.
45
+ */
46
+ export const GIT_REMOTE_WRITE_GRADES = Object.freeze({
47
+ 'http:': 'remote-write',
48
+ 'https:': 'remote-write',
49
+ 'file:': 'test-transport',
50
+ });
51
+
52
+ /** Rejection reason vocabulary; every entry is safe to surface (no URL). */
53
+ export const GIT_REMOTE_URL_REASONS = Object.freeze({
54
+ NOT_A_STRING: 'not-a-string',
55
+ CONTROL_CHARACTERS: 'control-characters',
56
+ UNPARSEABLE: 'unparseable',
57
+ PROTOCOL_NOT_ALLOWED: 'protocol-not-allowed',
58
+ MISSING_HOST: 'missing-host',
59
+ CREDENTIALS_PRESENT: 'credentials-present',
60
+ QUERY_OR_FRAGMENT_REJECTED: 'query-or-fragment-rejected',
61
+ NOT_A_GIT_PATH: 'not-a-git-path',
62
+ });
63
+
64
+ /**
65
+ * Human-readable failure text per rejection reason. Deliberately carries NO
66
+ * part of the offending URL (a credential-bearing URL must never be echoed
67
+ * back through an error surface).
68
+ */
69
+ const FAILURE_TEXT = Object.freeze({
70
+ [GIT_REMOTE_URL_REASONS.NOT_A_STRING]: 'must be a non-empty string',
71
+ [GIT_REMOTE_URL_REASONS.CONTROL_CHARACTERS]: 'contains control characters',
72
+ [GIT_REMOTE_URL_REASONS.UNPARSEABLE]:
73
+ 'must be an absolute URL parseable by the standard URL parser',
74
+ [GIT_REMOTE_URL_REASONS.PROTOCOL_NOT_ALLOWED]:
75
+ 'protocol must be http:, https:, or file: (file: is the test transport)',
76
+ [GIT_REMOTE_URL_REASONS.MISSING_HOST]: 'http(s) URLs must carry a host',
77
+ [GIT_REMOTE_URL_REASONS.CREDENTIALS_PRESENT]:
78
+ 'must never carry embedded credentials (username/password) — credentials belong to the host git credential helper, never to the URL',
79
+ [GIT_REMOTE_URL_REASONS.QUERY_OR_FRAGMENT_REJECTED]:
80
+ 'must carry no query or fragment',
81
+ [GIT_REMOTE_URL_REASONS.NOT_A_GIT_PATH]: 'pathname must end in .git',
82
+ });
83
+
84
+ /**
85
+ * Describe one rejection reason for error messages (always URL-free).
86
+ *
87
+ * @param {string} reason - One of GIT_REMOTE_URL_REASONS.
88
+ * @returns {string} Safe human-readable failure text.
89
+ */
90
+ export function describeGitRemoteUrlFailure(reason) {
91
+ return FAILURE_TEXT[reason] ?? 'is not an allowed Git remote URL';
92
+ }
93
+
94
+ /**
95
+ * The runtime contract check for one postPublish remote-write URL:
96
+ * 1. non-empty string, no control characters (checked on the RAW value —
97
+ * WHATWG parsing silently folds \t\r\n, so the raw check fails closed);
98
+ * 2. `new URL()` parse (the standard parser is the only parser);
99
+ * 3. protocol allowlist: http:, https:, file: (test transport);
100
+ * 4. http(s) must carry a host;
101
+ * 5. ALL protocols reject a non-empty username or password;
102
+ * 6. no query or fragment (a git remote URL is a bare location);
103
+ * 7. the PATHNAME ends in .git.
104
+ *
105
+ * @param {*} remoteUrl
106
+ * @returns {{ ok: true, protocol: string, grade: string }
107
+ * | { ok: false, reason: string }} The verdict never carries the input.
108
+ */
109
+ export function checkGitRemoteUrl(remoteUrl) {
110
+ if (typeof remoteUrl !== 'string' || remoteUrl.length === 0) {
111
+ return { ok: false, reason: GIT_REMOTE_URL_REASONS.NOT_A_STRING };
112
+ }
113
+ if (/[\x00-\x1f\x7f]/.test(remoteUrl)) {
114
+ return { ok: false, reason: GIT_REMOTE_URL_REASONS.CONTROL_CHARACTERS };
115
+ }
116
+ let url;
117
+ try {
118
+ url = new URL(remoteUrl);
119
+ } catch {
120
+ return { ok: false, reason: GIT_REMOTE_URL_REASONS.UNPARSEABLE };
121
+ }
122
+ if (!GIT_REMOTE_PROTOCOLS.includes(url.protocol)) {
123
+ return { ok: false, reason: GIT_REMOTE_URL_REASONS.PROTOCOL_NOT_ALLOWED };
124
+ }
125
+ if (url.protocol !== 'file:' && url.hostname === '') {
126
+ return { ok: false, reason: GIT_REMOTE_URL_REASONS.MISSING_HOST };
127
+ }
128
+ if (url.username !== '' || url.password !== '') {
129
+ return { ok: false, reason: GIT_REMOTE_URL_REASONS.CREDENTIALS_PRESENT };
130
+ }
131
+ if (url.search !== '' || url.hash !== '') {
132
+ return { ok: false, reason: GIT_REMOTE_URL_REASONS.QUERY_OR_FRAGMENT_REJECTED };
133
+ }
134
+ if (!url.pathname.endsWith('.git')) {
135
+ return { ok: false, reason: GIT_REMOTE_URL_REASONS.NOT_A_GIT_PATH };
136
+ }
137
+ return { ok: true, protocol: url.protocol, grade: GIT_REMOTE_WRITE_GRADES[url.protocol] };
138
+ }
139
+
140
+ /**
141
+ * Boolean form of the policy check (cross-check skip decisions, probes).
142
+ *
143
+ * @param {*} remoteUrl
144
+ * @returns {boolean} True only for policy-allowed Git remote URLs.
145
+ */
146
+ export function isAllowedGitRemoteUrl(remoteUrl) {
147
+ return checkGitRemoteUrl(remoteUrl).ok === true;
148
+ }
149
+
150
+ /**
151
+ * Remote-write grade for one policy-allowed URL; null when the URL does not
152
+ * pass the policy (grading never applies to rejected URLs).
153
+ *
154
+ * @param {*} remoteUrl
155
+ * @returns {'remote-write'|'test-transport'|null}
156
+ */
157
+ export function resolveGitRemoteWriteGrade(remoteUrl) {
158
+ const verdict = checkGitRemoteUrl(remoteUrl);
159
+ return verdict.ok ? verdict.grade : null;
160
+ }
161
+
162
+ /**
163
+ * Guarded credential redaction for long-lived outputs (evidence, error
164
+ * messages/details, preset observations). Delegates the actual redaction to
165
+ * the Foundation `redactUrlCredentials`:
166
+ * - values that parse as an absolute URL carrying userinfo come back as the
167
+ * Foundation's credential-free serialization;
168
+ * - unparseable `scheme://...@...` authority shapes cannot be proven
169
+ * credential-free and fail closed to the opaque placeholder (the same
170
+ * stance as the Foundation's degraded path);
171
+ * - every other value returns UNCHANGED (blindly applying the Foundation
172
+ * redactor would collapse ordinary non-URL strings into the placeholder).
173
+ *
174
+ * @param {*} value
175
+ * @returns {*} The credential-free value (or the input, unchanged).
176
+ */
177
+ export function redactUrlCredentialsIfPresent(value) {
178
+ if (typeof value !== 'string' || value.length === 0) return value;
179
+ let url;
180
+ try {
181
+ url = new URL(value);
182
+ } catch {
183
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]*@/.test(value)) {
184
+ return REDACTED_URL_PLACEHOLDER;
185
+ }
186
+ return value;
187
+ }
188
+ if (url.username === '' && url.password === '') return value;
189
+ return redactUrlCredentials(url);
190
+ }
191
+
192
+ /**
193
+ * Candidate URL-span tokenizer for prose (log lines, error messages). It only
194
+ * LOCATES spans shaped like `scheme://...`; the credential decision and the
195
+ * redaction itself are delegated to the standard URL parser + the Foundation
196
+ * redactor via redactUrlCredentialsIfPresent. This is what makes the error
197
+ * and evidence chokepoints safe "even if a validation misses" (F-05): a
198
+ * credential-bearing URL embedded mid-sentence cannot survive to disk.
199
+ */
200
+ const URL_SPAN_RE = /\b[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s'"<>`]+/g;
201
+
202
+ /**
203
+ * Redact credential-bearing URL spans anywhere inside a string (whole value
204
+ * or embedded in prose). Credential-free spans and all non-URL text return
205
+ * byte-for-byte unchanged.
206
+ *
207
+ * @param {*} text
208
+ * @returns {*} The credential-free string (or the input, unchanged).
209
+ */
210
+ export function redactEmbeddedUrlCredentials(text) {
211
+ if (typeof text !== 'string' || text.length === 0) return text;
212
+ if (!text.includes('://')) return text;
213
+ return text.replace(URL_SPAN_RE, (span) => redactUrlCredentialsIfPresent(span));
214
+ }