diff --git a/.gitattributes b/.gitattributes index 5d1afdd..843ff07 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,8 @@ * text=auto eol=lf *.mjs text eol=lf +*.json text eol=lf *.md text eol=lf +*.cff text eol=lf *.yml text eol=lf *.yaml text eol=lf +*.png binary diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..58aba47 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# The maintainer owns the complete repository, including this policy file. +* @TheStreamCode diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..4cc7281 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/TheStreamCode/agentic-rd-skill/security/advisories/new + about: Report sensitive vulnerabilities privately instead of opening a public issue. + - name: Questions and usage support + url: https://github.com/TheStreamCode/agentic-rd-skill/discussions + about: Ask questions and share usage feedback in GitHub Discussions. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7c94c3b..fb0fa9d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,11 +4,10 @@ ## Validation -- [ ] `npm test` -- [ ] `npm run validate` -- [ ] `npm run benchmark` when CLI, templates, or package size changes +- [ ] `npm run check` - [ ] `agentskills validate ` - [ ] `gh skill publish --dry-run` +- [ ] Documentation, changelog, and evaluation cases are updated when public behavior changes ## Compatibility And Safety Notes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3eb4a7f..a42445f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,20 +9,25 @@ on: permissions: contents: read +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - # Produces the `validate` check that branch protection requires. The script - # existed but was never run in CI, so the required context never appeared and - # every pull request stayed blocked on a check that could not arrive. + # Fast structural feedback; the final quality job aggregates every required gate. validate: name: validate runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 22 @@ -32,6 +37,7 @@ jobs: test: name: Node ${{ matrix.node }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} + timeout-minutes: 10 strategy: fail-fast: false matrix: @@ -49,10 +55,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: ${{ matrix.node }} @@ -62,20 +70,25 @@ jobs: specification: name: Agent Skills specification and discovery runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: read steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.13" - name: Install official reference validator - run: python -m pip install skills-ref + run: >- + python -m pip install --disable-pip-version-check --only-binary=:all: + --require-hashes -r requirements-validation.txt - name: Validate Agent Skill specification run: agentskills validate "$GITHUB_WORKSPACE/skills/agentic-rd-skill" @@ -88,15 +101,42 @@ jobs: performance: name: CLI performance guardrail runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: 22 - name: Run realistic workflow benchmark run: npm run benchmark + + quality: + name: quality + if: always() + needs: + - validate + - test + - specification + - performance + runs-on: ubuntu-latest + timeout-minutes: 2 + + steps: + - name: Require every quality job to pass + env: + VALIDATE_RESULT: ${{ needs.validate.result }} + TEST_RESULT: ${{ needs.test.result }} + SPECIFICATION_RESULT: ${{ needs.specification.result }} + PERFORMANCE_RESULT: ${{ needs.performance.result }} + run: | + test "$VALIDATE_RESULT" = success + test "$TEST_RESULT" = success + test "$SPECIFICATION_RESULT" = success + test "$PERFORMANCE_RESULT" = success diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml deleted file mode 100644 index 761ac6b..0000000 --- a/.github/workflows/dependabot-auto-merge.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Dependabot auto-merge - -# Enables auto-merge on Dependabot's patch and minor updates. GitHub then merges -# them once the branch protection checks pass — a red build never lands. -# -# Major bumps are deliberately excluded: they are the ones that break builds, and -# they stay open for a human to read. -# -# No step approves anything. The branch protection on these repositories requires -# passing status checks but not an approving review, so auto-merge is satisfied by -# a green CI alone. That is the point: GitHub documents restricting workflows from -# approving pull requests as a security best practice, and a bot approving its own -# dependency bumps would be oversight in name only. -# -# Why pull_request_target and not pull_request: GitHub treats Dependabot runs as if -# they came from a fork, so on `pull_request` the GITHUB_TOKEN is read-only and -# cannot enable auto-merge. `pull_request_target` runs in the base repository's -# context with a writable token. That trigger is dangerous when a workflow checks -# out and runs the pull request's code, because untrusted code would inherit those -# permissions — this workflow never checks out the PR, it only reads metadata and -# calls the API. - -on: pull_request_target - -permissions: - contents: write - pull-requests: write - -jobs: - auto-merge: - # Check the author rather than the actor, so the job cannot be driven by a - # pull request someone else opened. - if: github.event.pull_request.user.login == 'dependabot[bot]' - runs-on: ubuntu-latest - - steps: - - name: Fetch Dependabot metadata - id: meta - uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0 - - - name: Enable auto-merge for patch and minor updates - if: | - steps.meta.outputs.update-type == 'version-update:semver-patch' || - steps.meta.outputs.update-type == 'version-update:semver-minor' - run: gh pr merge --auto --squash "$PR_URL" - env: - PR_URL: ${{ github.event.pull_request.html_url }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Leave major updates for manual review - if: steps.meta.outputs.update-type == 'version-update:semver-major' - run: | - echo "::notice::Major update (${{ steps.meta.outputs.dependency-names }}) left for manual review." diff --git a/.gitignore b/.gitignore index a8a857e..18ae286 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,11 @@ node_modules/ +.venv/ /work/ /project-brief.md .DS_Store Thumbs.db *.log +coverage/ +*.lcov .env .env.* diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c57d1a..cd6d989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Added GitHub CLI installation guidance alongside the manual installation path. - Added discoverability-focused README sections for real use cases, workflow steps, Agent Laboratory comparison, FAQs, maintainer attribution, and search intent coverage. - Expanded repository and package metadata for GitHub, traditional search, and AI search discovery without keyword stuffing. +- Added a single `npm run check` quality gate, pinned validation dependencies, CODEOWNERS, and guided issue routing. ### Changed @@ -26,6 +27,14 @@ - Replaced the heading-based validator with stateful behavior and repository tests. - Removed broad host-specific `allowed-tools` pre-approval from skill frontmatter. - Improved CLI UX with lazy phase templates, readable phase aliases, actionable status output, and explicit stale-final recovery. +- Pinned GitHub Actions to immutable commits, disabled persisted checkout credentials, bounded job runtimes, and added an aggregate CI quality check. +- Removed the obsolete writable Dependabot auto-merge workflow after version updates were disabled. + +### Security + +- Made workflow-state updates atomic so interrupted writes preserve the previous valid state. +- Rejected symlinked Markdown artifacts and non-file template conflicts instead of following or silently keeping them. +- Rejected numeric CLI values outside JavaScript's safe integer range. ## 0.3.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1de681d..4cd0e2d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,17 @@ Contributions should preserve portability, deterministic safety checks, and progressive disclosure. +## Development Setup + +Use Node.js 20 or newer for the dependency-free test suite. Release validation also requires Python 3.11 or newer and GitHub CLI 2.96 or newer. + +Install the pinned Agent Skills reference validator into an isolated Python environment: + +```powershell +python -m venv .venv +.\.venv\Scripts\python.exe -m pip install --disable-pip-version-check --only-binary=:all: --require-hashes -r requirements-validation.txt +``` + ## Guidelines - Keep the installable package under `skills/agentic-rd-skill/`. @@ -17,10 +28,8 @@ Contributions should preserve portability, deterministic safety checks, and prog Run: ```powershell -npm test -npm run validate -npm run benchmark -agentskills validate (Resolve-Path '.\skills\agentic-rd-skill').Path +npm run check +.\.venv\Scripts\agentskills.exe validate (Resolve-Path '.\skills\agentic-rd-skill').Path gh skill publish --dry-run ``` diff --git a/README.md b/README.md index cc45b81..0f0e932 100644 --- a/README.md +++ b/README.md @@ -168,9 +168,7 @@ Legal, medical, financial, compliance, employment, insurance, credit, security, Run the local deterministic suite: ```powershell -npm test -npm run validate -npm run benchmark +npm run check npm run smoke:hosts # Optional model-backed activation checks: npm run smoke:hosts:model @@ -179,7 +177,9 @@ npm run smoke:hosts:model Release preparation also uses the official Agent Skills reference validator and GitHub CLI discovery: ```powershell -agentskills validate (Resolve-Path '.\skills\agentic-rd-skill').Path +python -m venv .venv +.\.venv\Scripts\python.exe -m pip install --disable-pip-version-check --only-binary=:all: --require-hashes -r requirements-validation.txt +.\.venv\Scripts\agentskills.exe validate (Resolve-Path '.\skills\agentic-rd-skill').Path gh skill publish --dry-run ``` diff --git a/package.json b/package.json index 8a42c28..6c6562e 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "type": "module", "scripts": { + "check": "npm run validate && npm test && npm run benchmark", "test": "node --test", "validate": "node scripts/validate-repo.mjs", "benchmark": "node scripts/benchmark.mjs", @@ -40,5 +41,6 @@ "agent-laboratory" ], "author": "Michael Gasperini (https://mikesoft.it)", + "funding": "https://github.com/sponsors/TheStreamCode", "license": "MIT" } diff --git a/requirements-validation.txt b/requirements-validation.txt new file mode 100644 index 0000000..2dd9ba6 --- /dev/null +++ b/requirements-validation.txt @@ -0,0 +1,7 @@ +# Reproducible tools for Agent Skills specification validation. +click==8.4.2 --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 +colorama==0.4.6 --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 +python-dateutil==2.9.0.post0 --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 +six==1.17.0 --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 +skills-ref==0.1.1 --hash=sha256:d35db5bb8de71ae301daf5ca9cb71f8a555e8c6f83a6d40e46a5bc09f8f461b5 +strictyaml==1.7.3 --hash=sha256:fb5c8a4edb43bebb765959e420f9b3978d7f1af88c80606c03fb420888f5d1c7 diff --git a/scripts/validate-repo.mjs b/scripts/validate-repo.mjs index 35c29a5..e121855 100644 --- a/scripts/validate-repo.mjs +++ b/scripts/validate-repo.mjs @@ -42,13 +42,16 @@ const REQUIRED_ROOT_FILES = [ '.gitattributes', '.gitignore', '.github/FUNDING.yml', + '.github/CODEOWNERS', '.github/PULL_REQUEST_TEMPLATE.md', + '.github/ISSUE_TEMPLATE/config.yml', '.github/ISSUE_TEMPLATE/bug_report.yml', '.github/ISSUE_TEMPLATE/documentation.yml', '.github/ISSUE_TEMPLATE/skill_improvement.yml', '.github/workflows/ci.yml', 'evals/manifest.json', 'evals/usability-review.md', + 'requirements-validation.txt', 'scripts/benchmark.mjs', 'scripts/smoke-hosts.mjs', 'tests/package.test.mjs', @@ -150,6 +153,9 @@ export function validateRepository(root = repositoryRoot) { if (packageJson.version !== '1.0.0') fail('package.json version must be 1.0.0'); if (packageJson.engines?.node !== '>=20') fail('package.json must require Node.js >=20'); if (packageJson.scripts?.test !== 'node --test') fail('package.json test script must run node --test'); + if (packageJson.scripts?.check !== 'npm run validate && npm test && npm run benchmark') { + fail('package.json check script must run validation, tests, and benchmark'); + } if (packageJson.scripts?.benchmark !== 'node scripts/benchmark.mjs') { fail('package.json benchmark script must run scripts/benchmark.mjs'); } @@ -160,6 +166,17 @@ export function validateRepository(root = repositoryRoot) { } } + for (const workflowPath of ['.github/workflows/ci.yml']) { + const fullPath = path.join(root, workflowPath); + if (!existsSync(fullPath)) continue; + const workflow = readText(fullPath); + for (const match of workflow.matchAll(/^\s*-?\s*uses:\s*[^@\s]+@([^\s#]+)/gm)) { + if (!/^[0-9a-f]{40}$/.test(match[1])) { + fail(`${workflowPath} must pin actions to full commit SHAs`); + } + } + } + const citationPath = path.join(root, 'CITATION.cff'); if (existsSync(citationPath) && !/^version:\s*"1\.0\.0"\s*$/m.test(readText(citationPath))) { fail('CITATION.cff version must be 1.0.0'); diff --git a/skills/agentic-rd-skill/scripts/rd.mjs b/skills/agentic-rd-skill/scripts/rd.mjs index 3057bcb..3ce8368 100644 --- a/skills/agentic-rd-skill/scripts/rd.mjs +++ b/skills/agentic-rd-skill/scripts/rd.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { randomUUID } from 'node:crypto'; import { copyFileSync, existsSync, @@ -7,7 +8,9 @@ import { mkdirSync, readFileSync, readdirSync, + renameSync, statSync, + unlinkSync, writeFileSync } from 'node:fs'; import path from 'node:path'; @@ -176,7 +179,12 @@ function ensureManagedDirectory(workspace, relativePath, dryRun = false) { function copyTemplateIfMissing(workspace, templateName, relativePath, dryRun = false) { const destination = resolveManagedPath(workspace, relativePath); assertNoSymlinkComponents(workspace, destination); - if (existsSync(destination)) return false; + if (existsSync(destination)) { + if (!lstatSync(destination).isFile()) { + throw new CliError(`Expected a regular file: ${destination}`, EXIT.FILESYSTEM); + } + return false; + } ensureManagedDirectory(workspace, path.dirname(relativePath), dryRun); if (!dryRun) copyFileSync(path.join(assetsRoot, templateName), destination); return true; @@ -186,7 +194,22 @@ function writeManagedJson(workspace, relativePath, value) { const destination = resolveManagedPath(workspace, relativePath); assertNoSymlinkComponents(workspace, destination); ensureManagedDirectory(workspace, path.dirname(relativePath)); - writeFileSync(destination, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); + const temporaryPath = resolveManagedPath( + workspace, + `${relativePath}.${process.pid}.${randomUUID()}.tmp` + ); + assertNoSymlinkComponents(workspace, temporaryPath); + try { + writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' }); + renameSync(temporaryPath, destination); + } catch (error) { + try { + if (existsSync(temporaryPath)) unlinkSync(temporaryPath); + } catch { + // Preserve the original write error; a stale temp file is safer than masking it. + } + throw error; + } } function readJsonFile(filePath, label) { @@ -197,6 +220,10 @@ function readJsonFile(filePath, label) { } } +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + function statePath(workspace) { return resolveManagedPath(workspace, 'work/run-state.json'); } @@ -208,6 +235,9 @@ function readState(workspace) { } assertNoSymlinkComponents(workspace, filePath); const state = readJsonFile(filePath, 'work/run-state.json'); + if (!isRecord(state)) { + throw new CliError('Workflow state must be a JSON object.', EXIT.STATE); + } if (state.schemaVersion !== 1 || state.workflowVersion !== '1.0') { throw new CliError('Unsupported workflow state version; expected v1.0.', EXIT.STATE); } @@ -215,15 +245,33 @@ function readState(workspace) { throw new CliError(`Unknown profile in workflow state: ${state.profile}`, EXIT.STATE); } if ( - !state.phases - || typeof state.phases !== 'object' - || !state.budgets - || !state.metrics - || !state.stageGate + !isRecord(state.phases) + || !isRecord(state.budgets) + || !isRecord(state.metrics) + || !isRecord(state.stageGate) || typeof state.finalStale !== 'boolean' ) { throw new CliError('Workflow state is missing required v1 fields.', EXIT.STATE); } + if ( + PHASES.some((phase) => typeof state.phases[phase] !== 'string') + || typeof state.currentPhase !== 'string' + || !Number.isSafeInteger(state.revisionRounds) + || state.revisionRounds < 0 + || !Number.isSafeInteger(state.budgets.maxRevisionRounds) + || !Number.isSafeInteger(state.metrics.phaseTransitions) + || !Number.isSafeInteger(state.metrics.executionAttempts) + || !Number.isSafeInteger(state.stageGate.blockers) + || (state.stageGate.score !== null && !Number.isSafeInteger(state.stageGate.score)) + || (state.stageGate.dimensions !== null && !Array.isArray(state.stageGate.dimensions)) + || (state.stageGate.decision !== null && typeof state.stageGate.decision !== 'string') + || typeof state.createdAt !== 'string' + || typeof state.updatedAt !== 'string' + || typeof state.humanReview !== 'string' + || (state.lastReason !== null && typeof state.lastReason !== 'string') + ) { + throw new CliError('Workflow state contains invalid v1 field types.', EXIT.STATE); + } return state; } @@ -319,9 +367,17 @@ function listMarkdownFiles(workspace, relativeDirectory) { const directory = resolveManagedPath(workspace, relativeDirectory); assertNoSymlinkComponents(workspace, directory); if (!existsSync(directory) || !statSync(directory).isDirectory()) return []; - return readdirSync(directory) - .filter((name) => name.toLowerCase().endsWith('.md')) - .map((name) => path.join(directory, name)); + const files = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (!entry.name.toLowerCase().endsWith('.md')) continue; + const filePath = resolveManagedPath(workspace, path.join(relativeDirectory, entry.name)); + assertNoSymlinkComponents(workspace, filePath); + if (!entry.isFile()) { + throw new CliError(`Expected a regular Markdown artifact: ${filePath}`, EXIT.STATE); + } + files.push(filePath); + } + return files; } function assertFilledFile(workspace, relativePath) { @@ -430,7 +486,11 @@ function resetLaterPhases(state, phase, workspace) { function parseIntegerOption(value, name, defaultValue = undefined) { if (value === undefined) return defaultValue; if (!/^\d+$/.test(value)) throw new CliError(`--${name} must be a non-negative integer`, EXIT.USAGE); - return Number(value); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) { + throw new CliError(`--${name} must be a safe non-negative integer`, EXIT.USAGE); + } + return parsed; } function parseDimensions(value, required = false) { @@ -468,6 +528,12 @@ function commandAdvance(tokens) { if (status === 'pending') { throw new CliError('The pending status is managed internally and cannot be set with advance.', EXIT.USAGE); } + if ( + phase !== 'stageGate' + && ['score', 'dimensions', 'blockers'].some((name) => parsed.options[name] !== undefined) + ) { + throw new CliError('--score, --dimensions, and --blockers are valid only for stage-gate.', EXIT.USAGE); + } const score = parseIntegerOption(parsed.options.score, 'score'); const dimensions = parseDimensions(parsed.options.dimensions, phase === 'stageGate' && status === 'approved'); @@ -499,7 +565,9 @@ function commandAdvance(tokens) { if (status === 'needs_revision' || status === 'blocked') assertPredecessor(state, phase); if (phase === 'stageGate') { - if (status === 'approved') { + if (status === 'in_progress') { + state.stageGate = { decision: null, score: null, dimensions: null, blockers: 0 }; + } else if (status === 'approved') { assertPredecessor(state, phase); assertPhaseArtifact(workspace, phase); if (score === undefined || score < 8 || score > 10) { @@ -625,10 +693,25 @@ function validateState(workspace, state) { failures.push('Approved gate has an invalid score or blockers'); } } + const expectedGateDecision = ['approved', 'needs_revision', 'blocked'].includes(state.phases.stageGate) + ? state.phases.stageGate + : null; + if (state.stageGate.decision !== expectedGateDecision) { + failures.push('Stage-gate decision metadata does not match its phase status'); + } if (state.revisionRounds > state.budgets.maxRevisionRounds) { failures.push('Revision round limit exceeded'); } if (state.budgets.maxRevisionRounds !== 2) failures.push('maxRevisionRounds must remain 2'); + for (const [name, expected] of Object.entries(PROFILE_LIMITS[state.profile])) { + if (state.budgets[name] !== expected) failures.push(`${name} must match the ${state.profile} profile`); + } + for (const name of ['phaseTransitions', 'executionAttempts']) { + if (state.metrics[name] < 0) failures.push(`${name} must be a non-negative integer`); + } + if (PHASES.includes(state.currentPhase) && state.phases[state.currentPhase] === 'pending') { + failures.push('currentPhase cannot point to a pending phase'); + } return [...new Set(failures)]; } diff --git a/tests/rd-cli.test.mjs b/tests/rd-cli.test.mjs index d4bd826..9365b01 100644 --- a/tests/rd-cli.test.mjs +++ b/tests/rd-cli.test.mjs @@ -84,6 +84,17 @@ test('init is idempotent and reports existing directories accurately', (t) => { assert.equal(state.budgets.maxSpecialists, 1); }); +test('init rejects file-path conflicts before creating workflow state', (t) => { + const workspace = workspaceFor(t, 'conflict-'); + mkdirSync(path.join(workspace, 'project-brief.md')); + + const result = runCli(['init', workspace]); + + assert.equal(result.status, 4); + assert.match(result.stderr, /Expected a regular file/); + assert.equal(existsSync(path.join(workspace, 'work', 'run-state.json')), false); +}); + test('dry-run writes nothing', (t) => { const workspace = workspaceFor(t, 'dry-'); const result = runCli(['init', workspace, '--dry-run']); @@ -272,6 +283,75 @@ test('pending is internal-only and malformed state returns a workflow error', (t const malformed = runCli(['status', workspace]); assert.equal(malformed.status, 3); assert.match(malformed.stderr, /missing required v1 fields/); + + writeFileSync(path.join(workspace, 'work', 'run-state.json'), 'null\n'); + const nonObject = runCli(['status', workspace]); + assert.equal(nonObject.status, 3); + assert.match(nonObject.stderr, /must be a JSON object/); +}); + +test('unsafe integers and stage-gate-only options are rejected as usage errors', (t) => { + const workspace = initializeFilledBrief(t); + const unsafeInteger = runCli([ + 'advance', workspace, '--phase', 'evidence', '--status', 'in_progress', + '--reason', 'bounded input', '--score', '999999999999999999999999' + ]); + assert.equal(unsafeInteger.status, 2); + assert.match(unsafeInteger.stderr, /valid only for stage-gate/); + + writeFilled(workspace, 'work/01-evidence/01-evidence.md'); + assert.equal(runCli(['advance', workspace, '--phase', 'evidence', '--status', 'complete']).status, 0); + writeFilled(workspace, 'work/02-plan.md'); + assert.equal(runCli(['advance', workspace, '--phase', 'plan', '--status', 'complete']).status, 0); + writeFilled(workspace, 'work/03-execution/01-execution.md'); + assert.equal(runCli(['advance', workspace, '--phase', 'execution', '--status', 'complete']).status, 0); + writeFilled(workspace, 'work/04-results/01-results.md'); + assert.equal(runCli(['advance', workspace, '--phase', 'results', '--status', 'complete']).status, 0); + writeFilled(workspace, 'work/05-cross-review.md'); + assert.equal(runCli(['advance', workspace, '--phase', 'cross-review', '--status', 'complete']).status, 0); + writeFilled(workspace, 'work/06-stage-gate.md'); + + const unsafeBlockers = runCli([ + 'advance', workspace, '--phase', 'stage-gate', '--status', 'blocked', + '--blockers', '999999999999999999999999' + ]); + assert.equal(unsafeBlockers.status, 2); + assert.match(unsafeBlockers.stderr, /safe non-negative integer/); +}); + +test('reopening an approved stage gate clears stale decision metadata', (t) => { + const workspace = initializeFilledBrief(t); + completeThroughCrossReview(workspace); + assert.equal(runCli([ + 'advance', workspace, '--phase', 'stage-gate', '--status', 'approved', '--score', '8', + '--dimensions', '2,2,1,1,2', '--blockers', '0' + ]).status, 0); + + const reopen = runCli(['advance', workspace, '--phase', 'stage-gate', '--status', 'in_progress']); + assert.equal(reopen.status, 0, reopen.stderr); + const state = JSON.parse(readFileSync(path.join(workspace, 'work', 'run-state.json'), 'utf8')); + assert.deepEqual(state.stageGate, { decision: null, score: null, dimensions: null, blockers: 0 }); + assert.equal(runCli(['validate', workspace]).status, 0); +}); + +test('Markdown artifact symlinks are rejected when the platform permits symlink creation', (t) => { + const workspace = initializeFilledBrief(t); + const outside = path.join(workspaceFor(t, 'artifact-outside-'), 'outside.md'); + writeFileSync(outside, '# External artifact\n\nMust not be followed.\n', 'utf8'); + const linkedArtifact = path.join(workspace, 'work', '01-evidence', 'linked.md'); + try { + symlinkSync(outside, linkedArtifact, 'file'); + } catch (error) { + if (error.code === 'EPERM' || error.code === 'EACCES' || error.code === 'UNKNOWN') { + t.skip(`symlink creation unavailable: ${error.code}`); + return; + } + throw error; + } + + const result = runCli(['advance', workspace, '--phase', 'evidence', '--status', 'complete']); + assert.equal(result.status, 4); + assert.match(result.stderr, /symlink/); }); test('managed symlink paths are rejected when the platform permits symlink creation', (t) => {