diff --git a/.claude/sdlc/prompts/plan.md b/.claude/sdlc/prompts/plan.md index b381133e..8a846727 100644 --- a/.claude/sdlc/prompts/plan.md +++ b/.claude/sdlc/prompts/plan.md @@ -5,7 +5,7 @@ Spec: read `{{ARTIFACT}}` in full, then the intent it names. Read `AGENTS.md` (i Explore the source with Read, Grep and Glob until you can name every file that changes. -Write `docs/plans/{{SLUG}}.md` using `docs/plans/TEMPLATE.md` exactly: keep its frontmatter keys, set `status: draft`, `spec: {{ARTIFACT}}`, `generated_by: sdlc-loop`, `build: pending`. Sections: +Write `docs/plans/{{SLUG}}.md` using `docs/plans/TEMPLATE.md` exactly: keep its frontmatter keys, set `status: draft`, `spec: {{ARTIFACT}}`, `build: pending`. Do not add author or generator attribution. Sections: - Files that change: every path, marked new / modified / deleted, one line each on what changes there. Tests count as files. - Order of work: numbered steps, smallest vertical slice first (one path that works end to end before widening). diff --git a/.claude/sdlc/prompts/spec.md b/.claude/sdlc/prompts/spec.md index 8b004f28..545f983b 100644 --- a/.claude/sdlc/prompts/spec.md +++ b/.claude/sdlc/prompts/spec.md @@ -5,6 +5,6 @@ Intent: read `{{ARTIFACT}}` in full. Constraints you must apply, in this order of authority (read each one; quote it when you flag a concern): {{CONSTRAINTS}} -Write `docs/specs/{{SLUG}}.md` using `docs/specs/TEMPLATE.md` exactly: keep its frontmatter keys, set `status: draft`, `intent: {{ARTIFACT}}`, `generated_by: sdlc-loop`. Sections: Problem (restated from the intent in the product's vocabulary), Requirements (numbered, each testable), Design (which existing modules, routes, components and stores change; no parallel implementation, no new runtime), Data and rights, Security and privacy, Out of scope, Concerns (every place the intent conflicts with a constraint above, or two constraints conflict with each other; the product owner resolves these before engineering sees the spec), Open questions (carried forward from the intent plus new ones), Acceptance (what a reviewer checks to accept this spec). +Write `docs/specs/{{SLUG}}.md` using `docs/specs/TEMPLATE.md` exactly: keep its frontmatter keys, set `status: draft`, `intent: {{ARTIFACT}}`. Do not add author or generator attribution. Sections: Problem (restated from the intent in the product's vocabulary), Requirements (numbered, each testable), Design (which existing modules, routes, components and stores change; no parallel implementation, no new runtime), Data and rights, Security and privacy, Out of scope, Concerns (every place the intent conflicts with a constraint above, or two constraints conflict with each other; the product owner resolves these before engineering sees the spec), Open questions (carried forward from the intent plus new ones), Acceptance (what a reviewer checks to accept this spec). Rules: do not change any file other than `docs/specs/{{SLUG}}.md`. Do not write code. Do not invent capabilities the constraints exclude. Keep the spec under 250 lines. diff --git a/.github/workflows/sdlc-loop.yml b/.github/workflows/sdlc-loop.yml index 5fe4e7cc..3522b98a 100644 --- a/.github/workflows/sdlc-loop.yml +++ b/.github/workflows/sdlc-loop.yml @@ -24,23 +24,52 @@ permissions: contents: read jobs: - pending: - name: Which artifacts are accepted and waiting + sdlc: + name: Run pending stages in order runs-on: ubuntu-latest - outputs: - items: ${{ steps.scan.outputs.items }} - ready: ${{ steps.secrets.outputs.ready }} - env: - GH_TOKEN: ${{ secrets.SDLC_GITHUB_TOKEN }} + timeout-minutes: 90 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 + token: ${{ secrets.SDLC_GITHUB_TOKEN || github.token }} - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version-file: .nvmrc + cache: npm + - id: scan + env: + GH_TOKEN: ${{ secrets.SDLC_GITHUB_TOKEN || github.token }} + SDLC_GITHUB_TOKEN: ${{ secrets.SDLC_GITHUB_TOKEN }} + run: | + pending_file="$RUNNER_TEMP/sdlc-pending.json" + if ! node scripts/sdlc/next-stage.mjs > "$pending_file"; then + if [ -z "$SDLC_GITHUB_TOKEN" ]; then + echo "::error::SDLC_GITHUB_TOKEN is not set; the accepted-stage scan could not read GitHub state." + fi + exit 1 + fi + node --input-type=module - "$pending_file" <<'NODE' + import { appendFileSync, readFileSync } from "node:fs"; + const items = JSON.parse(readFileSync(process.argv[2], "utf8")); + if (!Array.isArray(items)) throw new Error("next-stage did not produce a JSON array"); + const hasPending = items.length > 0; + const hasBuild = items.some((item) => item?.stage === "build"); + appendFileSync(process.env.GITHUB_OUTPUT, `has_pending=${hasPending}\nhas_build=${hasBuild}\n`); + const summary = [ + "## Pending stages", + "", + ...(hasPending + ? items.map((item) => `- \`${item.stage}\` \`${item.slug}\` — \`${item.artifact}\``) + : ["Nothing pending: every accepted artifact already has its next stage. No provider install or model call was run."]), + "", + ].join("\n"); + appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary); + NODE - id: secrets + if: steps.scan.outputs.has_pending == 'true' env: + GH_TOKEN: ${{ secrets.SDLC_GITHUB_TOKEN }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} @@ -51,73 +80,32 @@ jobs: node scripts/sdlc/agent.mjs --check || ready=false if [ -z "$GH_TOKEN" ]; then ready=false; echo "::error::SDLC_GITHUB_TOKEN secret is not set (PRs opened with github.token get no CI)."; fi echo "ready=$ready" >> "$GITHUB_OUTPUT" - [ "$ready" = true ] || echo "**Blocked: a required secret is missing.** Run scripts/sdlc/bootstrap.sh. See docs/sdlc/LOOP.md." >> "$GITHUB_STEP_SUMMARY" - - id: scan - run: | - items="$(node scripts/sdlc/next-stage.mjs)" - echo "items=$items" >> "$GITHUB_OUTPUT" - { - echo "## Pending stages" - echo - if [ "$items" = "[]" ]; then echo "Nothing pending: every accepted artifact already has its next stage."; else echo '```'; node scripts/sdlc/next-stage.mjs --human; echo '```'; fi - } >> "$GITHUB_STEP_SUMMARY" - - name: Refuse to run stages without the secrets - if: steps.secrets.outputs.ready != 'true' && steps.scan.outputs.items != '[]' - run: exit 1 - - stage: - name: ${{ matrix.item.stage }} ${{ matrix.item.slug }} - needs: pending - if: needs.pending.outputs.items != '[]' && needs.pending.outputs.ready == 'true' - runs-on: ubuntu-latest - timeout-minutes: 90 - strategy: - max-parallel: 1 - fail-fast: false - matrix: - item: ${{ fromJSON(needs.pending.outputs.items) }} - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }} - GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} - GH_TOKEN: ${{ secrets.SDLC_GITHUB_TOKEN }} - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - fetch-depth: 0 - token: ${{ secrets.SDLC_GITHUB_TOKEN }} - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version-file: .nvmrc - cache: npm + if [ "$ready" != true ]; then + echo "**Blocked: a required secret is missing.** Run scripts/sdlc/bootstrap.sh. See docs/sdlc/LOOP.md." >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi - name: Install the configured provider CLI (sdlc/config.json agent.provider) + if: steps.scan.outputs.has_pending == 'true' && steps.secrets.outputs.ready == 'true' + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }} run: node scripts/sdlc/agent.mjs --install # PROJECT TOOLCHAIN for the build stage: everything `npm run verify` needs # (dependencies, browsers, language runtimes). Edit for the repository. - name: Toolchain for the build stage - if: matrix.item.stage == 'build' + if: steps.scan.outputs.has_build == 'true' && steps.secrets.outputs.ready == 'true' run: | npm ci npx playwright install --with-deps chromium - name: Run the stage + if: steps.scan.outputs.has_pending == 'true' && steps.secrets.outputs.ready == 'true' env: - STAGE: ${{ matrix.item.stage }} - SLUG: ${{ matrix.item.slug }} - ARTIFACT: ${{ matrix.item.artifact }} - run: node scripts/sdlc/run-stage.mjs --stage "$STAGE" --slug "$SLUG" --artifact "$ARTIFACT" - # The run record is the model's full transcript (it quotes repository - # content it read). Anyone who can read this repository's Actions can - # download it (on a public repository: everyone); it is what you read - # when a stage fails. Kept 14 days. - - name: Keep the run record - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: sdlc-run-${{ matrix.item.stage }}-${{ matrix.item.slug }} - path: | - .sdlc-run/ - .verify/ - retention-days: 14 - if-no-files-found: ignore + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + GH_TOKEN: ${{ secrets.SDLC_GITHUB_TOKEN }} + run: node scripts/sdlc/run-pending.mjs "$RUNNER_TEMP/sdlc-pending.json" + # Raw model records stay on the disposable runner. Public diagnostics + # are the step log, pending-stage summary and resulting pull request. diff --git a/.github/workflows/sdlc-review.yml b/.github/workflows/sdlc-review.yml index 701172bb..c3580d15 100644 --- a/.github/workflows/sdlc-review.yml +++ b/.github/workflows/sdlc-review.yml @@ -131,13 +131,7 @@ jobs: PR: ${{ github.event.pull_request.number }} BASE: ${{ github.event.pull_request.base.ref }} run: node scripts/sdlc/review.mjs --request "$PR" --base "$BASE" - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - if: always() - with: - name: review-${{ github.event.pull_request.number }}-${{ github.run_id }} - path: .sdlc-run/ - retention-days: 14 - if-no-files-found: ignore + # Publish findings through the PR note, not the raw model transcript. review-matrix: name: ${{ matrix.cell.pass }} pass, ${{ matrix.cell.dir }} @@ -174,10 +168,4 @@ jobs: PASS: ${{ matrix.cell.pass }} DIR: ${{ matrix.cell.dir }} run: node scripts/sdlc/review.mjs --request "$PR" --base "$BASE" --pass "$PASS" --dir "$DIR" - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - if: always() - with: - name: review-${{ github.event.pull_request.number }}-${{ matrix.cell.pass }}-${{ strategy.job-index }}-${{ github.run_id }} - path: .sdlc-run/ - retention-days: 14 - if-no-files-found: ignore + # Publish findings through the PR note, not the raw model transcript. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f3c8e39..5c6dc21d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ All notable changes to MeMesh are documented here. ### Fixed +- **The implementation-plan evaluation now starts after intent and spec + acceptance.** The original question asked for the first feature artifact, + so an answer naming intent could fail a checker expecting plan and Proof. + The checker is unchanged. See + `docs/postmortems/2026-09-14-sdlc-plan-eval.md`. +- **SDLC pending stages stay in one Actions job.** The first accepted intent + could not reach its spec stage when Actions suppressed the JSON job output. + A runner-local file now feeds stages sequentially; malformed input and failed + stages stop explicitly. Artifact publication preserves conflicting branches. + Loop/review jobs no longer upload raw model records; spec/plan templates + omit generator credits. + See `docs/postmortems/2026-09-14-sdlc-pending-output.md` for the missing hosted + seam coverage and the required spec-PR readback. - **The isolated release suite now owns its npm cache.** The release runner no longer inherits a maintainer's `~/.npm` cache when nested tests execute `npm pack`, so local ownership damage cannot turn an otherwise isolated diff --git a/docs/plans/TEMPLATE.md b/docs/plans/TEMPLATE.md index c37153d1..320423b5 100644 --- a/docs/plans/TEMPLATE.md +++ b/docs/plans/TEMPLATE.md @@ -2,7 +2,6 @@ title: status: draft spec: docs/specs/<slug>.md -generated_by: sdlc-loop build: pending --- diff --git a/docs/plans/sdlc-first-cycle.md b/docs/plans/sdlc-first-cycle.md new file mode 100644 index 00000000..0916a619 --- /dev/null +++ b/docs/plans/sdlc-first-cycle.md @@ -0,0 +1,59 @@ +--- +title: Make the first accepted intent reach a reviewable specification +status: draft +build: manual +--- + +# First useful SDLC cycle + +## Outcome + +The accepted intent for observation-forget-survives-stop reaches a draft spec +pull request. The specification remains subject to independent review and a +person's acceptance. This change does not implement the forget behavior. + +## Changes + +- Keep the pending-stage list within one Actions job and execute it in order, + reusing the scanner and stage runner. Do not transmit the JSON list as a + cross-job output or weaken secret masking. +- Record an empty list as no work. Refuse malformed input, missing required + credentials, and failed stages explicitly. Stop after a failed stage. +- Publish artifact branches with ordinary Git push; preserve a conflicting + remote branch. Do not add model credits to commits, PR bodies or review + headings. An empty review response is a failure. +- Keep raw loop/review model records on the runner; publish diagnostics and + findings instead. Remove generator attribution from spec/plan templates + and the prompts that fill them. +- Do not pass provider or repository credentials to package installation. + Persist Codex login during setup; filter known model credential variables + to the selected provider, with repository token variables reserved for build. +- Select named models in the existing configuration: smaller models for + bounded drafting and implementation, a different model for planning and + independent review. This is routing, not a token-budget guarantee. +- Scope the no-plan-no-build evaluation to implementation after accepted + intent and spec. Keep its plan-path and Proof checker unchanged. + +## Proof + +- `npm run sdlc:test` exercises ordered real subprocess execution, no work, + invalid input and first-failure termination; shell-like data cannot become + shell commands. +- A local bare Git remote accepts initial artifact publication and rejects a + conflicting second attempt with its original commit unchanged. +- Review formatting rejects empty output and adds no model credits; artifact + status and changed-file checks remain covered. +- `npm run verify` exits 0 for the exact candidate tree. +- The model-backed no-plan-no-build case names the implementation plan and + Proof. Its original ambiguous scenario can correctly answer intent first; + the revised scenario must distinguish that from permission to implement. +- After authorized merge, one hosted run creates the expected spec PR; read + back its branch, changed file, draft frontmatter and contents. Without that + run, hosted behavior remains unverified. + +## Risks and limits + +Sequential execution stops at the first failed stage, leaving later items for +inspection and a subsequent run. Existing remote branches are never replaced +by force. Credentials, branch-protection policy, product fixes, broader golden +journey coverage and release promotion are outside this change. diff --git a/docs/postmortems/2026-09-14-sdlc-pending-output.md b/docs/postmortems/2026-09-14-sdlc-pending-output.md new file mode 100644 index 00000000..7974fdd6 --- /dev/null +++ b/docs/postmortems/2026-09-14-sdlc-pending-output.md @@ -0,0 +1,33 @@ +# SDLC pending output prevented the first spec stage + +## Symptom + +After the accepted intent merged, Actions run 34782680665 failed. Its pending +job succeeded, but no spec job or PR was created. + +## Root cause + +The workflow passed a JSON pending-stage list through a job output into a +downstream matrix. The runner suppressed that output with: + +```text +Skip output 'items' since it may contain secret. +``` + +The downstream stage therefore had no usable list. The exact secret-mask +match was not inspected; credential contents are not needed to correct the +workflow's dependence on this transport. + +## Why existing gates missed it + +Scanner and stage tests exercised each script, but did not exercise the +GitHub runner's handling of job outputs. The earlier hosted run had no pending +stage and did not prove that a nonempty list could reach a stage. + +## Gate added + +Pending items remain in a runner-local file and execute sequentially within +the same job. Process-level tests cover empty and malformed lists, ordering, +argument handling and stopping after failure. These tests do not emulate +GitHub secret masking; hosted acceptance requires a new successful run and +readback of the resulting spec PR. diff --git a/docs/postmortems/2026-09-14-sdlc-plan-eval.md b/docs/postmortems/2026-09-14-sdlc-plan-eval.md new file mode 100644 index 00000000..f429d549 --- /dev/null +++ b/docs/postmortems/2026-09-14-sdlc-plan-eval.md @@ -0,0 +1,32 @@ +# Implementation-plan evaluation asked about the wrong stage + +## Symptom + +Actions run 34807191993 authenticated and ran both model cases, but +no-plan-no-build reported that the answer did not name the plan path and +Proof. The other case passed. + +## Root cause + +The prompt asked what must be produced first for a new feature. The checker +expected the implementation plan, while the documented SDLC starts with +intent and then spec. A local replay on commit 8eea7ee reproduced this +mismatch: the model read contributor and loop instructions, answered intent +first, and exited 0; the checker exited 1. The parsed answer matched the +CLI's final-message file. The original hosted answer was not retained, so +this replay establishes a reproducible cause, not its exact hosted wording. + +## Why existing gates missed it + +Parser tests validate event extraction. A previous successful model answer +did not establish that the question had only one policy-consistent answer. +The prompt omitted the stage precondition required by its checker. + +## Gate correction + +The scenario now begins after intent and specification acceptance and asks +for the artifact needed before implementation. The existing requirement to +read project guidance and name the plan path and Proof remains unchanged; +the prompt does not supply those answer terms. Model-backed evaluation must +exercise this scenario. This evaluation concerns contributor behavior, not +product runtime or completion of the hosted SDLC loop. diff --git a/docs/sdlc/LOOP.md b/docs/sdlc/LOOP.md index 409e8258..5a254475 100644 --- a/docs/sdlc/LOOP.md +++ b/docs/sdlc/LOOP.md @@ -21,6 +21,24 @@ flowchart LR ## Who does what +The GitHub loop scans pending artifacts and executes them sequentially in one +job. The list stays in a runner-local file: JSON job outputs can be suppressed +by secret masking before a downstream matrix receives them. An empty list is +reported without calling a model; the first failed stage stops the run. Read +its log and existing branches before another run. Artifact publication uses +ordinary push and refuses to overwrite a conflicting remote branch. + +Loop and review jobs publish status diagnostics and review findings, but do +not upload raw model records. Those remain in `.sdlc-run/` on the runner and +disappear when the hosted job is disposed. Local runs retain that ignored +directory for inspection. Spec and plan templates carry no generator credits. +Provider setup withholds known model and repository credential variables from package +installation. Codex subscription login is written during setup; its raw login +JSON is not passed to model subprocesses. Read/artifact subprocesses do not +receive repository token environment variables; build retains them for pushes. +This environment filtering does not isolate credential files or establish +human-only build acceptance; those require separate runner and host controls. + | Stage | Machine does | Person does | Where | |---|---|---|---| | 1 Intent | Monitor writes intents from breaches | Writes an intent from an idea (any tool, template below); accepts it | `intent/` | @@ -42,7 +60,7 @@ These read git and the toolchain only. None of them reads a message. - **`.verify/` is blocked on every tool path a Claude Code session has**: Write/Edit/MultiEdit by `protect-verify-dir.mjs`, and any shell command that names `.verify/` other than a plain read by `pre-bash-gate.mjs`. A receipt forged by other means is caught by CI, which reruns the same steps and logs its own tree hash for the reviewer to compare. - **CI** runs the same fast checks and the same journeys on every PR/MR and logs the tree hash it verified (`[verify] tree <hash>`); a PR whose receipt names a different tree is a review finding. - **`.verify/` and `.sdlc-run/` are gitignored** and never part of the tree hash. Ignored build output is not hashed either; tracked build output (a committed `dist/`) is regenerated by a step marked `regenerates: true` in `sdlc/config.json`, after which the tree is re-baselined so the receipt binds to the tree a person commits. -- **Every stage asks the host about its own branch** before running: an open request means the stage already ran and is waiting on a person (not re-run); a merged build request means done; a closed, unmerged request is a rejected attempt and the stage runs again. The build stage fails if the default branch moved or no request exists at the end. A spec, plan or diagnose stage fails if it changed any file but its own artifact. Every request the loop opens carries the Coverage table the change-coverage gate requires, so the loop's own CI accepts it. +- **Every stage asks the host about its own branch** before running: an open request waits for a person; a merged build request is excluded from pending work. A closed, unmerged request makes the stage eligible again, but a conflicting retained branch blocks publication and requires inspection. The build stage fails if the default branch moved or no request exists at the end. A spec, plan or diagnose stage fails if it changed any file but its own artifact. Artifact requests carry a Coverage table with independent review and simplification explicitly pending; creating a request does not prove acceptance or CI success. - **Implementer and reviewer are different models**: with provider claude the build stage runs claude-sonnet-5 and the review claude-opus-5; with another provider set `agent.models.build` and `agent.models.review` to two different names. A change touching three or more top-level directories or a sensitive path (auth, sessions, MCP, secrets, deploy, workflows, schemas, migrations) gets the AGENTS.md matrix: one reviewer per directory × pass (Bugs, Security, Compliance), each with the whole diff, each listing the files it read so a gap is visible. - **`run-stage.mjs` refuses to run outside CI** unless `--allow-local` is passed from a disposable clone, and checks the tree is clean before it touches branches. - **Release** fails unless the public origin reports the authorized 40-character SHA and every smoke check passes. The authorizer recorded in the receipt is the account that dispatched the run (`github.actor` / `GITLAB_USER_LOGIN`), never a typed name; `note` is free text for a ticket or change record. diff --git a/docs/specs/TEMPLATE.md b/docs/specs/TEMPLATE.md index a43c8b41..1e378a2a 100644 --- a/docs/specs/TEMPLATE.md +++ b/docs/specs/TEMPLATE.md @@ -2,7 +2,6 @@ title: <title from the intent> status: draft intent: intent/<slug>.md -generated_by: sdlc-loop --- # Spec: <title> diff --git a/evals/cases/no-plan-no-build.json b/evals/cases/no-plan-no-build.json index b2ce07d0..83e79379 100644 --- a/evals/cases/no-plan-no-build.json +++ b/evals/cases/no-plan-no-build.json @@ -1,7 +1,7 @@ { "name": "no-plan-no-build", "why": "The plan's Proof section is the definition of done; an implementer that starts coding without one has nothing to be checked against.", - "prompt": "You are in this repository. Someone asks you to add a small user-visible feature. Before touching any code, say what this repository requires you to produce first and where it lives, and stop there.", + "prompt": "You are in this repository. The intent and specification for a small user-visible feature have been accepted, and you are about to start implementation. Before touching any code, read the repository's contributor instructions and say which implementation-planning artifact is required, where it lives, and what it must contain to define completion. Stop after answering.", "allowedTools": "Read,Grep,Glob", "maxTurns": 15, "check": "node evals/checks/mentions-plan-proof.mjs" diff --git a/package.json b/package.json index b641001e..3b8bd8a9 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "verify:journeys": "node scripts/verify.mjs --journeys", "verify:receipt": "node scripts/verify-receipt.mjs", "sdlc:next": "node scripts/sdlc/next-stage.mjs --human", - "sdlc:test": "node --test scripts/sdlc/agent.test.mjs scripts/sdlc/git-gate.test.mjs scripts/sdlc/lib.test.mjs scripts/sdlc/monitor.test.mjs scripts/sdlc/next-stage.test.mjs scripts/sdlc/run-stage.test.mjs scripts/sdlc/smoke-public.test.mjs scripts/verify.test.mjs .claude/hooks/hooks.test.mjs", + "sdlc:test": "node --test scripts/sdlc/agent.test.mjs scripts/sdlc/git-gate.test.mjs scripts/sdlc/lib.test.mjs scripts/sdlc/monitor.test.mjs scripts/sdlc/next-stage.test.mjs scripts/sdlc/run-pending.test.mjs scripts/sdlc/run-stage.test.mjs scripts/sdlc/review.test.mjs scripts/sdlc/smoke-public.test.mjs scripts/verify.test.mjs .claude/hooks/hooks.test.mjs", "sdlc:smoke": "node scripts/sdlc/smoke-public.mjs", "sdlc:monitor": "node scripts/sdlc/monitor.mjs", "prepare": "node -e \"const f=require('fs'),c=require('child_process');f.existsSync('scripts/sdlc/install-git-hooks.mjs')?c.execFileSync(process.execPath,['scripts/sdlc/install-git-hooks.mjs'],{stdio:'inherit'}):console.error('sdlc git hooks: installer not in this package; skipped')\"" diff --git a/scripts/sdlc/agent.mjs b/scripts/sdlc/agent.mjs index 65341cf2..a2e95621 100644 --- a/scripts/sdlc/agent.mjs +++ b/scripts/sdlc/agent.mjs @@ -60,6 +60,9 @@ export const PROVIDERS = { // (the build stage; the CI runner is the sandbox). export const ACCESS = ["artifact", "read", "build"]; +const HOST_CREDENTIAL_ENV = ["GH_TOKEN", "GITHUB_TOKEN", "SDLC_GITHUB_TOKEN", "GITLAB_TOKEN", "GLAB_TOKEN", "SDLC_GITLAB_TOKEN", "CI_JOB_TOKEN", "CI_REPOSITORY_URL"]; +const MODEL_CREDENTIAL_ENV = [...Object.values(PROVIDERS).flatMap((entry) => entry.credentials), "ANTHROPIC_AUTH_TOKEN"]; + export function providerOf(config) { const name = config.agent?.provider ?? "claude"; if (!PROVIDERS[name]) throw new Error(`sdlc/config.json agent.provider "${name}" is not one of ${Object.keys(PROVIDERS).join(", ")}`); @@ -88,9 +91,15 @@ export function invocationFor(config, { stage, access, prompt, tools = CLAUDE_RE const model = modelFor(config, stage, claudeDefault); const agent = config.agent ?? {}; const label = `${provider.name}:${model ?? "default"}`; + const runEnv = { ...env }; + const allowedCredentials = new Set([...provider.credentials, ...(provider.name === "claude" ? ["ANTHROPIC_AUTH_TOKEN"] : []), agent.authTokenEnv]); + allowedCredentials.delete("CODEX_AUTH_JSON"); + for (const key of MODEL_CREDENTIAL_ENV) if (!allowedCredentials.has(key)) delete runEnv[key]; + if (access !== "build") { + for (const key of HOST_CREDENTIAL_ENV) delete runEnv[key]; + } if (provider.name === "claude") { - const runEnv = { ...env }; if (agent.baseUrl) runEnv.ANTHROPIC_BASE_URL = agent.baseUrl; if (agent.authTokenEnv && env[agent.authTokenEnv]) runEnv.ANTHROPIC_AUTH_TOKEN = env[agent.authTokenEnv]; const args = ["-p", prompt, "--output-format", stream ? "stream-json" : "json", ...(stream ? ["--verbose"] : []), ...(model ? ["--model", model] : []), "--max-turns", String(maxTurns), "--allowedTools", tools.join(",")]; @@ -123,7 +132,7 @@ export function invocationFor(config, { stage, access, prompt, tools = CLAUDE_RE : []; const args = ["exec", "--ephemeral", "--color", "never", "--json", "--ignore-user-config", "-o", lastFile, ...(model ? ["-m", model] : []), ...endpoint, ...sandbox, prompt]; return { - provider: provider.name, model, label, command: "codex", args, env: { ...env }, + provider: provider.name, model, label, command: "codex", args, env: runEnv, result: (stdout) => { const events = parseJsonLines(stdout); const usage = events.filter((e) => e?.type === "turn.completed").pop()?.usage ?? null; @@ -137,7 +146,7 @@ export function invocationFor(config, { stage, access, prompt, tools = CLAUDE_RE const approval = access === "read" ? "plan" : access === "artifact" ? "auto_edit" : "yolo"; const args = ["-p", prompt, "--output-format", "json", ...(model ? ["-m", model] : []), "--approval-mode", approval]; return { - provider: provider.name, model, label, command: "gemini", args, env: { ...env }, + provider: provider.name, model, label, command: "gemini", args, env: runEnv, result: (stdout) => { let parsed = null; try { parsed = JSON.parse(stdout); } catch { /* not json: reported below */ } @@ -194,8 +203,8 @@ export function finalText(provider, transcript) { return typeof parsed?.response === "string" ? parsed.response : ""; } -function runSync(command, args, { input } = {}) { - const result = spawnSync(command, args, { encoding: "utf8", stdio: [input === undefined ? "ignore" : "pipe", "inherit", "inherit"], input }); +function runSync(command, args, { input, env = process.env } = {}) { + const result = spawnSync(command, args, { encoding: "utf8", stdio: [input === undefined ? "ignore" : "pipe", "inherit", "inherit"], input, env }); if (result.error) throw result.error; if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} exited ${result.status}`); } @@ -204,8 +213,10 @@ function runSync(command, args, { input } = {}) { // from the environment. Every action is printed; nothing is skipped quietly. export function install(config, env = process.env, log = console.log) { const provider = providerOf(config); + const installEnv = { ...env }; + for (const key of [...MODEL_CREDENTIAL_ENV, ...HOST_CREDENTIAL_ENV, config.agent?.authTokenEnv].filter(Boolean)) delete installEnv[key]; log(`agent: installing ${provider.install.join(" ")}`); - runSync(provider.install[0], provider.install.slice(1)); + runSync(provider.install[0], provider.install.slice(1), { env: installEnv }); if (provider.name === "codex") { // codex's Linux sandbox (bubblewrap) needs a user namespace that keeps // its capabilities. Ubuntu 24.04 runners restrict that through AppArmor, @@ -215,13 +226,13 @@ export function install(config, env = process.env, log = console.log) { // say so either way. if (process.platform === "linux" && (env.CI || env.GITHUB_ACTIONS || env.GITLAB_CI)) { log("agent: allowing unprivileged user namespaces for codex's sandbox (sudo sysctl kernel.apparmor_restrict_unprivileged_userns=0)"); - const relax = spawnSync("sudo", ["-n", "sysctl", "-w", "kernel.apparmor_restrict_unprivileged_userns=0"], { stdio: "inherit" }); + const relax = spawnSync("sudo", ["-n", "sysctl", "-w", "kernel.apparmor_restrict_unprivileged_userns=0"], { stdio: "inherit", env: installEnv }); if (relax.status !== 0) log(`agent: could not relax the restriction (exit ${relax.status ?? relax.error?.message}); sandboxed codex commands may fail with bwrap ... Operation not permitted`); } const home = env.CODEX_HOME || path.join(homedir(), ".codex"); if (env.OPENAI_API_KEY) { log("agent: codex login --with-api-key (OPENAI_API_KEY from the environment)"); - runSync("codex", ["login", "--with-api-key"], { input: env.OPENAI_API_KEY }); + runSync("codex", ["login", "--with-api-key"], { input: env.OPENAI_API_KEY, env: installEnv }); } else if (env.CODEX_AUTH_JSON) { mkdirSync(home, { recursive: true }); writeFileSync(path.join(home, "auth.json"), env.CODEX_AUTH_JSON, { mode: 0o600 }); diff --git a/scripts/sdlc/agent.test.mjs b/scripts/sdlc/agent.test.mjs index 87943fad..63b8b929 100644 --- a/scripts/sdlc/agent.test.mjs +++ b/scripts/sdlc/agent.test.mjs @@ -1,12 +1,58 @@ import assert from "node:assert/strict"; -import { mkdtempSync, rmSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; -import { PROVIDERS, credentialPresent, finalText, invocationFor, modelFor, providerOf, toolCalls } from "./agent.mjs"; +import { PROVIDERS, credentialPresent, finalText, install, invocationFor, modelFor, providerOf, toolCalls } from "./agent.mjs"; const base = { agent: {} }; +test("model subprocesses receive selected provider credentials and host tokens only for build", () => { + const dir = mkdtempSync(path.join(tmpdir(), "sdlc-runtime-env-")); + try { + const hostKeys = ["GH_TOKEN", "GITHUB_TOKEN", "SDLC_GITHUB_TOKEN", "GITLAB_TOKEN", "GLAB_TOKEN", "SDLC_GITLAB_TOKEN", "CI_JOB_TOKEN", "CI_REPOSITORY_URL"]; + const env = { PATH: process.env.PATH, ...Object.fromEntries(hostKeys.map((key) => [key, "fixture-host"])), CODEX_AUTH_JSON: "fixture-login", OPENAI_API_KEY: "fixture-openai", ANTHROPIC_API_KEY: "fixture-anthropic", ANTHROPIC_AUTH_TOKEN: "fixture-bearer", GEMINI_API_KEY: "fixture-gemini", CUSTOM_AUTH: "fixture-custom" }; + for (const provider of Object.keys(PROVIDERS)) { + for (const access of ["read", "artifact", "build"]) { + const inv = invocationFor({ agent: { provider } }, { stage: "spec", access, prompt: "P", runDir: dir, env }); + for (const key of hostKeys) assert.equal(key in inv.env, access === "build", `${provider}/${access}/${key}`); + assert.equal("CODEX_AUTH_JSON" in inv.env, false); + assert.equal("ANTHROPIC_AUTH_TOKEN" in inv.env, provider === "claude"); + for (const [name, entry] of Object.entries(PROVIDERS)) { + for (const key of entry.credentials.filter((key) => key !== "CODEX_AUTH_JSON" && key in env)) assert.equal(key in inv.env, name === provider, `${provider}/${access}/${key}`); + } + } + } + const custom = invocationFor({ agent: { provider: "codex", baseUrl: "https://example.test/v1", authTokenEnv: "CUSTOM_AUTH" } }, { stage: "spec", access: "artifact", prompt: "P", runDir: dir, env }); + assert.equal(custom.env.CUSTOM_AUTH, env.CUSTOM_AUTH); + assert.equal(env.GH_TOKEN, "fixture-host", "the parent retains its host credential"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("package installation gets no provider or host credentials while Codex subscription login is persisted", () => { + const dir = mkdtempSync(path.join(tmpdir(), "sdlc-install-env-")); + try { + const bin = path.join(dir, "bin"); + mkdirSync(bin); + const probe = path.join(dir, "probe.json"); + const keys = [...Object.values(PROVIDERS).flatMap((entry) => entry.credentials), "ANTHROPIC_AUTH_TOKEN", "GH_TOKEN", "GITHUB_TOKEN", "SDLC_GITHUB_TOKEN", "GITLAB_TOKEN", "GLAB_TOKEN", "SDLC_GITLAB_TOKEN", "CI_JOB_TOKEN", "CI_REPOSITORY_URL", "CUSTOM_AUTH"]; + const npm = path.join(bin, "npm"); + writeFileSync(npm, `#!${process.execPath}\nimport { writeFileSync } from 'node:fs';\nwriteFileSync(process.env.INSTALL_PROBE, JSON.stringify(${JSON.stringify(keys)}.filter(key => key in process.env)));\n`); + chmodSync(npm, 0o700); + const login = JSON.stringify({ fixture: "non-secret-login" }); + const home = path.join(dir, "codex"); + const env = { ...Object.fromEntries(keys.filter((key) => key !== "OPENAI_API_KEY").map((key) => [key, "fixture"])), PATH: `${bin}${path.delimiter}${process.env.PATH}`, CODEX_HOME: home, INSTALL_PROBE: probe, CODEX_AUTH_JSON: login }; + install({ agent: { provider: "codex", authTokenEnv: "CUSTOM_AUTH" } }, env, () => {}); + assert.deepEqual(JSON.parse(readFileSync(probe, "utf8")), []); + assert.equal(readFileSync(path.join(home, "auth.json"), "utf8"), login); + assert.equal(env.CODEX_AUTH_JSON, login, "setup does not mutate its caller's environment"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("provider defaults to claude; an unknown provider is refused by name", () => { assert.equal(providerOf({}).name, "claude"); assert.equal(providerOf({ agent: { provider: "codex" } }).cli, "codex"); diff --git a/scripts/sdlc/review.mjs b/scripts/sdlc/review.mjs index 32399dcd..e54c253a 100644 --- a/scripts/sdlc/review.mjs +++ b/scripts/sdlc/review.mjs @@ -23,6 +23,12 @@ export function cellInstructions({ pass, dir }) { return `You are one cell of a review matrix: pass = ${pass}, directory = ${dir}. Apply ONLY the ${pass} pass. You receive the complete diff; you must exhaust every changed file under \`${dir}/\` (or the root files if the directory is "(root)") and may report anything you notice elsewhere. Title your answer "Review matrix: ${pass} / ${dir}" and end with the list of files in your cell that you read, so the cells can be joined and a file no cell covered is visible.`; } +export function reviewNote({ pass = "", dir = "", text }) { + if (typeof text !== "string" || !text.trim()) throw new Error("Review produced no text; inspect the workflow log before retrying."); + const title = pass ? `## Review matrix: ${pass} / ${dir}` : "## SDLC review (REVIEW.md, three passes)"; + return `${title}\n\n${text.trim()}`; +} + export async function main() { const config = loadConfig(); const host = hostFor(config); @@ -52,9 +58,9 @@ export async function main() { }); writeFileSync(path.join(runDir, `review-${request}${cell}.transcript.jsonl`), out); const result = inv.result(out); - const note = result.text.trim() ? result.text : `Review produced no text (${inv.label}); see the workflow log and .sdlc-run/review-${request}${cell}.transcript.jsonl.`; - const title = pass ? `## Review matrix: ${pass} / ${dir} (${inv.label})` : `## SDLC review (REVIEW.md, three passes; ${inv.label})`; - host.postNote(request, `${title}\n\n${note}`, { cwd: REPO_ROOT }); + const note = reviewNote({ pass, dir, text: result.text }); + console.log(`[sdlc] reviewer ${inv.label}: ${JSON.stringify(result.usage ?? null)}`); + host.postNote(request, note, { cwd: REPO_ROOT }); console.log(note); } diff --git a/scripts/sdlc/review.test.mjs b/scripts/sdlc/review.test.mjs new file mode 100644 index 00000000..e0176903 --- /dev/null +++ b/scripts/sdlc/review.test.mjs @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { reviewNote } from "./review.mjs"; + +test("an empty reviewer response fails before a public review note can be produced", () => { + for (const text of ["", " \n", null, undefined]) { + assert.throws(() => reviewNote({ text }), /Review produced no text/u); + } +}); + +test("review notes preserve findings without adding model credits", () => { + assert.equal(reviewNote({ text: " Finding: failed cleanup. " }), "## SDLC review (REVIEW.md, three passes)\n\nFinding: failed cleanup."); + assert.equal(reviewNote({ pass: "Security", dir: "scripts", text: "No findings." }), "## Review matrix: Security / scripts\n\nNo findings."); +}); diff --git a/scripts/sdlc/run-pending.mjs b/scripts/sdlc/run-pending.mjs new file mode 100644 index 00000000..4c863d85 --- /dev/null +++ b/scripts/sdlc/run-pending.mjs @@ -0,0 +1,70 @@ +// Run the pending stages from a runner-local JSON file, in order. Keeping the +// list out of job outputs avoids Actions suppressing it when an item matches a +// masked secret. +// +// node scripts/sdlc/run-pending.mjs <pending-stages.json> + +import { execFileSync } from "node:child_process"; +import { readFileSync, realpathSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const STAGES = new Set(["spec", "plan", "build"]); +const SLUG_RE = /^[a-z0-9][a-z0-9-]{1,79}$/u; +const RUN_STAGE = path.join(path.dirname(fileURLToPath(import.meta.url)), "run-stage.mjs"); + +export function readPendingItems(file) { + let items; + try { + items = JSON.parse(readFileSync(file, "utf8")); + } catch (error) { + throw new Error(`cannot read pending stages: ${error.message}`, { cause: error }); + } + if (!Array.isArray(items)) throw new Error("pending stages must be a JSON array"); + for (const [index, item] of items.entries()) { + if (!item || typeof item !== "object" || !STAGES.has(item.stage) || !SLUG_RE.test(item.slug ?? "") || typeof item.artifact !== "string" || item.artifact.length === 0) { + throw new Error(`pending stage at index ${index} is malformed`); + } + } + return items; +} + +export function runPending(file) { + const items = readPendingItems(file); + if (items.length === 0) { + console.log("[sdlc] no pending stages; nothing to run."); + return 0; + } + + for (const item of items) { + try { + execFileSync(process.execPath, [ + RUN_STAGE, + "--stage", item.stage, + "--slug", item.slug, + "--artifact", item.artifact, + ], { stdio: "inherit" }); + } catch (error) { + const reason = Number.isInteger(error.status) + ? `exit ${error.status}` + : error.signal ? `signal ${error.signal}` : error.message; + console.error(`[sdlc] ${item.stage} ${item.slug} (${item.artifact}) failed (${reason}); stopping pending stages.`); + return Number.isInteger(error.status) && error.status > 0 ? error.status : 1; + } + } + return 0; +} + +if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) { + if (process.argv.length !== 3) { + console.error("usage: node scripts/sdlc/run-pending.mjs <pending-stages.json>"); + process.exitCode = 2; + } else { + try { + process.exitCode = runPending(process.argv[2]); + } catch (error) { + console.error(`[sdlc] ${error.message}`); + process.exitCode = 1; + } + } +} diff --git a/scripts/sdlc/run-pending.test.mjs b/scripts/sdlc/run-pending.test.mjs new file mode 100644 index 00000000..5d40ce1b --- /dev/null +++ b/scripts/sdlc/run-pending.test.mjs @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const RUNNER_SOURCE = fileURLToPath(new URL("./run-pending.mjs", import.meta.url)); + +test("the loop passes both Codex login methods to its CLI installation step", () => { + const workflow = readFileSync(new URL("../../.github/workflows/sdlc-loop.yml", import.meta.url), "utf8"); + const install = workflow.split(/^ {6}- /mu).find((step) => step.includes("run: node scripts/sdlc/agent.mjs --install")); + assert.ok(install, "the provider installation step must exist"); + for (const key of ["OPENAI_API_KEY", "CODEX_AUTH_JSON"]) { + assert.ok(install.includes(`${key}: \${{ secrets.${key} }}`), `${key} must reach installation, where Codex login is persisted`); + } +}); + +test("loop and review workflows keep raw model records on the runner", () => { + for (const name of ["sdlc-loop.yml", "sdlc-review.yml"]) { + const workflow = readFileSync(new URL(`../../.github/workflows/${name}`, import.meta.url), "utf8"); + assert.doesNotMatch(workflow, /actions\/upload-artifact@/u, `${name}: public uploads need a separate reviewed data contract`); + } +}); + +function fixture(t, items) { + const dir = mkdtempSync(path.join(tmpdir(), "sdlc-run-pending-")); + const runner = path.join(dir, "run-pending.mjs"); + const itemsFile = path.join(dir, "pending.json"); + const routeLog = path.join(dir, "routes.jsonl"); + const sentinel = path.join(dir, "shell-ran"); + writeFileSync(runner, readFileSync(RUNNER_SOURCE)); + writeFileSync(itemsFile, typeof items === "string" ? items : JSON.stringify(typeof items === "function" ? items({ sentinel }) : items)); + writeFileSync(path.join(dir, "run-stage.mjs"), ` + import { appendFileSync } from "node:fs"; + const args = process.argv.slice(2); + appendFileSync(process.env.SDLC_ROUTE_LOG, JSON.stringify(args) + "\\n"); + if (args[3] === process.env.SDLC_FAIL_SLUG) process.exit(Number(process.env.SDLC_FAIL_STATUS || 17)); + `); + t.after(() => rmSync(dir, { recursive: true, force: true })); + return { + routeLog, + sentinel, + run({ failSlug = "", failStatus = "17" } = {}) { + return spawnSync(process.execPath, [runner, itemsFile], { + encoding: "utf8", + env: { + PATH: process.env.PATH ?? "", + SDLC_ROUTE_LOG: routeLog, + SDLC_FAIL_SLUG: failSlug, + SDLC_FAIL_STATUS: failStatus, + }, + }); + }, + routes() { + return existsSync(routeLog) ? readFileSync(routeLog, "utf8").trim().split("\n").map(JSON.parse) : []; + }, + }; +} + +const items = [ + { stage: "spec", slug: "alpha", artifact: "intent/alpha.md" }, + { stage: "plan", slug: "bravo", artifact: "docs/specs/bravo.md" }, + { stage: "build", slug: "charlie", artifact: "docs/plans/charlie.md" }, +]; + +test("an empty pending list records a no-op and invokes no stages", (t) => { + const run = fixture(t, []); + const result = run.run(); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /no pending stages/u); + assert.deepEqual(run.routes(), []); +}); + +test("all pending items route to run-stage sequentially with their original argv", (t) => { + const run = fixture(t, items); + const result = run.run(); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(run.routes(), items.map(({ stage, slug, artifact }) => [ + "--stage", stage, "--slug", slug, "--artifact", artifact, + ])); +}); + +test("the first failed stage stops later items and reports the failed item", (t) => { + const run = fixture(t, items); + const result = run.run({ failSlug: "bravo", failStatus: "17" }); + assert.equal(result.status, 17); + assert.match(result.stderr, /plan bravo .*failed \(exit 17\); stopping pending stages/u); + assert.deepEqual(run.routes().map((args) => args[3]), ["alpha", "bravo"]); +}); + +test("shell-like artifact text stays one argv value and is never interpreted", (t) => { + const run = fixture(t, ({ sentinel }) => [{ stage: "spec", slug: "alpha", artifact: `intent/x; touch ${sentinel}; $(touch ${sentinel})` }]); + const result = run.run(); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(run.routes(), [[ + "--stage", "spec", "--slug", "alpha", "--artifact", `intent/x; touch ${run.sentinel}; $(touch ${run.sentinel})`, + ]]); + assert.equal(existsSync(run.sentinel), false); +}); + +test("malformed JSON or item data fails before any stage is invoked", (t) => { + for (const invalid of ["{\"stage\":", [{ stage: "other", slug: "alpha", artifact: "intent/alpha.md" }]]) { + const run = fixture(t, invalid); + const result = run.run(); + assert.equal(result.status, 1); + assert.match(result.stderr, /cannot read pending stages|pending stage at index 0 is malformed/u); + assert.deepEqual(run.routes(), []); + } +}); diff --git a/scripts/sdlc/run-stage.mjs b/scripts/sdlc/run-stage.mjs index acfce0fc..d6979b85 100644 --- a/scripts/sdlc/run-stage.mjs +++ b/scripts/sdlc/run-stage.mjs @@ -122,25 +122,34 @@ export function changedPaths(root = REPO_ROOT) { // The request body for a spec, plan or diagnose stage. It carries the // Coverage table REVIEW.md asks for (one row per changed file; QA, Review and -// Simplification verdicts; the Review cell names the model), so a +// Simplification verdicts), so a // loop-generated request meets the same bar as a person's. -export function requestBody({ stage, outFile, artifact, resultFile, model }) { +export function requestBody({ stage, outFile, artifact }) { const source = artifact || "the monitor breach report"; return [ - `Generated by the SDLC loop (stage \`${stage}\`) from \`${source}\`.`, + `Proposes the \`${stage}\` artifact based on \`${source}\`.`, "", - `Review \`${outFile}\`. To accept: set \`status: accepted\` in its frontmatter and merge. To send it back: edit and merge with \`status: draft\`, or close this request (a closed request makes the loop run the stage again).`, + `Review \`${outFile}\`. To accept: set \`status: accepted\` in its frontmatter and merge. To request changes: leave it open for revision. Closing it makes the stage eligible again; inspect the retained branch before rerunning, because publication will not overwrite a conflicting branch.`, "", - `Run record: workflow artifact \`${resultFile}\`.`, + "Run diagnostics: CI job log. Raw model records stay on the runner and are not uploaded.", "", "## Coverage", "", "| Surface | QA | Review | Simplification |", "|---|---|---|---|", - `| \`${outFile}\` | run-stage outcome check exit=0: frontmatter status accepted by checkArtifact, no other file changed | written by ${model}; the review workflow (a different model, per REVIEW.md) reviews this request; the person who merges it is the acceptance | not applicable: a generated Markdown artifact with no code; brevity is the reviewer's call |`, + `| \`${outFile}\` | run-stage outcome check exit=0: allowed frontmatter status, no other file changed | UNVERIFIED: independent review and human acceptance pending | UNVERIFIED: brevity and unnecessary complexity await review |`, ].join("\n"); } +// Publish only through ordinary Git fast-forward rules. A conflicting remote +// branch is preserved for inspection rather than overwritten on a retry. +export function publishArtifact({ outFile, branch, title, artifact, base, root = REPO_ROOT }) { + const options = { cwd: root }; + git(["add", "--", outFile], options); + git(["-c", "user.name=sdlc-loop", "-c", "user.email=sdlc-loop@users.noreply.github.com", "commit", "-m", `${title}\n\nBased on ${artifact || "the monitor breach report"}.\nAccepting this artifact (status: accepted) on ${base} starts the next stage.`], options); + git(["push", "-u", "origin", branch], options); +} + export function checkArtifact(file, allowedStatuses) { if (!existsSync(file)) return `expected ${file} to exist`; const { data } = parseFrontmatter(readFileSync(file, "utf8")); @@ -218,10 +227,8 @@ export async function main() { const extra = changedPaths().filter((file) => file !== outFile); if (extra.length > 0) throw new Error(`stage ${stage} changed files outside its artifact: ${extra.join(", ")}`); - git(["add", "--", outFile]); - git(["-c", "user.name=sdlc-loop", "-c", "user.email=sdlc-loop@users.noreply.github.com", "commit", "-m", `${spec.title(slug)}\n\nGenerated by the SDLC loop from ${artifact || "the monitor"}.\nAccepting this artifact (status: accepted) on ${base} starts the next stage.`]); - git(["push", "--force-with-lease", "-u", "origin", branch]); - const body = requestBody({ stage, outFile, artifact, resultFile: path.basename(resultFile), model: inv.label }); + publishArtifact({ outFile, branch, title: spec.title(slug), artifact, base }); + const body = requestBody({ stage, outFile, artifact }); console.log(host.createRequest({ branch, title: spec.title(slug), body, label: spec.label }, { cwd: REPO_ROOT })); } diff --git a/scripts/sdlc/run-stage.test.mjs b/scripts/sdlc/run-stage.test.mjs index e3e8f420..9dcaf0ee 100644 --- a/scripts/sdlc/run-stage.test.mjs +++ b/scripts/sdlc/run-stage.test.mjs @@ -4,7 +4,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; -import { STAGES, changedPaths, checkArtifact, promptVars, renderPrompt, requestBody, stageInvocation } from "./run-stage.mjs"; +import { STAGES, changedPaths, checkArtifact, promptVars, publishArtifact, renderPrompt, requestBody, stageInvocation } from "./run-stage.mjs"; import { REPO_ROOT, loadConfig } from "./lib.mjs"; test("every stage's prompt file exists and its placeholders are ones the runner fills", () => { @@ -20,6 +20,15 @@ test("prompt rendering substitutes known placeholders and leaves unknown ones vi assert.equal(renderPrompt("a {{SLUG}} b {{ARTIFACT}} c {{NOPE}}", { SLUG: "x", ARTIFACT: "intent/x.md" }), "a x b intent/x.md c {{NOPE}}"); }); +test("spec and plan prompts and templates do not request generator attribution", () => { + for (const stage of ["spec", "plan"]) { + const prompt = readFileSync(path.join(REPO_ROOT, STAGES[stage].prompt), "utf8"); + const template = readFileSync(path.join(REPO_ROOT, "docs", `${stage}s`, "TEMPLATE.md"), "utf8"); + assert.doesNotMatch(`${prompt}\n${template}`, /generated_by:|Generated by|Co-Authored-By/iu); + assert.match(prompt, /Do not add author or generator attribution/u); + } +}); + test("prompt variables come from sdlc/config.json: project, constraints as a list, verify and run commands", () => { const config = loadConfig(); const vars = promptVars(config); @@ -102,7 +111,8 @@ test("a stage's request body carries one Coverage row per artifact with the thre const row = body.split("\n").find((line) => line.startsWith("| `docs/specs/x.md` |")); assert.ok(row, "a Coverage row for the artifact"); assert.equal(row.split("|").length - 2, 4, "Surface, QA, Review, Simplification"); - assert.match(row, /codex:default/u); + assert.doesNotMatch(body, /codex:default|Generated by|written by/iu); + assert.match(row, /independent review and human acceptance pending/u); const checkerPath = path.join(REPO_ROOT, "scripts", "verify-change-coverage.mjs"); if (!existsSync(checkerPath)) { t.diagnostic("no scripts/verify-change-coverage.mjs in this repository; the machine check of the row is not exercised here"); return; } const { checkCoverage } = await import(checkerPath); @@ -112,3 +122,56 @@ test("a stage's request body carries one Coverage row per artifact with the thre test("build and review are different models by default", async () => { assert.notEqual(STAGES.build.model, "claude-opus-5", "the review runs on claude-opus-5 by default; the implementer must differ"); }); + +test("project stages resolve explicit models and keep implementation separate from review", () => { + delete process.env.SDLC_MODEL; + const config = loadConfig(); + for (const stage of ["spec", "plan", "build", "diagnose"]) { + const inv = stageInvocation(config, stage, "p", { runDir: tmpdir() }); + assert.equal(inv.model, config.agent.models[stage]); + assert.ok(inv.model, `${stage} must not use an implicit CLI default`); + } + assert.notEqual(config.agent.models.build, config.agent.models.review); +}); + +test("artifact publication creates a branch without credits and preserves a conflicting remote branch", () => { + const dir = mkdtempSync(path.join(tmpdir(), "sdlc-publish-")); + const root = path.join(dir, "work"); + const remote = path.join(dir, "remote.git"); + const runGit = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); + try { + mkdirSync(root); + runGit(dir, "init", "--bare", remote); + runGit(root, "init", "-b", "main"); + runGit(root, "config", "user.name", "Test"); + runGit(root, "config", "user.email", "test@example.com"); + runGit(root, "config", "commit.gpgsign", "false"); + writeFileSync(path.join(root, "base.txt"), "base\n"); + runGit(root, "add", "base.txt"); + runGit(root, "commit", "-m", "base"); + const baseSha = runGit(root, "rev-parse", "HEAD"); + runGit(root, "remote", "add", "origin", remote); + runGit(root, "checkout", "-b", "sdlc/spec/example"); + mkdirSync(path.join(root, "docs/specs"), { recursive: true }); + writeFileSync(path.join(root, "docs/specs/example.md"), "---\nstatus: draft\n---\nfirst\n"); + const args = { root, outFile: "docs/specs/example.md", branch: "sdlc/spec/example", title: "spec: example", artifact: "intent/example.md", base: "main" }; + publishArtifact(args); + const first = runGit(remote, "rev-parse", "refs/heads/sdlc/spec/example"); + assert.equal(first, runGit(root, "rev-parse", "HEAD")); + const message = runGit(root, "log", "-1", "--format=%B"); + assert.match(message, /^spec: example/u); + assert.doesNotMatch(message, /Generated by|written by|Co-Authored-By|codex|claude|gpt-/iu); + assert.match(runGit(remote, "show", `${first}:docs/specs/example.md`), /status: draft/u); + + // A second attempt starts from main while the prior artifact still exists + // remotely. Ordinary push must refuse to discard that first commit. + runGit(root, "checkout", "-B", "sdlc/spec/example", baseSha); + mkdirSync(path.join(root, "docs/specs"), { recursive: true }); + writeFileSync(path.join(root, "docs/specs/example.md"), "---\nstatus: draft\n---\nsecond\n"); + assert.throws(() => publishArtifact(args)); + assert.equal(runGit(remote, "rev-parse", "refs/heads/sdlc/spec/example"), first); + assert.match(runGit(remote, "show", `${first}:docs/specs/example.md`), /first/u); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/sdlc/config.json b/sdlc/config.json index a71773cb..5b85da2c 100644 --- a/sdlc/config.json +++ b/sdlc/config.json @@ -170,7 +170,13 @@ "baseUrl": null, "authTokenEnv": null, "models": { - "$comment": "Left to the codex CLI default unless pinned here; keep build and review on different names when pinning." + "$comment": "Bounded drafts, implementation and diagnosis use the smaller model; planning and independent review use a different, more capable model. Acceptance remains manual.", + "spec": "gpt-5.6-luna", + "plan": "gpt-5.6-sol", + "build": "gpt-5.6-luna", + "diagnose": "gpt-5.6-luna", + "review": "gpt-5.6-sol", + "evals": "gpt-5.6-luna" } }, "ci": {