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/.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 `); 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:
status: draft
intent: intent/.md
-generated_by: sdlc-loop
---
# Spec:
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
+
+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 ");
+ 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": {