From 57fb0eb2c94415904685e7e1e2f9c6bf8cda6ca1 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Thu, 16 Apr 2026 13:16:00 +0200 Subject: [PATCH 1/2] Prompt OpenSpec refresh when gx detects newer toolchain Typing 'gx' now performs a lightweight OpenSpec package freshness check (interactive by default), then offers an explicit y/n prompt to run both the npm upgrade and openspec tool refresh command. This keeps the existing guardex self-update path intact while extending the same explicit-consent behavior to OpenSpec updates. The check is skipped in non-interactive shells unless explicitly forced via env for automation/tests. Constraint: Keep startup flow non-destructive and explicit-consent only Rejected: Auto-running openspec upgrades without prompt | too side-effectful for default status invocation Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep OpenSpec prompt strict [y/n]; do not reintroduce implicit Enter acceptance Tested: npm test --silent; node --check bin/multiagent-safety.js; git diff --check Not-tested: Interactive terminal prompt UX with real user input --- bin/multiagent-safety.js | 102 ++++++++++++++++++++++++++++++++++++++- test/install.test.js | 62 ++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 2 deletions(-) diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index 1c30dd6..895535a 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -10,9 +10,10 @@ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); const TOOL_NAME = 'guardex'; const SHORT_TOOL_NAME = 'gx'; const LEGACY_NAMES = ['musafety', 'multiagent-safety']; +const OPENSPEC_PACKAGE = '@fission-ai/openspec'; const GLOBAL_TOOLCHAIN_PACKAGES = [ 'oh-my-codex', - '@fission-ai/openspec', + OPENSPEC_PACKAGE, '@imdeadpool/codex-account-switcher', ]; const GH_BIN = process.env.MUSAFETY_GH_BIN || 'gh'; @@ -28,6 +29,7 @@ const MAINTAINER_RELEASE_REPO = path.resolve( process.env.MUSAFETY_RELEASE_REPO || '/tmp/multiagent-safety', ); const NPM_BIN = process.env.MUSAFETY_NPM_BIN || 'npm'; +const OPENSPEC_BIN = process.env.MUSAFETY_OPENSPEC_BIN || 'openspec'; const SCORECARD_BIN = process.env.MUSAFETY_SCORECARD_BIN || 'scorecard'; const GIT_PROTECTED_BRANCHES_KEY = 'multiagent.protectedBranches'; const GIT_BASE_BRANCH_KEY = 'multiagent.baseBranch'; @@ -3112,6 +3114,95 @@ function maybeSelfUpdateBeforeStatus() { console.log(`[${TOOL_NAME}] ✅ Updated to latest published version.`); } +function checkForOpenSpecPackageUpdate() { + if (envFlagEnabled('MUSAFETY_SKIP_OPENSPEC_UPDATE_CHECK')) { + return { checked: false, reason: 'disabled' }; + } + + const forceCheck = envFlagEnabled('MUSAFETY_FORCE_OPENSPEC_UPDATE_CHECK'); + if (!forceCheck && !isInteractiveTerminal()) { + return { checked: false, reason: 'non-interactive' }; + } + + const detection = detectGlobalToolchainPackages(); + if (!detection.ok) { + return { checked: false, reason: 'package-detect-failed' }; + } + + const current = String((detection.installedVersions || {})[OPENSPEC_PACKAGE] || '').trim(); + if (!current) { + return { checked: false, reason: 'not-installed' }; + } + + const latestResult = run(NPM_BIN, ['view', OPENSPEC_PACKAGE, 'version', '--json'], { timeout: 5000 }); + if (latestResult.status !== 0) { + return { checked: false, reason: 'lookup-failed' }; + } + + const latest = parseNpmVersionOutput(latestResult.stdout); + if (!latest) { + return { checked: false, reason: 'invalid-latest-version' }; + } + + return { + checked: true, + current, + latest, + updateAvailable: isNewerVersion(latest, current), + }; +} + +function printOpenSpecUpdateAvailableBanner(current, latest) { + const title = colorize('OPENSPEC UPDATE AVAILABLE', '1;33'); + console.log(`[${TOOL_NAME}] ${title}`); + console.log(`[${TOOL_NAME}] Current: ${current}`); + console.log(`[${TOOL_NAME}] Latest : ${latest}`); + console.log(`[${TOOL_NAME}] Command: ${NPM_BIN} i -g ${OPENSPEC_PACKAGE}@latest`); + console.log(`[${TOOL_NAME}] Then : ${OPENSPEC_BIN} update`); +} + +function maybeOpenSpecUpdateBeforeStatus() { + const check = checkForOpenSpecPackageUpdate(); + if (!check.checked || !check.updateAvailable) { + return; + } + + printOpenSpecUpdateAvailableBanner(check.current, check.latest); + + const autoApproval = parseAutoApproval('MUSAFETY_AUTO_OPENSPEC_UPDATE_APPROVAL'); + const interactive = isInteractiveTerminal(); + + if (!interactive && autoApproval == null) { + console.log(`[${TOOL_NAME}] Non-interactive shell; skipping OpenSpec update prompt.`); + return; + } + + const shouldUpdate = interactive + ? promptYesNoStrict( + `Update OpenSpec now? (${NPM_BIN} i -g ${OPENSPEC_PACKAGE}@latest && ${OPENSPEC_BIN} update)`, + ) + : autoApproval; + + if (!shouldUpdate) { + console.log(`[${TOOL_NAME}] Skipped OpenSpec update.`); + return; + } + + const installResult = run(NPM_BIN, ['i', '-g', `${OPENSPEC_PACKAGE}@latest`], { stdio: 'inherit' }); + if (installResult.status !== 0) { + console.log(`[${TOOL_NAME}] ⚠️ OpenSpec npm install failed. You can retry manually.`); + return; + } + + const toolUpdateResult = run(OPENSPEC_BIN, ['update'], { stdio: 'inherit' }); + if (toolUpdateResult.status !== 0) { + console.log(`[${TOOL_NAME}] ⚠️ OpenSpec tool update failed. Run '${OPENSPEC_BIN} update' manually.`); + return; + } + + console.log(`[${TOOL_NAME}] ✅ OpenSpec updated to latest package and tool plugins refreshed.`); +} + function promptYesNoStrict(question) { while (true) { process.stdout.write(`${question} [y/n] `); @@ -3176,15 +3267,21 @@ function detectGlobalToolchainPackages() { const installed = []; const missing = []; + const installedVersions = {}; for (const pkg of GLOBAL_TOOLCHAIN_PACKAGES) { if (installedSet.has(pkg)) { installed.push(pkg); + const rawVersion = dependencyMap[pkg] && dependencyMap[pkg].version; + const version = String(rawVersion || '').trim(); + if (version) { + installedVersions[pkg] = version; + } } else { missing.push(pkg); } } - return { ok: true, installed, missing }; + return { ok: true, installed, missing, installedVersions }; } function detectRequiredSystemTools() { @@ -5064,6 +5161,7 @@ function main() { if (args.length === 0) { maybeSelfUpdateBeforeStatus(); + maybeOpenSpecUpdateBeforeStatus(); status([]); return; } diff --git a/test/install.test.js b/test/install.test.js index d2968e5..6e642ea 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -54,6 +54,14 @@ function createFakeNpmScript(scriptBody) { return fakeNpmPath; } +function createFakeOpenSpecScript(scriptBody) { + const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'musafety-fake-openspec-')); + const fakeOpenSpecPath = path.join(fakeBin, 'openspec'); + fs.writeFileSync(fakeOpenSpecPath, `#!/usr/bin/env bash\nset -e\n${scriptBody}\n`, 'utf8'); + fs.chmodSync(fakeOpenSpecPath, 0o755); + return fakeOpenSpecPath; +} + function createFakeScorecardScript(scriptBody) { const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'musafety-fake-scorecard-')); const fakePath = path.join(fakeBin, 'scorecard'); @@ -1677,6 +1685,60 @@ test('self-update prompt requires explicit y/n when approval is not preconfigure ); }); +test('default invocation checks for openspec package updates and runs openspec update', () => { + const repoDir = initRepo(); + const npmMarkerPath = path.join(repoDir, '.openspec-npm-update-called'); + const toolMarkerPath = path.join(repoDir, '.openspec-tool-update-called'); + const fakeNpm = createFakeNpmScript(` +if [[ "$1" == "list" && "$2" == "-g" ]]; then + echo '{"dependencies":{"@fission-ai/openspec":{"version":"1.2.0"}}}' + exit 0 +fi +if [[ "$1" == "view" && "$2" == "@fission-ai/openspec" && "$3" == "version" ]]; then + echo '"1.3.0"' + exit 0 +fi +if [[ "$1" == "i" && "$2" == "-g" && "$3" == "@fission-ai/openspec@latest" ]]; then + echo "updated" > "${npmMarkerPath}" + exit 0 +fi +echo "unexpected npm args: $*" >&2 +exit 1 +`); + const fakeOpenSpec = createFakeOpenSpecScript(` +if [[ "$1" == "update" ]]; then + echo "updated" > "${toolMarkerPath}" + exit 0 +fi +echo "unexpected openspec args: $*" >&2 +exit 1 +`); + + const result = runNodeWithEnv([], repoDir, { + MUSAFETY_NPM_BIN: fakeNpm, + MUSAFETY_OPENSPEC_BIN: fakeOpenSpec, + MUSAFETY_SKIP_UPDATE_CHECK: '1', + MUSAFETY_FORCE_OPENSPEC_UPDATE_CHECK: '1', + MUSAFETY_AUTO_OPENSPEC_UPDATE_APPROVAL: 'yes', + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /OPENSPEC UPDATE AVAILABLE/); + assert.match(result.stdout, /Current:\s+1\.2\.0/); + assert.match(result.stdout, /Latest\s+:\s+1\.3\.0/); + assert.match(result.stdout, /OpenSpec updated to latest package and tool plugins refreshed/); + assert.equal(fs.existsSync(npmMarkerPath), true, 'expected openspec npm install to run'); + assert.equal(fs.existsSync(toolMarkerPath), true, 'expected openspec update command to run'); +}); + +test('openspec update prompt requires explicit y/n when approval is not preconfigured', () => { + const source = fs.readFileSync(cliPath, 'utf8'); + assert.match( + source, + /const shouldUpdate = interactive\s*\?\s*promptYesNoStrict\(\s*`Update OpenSpec now\?\s*\(\$\{NPM_BIN\} i -g \$\{OPENSPEC_PACKAGE\}@latest && \$\{OPENSPEC_BIN\} update\)`\s*,?\s*\)\s*:\s*autoApproval;/s, + ); +}); + test('status --json returns cli, services, and repo summary', () => { const repoDir = initRepo(); From 39effcff91b09d5474689c9e1db976ccccdc44ae Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Thu, 16 Apr 2026 13:17:44 +0200 Subject: [PATCH 2/2] Scaffold local OpenSpec change workspaces for branch-start flows Add repository-local Usage: scripts/openspec/init-change-workspace.sh [capability-slug] Example: scripts/openspec/init-change-workspace.sh add-dashboard-live-usage runtime-migration so the branch-start OpenSpec bootstrap can initialize change artifacts directly in active worktrees without relying on template-only assets. Constraint: Existing branch-start and codex-agent bootstrap flow already expects local helper scripts in sandbox worktrees Rejected: Keep change-workspace scaffold only under templates/ | runtime worktrees can miss helper parity when local script is absent Confidence: high Scope-risk: narrow Directive: Keep local openspec helper scripts in sync with template counterparts to avoid bootstrap drift Tested: node --test test/install.test.js Not-tested: Full test matrix beyond install suite --- scripts/openspec/init-change-workspace.sh | 87 +++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100755 scripts/openspec/init-change-workspace.sh diff --git a/scripts/openspec/init-change-workspace.sh b/scripts/openspec/init-change-workspace.sh new file mode 100755 index 0000000..2f490ce --- /dev/null +++ b/scripts/openspec/init-change-workspace.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 1 || $# -gt 2 ]]; then + echo "Usage: $0 [capability-slug]" + echo "Example: $0 add-dashboard-live-usage runtime-migration" + exit 1 +fi + +CHANGE_SLUG="$1" +CAPABILITY_SLUG="${2:-$CHANGE_SLUG}" + +if [[ "$CHANGE_SLUG" =~ [^a-z0-9-] ]]; then + echo "Error: change slug must be kebab-case (lowercase letters, numbers, hyphens)." + exit 1 +fi + +if [[ "$CAPABILITY_SLUG" =~ [^a-z0-9-] ]]; then + echo "Error: capability slug must be kebab-case (lowercase letters, numbers, hyphens)." + exit 1 +fi + +CHANGE_DIR="openspec/changes/${CHANGE_SLUG}" +SPEC_DIR="${CHANGE_DIR}/specs/${CAPABILITY_SLUG}" +TODAY="$(date -u +%Y-%m-%d)" + +mkdir -p "$SPEC_DIR" + +if [[ ! -f "${CHANGE_DIR}/.openspec.yaml" ]]; then + cat > "${CHANGE_DIR}/.openspec.yaml" < "${CHANGE_DIR}/proposal.md" < "${CHANGE_DIR}/tasks.md" < "${SPEC_DIR}/spec.md" <