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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codebuddy-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +48 -0
- package/CONTRIBUTING.md +1 -1
- package/INSTALL.md +47 -2
- package/INSTALL.zh-CN.md +29 -2
- package/README.md +137 -12
- package/README.zh-CN.md +110 -9
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/error-codes.json +1 -1
- package/adapters/claude/bin/kernel-protocol.json +1 -1
- package/adapters/claude/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/claude/bin/license-texts/MIT.txt +21 -0
- package/adapters/claude/bin/registry.json +64 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +50570 -42208
- package/adapters/claude/bin/rules.json +1 -1
- package/adapters/claude/schemas/.render-manifest.json +10 -6
- package/adapters/claude/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/claude/schemas/release-plan.schema.json +200 -4
- package/adapters/claude/schemas/release-project.schema.json +86 -5
- package/adapters/claude/schemas/release-run.schema.json +11 -6
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/error-codes.json +1 -1
- package/adapters/codex/bin/kernel-protocol.json +1 -1
- package/adapters/codex/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/codex/bin/license-texts/MIT.txt +21 -0
- package/adapters/codex/bin/registry.json +64 -1
- package/adapters/codex/bin/release-skill.bundle.mjs +50570 -42208
- package/adapters/codex/bin/rules.json +1 -1
- package/adapters/codex/schemas/.render-manifest.json +10 -6
- package/adapters/codex/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/codex/schemas/release-plan.schema.json +200 -4
- package/adapters/codex/schemas/release-project.schema.json +86 -5
- package/adapters/codex/schemas/release-run.schema.json +11 -6
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/error-codes.json +1 -1
- package/adapters/kimi/bin/kernel-protocol.json +1 -1
- package/adapters/kimi/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/kimi/bin/license-texts/MIT.txt +21 -0
- package/adapters/kimi/bin/registry.json +64 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +50570 -42208
- package/adapters/kimi/bin/rules.json +1 -1
- package/adapters/kimi/schemas/.render-manifest.json +10 -6
- package/adapters/kimi/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/kimi/schemas/release-plan.schema.json +200 -4
- package/adapters/kimi/schemas/release-project.schema.json +86 -5
- package/adapters/kimi/schemas/release-run.schema.json +11 -6
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/error-codes.json +1 -1
- package/adapters/workbuddy/bin/kernel-protocol.json +1 -1
- package/adapters/workbuddy/bin/license-texts/Apache-2.0.txt +201 -0
- package/adapters/workbuddy/bin/license-texts/MIT.txt +21 -0
- package/adapters/workbuddy/bin/registry.json +64 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +50570 -42208
- package/adapters/workbuddy/bin/rules.json +1 -1
- package/adapters/workbuddy/schemas/.render-manifest.json +10 -6
- package/adapters/workbuddy/schemas/postpublish-approval-record.schema.json +47 -0
- package/adapters/workbuddy/schemas/release-plan.schema.json +200 -4
- package/adapters/workbuddy/schemas/release-project.schema.json +86 -5
- package/adapters/workbuddy/schemas/release-run.schema.json +11 -6
- package/bin/error-codes.json +1 -1
- package/bin/kernel-protocol.json +1 -1
- package/bin/license-texts/Apache-2.0.txt +201 -0
- package/bin/license-texts/MIT.txt +21 -0
- package/bin/registry.json +64 -1
- package/bin/release-skill-cli.mjs +187 -9
- package/bin/release-skill.bundle.mjs +50570 -42208
- package/bin/rules.json +1 -1
- package/package.json +6 -4
- package/platform-manifest.json +4 -4
- package/references/.render-manifest.json +5 -5
- package/references/01-state-machine.md +22 -2
- package/references/02-project-config.md +1 -1
- package/references/06-adapter-contract.md +6 -3
- package/schemas/.render-manifest.json +10 -6
- package/schemas/postpublish-approval-record.schema.json +47 -0
- package/schemas/release-plan.schema.json +200 -4
- package/schemas/release-project.schema.json +86 -5
- package/schemas/release-run.schema.json +11 -6
- package/src/adapters/distribute-git.mjs +17 -11
- package/src/commands/approve.mjs +163 -1
- package/src/commands/distribute.mjs +649 -89
- package/src/commands/postverify.mjs +808 -0
- package/src/commands/prepare.mjs +456 -45
- package/src/commands/setup.mjs +715 -0
- package/src/commands/ship.mjs +152 -5
- package/src/commands/verify.mjs +92 -15
- package/src/core/approval.mjs +93 -68
- package/src/core/bounded-output.mjs +46 -0
- package/src/core/derived-artifact-gates.mjs +258 -0
- package/src/core/docs-refresh-preset.mjs +171 -0
- package/src/core/errors.mjs +4 -0
- package/src/core/evidence.mjs +8 -1
- package/src/core/git-url-policy.mjs +214 -0
- package/src/core/hooks.mjs +28 -0
- package/src/core/marketplace-registry-entry.mjs +175 -0
- package/src/core/notify-handoff.mjs +76 -0
- package/src/core/postpublish-approval.mjs +241 -0
- package/src/core/postpublish-bundle.mjs +342 -0
- package/src/core/postpublish-projection.mjs +210 -0
- package/src/core/postpublish.mjs +449 -25
- package/src/core/preset-executor.mjs +175 -0
- package/src/core/preset-gitwrite.mjs +484 -0
- package/src/core/presets.mjs +754 -0
- package/src/core/proposal-inbox.mjs +646 -0
- package/src/core/redact.mjs +16 -3
- package/src/core/run.mjs +91 -6
- package/src/producers/build-adapters.mjs +28 -14
package/src/commands/setup.mjs
CHANGED
|
@@ -28,10 +28,13 @@ import { checkNpmEntryClosure } from '../npm/npm-entry-closure.mjs';
|
|
|
28
28
|
import {
|
|
29
29
|
CONFIG_EXISTS,
|
|
30
30
|
CONFIG_INVALID,
|
|
31
|
+
CONFIG_MISSING,
|
|
31
32
|
ReleaseError,
|
|
32
33
|
SETUP_DIGEST_MISMATCH,
|
|
33
34
|
} from '../core/errors.mjs';
|
|
34
35
|
import { readTrustedPackageResource } from '../core/trusted-resource.mjs';
|
|
36
|
+
import { validatePresetHook } from '../core/presets.mjs';
|
|
37
|
+
import { validatePostPublishDeclaration } from '../core/postpublish.mjs';
|
|
35
38
|
|
|
36
39
|
const execFile = promisify(execFileCb);
|
|
37
40
|
const SKIP_DIRS = new Set([
|
|
@@ -1854,3 +1857,715 @@ export async function setupProject({ root, answersPath, write = false, confirmSe
|
|
|
1854
1857
|
createdReport.compactSummary = buildCompactSummary(createdReport);
|
|
1855
1858
|
return createdReport;
|
|
1856
1859
|
}
|
|
1860
|
+
|
|
1861
|
+
// ===========================================================================
|
|
1862
|
+
// R5 — downstream discovery + postPublish hook declaration proposal
|
|
1863
|
+
// (guided configuration) and the independent incremental-proposal mode.
|
|
1864
|
+
//
|
|
1865
|
+
// Design §2.9 / §2.8 R5. The pipeline reuses the existing setup shape:
|
|
1866
|
+
// read-only discovery -> mechanical proposal extraction -> human confirms
|
|
1867
|
+
// the exact setupDigest -> write. No new automatic writes are introduced.
|
|
1868
|
+
//
|
|
1869
|
+
// The incremental mode is deliberately SEPARATE from setupProject: it never
|
|
1870
|
+
// regenerates an existing configuration (the create-once hard boundary stays
|
|
1871
|
+
// intact) and, after exact digest confirmation, appends ONLY the target
|
|
1872
|
+
// release unit's postPublish.hooks block — every other byte of a human-owned
|
|
1873
|
+
// config is preserved (comment/formatting-preserving YAML edit + semantic guard).
|
|
1874
|
+
// ===========================================================================
|
|
1875
|
+
|
|
1876
|
+
const R5_REMOTE_URL_RE = /^(?:https?|file):\/\/.+\.git$/;
|
|
1877
|
+
const FOUNDATION_PROFILE_FILENAME = 'foundation-profile.json';
|
|
1878
|
+
const MAX_NEIGHBOR_SCAN = 64;
|
|
1879
|
+
|
|
1880
|
+
// postPublishHook schema fields that may be carried into an appended draft;
|
|
1881
|
+
// report-only fields (source/rationale) are stripped before writing because
|
|
1882
|
+
// the schema is additionalProperties:false. Preset proposals never carry the
|
|
1883
|
+
// command-hook fields (command/cwd/timeoutMs/envAllowlist) — listing them
|
|
1884
|
+
// here would be an unreachable allowlist, so they are deliberately absent
|
|
1885
|
+
// (R5 review note-2).
|
|
1886
|
+
const R5_HOOK_DRAFT_FIELDS = [
|
|
1887
|
+
'id', 'preset', 'phase', 'config', 'requiresApproval', 'blocksVerified',
|
|
1888
|
+
];
|
|
1889
|
+
|
|
1890
|
+
async function r5IsRegularFile(path) {
|
|
1891
|
+
try {
|
|
1892
|
+
const entry = await lstat(path);
|
|
1893
|
+
return entry.isFile() && !entry.isSymbolicLink();
|
|
1894
|
+
} catch {
|
|
1895
|
+
return false;
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
async function r5IsDirectory(path) {
|
|
1900
|
+
try {
|
|
1901
|
+
const entry = await lstat(path);
|
|
1902
|
+
return entry.isDirectory() && !entry.isSymbolicLink();
|
|
1903
|
+
} catch {
|
|
1904
|
+
return false;
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
async function r5HasGitEntry(absDir) {
|
|
1909
|
+
try {
|
|
1910
|
+
await lstat(join(absDir, '.git'));
|
|
1911
|
+
return true;
|
|
1912
|
+
} catch {
|
|
1913
|
+
return false;
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
/**
|
|
1918
|
+
* Enumerate the immediate sibling directories of the project root (bounded,
|
|
1919
|
+
* read-only). Symlinks and SKIP_DIRS are excluded; the result is sorted and
|
|
1920
|
+
* capped so discovery stays deterministic and cheap.
|
|
1921
|
+
*/
|
|
1922
|
+
async function r5SiblingDirs(rootReal) {
|
|
1923
|
+
const parent = dirname(rootReal);
|
|
1924
|
+
if (!parent || parent === rootReal) return [];
|
|
1925
|
+
let entries;
|
|
1926
|
+
try {
|
|
1927
|
+
entries = await readdir(parent, { withFileTypes: true });
|
|
1928
|
+
} catch {
|
|
1929
|
+
return [];
|
|
1930
|
+
}
|
|
1931
|
+
const rootBase = basename(rootReal);
|
|
1932
|
+
const siblings = [];
|
|
1933
|
+
for (const entry of entries) {
|
|
1934
|
+
if (entry.name === rootBase) continue;
|
|
1935
|
+
if (entry.isSymbolicLink()) continue;
|
|
1936
|
+
if (!entry.isDirectory()) continue;
|
|
1937
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
1938
|
+
siblings.push(join(parent, entry.name));
|
|
1939
|
+
}
|
|
1940
|
+
return siblings.sort().slice(0, MAX_NEIGHBOR_SCAN);
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/**
|
|
1944
|
+
* Detect downstream clue markers at the TOP LEVEL of a directory (read-only,
|
|
1945
|
+
* bounded): marketplace manifests (registry-driven suffixes), and docs
|
|
1946
|
+
* repositories (mkdocs.yml or docs/ together with a .git entry).
|
|
1947
|
+
*/
|
|
1948
|
+
async function r5DetectCluesInDir(absDir) {
|
|
1949
|
+
const clues = [];
|
|
1950
|
+
for (const suffix of DISCOVERY_MANIFEST_SUFFIXES) {
|
|
1951
|
+
if (typeof suffix !== 'string' || !suffix.includes('marketplace.json')) continue;
|
|
1952
|
+
if (await r5IsRegularFile(join(absDir, suffix))) {
|
|
1953
|
+
clues.push({ path: suffix, kind: 'marketplace' });
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
const hasMkdocs = await r5IsRegularFile(join(absDir, 'mkdocs.yml'));
|
|
1957
|
+
const hasDocsDir = await r5IsDirectory(join(absDir, 'docs'));
|
|
1958
|
+
if (hasMkdocs || hasDocsDir) {
|
|
1959
|
+
const gitBacked = await r5HasGitEntry(absDir);
|
|
1960
|
+
clues.push({ path: hasMkdocs ? 'mkdocs.yml' : 'docs', kind: gitBacked ? 'docs-repo' : 'docs' });
|
|
1961
|
+
}
|
|
1962
|
+
return clues;
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
function r5ValidateProfileShape(value) {
|
|
1966
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
1967
|
+
throw new Error('foundation profile must be a JSON object');
|
|
1968
|
+
}
|
|
1969
|
+
if (value.profileVersion !== 1) {
|
|
1970
|
+
throw new Error('foundation profile profileVersion must be 1');
|
|
1971
|
+
}
|
|
1972
|
+
const hooks = value.hooks ?? [];
|
|
1973
|
+
const targets = value.targets ?? [];
|
|
1974
|
+
if (!Array.isArray(hooks) || !Array.isArray(targets)) {
|
|
1975
|
+
throw new Error('foundation profile hooks and targets must be arrays');
|
|
1976
|
+
}
|
|
1977
|
+
return { profileVersion: 1, hooks, targets };
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
/**
|
|
1981
|
+
* Search (read-only) for a foundation profile. Deterministic order: explicit
|
|
1982
|
+
* path, project-local, then immediate siblings. The first existing regular
|
|
1983
|
+
* file wins. Descriptors are root-relative / sibling-relative (never absolute)
|
|
1984
|
+
* so reports and digests carry no /Users/... layout.
|
|
1985
|
+
*/
|
|
1986
|
+
async function r5FindFoundationProfile(rootReal, explicitPath) {
|
|
1987
|
+
const candidates = [];
|
|
1988
|
+
if (explicitPath && typeof explicitPath === 'string' && explicitPath.length > 0) {
|
|
1989
|
+
candidates.push({
|
|
1990
|
+
abs: isAbsolute(explicitPath) ? explicitPath : resolve(rootReal, explicitPath),
|
|
1991
|
+
desc: explicitPath,
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1994
|
+
candidates.push(
|
|
1995
|
+
{ abs: join(rootReal, '.release-skill', FOUNDATION_PROFILE_FILENAME), desc: `.release-skill/${FOUNDATION_PROFILE_FILENAME}` },
|
|
1996
|
+
{ abs: join(rootReal, FOUNDATION_PROFILE_FILENAME), desc: FOUNDATION_PROFILE_FILENAME },
|
|
1997
|
+
);
|
|
1998
|
+
for (const sibling of await r5SiblingDirs(rootReal)) {
|
|
1999
|
+
const name = basename(sibling);
|
|
2000
|
+
candidates.push(
|
|
2001
|
+
{ abs: join(sibling, '.release-skill', FOUNDATION_PROFILE_FILENAME), desc: `../${name}/.release-skill/${FOUNDATION_PROFILE_FILENAME}` },
|
|
2002
|
+
{ abs: join(sibling, FOUNDATION_PROFILE_FILENAME), desc: `../${name}/${FOUNDATION_PROFILE_FILENAME}` },
|
|
2003
|
+
);
|
|
2004
|
+
}
|
|
2005
|
+
for (const candidate of candidates) {
|
|
2006
|
+
if (await r5IsRegularFile(candidate.abs)) return candidate;
|
|
2007
|
+
}
|
|
2008
|
+
return null;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
/**
|
|
2012
|
+
* Strictly read-only downstream discovery (design §2.9 clue set): git remote
|
|
2013
|
+
* enumeration (mirror candidates), workspace + neighbor marketplace/docs
|
|
2014
|
+
* clues, artifact-graph.config.yaml presence, and the foundation profile
|
|
2015
|
+
* (when present). Never writes anywhere.
|
|
2016
|
+
*/
|
|
2017
|
+
async function r5Discover(rootReal, foundationProfilePath) {
|
|
2018
|
+
const configPath = join(rootReal, '.release-skill', 'project.yaml');
|
|
2019
|
+
const configExists = await r5IsRegularFile(configPath);
|
|
2020
|
+
|
|
2021
|
+
const git = await discoverGit(rootReal);
|
|
2022
|
+
const gitRemotes = git.remotes.map((remote) => ({ name: remote.name, url: remote.url, repo: remote.repo }));
|
|
2023
|
+
const sourceRepositoryCandidates = [...new Set(
|
|
2024
|
+
git.remotes.map((remote) => remote.repo).filter(Boolean),
|
|
2025
|
+
)].sort();
|
|
2026
|
+
const mirrorCandidates = git.remotes
|
|
2027
|
+
.filter((remote) => R5_REMOTE_URL_RE.test(remote.url))
|
|
2028
|
+
.map((remote) => ({ remoteName: remote.name, remoteUrl: remote.url, repo: remote.repo }))
|
|
2029
|
+
.sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b)));
|
|
2030
|
+
|
|
2031
|
+
const artifactGraphPresent = await r5IsRegularFile(join(rootReal, 'artifact-graph.config.yaml'));
|
|
2032
|
+
|
|
2033
|
+
const workspaceClues = (await r5DetectCluesInDir(rootReal))
|
|
2034
|
+
.sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b)));
|
|
2035
|
+
|
|
2036
|
+
const neighborClues = [];
|
|
2037
|
+
for (const sibling of await r5SiblingDirs(rootReal)) {
|
|
2038
|
+
const name = basename(sibling);
|
|
2039
|
+
if (/hub/i.test(name)) neighborClues.push({ sibling: name, kind: 'hub' });
|
|
2040
|
+
for (const clue of await r5DetectCluesInDir(sibling)) {
|
|
2041
|
+
neighborClues.push({ sibling: name, kind: clue.kind, path: clue.path });
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
neighborClues.sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b)));
|
|
2045
|
+
|
|
2046
|
+
const profileDiagnostics = [];
|
|
2047
|
+
let foundationProfile = null;
|
|
2048
|
+
let foundationProfileParsed = null;
|
|
2049
|
+
const found = await r5FindFoundationProfile(rootReal, foundationProfilePath);
|
|
2050
|
+
if (found) {
|
|
2051
|
+
try {
|
|
2052
|
+
const raw = JSON.parse(await readFile(found.abs, 'utf8'));
|
|
2053
|
+
const parsed = r5ValidateProfileShape(raw);
|
|
2054
|
+
foundationProfile = {
|
|
2055
|
+
path: found.desc,
|
|
2056
|
+
profileVersion: parsed.profileVersion,
|
|
2057
|
+
hookCount: parsed.hooks.length,
|
|
2058
|
+
targetCount: parsed.targets.length,
|
|
2059
|
+
};
|
|
2060
|
+
foundationProfileParsed = parsed;
|
|
2061
|
+
} catch (error) {
|
|
2062
|
+
profileDiagnostics.push({ path: found.desc, error: error.message });
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
return {
|
|
2067
|
+
mode: 'postpublish-discovery',
|
|
2068
|
+
configExists,
|
|
2069
|
+
gitRemotes,
|
|
2070
|
+
sourceRepositoryCandidates,
|
|
2071
|
+
mirrorCandidates,
|
|
2072
|
+
artifactGraphConfig: {
|
|
2073
|
+
present: artifactGraphPresent,
|
|
2074
|
+
...(artifactGraphPresent ? { path: 'artifact-graph.config.yaml' } : {}),
|
|
2075
|
+
},
|
|
2076
|
+
workspaceClues,
|
|
2077
|
+
neighborClues,
|
|
2078
|
+
foundationProfile,
|
|
2079
|
+
foundationProfileParsed,
|
|
2080
|
+
foundationProfileLoadDiagnostics: profileDiagnostics,
|
|
2081
|
+
};
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
/** Read-only downstream discovery entry point (R5). */
|
|
2085
|
+
export async function discoverDownstream({ root, foundationProfilePath } = {}) {
|
|
2086
|
+
if (!root || typeof root !== 'string' || !isAbsolute(root)) {
|
|
2087
|
+
throw setupError(CONFIG_INVALID, 'discovery root must be an absolute path');
|
|
2088
|
+
}
|
|
2089
|
+
const rootReal = await realpath(root).catch((error) => {
|
|
2090
|
+
throw setupError(CONFIG_INVALID, `cannot resolve discovery root: ${error.message}`);
|
|
2091
|
+
});
|
|
2092
|
+
const discovery = await r5Discover(rootReal, foundationProfilePath);
|
|
2093
|
+
// The parsed profile is an internal carrier for the proposal stage; the
|
|
2094
|
+
// public discovery report keeps only the profile metadata.
|
|
2095
|
+
const { foundationProfileParsed, ...publicDiscovery } = discovery;
|
|
2096
|
+
return publicDiscovery;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
/**
|
|
2100
|
+
* Mechanically extract postPublish declaration drafts from discovery.
|
|
2101
|
+
* Only LEGAL hooks[]-form drafts are produced (validated against the preset
|
|
2102
|
+
* registry); targets-form presets (git-mirror / marketplace-index-render) are
|
|
2103
|
+
* surfaced as targetProposals for guided/new-project use, never as hooks.
|
|
2104
|
+
* foundation profile is one input among several and never auto-applies.
|
|
2105
|
+
*/
|
|
2106
|
+
function r5BuildProposals(discovery, existingHookIds) {
|
|
2107
|
+
const existing = new Set(existingHookIds);
|
|
2108
|
+
const hookProposals = [];
|
|
2109
|
+
const targetProposals = [];
|
|
2110
|
+
const candidates = [];
|
|
2111
|
+
const notes = [];
|
|
2112
|
+
const diagnostics = [...discovery.foundationProfileLoadDiagnostics];
|
|
2113
|
+
|
|
2114
|
+
const proposeHook = (draft, source, rationale) => {
|
|
2115
|
+
hookProposals.push({ ...draft, source, rationale });
|
|
2116
|
+
};
|
|
2117
|
+
|
|
2118
|
+
// 1. Zero-write floor — always available to every project.
|
|
2119
|
+
proposeHook(
|
|
2120
|
+
{ id: 'downstream-notify-handoff', preset: 'notify-handoff', phase: 'distribute' },
|
|
2121
|
+
'discovery-baseline',
|
|
2122
|
+
'zero-write floor: renders the downstream sync checklist in evidence',
|
|
2123
|
+
);
|
|
2124
|
+
|
|
2125
|
+
// 2. proposal-inbox from a hub-looking git remote (git-push transport).
|
|
2126
|
+
const hub = discovery.mirrorCandidates.find((candidate) => (
|
|
2127
|
+
/hub/i.test(candidate.remoteName) || /hub/i.test(candidate.repo ?? '')
|
|
2128
|
+
));
|
|
2129
|
+
if (hub) {
|
|
2130
|
+
proposeHook(
|
|
2131
|
+
{
|
|
2132
|
+
id: 'downstream-hub-proposal',
|
|
2133
|
+
preset: 'proposal-inbox',
|
|
2134
|
+
phase: 'postVerify',
|
|
2135
|
+
requiresApproval: true,
|
|
2136
|
+
config: { delivery: 'git-push', target: { remoteUrl: hub.remoteUrl, branch: 'main' } },
|
|
2137
|
+
},
|
|
2138
|
+
'git-remote',
|
|
2139
|
+
`hub-looking remote "${hub.remoteName}" suggests a proposal inbox`,
|
|
2140
|
+
);
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
// 3. git-mirror target drafts (targets-form) from non-self remotes.
|
|
2144
|
+
const selfRepo = discovery.sourceRepositoryCandidates.length === 1
|
|
2145
|
+
? discovery.sourceRepositoryCandidates[0]
|
|
2146
|
+
: null;
|
|
2147
|
+
for (const candidate of discovery.mirrorCandidates) {
|
|
2148
|
+
if (selfRepo && candidate.repo === selfRepo) continue;
|
|
2149
|
+
const slug = (candidate.repo ?? candidate.remoteName)
|
|
2150
|
+
.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
2151
|
+
targetProposals.push({
|
|
2152
|
+
id: `mirror-${slug}`,
|
|
2153
|
+
kind: 'payload-mirror',
|
|
2154
|
+
remoteUrl: candidate.remoteUrl,
|
|
2155
|
+
visibility: 'public',
|
|
2156
|
+
branch: 'main',
|
|
2157
|
+
source: 'git-remote',
|
|
2158
|
+
rationale: `remote "${candidate.remoteName}" is a payload-mirror candidate`,
|
|
2159
|
+
});
|
|
2160
|
+
}
|
|
2161
|
+
if (!selfRepo && discovery.mirrorCandidates.length > 0) {
|
|
2162
|
+
notes.push('source repository is ambiguous; review mirror target drafts before adopting them.');
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
// 4. foundation profile hooks/targets (validated; invalid entries reported).
|
|
2166
|
+
const profile = discovery.foundationProfileParsed;
|
|
2167
|
+
if (profile) {
|
|
2168
|
+
for (const [index, hook] of profile.hooks.entries()) {
|
|
2169
|
+
const id = hook && typeof hook.id === 'string' ? hook.id : null;
|
|
2170
|
+
try {
|
|
2171
|
+
if (!id) throw new Error('hook must declare a string id');
|
|
2172
|
+
// validatePresetHook enforces registry membership, targets-form-only
|
|
2173
|
+
// rejection, secret scanning, and preset config semantics (fail-closed).
|
|
2174
|
+
validatePresetHook(hook, `foundationProfile.hooks[${index}]`);
|
|
2175
|
+
const draft = { id, preset: hook.preset };
|
|
2176
|
+
if (hook.phase !== undefined) draft.phase = hook.phase;
|
|
2177
|
+
if (hook.config !== undefined) draft.config = hook.config;
|
|
2178
|
+
if (hook.requiresApproval !== undefined) draft.requiresApproval = hook.requiresApproval;
|
|
2179
|
+
if (hook.blocksVerified !== undefined) draft.blocksVerified = hook.blocksVerified;
|
|
2180
|
+
proposeHook(draft, 'foundation-profile', 'declared by the foundation profile');
|
|
2181
|
+
} catch (error) {
|
|
2182
|
+
diagnostics.push({ id, error: error.message });
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
for (const target of profile.targets) {
|
|
2186
|
+
targetProposals.push({
|
|
2187
|
+
...target,
|
|
2188
|
+
source: 'foundation-profile',
|
|
2189
|
+
rationale: 'declared by the foundation profile',
|
|
2190
|
+
});
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
// 5. Existence clues become candidate evidence + guidance notes.
|
|
2195
|
+
if (discovery.artifactGraphConfig.present) {
|
|
2196
|
+
candidates.push({ kind: 'artifact-graph', path: discovery.artifactGraphConfig.path, source: 'workspace' });
|
|
2197
|
+
notes.push('artifact-graph.config.yaml present: consider a marketplace-registry-entry hook via the foundation profile.');
|
|
2198
|
+
}
|
|
2199
|
+
for (const clue of discovery.workspaceClues) candidates.push({ ...clue, source: 'workspace' });
|
|
2200
|
+
for (const clue of discovery.neighborClues) candidates.push({ ...clue, source: 'neighbor-scan' });
|
|
2201
|
+
if (discovery.neighborClues.some((clue) => clue.kind === 'docs-repo')) {
|
|
2202
|
+
notes.push('docs repository clue found: a docs-refresh hook needs mappings from the foundation profile or human input.');
|
|
2203
|
+
}
|
|
2204
|
+
if (discovery.neighborClues.some((clue) => clue.kind === 'marketplace')) {
|
|
2205
|
+
notes.push('marketplace clue found: a marketplace-registry-entry hook needs entryKey/fieldsFromPlan from the foundation profile or human input.');
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
// Conflicts: a proposal id that already exists must never be overwritten.
|
|
2209
|
+
const appendableHookProposals = [];
|
|
2210
|
+
const conflicts = [];
|
|
2211
|
+
for (const proposal of hookProposals) {
|
|
2212
|
+
if (existing.has(proposal.id)) {
|
|
2213
|
+
conflicts.push({ existingHookId: proposal.id, preset: proposal.preset, source: proposal.source });
|
|
2214
|
+
} else {
|
|
2215
|
+
appendableHookProposals.push(proposal);
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
return {
|
|
2220
|
+
hookProposals,
|
|
2221
|
+
appendableHookProposals,
|
|
2222
|
+
targetProposals,
|
|
2223
|
+
candidates,
|
|
2224
|
+
notes,
|
|
2225
|
+
conflicts,
|
|
2226
|
+
foundationProfileDiagnostics: diagnostics,
|
|
2227
|
+
};
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
/** Strip report-only fields so an appended draft is schema-clean. */
|
|
2231
|
+
function r5CleanHookDraft(proposal) {
|
|
2232
|
+
const draft = {};
|
|
2233
|
+
for (const field of R5_HOOK_DRAFT_FIELDS) {
|
|
2234
|
+
if (proposal[field] !== undefined) draft[field] = proposal[field];
|
|
2235
|
+
}
|
|
2236
|
+
return draft;
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
/**
|
|
2240
|
+
* Append hook drafts into releaseUnits[unitIndex].postPublish.hooks.
|
|
2241
|
+
*
|
|
2242
|
+
* First-time hooks-key creation is a byte-level splice (r5HooksBlockSplice):
|
|
2243
|
+
* only the rendered `hooks:` block is inserted after the postPublish map's
|
|
2244
|
+
* last content line, so every pre-existing line — including inline comments —
|
|
2245
|
+
* keeps its exact source bytes (R5 review minor-1). The splice falls back to
|
|
2246
|
+
* a document-level setIn/toString only for non-block-map postPublish shapes.
|
|
2247
|
+
* Appending to an already-present hooks sequence re-serializes the document;
|
|
2248
|
+
* the semantic identity of every non-hooks section is then asserted by the
|
|
2249
|
+
* caller (r5AppendHooksOnce) before any write.
|
|
2250
|
+
*/
|
|
2251
|
+
function r5AppendHooksToYaml(text, hookDrafts, unitIndex) {
|
|
2252
|
+
const doc = YAML.parseDocument(text);
|
|
2253
|
+
const hooksPath = ['releaseUnits', unitIndex, 'postPublish', 'hooks'];
|
|
2254
|
+
const existingHooks = doc.getIn(hooksPath);
|
|
2255
|
+
if (existingHooks === undefined || existingHooks === null) {
|
|
2256
|
+
const postPublishMap = doc.getIn(hooksPath.slice(0, -1));
|
|
2257
|
+
const splice = r5HooksBlockSplice(text, postPublishMap, hookDrafts);
|
|
2258
|
+
if (splice !== null) return splice;
|
|
2259
|
+
doc.setIn(hooksPath, hookDrafts);
|
|
2260
|
+
return doc.toString();
|
|
2261
|
+
}
|
|
2262
|
+
for (const draft of hookDrafts) existingHooks.add(draft);
|
|
2263
|
+
return doc.toString();
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
/**
|
|
2267
|
+
* Render the new `hooks:` block and splice it into the source bytes right
|
|
2268
|
+
* after the target postPublish map's last content line. Returns null when the
|
|
2269
|
+
* map shape is not a plain block map (flow, alias, or absent), in which case
|
|
2270
|
+
* the caller falls back to a document-level serialization.
|
|
2271
|
+
*/
|
|
2272
|
+
function r5HooksBlockSplice(text, postPublishMap, hookDrafts) {
|
|
2273
|
+
if (!YAML.isMap(postPublishMap) || postPublishMap.flow || YAML.isAlias(postPublishMap)) return null;
|
|
2274
|
+
const [mapStart, valueEnd] = postPublishMap.range;
|
|
2275
|
+
if (!Number.isInteger(mapStart) || !Number.isInteger(valueEnd)) return null;
|
|
2276
|
+
if (mapStart < 0 || valueEnd > text.length || mapStart > valueEnd) return null;
|
|
2277
|
+
// The block's indentation is the whitespace before its first key.
|
|
2278
|
+
const lineStart = text.lastIndexOf('\n', mapStart - 1) + 1;
|
|
2279
|
+
const indent = text.slice(lineStart, mapStart);
|
|
2280
|
+
if (!/^[ ]*$/.test(indent)) return null; // tabs or unexpected content
|
|
2281
|
+
// Insert after the newline that terminates the map's last content line;
|
|
2282
|
+
// a same-line trailing comment stays with that line.
|
|
2283
|
+
const nl = text.indexOf('\n', Math.max(lineStart, valueEnd - 1));
|
|
2284
|
+
const insertPos = nl === -1 ? text.length : nl + 1;
|
|
2285
|
+
const fragment = YAML.stringify({ hooks: hookDrafts }, { lineWidth: 0 });
|
|
2286
|
+
const block = fragment.replace(/\n$/, '').split('\n').map((line) => indent + line);
|
|
2287
|
+
const terminator = insertPos === text.length && !text.endsWith('\n') ? '' : '\n';
|
|
2288
|
+
return text.slice(0, insertPos) + block.join('\n') + terminator + text.slice(insertPos);
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
/** Remove every release unit's postPublish.hooks (for the unchanged-section guard). */
|
|
2292
|
+
function r5StripHooks(config) {
|
|
2293
|
+
const copy = JSON.parse(JSON.stringify(config ?? {}));
|
|
2294
|
+
for (const unit of copy.releaseUnits ?? []) {
|
|
2295
|
+
if (unit?.postPublish) delete unit.postPublish.hooks;
|
|
2296
|
+
}
|
|
2297
|
+
return copy;
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
/**
|
|
2301
|
+
* Atomically replace project.yaml with the hooks-appended bytes using the
|
|
2302
|
+
* safe-fs bound-directory machinery. The existing file's read identity
|
|
2303
|
+
* authorizes the replace (TOCTOU-safe); the merged config is schema-validated
|
|
2304
|
+
* and every non-hooks section is asserted unchanged before the rename.
|
|
2305
|
+
*/
|
|
2306
|
+
async function r5AppendHooksOnce(rootReal, hookDrafts, unitIndex) {
|
|
2307
|
+
const { loadSafeFs } = await import('../artifacts/safe-fs.mjs');
|
|
2308
|
+
const safeFs = await loadSafeFs();
|
|
2309
|
+
const bound = await openBoundConfigDirectory(rootReal, safeFs);
|
|
2310
|
+
let tempToken;
|
|
2311
|
+
try {
|
|
2312
|
+
const existing = await bound.releaseHandle.readFile('project.yaml');
|
|
2313
|
+
if (existing === null) {
|
|
2314
|
+
throw setupError(CONFIG_MISSING, 'configuration disappeared before hooks could be appended');
|
|
2315
|
+
}
|
|
2316
|
+
const existingText = existing.bytes.toString('utf8');
|
|
2317
|
+
const newText = r5AppendHooksToYaml(existingText, hookDrafts, unitIndex);
|
|
2318
|
+
const merged = YAML.parse(newText);
|
|
2319
|
+
if (!validateProjectConfig(merged)) {
|
|
2320
|
+
const errors = validateProjectConfig.errors ?? [];
|
|
2321
|
+
throw setupError(
|
|
2322
|
+
CONFIG_INVALID,
|
|
2323
|
+
`appended configuration would be invalid: ${errors.map((error) => `${error.instancePath || '/'} ${error.message}`).join('; ')}`,
|
|
2324
|
+
{ validationErrors: errors },
|
|
2325
|
+
);
|
|
2326
|
+
}
|
|
2327
|
+
const before = YAML.parse(existingText);
|
|
2328
|
+
if (canonicalJson(r5StripHooks(before)) !== canonicalJson(r5StripHooks(merged))) {
|
|
2329
|
+
throw setupError(CONFIG_INVALID, 'hooks append would alter non-hooks configuration sections; refusing to write');
|
|
2330
|
+
}
|
|
2331
|
+
// Defense in depth (R5 review note-1): fail closed at declaration time
|
|
2332
|
+
// when the merged unit would not survive the runtime declaration
|
|
2333
|
+
// validation that prepare/distribute/postverify enforce anyway. This
|
|
2334
|
+
// never writes a hooks block onto a unit that can never pass runtime.
|
|
2335
|
+
const mergedUnit = Array.isArray(merged.releaseUnits) ? merged.releaseUnits[unitIndex] : null;
|
|
2336
|
+
validatePostPublishDeclaration(mergedUnit?.postPublish, { unitId: mergedUnit?.id });
|
|
2337
|
+
const newBytes = Buffer.from(newText, 'utf8');
|
|
2338
|
+
tempToken = await bound.releaseHandle.createTemp('project.yaml', 0o600, newBytes);
|
|
2339
|
+
await assertConfigDirectoryStillBound(rootReal, safeFs, bound);
|
|
2340
|
+
// `existing` carries the read identity that authorizes replacing this file.
|
|
2341
|
+
await bound.releaseHandle.rename(tempToken, 'project.yaml', existing);
|
|
2342
|
+
tempToken = null;
|
|
2343
|
+
await bound.releaseHandle.fsync();
|
|
2344
|
+
await bound.rootHandle.fsync();
|
|
2345
|
+
return { configSha256: sha256Hex(newBytes) };
|
|
2346
|
+
} finally {
|
|
2347
|
+
if (tempToken) await bound.releaseHandle.abortTemp(tempToken).catch(() => {});
|
|
2348
|
+
await bound.releaseHandle.close().catch(() => {});
|
|
2349
|
+
await bound.rootHandle.close().catch(() => {});
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
/** Assemble the digest authority + report for one discovery/proposal pass. */
|
|
2354
|
+
async function r5Compute({ rootReal, foundationProfilePath, selectedHookIds, unitId }) {
|
|
2355
|
+
const configPath = join(rootReal, '.release-skill', 'project.yaml');
|
|
2356
|
+
let configBytes = null;
|
|
2357
|
+
let existingParsed = null;
|
|
2358
|
+
const configExists = await r5IsRegularFile(configPath);
|
|
2359
|
+
if (configExists) {
|
|
2360
|
+
configBytes = await readFile(configPath, 'utf8');
|
|
2361
|
+
try {
|
|
2362
|
+
existingParsed = YAML.parse(configBytes);
|
|
2363
|
+
} catch (error) {
|
|
2364
|
+
throw setupError(CONFIG_INVALID, `existing configuration cannot be parsed; refusing incremental proposal: ${error.message}`);
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
// postPublish is a per-release-unit block. Resolve the single unit whose
|
|
2369
|
+
// hooks will be appended: an explicit unitId, or the unique unit that
|
|
2370
|
+
// already declares the materialize + commitIdentity distribute base.
|
|
2371
|
+
const units = Array.isArray(existingParsed?.releaseUnits) ? existingParsed.releaseUnits : [];
|
|
2372
|
+
const hasBase = (unit) => Boolean(unit?.postPublish?.materialize && unit?.postPublish?.commitIdentity);
|
|
2373
|
+
const baseUnitIndexes = units.map((unit, index) => ({ unit, index })).filter(({ unit }) => hasBase(unit)).map(({ index }) => index);
|
|
2374
|
+
let targetUnitIndex = null;
|
|
2375
|
+
let targetUnitId = null;
|
|
2376
|
+
let targetRefusal = null;
|
|
2377
|
+
if (configExists) {
|
|
2378
|
+
if (unitId !== undefined && unitId !== null) {
|
|
2379
|
+
const idx = units.findIndex((unit) => unit?.id === unitId);
|
|
2380
|
+
if (idx === -1) targetRefusal = `release unit "${unitId}" was not found in the existing configuration`;
|
|
2381
|
+
else if (!hasBase(units[idx])) targetRefusal = `release unit "${unitId}" lacks the postPublish materialize/commitIdentity base required before hooks can be appended`;
|
|
2382
|
+
else { targetUnitIndex = idx; targetUnitId = unitId; }
|
|
2383
|
+
} else if (baseUnitIndexes.length === 1) {
|
|
2384
|
+
targetUnitIndex = baseUnitIndexes[0];
|
|
2385
|
+
targetUnitId = units[targetUnitIndex]?.id ?? null;
|
|
2386
|
+
} else if (baseUnitIndexes.length === 0) {
|
|
2387
|
+
targetRefusal = 'no release unit declares the postPublish materialize/commitIdentity base required before hooks can be appended; declare the distribute base first (human decision)';
|
|
2388
|
+
} else {
|
|
2389
|
+
targetRefusal = `multiple release units declare a postPublish base (${baseUnitIndexes.map((index) => units[index]?.id ?? `#${index}`).join(', ')}); pass an explicit unitId to choose one`;
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
const existingHookIds = targetUnitIndex === null
|
|
2394
|
+
? []
|
|
2395
|
+
: (units[targetUnitIndex]?.postPublish?.hooks ?? [])
|
|
2396
|
+
.map((hook) => hook?.id)
|
|
2397
|
+
.filter((id) => typeof id === 'string');
|
|
2398
|
+
const hasPostPublishBase = targetUnitIndex !== null;
|
|
2399
|
+
|
|
2400
|
+
const discovery = await r5Discover(rootReal, foundationProfilePath);
|
|
2401
|
+
const proposals = r5BuildProposals(discovery, existingHookIds);
|
|
2402
|
+
|
|
2403
|
+
const appendableIds = proposals.appendableHookProposals.map((proposal) => proposal.id);
|
|
2404
|
+
let selected;
|
|
2405
|
+
if (selectedHookIds !== undefined && selectedHookIds !== null) {
|
|
2406
|
+
if (!Array.isArray(selectedHookIds)) {
|
|
2407
|
+
throw setupError(CONFIG_INVALID, 'selectedHookIds must be an array of proposal ids');
|
|
2408
|
+
}
|
|
2409
|
+
const requested = [...new Set(selectedHookIds)];
|
|
2410
|
+
for (const id of requested) {
|
|
2411
|
+
if (!appendableIds.includes(id)) {
|
|
2412
|
+
throw setupError(CONFIG_INVALID, `selectedHookIds contains unknown or conflicting proposal "${id}"`);
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
selected = requested.sort();
|
|
2416
|
+
} else {
|
|
2417
|
+
selected = [...appendableIds].sort();
|
|
2418
|
+
}
|
|
2419
|
+
const selectedProposals = proposals.appendableHookProposals
|
|
2420
|
+
.filter((proposal) => selected.includes(proposal.id));
|
|
2421
|
+
const selectedDrafts = selectedProposals.map(r5CleanHookDraft);
|
|
2422
|
+
|
|
2423
|
+
const authority = {
|
|
2424
|
+
setupVersion: 1,
|
|
2425
|
+
mode: 'postpublish-hooks-proposal',
|
|
2426
|
+
configExists,
|
|
2427
|
+
existingConfigSha256: configExists ? sha256Hex(configBytes) : null,
|
|
2428
|
+
targetUnitId,
|
|
2429
|
+
discovery,
|
|
2430
|
+
selectedHookIds: selected,
|
|
2431
|
+
selectedDrafts,
|
|
2432
|
+
};
|
|
2433
|
+
const setupDigest = sha256Hex(canonicalJson(authority));
|
|
2434
|
+
|
|
2435
|
+
return {
|
|
2436
|
+
configPath,
|
|
2437
|
+
configExists,
|
|
2438
|
+
configBytes,
|
|
2439
|
+
existingParsed,
|
|
2440
|
+
hasPostPublishBase,
|
|
2441
|
+
targetUnitIndex,
|
|
2442
|
+
targetUnitId,
|
|
2443
|
+
targetRefusal,
|
|
2444
|
+
discovery,
|
|
2445
|
+
proposals,
|
|
2446
|
+
selected,
|
|
2447
|
+
selectedDrafts,
|
|
2448
|
+
authority,
|
|
2449
|
+
setupDigest,
|
|
2450
|
+
};
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
/**
|
|
2454
|
+
* R5 guided proposal + independent incremental-proposal mode.
|
|
2455
|
+
*
|
|
2456
|
+
* Dry-run (default): read-only discovery + hook declaration drafts + a
|
|
2457
|
+
* setupDigest binding the existing config, the discovery facts, and the
|
|
2458
|
+
* selected drafts. Write mode: after exact setupDigest confirmation and under
|
|
2459
|
+
* the project lock, appends ONLY postPublish.hooks to an EXISTING config
|
|
2460
|
+
* (create-once is never touched). foundation profile is one proposal input and
|
|
2461
|
+
* never auto-applies.
|
|
2462
|
+
*/
|
|
2463
|
+
export async function proposePostPublishHooks({
|
|
2464
|
+
root,
|
|
2465
|
+
write = false,
|
|
2466
|
+
confirmSetup,
|
|
2467
|
+
selectedHookIds,
|
|
2468
|
+
foundationProfilePath,
|
|
2469
|
+
unitId,
|
|
2470
|
+
faultInjector,
|
|
2471
|
+
} = {}) {
|
|
2472
|
+
if (!root || typeof root !== 'string' || !isAbsolute(root)) {
|
|
2473
|
+
throw setupError(CONFIG_INVALID, 'proposal root must be an absolute path');
|
|
2474
|
+
}
|
|
2475
|
+
const rootReal = await realpath(root).catch((error) => {
|
|
2476
|
+
throw setupError(CONFIG_INVALID, `cannot resolve proposal root: ${error.message}`);
|
|
2477
|
+
});
|
|
2478
|
+
const computed = await r5Compute({ rootReal, foundationProfilePath, selectedHookIds, unitId });
|
|
2479
|
+
const report = {
|
|
2480
|
+
setupVersion: 1,
|
|
2481
|
+
mode: 'postpublish-hooks-proposal',
|
|
2482
|
+
status: 'HOOKS_PROPOSAL_READY',
|
|
2483
|
+
configPath: computed.configPath,
|
|
2484
|
+
configExists: computed.configExists,
|
|
2485
|
+
targetUnitId: computed.targetUnitId,
|
|
2486
|
+
targetRefusal: computed.targetRefusal ?? null,
|
|
2487
|
+
existingConfigSha256: computed.configExists ? sha256Hex(computed.configBytes) : null,
|
|
2488
|
+
discovery: (() => {
|
|
2489
|
+
const { foundationProfileParsed, ...publicDiscovery } = computed.discovery;
|
|
2490
|
+
return publicDiscovery;
|
|
2491
|
+
})(),
|
|
2492
|
+
hookProposals: computed.proposals.hookProposals,
|
|
2493
|
+
appendableHookIds: computed.proposals.appendableHookProposals.map((proposal) => proposal.id),
|
|
2494
|
+
targetProposals: computed.proposals.targetProposals,
|
|
2495
|
+
candidates: computed.proposals.candidates,
|
|
2496
|
+
notes: computed.proposals.notes,
|
|
2497
|
+
conflicts: computed.proposals.conflicts,
|
|
2498
|
+
foundationProfileDiagnostics: computed.proposals.foundationProfileDiagnostics,
|
|
2499
|
+
selectedHookIds: computed.selected,
|
|
2500
|
+
setupDigest: computed.setupDigest,
|
|
2501
|
+
writeContract: {
|
|
2502
|
+
default: 'dry-run',
|
|
2503
|
+
requires: ['--write', `--confirm-setup ${computed.setupDigest}`],
|
|
2504
|
+
appendOnly: 'releaseUnits[<target>].postPublish.hooks',
|
|
2505
|
+
overwrite: false,
|
|
2506
|
+
createOnceBoundary: 'setup create-once is never touched by this mode',
|
|
2507
|
+
},
|
|
2508
|
+
};
|
|
2509
|
+
|
|
2510
|
+
if (!write) return report;
|
|
2511
|
+
|
|
2512
|
+
if (!computed.configExists) {
|
|
2513
|
+
throw setupError(
|
|
2514
|
+
CONFIG_MISSING,
|
|
2515
|
+
'incremental postPublish proposal requires an existing configuration; run setup create first',
|
|
2516
|
+
{ configPath: computed.configPath },
|
|
2517
|
+
);
|
|
2518
|
+
}
|
|
2519
|
+
if (!computed.hasPostPublishBase) {
|
|
2520
|
+
throw setupError(
|
|
2521
|
+
CONFIG_INVALID,
|
|
2522
|
+
computed.targetRefusal ?? 'existing configuration lacks the postPublish base required before hooks can be appended',
|
|
2523
|
+
{ configPath: computed.configPath },
|
|
2524
|
+
);
|
|
2525
|
+
}
|
|
2526
|
+
if (confirmSetup !== computed.setupDigest) {
|
|
2527
|
+
throw setupError(
|
|
2528
|
+
SETUP_DIGEST_MISMATCH,
|
|
2529
|
+
'proposal confirmation does not match the current configuration, discovery facts, and selection; rerun dry-run and review again',
|
|
2530
|
+
{ expected: computed.setupDigest, received: confirmSetup ?? null },
|
|
2531
|
+
);
|
|
2532
|
+
}
|
|
2533
|
+
if (computed.selectedDrafts.length === 0) {
|
|
2534
|
+
return { ...report, status: 'HOOKS_NO_CHANGE', next: '没有可追加的 hook 草案;无写入。' };
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
const lock = await acquireProjectLock({ root: rootReal, command: 'setup-postpublish', mode: 'exclusive' });
|
|
2538
|
+
let appendResult;
|
|
2539
|
+
try {
|
|
2540
|
+
appendResult = await lock.capture(async () => {
|
|
2541
|
+
if (faultInjector) await faultInjector('before-hooks-append');
|
|
2542
|
+
const locked = await r5Compute({ rootReal, foundationProfilePath, selectedHookIds, unitId });
|
|
2543
|
+
if (locked.setupDigest !== confirmSetup) {
|
|
2544
|
+
throw setupError(
|
|
2545
|
+
SETUP_DIGEST_MISMATCH,
|
|
2546
|
+
'configuration or discovery facts changed immediately before hooks append; rerun dry-run and review the new digest',
|
|
2547
|
+
{ expected: locked.setupDigest, received: confirmSetup },
|
|
2548
|
+
);
|
|
2549
|
+
}
|
|
2550
|
+
if (!locked.hasPostPublishBase) {
|
|
2551
|
+
throw setupError(
|
|
2552
|
+
CONFIG_INVALID,
|
|
2553
|
+
locked.targetRefusal ?? 'postPublish base disappeared before hooks append',
|
|
2554
|
+
{ configPath: locked.configPath },
|
|
2555
|
+
);
|
|
2556
|
+
}
|
|
2557
|
+
return r5AppendHooksOnce(rootReal, locked.selectedDrafts, locked.targetUnitIndex);
|
|
2558
|
+
});
|
|
2559
|
+
} finally {
|
|
2560
|
+
await lock.release();
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
return {
|
|
2564
|
+
...report,
|
|
2565
|
+
status: 'HOOKS_APPENDED',
|
|
2566
|
+
configSha256: appendResult.configSha256,
|
|
2567
|
+
appendedHookIds: computed.selected,
|
|
2568
|
+
committedSetupDigest: computed.setupDigest,
|
|
2569
|
+
next: '运行 release-skill assess;分发前审阅追加的 postPublish hooks 与其批准分级。',
|
|
2570
|
+
};
|
|
2571
|
+
}
|