diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml index 5eaa5c7..e7d7402 100644 --- a/.github/workflows/selftest.yml +++ b/.github/workflows/selftest.yml @@ -32,9 +32,18 @@ jobs: # Fixtures write LF bytes; do not convert their committed snapshots. git config --global core.autocrlf false shell: bash + # tools/verify.sh bounds each command and kills whole process groups + # through tools/_run.py; every GitHub runner image ships python3, and the + # automation suite asserts that path. Fail loudly if one ever does not. + - name: python3 is available (tools/verify.sh bounded execution) + run: python3 -c "import sys; print(sys.version)" + shell: bash - name: selftest (gate mechanics) run: bash gates/selftest.sh shell: bash - name: e2e (the loop end to end, local fixtures only) run: bash gates/e2e.sh shell: bash + - name: autotest (the automation layer, local fixtures only) + run: bash gates/autotest.sh + shell: bash diff --git a/.gitignore b/.gitignore index f4a63fa..6a4be81 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ .sdlc/ scratch/ .DS_Store +# tools/_run.py is executed, never imported; this only appears if someone +# byte-compiles it by hand +__pycache__/ diff --git a/AGENTS.md b/AGENTS.md index 53d095a..df16bf0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,6 +148,30 @@ when closing. stage's adversary review runs and the risk authorization must exist (intent defines no adversary — a hit on intent.md gets a fresh-context adversary). + **A waived gate is not a stop.** A stage skill's "tell the human, then + STOP" applies to the gates the project's lazymode keeps HUMAN. Where the + level waives one, the agent runs that stage's review, records the approval + with `--lazy --review`, posts the summary as FYI, and CONTINUES — it does + not ask, and it does not wait. Exactly four things still stop a waived + loop, at every level including 4: work outside the authorized scope, an + unresolved MATERIAL question in intent.md, a blocker surviving its round + cap, and external delivery beyond a review branch (merge, deploy). Ask each + of them ONCE, as one concrete decision; a question already answered for + this scope is not asked again. + + **The full-auto intent contract.** An unattended run may act on an + `intent.md` only when it states an actionable outcome, its scope and + non-goals, acceptance criteria, labelled evidence, a `Scope authorization` + line (the human's words), and a `## Material questions` section with + nothing unresolved in it. Known facts from the ticket, the code, and + DOMAIN.md come first; what remains are the questions. A question is + MATERIAL when a wrong answer would change what gets built, break something, + or exceed the authorized scope — it goes to the human, and is never guessed + away to make progress. Optional uncertainty is decided from evidence or + carried as `[assumed: why]` under `## Open questions`, and blocks nothing. + Missing required evidence or an unauthorized risk blocks the same way. + `tools/auto.sh intent-check ` reports the verdict. + **A blocker surviving its round cap blocks `--lazy` at every stage**: the gate reverts to a human ask; at lazymode 4 the loop stops. Approvals are still recorded, and every auto-approved gate still posts its @@ -243,6 +267,23 @@ when closing. suite is never a silent substitute, and a delivery over a known gap is allowed only when the human accepts that gap explicitly. + **A receipt makes a missing proof detectable** (optional, and the loop + works without it). A project that fills `.sdlc/verify.md` + (templates/verify.md) maps each requirement to its own real command, plus + the launch, doctor, and cleanup commands around them; `tools/verify.sh run + ` executes EVERY one of them, bounded and isolated, and records a + receipt bound to the source snapshot before and after the run, the recipe, + and each command's and output's digest. Editing the code, the commands, or + the recipe makes the receipt `stale`; a cited log that is missing or was + edited, or checks that do not add up, make it `invalid`. Under + `profile: strict` a feature is not review-ready without a passing `runtime` + or `e2e` check against a runtime that run actually launched, and a doctor + that never comes up is NOT VERIFIED — never "the unit suite is green". + A receipt is CHANGE DETECTION, not authentication: it makes "this never + ran" and "this was edited afterwards" visible, and says nothing about who + produced it. It does not replace the independent fresh-context verifier + (rule 5). + **"Shipped" means delivered.** The ship approval is a decision to deliver; it is not a delivery. A feature closes as `shipped` only when the agreed target — local implementation, PR, or deploy — is proven to @@ -271,6 +312,18 @@ when closing. name — cannot be bound: approve.sh refuses it by name, and one that appears after the review closes the gate as an invalid source. + **Review-ready is not merged, and not deployed.** A loop's own exit is a + FEATURE BRANCH pushed for a human to review: `tools/handoff.sh push + --authorized ""` refuses without that authorization, + refuses protected or shared branches, never force-pushes, refuses a commit + whose tree does not CONTAIN the reviewed source, and repeats no push that + already happened. `tools/handoff.sh check ` establishes the remote + branch's SHA with git, never in prose. Merging that branch or deploying it + is a separate human approval, recorded as `Authorized-by:` in delivery.md at + every lazymode level. delivery.md's `Remote`, `Branch`, `Handoff` and + `Authorized-by` lines are optional and backward-compatible: an older record + closes exactly as it did. + **A bug fix carries its own proof chain** (skills/6-maintain): the failure observed before the fix, the causal mechanism, the SAME reproduction passing after, and the adjacent flows that share the changed @@ -306,6 +359,23 @@ when closing. its age so silence and a dead loop look different. History stays where it already lives (deviations.md, evidence.md, harvest.md). +## Driving the loop from a host (no daemon, no scheduler) + +`gates/status.sh --json` (= `tools/auto.sh status --json`, schema +`sdlc-kit/auto-status@1`) is the machine view: per feature the stage, a +`status` of `ready | needs-human | blocked | complete`, the next action, the +blockers, the source identity, the verification and delivery state, and the +`exit_condition` (`review-ready` ≠ `deployed`). `tools/auto.sh next ` +prints one line and exits 0 / 10 / 20 / 30 for those four states. Every verdict +comes from `gates/_common.sh`, so the machine view is never more permissive +than the gates. + +These scripts REPORT and RECORD. They run no model and perform no stage: a +`ready` status means the next action is one the project's lazymode lets an +agent take, and the agent still takes it under the stage skill. The drive / +resume procedure, the verification recipe, the handoff boundary, the checkpoint +and its retry classes are documented in `docs/automation.md`. + ## Greenfield vs brownfield Stage 1 records the classification in `intent.md`; downstream skills branch diff --git a/README.ko.md b/README.ko.md index 3fea18f..e73439a 100644 --- a/README.ko.md +++ b/README.ko.md @@ -220,9 +220,11 @@ abandoned나 dead-end는 교훈이 없으면 닫히지 않습니다(lazymode 3 ```bash gates/status.sh [--all[=n]] [slug] # 열린 피처 + 다음 액션 하나, --all은 최신 아카이브 20건 포함 +gates/status.sh --json [slug] # 같은 상태를 기계가 읽는 형식으로(tools/auto.sh) gates/stats.sh [--all] # 단계별 소요 시간 + 재승인 횟수, 기본은 열린 피처 + 최근 종결 20건 gates/selftest.sh # 게이트, 종결, 인젝션, lazymode, status 렌더, YAML 무결성 gates/e2e.sh [kit] # 일회용 git 픽스처에서 루프 전체를 검사(로컬 전용, 원격 호출 없음) +gates/autotest.sh [kit] # 자동화 계층을 자체 픽스처에서 검사(로컬 bare 원격, 네트워크 없음) ``` 예시: @@ -236,6 +238,47 @@ gates/e2e.sh [kit] # 일회용 git 픽스처에서 루프 전체를 검사 next → plan gate (tiered): gates/approve.sh plan ... ``` +## 호스트에서 루프 돌리기 (v0.10.0) + +스케줄러, 웹훅, 멀티 에이전트 런타임이 산문을 파싱하지 않고 루프를 구동할 수 있습니다. +데몬도, 데이터베이스도, 새 의존성도 없는 작은 스크립트 네 개입니다. + +```bash +tools/auto.sh next # 한 줄 출력, 종료 코드 0 ready · 10 needs-human · 20 blocked · 30 complete +tools/auto.sh status --json [slug] # 스키마 sdlc-kit/auto-status@1 +tools/auto.sh intent-check # 이 intent.md를 무인으로 실행해도 되는가 +tools/auto.sh checkpoint … # 대기 중인 단계, 제한된 재시도, 완료된 외부 효과 +tools/verify.sh run|check # 프로젝트의 검증 레시피 실행(python3 필요), 소스에 결합된 영수증 기록 +tools/handoff.sh push|check # 리뷰용 브랜치가 원격에 실제로 있음을 증명 +``` + +호스트가 에이전트를 깨우면, 에이전트는 `next`를 읽고 단계 지시서에 따라 그 액션 하나를 +수행한 뒤 다시 반복합니다. 이 스크립트들은 보고하고 기록할 뿐, 모델을 돌리거나 단계를 +수행하지 않습니다. `ready`는 "다음 액션이 이 프로젝트의 lazymode가 에이전트에게 허용한 +것"이라는 뜻이지, 셸 스크립트가 코드를 리뷰했다는 뜻이 아닙니다. + +움직이지 않는 경계가 셋 있습니다. + +- **중대한 질문은 루프를 멈춥니다.** 틀린 답이 만들 물건을 바꾸거나 사람이 허가한 범위를 + 벗어나게 하는 질문을, 무인 실행이 진도를 위해 추측으로 넘기지 않습니다. +- **런타임 증명은 주장하는 것이 아니라 실행하는 것입니다.** `.sdlc/verify.md`가 요구사항마다 + 프로젝트 자신의 명령을 지정하고, `tools/verify.sh`가 설정된 모든 검사를 실행합니다 — + 시간 제한이 걸린 채, stdin은 닫힌 채, 각각 자기 프로세스 그룹에서. 영수증은 결과를 + 실행 전후의 소스·레시피·각 명령의 출력 해시에 묶습니다. 코드가 바뀌면 `stale`, + 인용한 로그가 사라지거나 수정되면 `invalid`가 됩니다. `profile: strict`에서는 그 실행이 + 직접 띄운 런타임에 대한 runtime/e2e 검사가 통과하지 않으면 리뷰 준비 완료가 아니며, + 유닛 테스트 통과가 그 자리를 대신하지 않습니다. 영수증은 **변경 탐지**이지 인증이 + 아닙니다: 실행되지 않았거나 나중에 고쳐진 증거를 드러낼 뿐, 누가 만들었는지는 말하지 + 않습니다. +- **루프는 푸시된 피처 브랜치에서 끝납니다.** `tools/handoff.sh push`는 푸시 직전에 + ship 게이트 전체(`gates/check-gate.sh ship`)와 검증을 다시 실행하고, `intent.md`에 + 기록된 승인 범위가 브랜치 공개를 실제로 명시할 때만 진행합니다 — 에이전트가 스스로에게 + 외부 효과를 허가할 수는 없습니다. 그 브랜치를 머지하거나 배포하는 것은 lazymode와 + 무관하게 별도의 사람 승인이며 `delivery.md`에 기록되고, 킷이 여기서 검증할 수 있는 + 사실이 아닙니다. + +전체 계약, 구동·재개 절차, Symphony 예시: [`docs/automation.md`](docs/automation.md). + ## 복잡한 코드베이스에서도 sdlc-kit은 프로세스 계층이지 프로젝트 규칙의 대체물이 아닙니다. @@ -280,9 +323,11 @@ init.sh 멱등 프로젝트 시드 .gitattributes LF 고정, Windows 클론에서도 스크립트 생존 skills/1-6/ 단계별 지시서 roles/ verifier · adversary · researcher 계약 -gates/ approve · check · close · status · stats · selftest · e2e (공용 헬퍼 _common.sh 포함) -templates/ intent · spec · plan · evidence · delivery · lesson +gates/ approve · check · close · status · stats · selftest · e2e · autotest (공용 헬퍼 _common.sh, _auto.sh 포함) +tools/ auto(기계 상태) · verify(영수증, python3 필요) · handoff(리뷰 브랜치) · _run.py(제한된 실행) · tripwire · refcheck +templates/ intent · spec · plan · evidence · delivery · verify · lesson docs/index.html EN/KO 랜딩 페이지 +docs/automation.md 기계 계약: status JSON, 영수증, 핸드오프, 체크포인트 ``` ## 킷 검증 @@ -290,11 +335,19 @@ docs/index.html EN/KO 랜딩 페이지 ```bash ./gates/selftest.sh # 게이트 동작 ./gates/e2e.sh # 자체 일회용 픽스처에서 루프 전체 +./gates/autotest.sh # 자체 일회용 픽스처에서 자동화 계층 ``` 셀프테스트는 게이트 상태와 경로·내용 결합(다른 경로 재사용, 경로 이탈, 심볼릭 링크, 결합 이전 기록은 모두 닫힌 상태로 실패), 단계명 인젝션, 경로 이탈 거부, delegated와 lazy 승인 및 그 리뷰·위험 허가 기록, 컴팩트 루트와 승격 시 재승인, 전달 기록을 요구하는 `shipped` 종결, `refcheck.sh`의 드리프트 감지, 종결 시 교훈 요구, 이중 종결 거부, 종결 시 아카이브(승인 기록 이동과 status 범위 포함), YAML 프런트매터 파싱, 전체 스크립트의 LF 줄 끝을 검사합니다. 여기에 엔드투엔드 워크플로 픽스처 두 가지 — 컴팩트 버그 수정의 intent부터 전달 종결까지, 그리고 그 주변 실패 경로 — 가 함께 돌고, 리뷰 전에 이미 커밋된 작업의 소스 결합과 `pr` 전달의 커밋 포함 여부 검사도 포함됩니다. -`gates/e2e.sh`는 그 위의 통합 스위트입니다. 자체 임시 디렉토리에 일회용 git 프로젝트를 만들어 실제 스크립트로 컴팩트 루트, 풀 루트, 그리고 모든 부정 시나리오를 돌립니다. 리뷰 후 수정, 파일 추가, chmod와 심볼릭 링크 교체, 전달 소스로 지목된 엉뚱한 옛 커밋, 예전 킷의 ship 결합, ship 리뷰 이후 수정되거나 삭제된 풀 루트의 spec·plan, 그리고 `status.sh`·`check-gate.sh`·`close.sh`가 같은 판정을 내는지까지 검사합니다. 픽스처 밖에는 아무것도 쓰지 않고 네트워크·원격·`gh` 호출도 하지 않습니다. `pr`과 `deploy` 전달은 로컬에서만 재현하며, 그것이 `close.sh`가 실제로 확인하는 전부입니다. 셀프테스트를 내부에서 다시 실행하지는 않습니다 — 두 스위트는 독립입니다. CI는 Ubuntu, macOS, Windows(Git Bash)에서 둘 다 실행합니다. +`gates/autotest.sh`는 같은 원칙으로 자동화 계층을 검사합니다. 풀오토 intent 계약(중대한 질문은 +막고, 해결되면 풀린다), 검증 영수증(검사 실패, 영수증 없음, runtime 증거 없는 strict 프로파일, +코드·명령·레시피가 바뀐 경우 모두 차단), 로컬 bare 원격을 상대로 한 리뷰 핸드오프(허가 없는 푸시, +보호 브랜치, force, 리뷰된 소스를 담지 않은 커밋은 거부, 두 번째 푸시는 아무 효과도 반복하지 않음, +원격 SHA가 다르면 리뷰 준비 완료가 차단, 머지·배포는 `Authorized-by:` 필요), 제한된 재시도와 재개, +그리고 lazymode 0 동작과 소스 결합이 그대로임을 확인합니다. + +`gates/e2e.sh`는 그 위의 통합 스위트입니다. 자체 임시 디렉토리에 일회용 git 프로젝트를 만들어 실제 스크립트로 컴팩트 루트, 풀 루트, 그리고 모든 부정 시나리오를 돌립니다. 리뷰 후 수정, 파일 추가, chmod와 심볼릭 링크 교체, 전달 소스로 지목된 엉뚱한 옛 커밋, 예전 킷의 ship 결합, ship 리뷰 이후 수정되거나 삭제된 풀 루트의 spec·plan, 그리고 `status.sh`·`check-gate.sh`·`close.sh`가 같은 판정을 내는지까지 검사합니다. 픽스처 밖에는 아무것도 쓰지 않고 네트워크·원격·`gh` 호출도 하지 않습니다. `pr`과 `deploy` 전달은 로컬에서만 재현하며, 그것이 `close.sh`가 실제로 확인하는 전부입니다. 셀프테스트를 내부에서 다시 실행하지는 않습니다 — 두 스위트는 독립입니다. CI는 Ubuntu, macOS, Windows(Git Bash)에서 세 스위트를 모두 실행합니다. ## 이것이 아닌 것 diff --git a/README.md b/README.md index 474e384..ae596de 100644 --- a/README.md +++ b/README.md @@ -220,9 +220,11 @@ When the incident cannot be reproduced, fresh-context adversaries recount the sc ```bash gates/status.sh [--all[=n]] [slug] # open features + one next action; --all adds the newest 20 archived +gates/status.sh --json [slug] # the same state, machine-readable (tools/auto.sh) gates/stats.sh [--all] # time per stage + re-approval counts; default open + 20 recent closed gates/selftest.sh # gate, close, injection, lazymode, status render, YAML integrity gates/e2e.sh [kit] # the loop end to end in throwaway git fixtures (local only, no remotes) +gates/autotest.sh [kit] # the automation layer in its own fixtures (local bare remotes, no network) ``` Example: @@ -236,6 +238,49 @@ Example: next → plan gate (tiered): gates/approve.sh plan ... ``` +## Drive it from a host (v0.10.0) + +A scheduler, a webhook, or a multi-agent runtime can drive the loop without +reading prose. Four small scripts, no daemon, no database, no new dependency: + +```bash +tools/auto.sh next # one line; exit 0 ready · 10 needs-human · 20 blocked · 30 complete +tools/auto.sh status --json [slug] # schema sdlc-kit/auto-status@1 +tools/auto.sh intent-check # is this intent.md safe to run unattended? +tools/auto.sh checkpoint … # pending step, bounded attempts, completed effects +tools/verify.sh run|check # run the project's verification recipe (needs python3); receipt bound to the source +tools/handoff.sh push|check # the review branch, proven to be on the remote +``` + +The host wakes an agent; the agent reads `next`, performs that ONE stage action +under the stage skill, and loops. These scripts report and record — they run no +model and perform no stage. `ready` means the next action is one this project's +lazymode lets an agent take, not that a shell script reviewed anything. + +Three boundaries are explicit and do not move: + +- **A material question stops the loop.** An unattended run never guesses away a + question whose wrong answer would change what gets built or exceed the scope + the human authorized. +- **Runtime proof is executed, not asserted.** `.sdlc/verify.md` maps each + requirement to the project's own command; `tools/verify.sh` runs every one of + them — bounded, stdin closed, each in its own process group — and binds the + result to the source before and after the run, the recipe, and each command's + output. Change the code and it goes `stale`; edit a log it cites and it goes + `invalid`. Under `profile: strict`, no passing runtime/e2e check against a + runtime that run launched means not review-ready — a green unit suite is never + a stand-in. The receipt is change detection, not authentication: it makes a + missing or edited proof visible, and never says who produced it. +- **The loop ends at a pushed feature branch.** `tools/handoff.sh push` re-runs + the complete ship gate and the verification immediately before it pushes, and + requires that the scope `intent.md` records actually names a publication — an + agent cannot authorize an external effect for itself. Merging that branch or + deploying it is a separate human approval, recorded in `delivery.md`, at every + lazymode level; neither is something this kit can verify from here. + +Full contract, drive/resume procedure, and a Symphony example: +[`docs/automation.md`](docs/automation.md). + ## Works in complex codebases sdlc-kit is a process layer, not a replacement for the project's existing rules: @@ -280,9 +325,11 @@ init.sh idempotent project seed .gitattributes pins LF endings so scripts survive a Windows clone skills/1-6/ stage instructions roles/ verifier · adversary · researcher contracts -gates/ approve · check · close · status · stats · selftest · e2e (+ _common.sh helpers) -templates/ intent · spec · plan · evidence · delivery · lesson +gates/ approve · check · close · status · stats · selftest · e2e · autotest (+ _common.sh, _auto.sh) +tools/ auto (machine status) · verify (receipts, needs python3) · handoff (review branch) · _run.py (bounded execution) · tripwire · refcheck +templates/ intent · spec · plan · evidence · delivery · verify · lesson docs/index.html bilingual EN/KO landing page +docs/automation.md the machine contract: status JSON, receipts, handoff, checkpoint ``` ## Verify the kit @@ -290,10 +337,25 @@ docs/index.html bilingual EN/KO landing page ```bash ./gates/selftest.sh # gate mechanics ./gates/e2e.sh # the whole loop, in its own throwaway fixtures +./gates/autotest.sh # the automation layer, in its own throwaway fixtures ``` The selftest covers gate state and its path/content binding (cross-path reuse, traversal, symlinks, and pre-binding records all fail closed), stage-name injection, bare-path rejection, delegated and lazy approvals with their recorded review and risk authorization, the compact route and its upgrade revalidation, delivery-backed `shipped` closes, `refcheck.sh` drift detection, lesson requirements for closing, double-close rejection, archive-on-close (with approval records and status scoping), YAML frontmatter parsing, and LF line endings in every script. It also runs two end-to-end workflow fixtures: a compact bug fix from intent to a delivered close, and the failure paths around it — plus the source binding over work that was committed BEFORE the review and the commit-containment check on a `pr` delivery. +`gates/autotest.sh` covers the automation layer on the same principle: the +full-auto intent contract (a material question blocks, a resolved one releases), +verification receipts (a failing check, a missing receipt, a strict profile with +no runtime evidence, and stale code, commands, or recipe all block), the review +handoff against a local bare remote (unauthorized, protected-branch, force, and +non-containing pushes refused; a second push repeats nothing; a remote SHA that +differs blocks review-ready; merge and deploy need `Authorized-by:`), bounded +retries and resume, and the lazymode-0 and source-binding behavior unchanged. +It also carries a regression case for every finding of the first independent +review: material questions written without bullets, a check that reads stdin, a +launched runtime that must not leak its children, an unowned runtime answering +the doctor, a hung check, a push over a closed ship gate, a `pr` feature that +was never pushed, and a local target that must never be pushed at all. + `gates/e2e.sh` is the integration suite on top of that: it builds throwaway git projects in its own temp fixture and drives the real scripts through the compact route, the full route, and every negative case — including post-review edits, added files, chmod and symlink swaps, an old commit named as the delivered source, legacy ship bindings, a full-route spec or plan rewritten or deleted after the ship review, and the agreement between `status.sh`, `check-gate.sh`, and `close.sh`. It writes nothing outside its fixture and makes no network, remote, or `gh` call; `pr` and `deploy` deliveries are exercised locally, which is all `close.sh` inspects. It does not run the selftest inside itself — the two suites are independent. CI runs both on Ubuntu, macOS, and Windows (Git Bash). ## What this is not diff --git a/SKILL.md b/SKILL.md index de5e29e..e61d18f 100644 --- a/SKILL.md +++ b/SKILL.md @@ -35,6 +35,7 @@ they do it under this contract (gates, artifacts, memory), not beside it. | ticket too big or foggy for one intent pass | `map.md` in the same slug dir first (skills/1-intent "Chart a map first"); one Unknown per session, six sessions max. | | "continue " / "what's next" | Run `gates/status.sh ` from the project root. Its `next →` line names the stage skill or gate command. | | "where are we" / "sdlc status" | `gates/status.sh` (open features; `--all` adds the newest 20 archived) + `gates/stats.sh` (open + recent closed). Full-archive sweeps: `ls`/`grep .sdlc/archive/`, never the whole listing into context. | +| a host/scheduler drives the loop, or you need machine state | `gates/status.sh --json` (= `tools/auto.sh status --json`) and `tools/auto.sh next ` (exit 0 ready · 10 needs-human · 20 blocked · 30 complete). Verification receipts: `tools/verify.sh`; review handoff: `tools/handoff.sh`. Contract: `docs/automation.md`. These report and record — they run no stage. | | gate request answered "approve" in chat | `gates/approve.sh --delegated` per AGENTS.md rule 3. | | incident / bug / alert on a shipped feature | Read `skills/6-maintain/SKILL.md`. | | "we're done / drop this / dead end" for a feature | `gates/close.sh "reason"`. `shipped` needs a confirmed `delivery.md` (templates/delivery.md) and an unchanged ship approval; dead-end/abandoned need a lesson (lazymode ≥3: the reason line suffices); handed-off needs an external ticket/PR reference. close.sh archives the feature to `.sdlc/archive//`. | diff --git a/VERSION b/VERSION index f979ade..bf057db 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.9.0 +v0.10.0 diff --git a/docs/automation.md b/docs/automation.md new file mode 100644 index 0000000..29ac6d3 --- /dev/null +++ b/docs/automation.md @@ -0,0 +1,422 @@ +# The automation layer (v0.10.0) + +The loop has always been drivable by hand. This layer makes it drivable by a +host — a scheduler, a ticket webhook, a multi-agent runtime such as Symphony — +without anyone parsing prose, and without loosening a single gate. + +Four scripts, no daemon, no database. One optional dependency: **python3**, used +by `tools/verify.sh` (only) to bound a command by wall clock, give it its own +process group, and take that whole group down again — a POSIX shell cannot do +those three portably, and stock macOS ships no `timeout(1)`. The gates, +`tools/auto.sh` and `tools/handoff.sh` are shell + git only. Without python3, +`tools/verify.sh run` refuses with that reason and the loop keeps working: the +gates never required a receipt. CI runs all three suites on ubuntu/macos/windows +runners, which all ship python3. + +| command | what it does | +|---|---| +| `tools/auto.sh status [--json] [slug]` | the machine view of every open feature: stage, status, next action, blockers, source identity | +| `tools/auto.sh next ` | one line plus an exit code, for a driver | +| `tools/auto.sh intent-check ` | the full-auto intent contract | +| `tools/auto.sh checkpoint …` | pending execution metadata: step, bounded attempts, completed external effects | +| `tools/verify.sh run\|check\|doctor\|show ` | executes the project's verification recipe and records a receipt bound to the source | +| `tools/handoff.sh check\|push ` | the review handoff: a feature branch a human can review, proven to be there | + +`gates/status.sh --json` is the same output through the cockpit everyone +already knows; both go through `gates/_auto.sh`, which takes every gate verdict +from `gates/_common.sh` — the functions `check-gate.sh` and `close.sh` use. The +machine view can never be more permissive than the gates themselves. + +**What these scripts are not.** None of them reasons, writes an artifact, or +performs a stage. `status: ready` means *the next action is one this project's +lazymode lets an agent take* — an LLM, under the stage skills, still takes it. +A shell script cannot review code, and this kit never claims one did. + +## 1. The machine status contract + +`tools/auto.sh status --json` prints `sdlc-kit/auto-status@1`: + +```json +{ + "schema": "sdlc-kit/auto-status@1", + "kit_version": "v0.10.0", + "generated_at": "2026-09-17T09:12:44Z", + "project_root": "/repo", + "lazymode": 4, + "source_digest": "c13948dd…", + "git_head": "a9eb48da…", + "features": [ + { + "slug": "a20-1234-fix-login", + "track": "compact", + "stage": "build", + "status": "ready", + "exit_condition": "open", + "next_action": { "kind": "verify", "command": "tools/verify.sh run a20-1234-fix-login", "text": "…" }, + "blockers": [ { "code": "verify.fail", "detail": "…" } ], + "gaps": [ { "code": "verify.unconfigured", "detail": "…" } ], + "intent_contract": { "state": "ok", "detail": "…" }, + "verification": { "state": "ok", "profile": "strict", "detail": "…", "receipt": "…" }, + "source": { "state": "ok", "reviewed_digest": "…", "current_digest": "…" }, + "delivery": { "state": "ok", "target": "pr", "detail": "…" }, + "handoff": { "target": "review-ready", "remote": "origin", "branch": "…", + "remote_sha": "", "remote_verdict": "not-checked" }, + "checkpoint": { "state": "fresh", "step": "ship.push", "detail": "…" }, + "heartbeat": { "line": "build 2/3 · …", "age_seconds": 41 } + } + ] +} +``` + +- `status` — `ready` · `needs-human` · `blocked` · `complete`. +- `stage` — `map` · `intent` · `spec` · `plan` · `build` · `ship` · `delivery` · `closed`. +- `exit_condition` — `open` · `review-ready` · `merged` · `deployed` · `local` · `closed`. +- `blockers` stop progress; `gaps` are honest holes that do not (a project with + no verification recipe, for instance). +- `remote_verdict` — `match` · `mismatch` · `absent` · `not-checked`. A + delivery that CLAIMS a remote branch (`Handoff: review-ready|merged|deployed`) + is checked against the remote **by default**: a handoff nobody can see is not + a handoff. `--no-remote-check` makes the run touch no network; it then reports + `not-checked`, keeps `exit_condition: open`, and points at + `tools/handoff.sh check` instead of ever calling the feature review-ready or + suggesting `close.sh … shipped`. (`--remote-check` is still accepted, as a + no-op, for drivers written against the first draft.) +- `unusable_feature_dirs` — directory names under `.sdlc/work/` that are not + usable slugs (`[a-zA-Z0-9._-]+`). They are reported, never evaluated and never + split into features that do not exist. +- `status` lists OPEN features only, exactly like `gates/status.sh`. A closed + feature is answered by `tools/auto.sh next ` (`complete`, exit 30). + +`tools/auto.sh next ` prints +` :: ` plus one `blocker:` +line each, and exits **0 ready · 10 needs-human · 20 blocked · 30 complete · +1 usage/environment**. That exit code is the whole integration surface a host +needs. + +## 2. The drive / resume procedure + +This is a documented procedure for an agent, not a script that runs the loop: + +1. The host wakes an agent for a ticket (Symphony dispatch, cron, webhook). +2. The agent runs `tools/auto.sh next ` from the project root. +3. **ready** → it performs that one stage action under the stage skill + (`skills/1-intent` … `skills/5-ship`), records the artifact or the approval, + updates `progress.md`, and loops back to step 2. +4. **needs-human** → it posts the blocker and the decision that is owed, and + stops. The host may sleep the ticket until a human answers. +5. **blocked** → it repairs what the blocker names (re-run the verification, + re-approve a stale gate, fix a failing check) within the retry budget, or + escalates. It never edits the gate machinery to get past a blocker. +6. **complete** → the feature is closed; the host may release the ticket. + +Resuming after a crash, a context reset, or a new session is the same loop: +step 2 reads state from the artifacts on disk, which are the authority. The +checkpoint adds only what the artifacts cannot know — how many attempts a step +has had, and which external effects already happened. + +## 3. The full-auto intent contract + +An unattended run may act on an `intent.md` only when it carries: + +- an actionable **Goal** (one plain sentence, what the caller can do afterwards), +- a **Scope authorization** line: the scope the human already authorized, in + their words — this is *authority*, separate from any stage approval, +- at least one **Success criteria** checkbox (machine-checkable where possible), +- at least one **Out of scope / must not change** bullet (the non-goals), +- at least one labelled **Evidence** claim (`[verified: …]` / `[assumed: …]`), +- a **Material questions** section — present, and empty of unresolved lines. + +`tools/auto.sh intent-check ` reports `ok` (0), `material` (10), or +`incomplete` / `absent` (20). + +**Material vs optional.** A question is MATERIAL when a wrong answer would +change what gets built, break something, or exceed the authorized scope. It +goes to the human: an unattended run never guesses one away to make progress. + +Inside `## Material questions`, **content blocks — markers do not matter**. A +nested bullet, a `*` bullet, a numbered item and a bare sentence all count the +same. Two things, and only these two, stop a line from blocking: + +- the **canonical resolution marker**, with the answer and its source, written in + place so the trail survives: + `- — resolved: ` + (` - resolved:` and a leading `resolved:` / `[resolved …]` read the same once + Markdown markers are peeled). The marker is **anchored**, not searched for: it + must open the line or follow that separator, so a line that merely contains the + word never clears a question — `unresolved:`, `not resolved: pending`, + `non-resolved:` and `to be resolved with the PM` all keep blocking. +- a single `none` (or `n/a`) line declaring the section empty. An empty section + does the same. + +Lines still holding the template's `` read as `incomplete`, not as +"no questions". `gates/status.sh` prints the same verdict as +`tools/auto.sh`, and at lazymode 4 it overrides its own next action with it: the +prose cockpit is the screen an agent actually reads, and it may not offer the +lazy intent gate over a question a human owes an answer to. +Optional uncertainty is decided from evidence during the work, or carried as a +labelled `[assumed: why]` — it belongs under `## Open questions` and blocks +nothing. Known facts from the ticket, the code and DOMAIN.md come first; +questions are what remains after the evidence. + +Lazymode is unchanged by all of this. Lazymode answers *who decides*; +authorization answers *may this be done at all*. A red flag outside the +authorized scope stops the loop at every level, including 4. + +## 4. The verification contract + +`.sdlc/verify.md` (seed from `templates/verify.md`) maps each requirement to the +project's own command and names the launch / doctor / cleanup commands around +them: + +``` +profile: strict +launch: npm run start:test +doctor: curl -fsS http://localhost:3000/health +doctor_timeout: 60 +cleanup: docker compose -f compose.test.yml down -v +environment: local instance, seeded fixture data +check: build | build | npm run build +check: R1 | unit | npm test -- login +check: R2 | e2e | npx playwright test --grep @login +``` + +`tools/verify.sh run ` refuses an unfilled or malformed recipe outright, +deletes any previous receipt, launches the runtime in its own process group, +waits for the doctor, runs **every** configured check (stdin on `/dev/null`, one +own process group each, bounded by `check_timeout`), stops the whole launched +group, runs cleanup, re-hashes the source, and records +`.sdlc/work//verify-receipt.md`: + +``` +receipt_schema: sdlc-kit/verify-receipt@1 +source_digest_before: +source_digest_after: +recipe_digest: +launch: started (pgid 4711) | failed | skipped (--no-launch) | none +runtime_instance: owned | external | none +doctor: pass | fail (after 60s) | unowned-runtime | skip +cleanup: ok | failed | none +checks_configured: 5 +checks_run: 5 +runtime_evidence: yes | no +result: pass | fail | inconclusive +check: R2 | e2e | 0 | | | +``` + +Consequences, all of them deliberate: + +- **Every configured check runs.** A check that reads stdin (`ssh host cmd`, + `docker compose run` without `-T`, `mvn`, an interactive CLI) used to swallow + the rest of the recipe; the receipt now states how many checks were configured + and how many ran, and a mismatch reads `invalid`. +- **The run is bounded.** Each check, each doctor attempt and the cleanup have a + wall-clock limit (`check_timeout`, `doctor_attempt_timeout`, + `cleanup_timeout`). A hung project command is killed — process group included + — instead of stopping an unattended driver for good. +- **The runtime is owned or it is not evidence.** `launch:` starts in its own + process group and the whole group is stopped afterwards, children included. If + the doctor answers while the launched process is already dead, something else + is serving that endpoint: the receipt records `doctor: unowned-runtime` and + the state is `blocked`. `--no-launch` labels the instance `external` and, under + `profile: strict`, blocks — nothing proves an instance this run did not start + is running this source. `strict` + `launch:` also requires a passing `doctor:` + (make it assert the build or version, not just a listening port). +- **A cleanup that fails is loud.** `cleanup: failed` blocks instead of leaving a + leftover runtime to make the next result meaningless. +- **An interruption stops the check that is running.** INT/TERM take down this + run's current child and that child's process group, run the cleanup, and exit + non-zero with no receipt; no further check starts. Nothing the run did not + launch is signalled. (Windows/Git Bash: the same path, through + `tools/_run.py` and `taskkill /T`, plus a job object that takes the running + check down even when MSYS `kill` terminates the helper outright. If that job + object cannot be created, `tools/_run.py` prints a `process tree NOT bound` + line naming the Win32 call that refused — on stderr and in the check's log — + instead of claiming a containment it does not have; the run still exits + non-zero with no receipt, but the interrupted command may survive it.) +- **A source change during the run is `inconclusive`, not a pass.** A check that + writes into the tree (coverage output, a generated fixture) makes the result + belong to no single snapshot. Saying so beats returning 0 and then reporting + `stale` forever, which livelocked the driver. +- Change the code, the commands, or the recipe and `tools/verify.sh check` + answers `stale`. Old results never carry over to new code. +- `profile: strict` refuses review-ready without a passing `runtime` or `e2e` + check: local unit and build results never stand in for the real run + (AGENTS.md rule 6). A doctor that never succeeds is `blocked`, i.e. NOT + VERIFIED — never a downgrade to "the unit suite is green". +- A project with no `.sdlc/verify.md` keeps working exactly as in v0.9.0; the + machine view reports `verify.unconfigured` as a **gap**, so the hole is + visible rather than silently filled. + +**What a receipt is worth.** It is CHANGE DETECTION, and that is all. Each state +is re-derived on every read: the cited logs must exist and still hash to the +recorded digests, each command digest must match the recipe's command, every +configured check must be accounted for, and the source and recipe digests must +still match the working tree. That makes "these commands never ran", "this log +was edited afterwards" and "this receipt is for other code" **detectable**. It is +NOT authentication: nothing here says who produced the file, and anyone who can +write the receipt can also run the commands. It is not a review either — the +independent verifier (`roles/verifier.md`, fresh context) is still required and +is not replaced by any receipt. + +`tools/verify.sh check ` reports `ok` · `fail` · `stale` · `missing` · +`invalid` · `inconclusive` · `blocked` · `recipe` · `unconfigured`. + +## 5. The review handoff, and where the loop stops + +``` +tools/handoff.sh push --authorized "" [--remote r] [--branch b] +tools/handoff.sh check +``` + +Every guard rail on `push` is re-checked **immediately before** the push, and +again before `check` confirms one: + +- the **complete ship gate**, by running `gates/check-gate.sh ship` itself — the + same program, the same words. An `evidence.md` edited after approval, a stale + upstream, source drift: the push refuses and quotes the gate. +- the **verification**: a configured recipe must read `ok`. +- **authority**: `--authorized` is required, *and* `intent.md`'s + `- Scope authorization:` must actually name publishing a branch (push, PR, MR, + review branch). An agent typing a flag does not authorize an external effect + for itself, and neither does a line an agent writes into `delivery.md`. +- a delivery target of `local` is **never** pushed. +- **remote drift**: a review branch that moved somewhere this history does not + contain stops the push instead of racing it. + +Beyond that, `push` refuses to run without `--authorized`; refuses protected or shared +branches (built-in list plus `protected_branches:` in `.sdlc/config.md`); +refuses `--force` / `--force-with-lease` outright; refuses a HEAD whose tree +does not CONTAIN the source the ship review saw (the same whole-source snapshot +the ship approval binds, never a patch-id shortcut); and, when the remote branch +is already at this commit, reports `already pushed` instead of repeating the +effect. The completed push is recorded in the checkpoint. + +`check` verifies the remote branch's SHA with `git ls-remote` — the fact is +established with git, never asserted in prose — and prints the exit condition: + +- **review-ready** — the branch is on the remote at the delivered commit, and + that commit contains the reviewed source. A human review of that branch is + the next step. **This is where an unattended loop stops.** +- **merged / deployed** — outside the loop's authority. `delivery.md` must carry + `Authorized-by:` with the human's own words, at every lazymode; without it + `check` refuses and `tools/auto.sh next` reports `needs-human`. With it, + `check` prints `PENDING EXTERNAL PROOF` and says exactly what it did and did + not establish: a feature ref on a remote is **not** a merge commit on the + target branch and **not** a deployment. The machine view carries the same + thing as the gap `handoff.external-proof`. + +`close.sh shipped` enforces the same fact where it matters most: a +`delivery.md` that claims `Handoff: review-ready|merged|deployed` must name +Remote and Branch, and `close.sh` runs `git ls-remote` itself before accepting +the close. A pre-0.10 delivery record — no `Handoff:` line — closes exactly as +it did in v0.9.0: opting into the automation's exit condition is what opts into +the automation's check. + +`delivery.md`'s new `Remote`, `Branch`, `Handoff` and `Authorized-by` lines are +optional: a v0.9.0 delivery record behaves exactly as before, and `close.sh` +does not require them. + +## 6. Checkpoint, retries, pause and resume + +``` +tools/auto.sh checkpoint --set-step ship.push +tools/auto.sh checkpoint --attempt build.fix --class transient +tools/auto.sh checkpoint --effect "push|origin|a20-1234|" +tools/auto.sh checkpoint --show | --clear +``` + +- The artifacts remain the authority. `checkpoint.md` (gitignored) holds only + pending execution metadata; deleting it changes no gate verdict. +- **Attempt classes.** `transient` (a flaky network, a port in use) gets three + attempts; `deterministic` (a failing assertion, a missing binary) gets one — + a deterministic failure repeats deterministically, so it escalates instead of + burning the budget. Past the cap the command exits 20 and the loop escalates + to the human. These sit beside the loop's existing caps (deviations 5, + re-gates 2 per stage, fix-loop rounds 3, ship adversary rounds 2, map + sessions 6) and replace none of them. +- **Step names and effect kinds are validated** (`[a-zA-Z0-9._-]+`, one line) + and matched LITERALLY. `build.fi.` is a step of its own, not a pattern that + wipes `build.fix`'s counter. +- **A source change resets the attempt counters** and invalidates receipts: the + failures belonged to code that no longer exists. Completed external effects + stay on record, so a resume never pushes twice. +- **Pause** is simply the absence of a next dispatch: every counter is on disk. + **Resume** re-runs `tools/auto.sh next `. +- Nothing here auto-fixes anything unrelated. A blocker outside the feature's + scope goes to the human. + +## 7. Symphony integration example + +Symphony (or any host) provides the wake and the dispatch; the kit provides the +state and the boundaries. A minimal ticket driver: + +```bash +#!/usr/bin/env bash +# drive.sh — one wake-up of one ticket. The HOST loops this; the agent +# does the thinking. Run from the project root. +set -u +KIT=~/sdlc-kit +out=$("$KIT/tools/auto.sh" next "$1"); rc=$? +echo "$out" +case $rc in + 0) # ready: dispatch the implementer agent with the kit contract and this line. + # It performs exactly one stage action, then exits; the host wakes it again. + symphony dispatch implementer \ + --prompt "Read $KIT/AGENTS.md, then the stage skill for: $out" \ + --cwd "$PWD" ;; + 10) symphony ask-human --subject "$1" --body "$out" ;; # a decision is owed + 20) symphony ask-human --subject "$1 BLOCKED" --body "$out" ;; + 30) symphony close-ticket "$1" ;; # feature closed + *) echo "environment problem" >&2; exit 1 ;; +esac +``` + +The human boundary is explicit and is not the host's to move: + +- the loop may write artifacts, record gates its lazymode waives, run the + verification recipe, commit, and push a **feature branch** for review; +- **merging that branch, and deploying it, are separate human approvals**, bound + in `delivery.md` (`Authorized-by:`) — at lazymode 4 as much as at 0; +- anything outside the authorized scope in `intent.md` stops the loop, whatever + the host would like to happen next. + +A deployment-environment adapter (which service, which environment, which QA +command) belongs in the project, not in this kit: `.sdlc/config.md` and +`.sdlc/verify.md` are where a project names its own commands. The kit stays +generic. + +## 8. What this layer does not do + +Read these as the boundary of the machine view, not as a to-do list. + +- **L1 no authentication, anywhere.** Receipts, source snapshots, approval + records and `Authorized-by:` lines are change detection and record-keeping. + They make edits, stale results and missing runs *detectable*; none of them + establishes who did anything. An independent fresh-context reviewer + (`roles/verifier.md`) is still required and is replaced by nothing here. +- **L2 `ready` is not autonomy.** No script in this layer reasons or performs a + stage. A host that treats `ready` as "it happened" gets nothing done. +- **L3 the authority chain ends at prose a human wrote.** `push` checks that + `intent.md`'s Scope authorization names a publication, and that its artifact + passed the intent gate. That binds the *text* to the gate; it does not prove + a human typed it. A lazymode-4 project where an agent writes intent.md is + trusting the agent, and the gate record says so. +- **L4 merge and deployment are unverifiable from here.** The kit can see a + feature ref on a remote. It cannot see a merge commit on someone else's + branch, a closed PR, or a running deployment. `merged`/`deployed` are + therefore reported as pending external proof, always. +- **L5 the doctor is only as good as the recipe.** `tools/verify.sh` proves the + process it launched is alive and that the doctor answered; it cannot tell + whether the thing answering is the right *build* unless the project's own + doctor command asserts that (see `templates/verify.md`). +- **L6 timeouts need python3.** Without it `tools/verify.sh run` refuses rather + than running unbounded. Everything else in the kit is shell + git. +- **L7 strict is opt-in.** A project with no `.sdlc/verify.md` is reported as a + gap, not blocked — backwards compatibility, and also a way to stay unproven. +- **L8 two fields are read from prose.** `## Material questions` and + `Delivery target` are structured, documented Markdown lines, but a project + that ignores the template reads as `incomplete`. +- **L9 no deployment adapter.** Which service, which environment, which QA + command belongs in `.sdlc/config.md` and `.sdlc/verify.md`, not in the kit. +- **L10 `gates/status.sh --json --all` errors** (unknown option) instead of + ignoring `--all`: the JSON view is open-features-only by design. diff --git a/docs/index.html b/docs/index.html index 631c3f9..fa442da 100644 --- a/docs/index.html +++ b/docs/index.html @@ -668,6 +668,10 @@

What the gates do

Plain speech, by rule
Rule 8: every message to a human starts with context, keeps to short active sentences, and ends with the one decision you owe.
+
+
Drivable by a host
+
gates/status.sh --json reports stage, ready | needs-human | blocked | complete, the next action, and the blockers, so a scheduler never parses prose. tools/verify.sh executes every check in the project's own verification recipe — bounded, isolated, stdin closed — and binds the receipt to the source it ran over, so a check that never ran or a log edited afterwards is detectable (change detection, not authentication). tools/handoff.sh re-runs the whole ship gate immediately before it pushes, and proves the review branch is on the remote. The loop ends at a pushed feature branch: merging or deploying stays a separate human approval, and neither is something the kit can verify from here.
+
Every run gets an ending
close.sh records shipped, abandoned, dead-end, or handed-off, then archives the feature and its approvals to .sdlc/archive/. shipped requires a verified delivery record; failed work requires a lesson; handoffs require the external ticket or PR.
@@ -788,6 +792,8 @@

"l6-d": '실수는 ≤50줄짜리 INDEX.md에 기록합니다. 같은 실수를 막을 수 있을 때 교훈을 스킬로 승격합니다.', "l7-t": '규칙이 된 평문 원칙', "l7-d": '규칙 8: 사람에게 보내는 모든 메시지는 맥락으로 시작하고, 짧은 능동문을 유지하며, 마지막은 당신이 내려야 할 단 하나의 결정입니다.', + "l9-t": '호스트가 구동할 수 있음', + "l9-d": 'gates/status.sh --json이 단계, ready | needs-human | blocked | complete, 다음 액션, 차단 사유를 보고하므로 스케줄러가 산문을 파싱할 일이 없습니다. tools/verify.sh는 프로젝트 자신의 검증 레시피에 설정된 모든 검사를 시간 제한과 프로세스 그룹 격리 아래 실행하고 그 영수증을 실행 대상 소스에 결합합니다 — 실행되지 않은 검사나 나중에 수정된 로그가 드러납니다(인증이 아니라 변경 탐지입니다). tools/handoff.sh는 푸시 직전에 ship 게이트 전체를 다시 실행하고 리뷰용 브랜치가 원격에 있음을 증명합니다. 루프는 푸시된 피처 브랜치에서 끝납니다. 머지와 배포는 별도의 사람 승인이며, 킷이 여기서 검증할 수 있는 사실도 아닙니다.', "l8-t": '모든 실행에 끝 상태 기록', "l8-d": 'close.sh는 shipped, abandoned, dead-end, handed-off 중 하나를 기록한 뒤 피처와 승인 기록을 .sdlc/archive/로 옮깁니다. shipped는 검증된 전달 기록을 요구하고, 실패한 작업은 교훈을, 이관한 작업은 외부 티켓이나 PR을 남겨야 합니다.', "inst-h": '명령 세 줄, 그다음은 하네스에 맡김', diff --git a/gates/_auto.sh b/gates/_auto.sh new file mode 100644 index 0000000..26839a9 --- /dev/null +++ b/gates/_auto.sh @@ -0,0 +1,432 @@ +#!/usr/bin/env bash +# _auto.sh — shared automation helpers for tools/auto.sh, tools/verify.sh and +# tools/handoff.sh. Sourced, never run directly. Requires gates/_common.sh. +# +# This file computes MACHINE STATE from the artifacts and approval records that +# already exist. It decides nothing on its own: every gate verdict comes from +# _common.sh (the same functions check-gate.sh, status.sh and close.sh use), so +# the JSON view and the prose view can never disagree about a binding. +# +# It runs no model and performs no reasoning. `ready` means "the next action is +# an action this project's lazymode lets an agent take", not "a script did it". +# Keep it dependency-free: POSIX tools plus git. Bash 3.2 compatible. + +# --- small utilities --------------------------------------------------------- +sdlc_auto_now() { date -u +%Y-%m-%dT%H:%M:%SZ; } + +# JSON string escaping without jq. RFC 8259 requires every C0 control character +# to be escaped, not just the ones with a short form: a stray ESC or BEL in +# progress.md used to produce JSON that a driver could not parse (and an exit +# code of 0 while doing it). Newlines become \n; every other C0 byte becomes +# \u00XX. LC_ALL=C keeps awk byte-oriented so UTF-8 text passes through intact. +sdlc_json_str() { # → "escaped" + printf '%s' "${1-}" | LC_ALL=C awk ' + BEGIN { ORS = ""; printf "\"" } + { line = $0 + gsub(/\\/, "\\\\", line); gsub(/"/, "\\\"", line) + for (i = 1; i <= 31; i++) { + c = sprintf("%c", i) + if (index(line, c) > 0) gsub(c, sprintf("\\u%04x", i), line) + } + if (NR > 1) printf "\\n" + printf "%s", line } + END { printf "\"" }' +} + +# A slug names a directory under .sdlc/work/ and is pasted into commands, log +# paths and JSON. Anything outside this set is refused by name rather than +# word-split into features that do not exist. +sdlc_auto_valid_slug() { # → 0 when usable + case "${1-}" in + ''|.|..) return 1;; + *[!a-zA-Z0-9._-]*) return 1;; + -*) return 1;; + esac + return 0 +} + +# --- project-level configuration --------------------------------------------- +# lazymode with the SAME fail-closed rule as approve.sh/status.sh: anything +# outside 0-4 (or absent) counts as 0. +sdlc_auto_lazymode() { # → 0..4 + local raw lm + raw=$(awk '/^lazymode: /{gsub(/\r/,""); print $2; exit}' .sdlc/config.md 2>/dev/null || true) + lm="$raw" + case "$lm" in (''|*[!0-9]*) lm=0;; (*) [ "$lm" -le 4 ] || lm=0;; esac + printf '%s\n' "$lm" +} +sdlc_auto_lazy_min() { # → the lazymode level that waives its human gate + case "$1" in plan) echo 1;; spec) echo 2;; ship) echo 3;; intent) echo 4;; *) echo 99;; esac +} +sdlc_auto_kit_version() { + local kitdir="$1" v="" + if [ "$(git -C "$kitdir" rev-parse --show-toplevel 2>/dev/null)" = "$kitdir" ]; then + v=$(git -C "$kitdir" describe --tags --always 2>/dev/null || true) + fi + [ -n "$v" ] || v=$(cat "$kitdir/VERSION" 2>/dev/null || echo unknown) + printf '%s\n' "$v" +} + +# --- track and per-stage gate state ------------------------------------------ +sdlc_auto_track() { # → compact | full + local dir=".sdlc/work/$1" rec=".sdlc/approvals/$1.intent.approval" t=full + if [ -f "$dir/intent.md" ] && grep -qiE '^- *track: *(compact|micro)([^a-z]|$)' "$dir/intent.md"; then t=compact; fi + [ -f "$dir/spec.md" ] && t=full + # the intent approval FROZE the verdict (approve.sh): a post-approval rewrite + # to compact does not skip spec/plan + if [ "$t" = compact ] && [ -f "$rec" ] && [ "$(sdlc_field "$rec" track || true)" != "compact" ]; then t=full; fi + printf '%s\n' "$t" +} +sdlc_auto_artifact_for() { case "$1" in + intent) echo intent.md;; spec) echo spec.md;; plan) echo plan.md;; ship) echo evidence.md;; esac; } + +# One stage's state: "|" +# absent — no artifact yet +# pending — artifact exists, no approval record +# approved — record binds this artifact and its upstreams, all unchanged +# stale — a binding no longer holds (detail names the repair) +sdlc_auto_stage_state() { # + local slug="$1" stage="$2" dir=".sdlc/work/$1" art rec want up upw upart + art="$dir/$(sdlc_auto_artifact_for "$stage")" + rec=".sdlc/approvals/${slug}.${stage}.approval" + [ -f "$art" ] || { echo "absent|$(sdlc_auto_artifact_for "$stage") not written yet"; return 0; } + [ -f "$rec" ] || { echo "pending|$art awaits the $stage gate"; return 0; } + want=$(sdlc_field "$rec" artifact_sha256 || true) + if [ -z "$want" ]; then + echo "stale|the $stage record predates content binding — gates/approve.sh $stage $art"; return 0; fi + if [ "$(sdlc_sha256_file "$art")" != "$want" ]; then + echo "stale|$art changed after approval — gates/approve.sh $stage $art"; return 0; fi + for up in $(sdlc_upstream_stages "$stage"); do + upw=$(sdlc_field "$rec" "upstream_$up" || true) + upart="$dir/$(sdlc_auto_artifact_for "$up")" + [ -n "$upw" ] || continue + if [ ! -f "$upart" ] || [ "$(sdlc_sha256_file "$upart" 2>/dev/null || true)" != "$upw" ]; then + echo "stale|$(sdlc_auto_artifact_for "$up") changed since the $stage approval — re-approve $up, then $stage"; return 0; fi + done + for up in $(sdlc_upstream_unbound "$rec" "$slug"); do + echo "stale|$(sdlc_auto_artifact_for "$up") is not part of the approved $stage basis — gates/approve.sh $stage $art"; return 0 + done + echo "approved|approved at $(sdlc_field "$rec" approved_at || true)" +} + +# --- the full-auto intent contract ------------------------------------------- +# What an unattended run needs from intent.md before it may act on it +# (AGENTS.md rule 3a). Prints "|": +# ok — actionable outcome, scope, non-goals, acceptance criteria, +# evidence, scope authorization, and no unresolved MATERIAL question +# material — a MATERIAL question is open: a human decides, never a guess +# incomplete— a required section is missing or still a template placeholder +# absent — no intent.md +# Optional (non-material) uncertainty never blocks: it is carried as a labelled +# assumption. +# +# Under "## Material questions", ANY content blocks unless it carries the exact +# resolution syntax. Markdown markers are irrelevant: a nested bullet, a `*` +# bullet, a numbered item and a bare prose line all count, because a question a +# human still owes an answer to does not become harmless by being typed without +# a dash. The only two ways a line stops blocking are +# the CANONICAL resolution marker — `resolved:` (or the bracket form +# `[resolved …]`) at the start of the line once Markdown markers are peeled, +# or right after the ` — ` / ` - ` separator of the documented form +# `- — resolved: ` — and +# a bare `none` / `n/a` line declaring the section empty. +# The marker is ANCHORED rather than searched for anywhere in the line, so prose +# that merely CONTAINS the word never clears a question: "unresolved:", +# "not resolved: pending", "non-resolved:" and "to be resolved with the PM" all +# still block, and no list of negation words has to be maintained to keep them +# blocking. HTML comments (including the template's multi-line one) are not content. +sdlc_auto_material_counts() { # → " " + awk ' + function trim(x) { sub(/^[ \t]+/, "", x); sub(/[ \t\r]+$/, "", x); return x } + BEGIN { insec = 0; incomment = 0; mat = 0; ph = 0 } + { + line = $0; sub(/\r$/, "", line) + if (line ~ /^##[ \t]*Material questions/) { insec = 1; next } + else if (line ~ /^#+[ \t]/) { insec = 0 } + if (!insec) next + # strip HTML comments, which may span lines + while (1) { + if (incomment) { + p = index(line, "-->") + if (p == 0) { line = ""; break } + line = substr(line, p + 3); incomment = 0 + } else { + p = index(line, "") + if (q == 0) { line = substr(line, 1, p - 1); incomment = 1; break } + line = substr(line, 1, p - 1) substr(rest, q + 3) + } + } + c = trim(line) + if (c == "") next + # markers carry no meaning here: peel bullets, numbers and checkboxes off + while (c ~ /^([-*+]|[0-9]+[.)])[ \t]+/) { sub(/^([-*+]|[0-9]+[.)])[ \t]+/, "", c); c = trim(c) } + sub(/^\[[ xX]\][ \t]*/, "", c); c = trim(c) + if (c == "") next + # anchored: line start, or immediately after a dash separator that is + # itself preceded by whitespace (the documented "— resolved:" form). + anchor = "(^|(^|[ \t])(—|–|--|-)[ \t]+)" + if (c ~ anchor "resolved:" || c ~ anchor "\\[resolved") next + bare = tolower(c); gsub(/[*_.()\[\]~` \t-]/, "", bare) + if (bare == "none" || bare == "na" || bare == "n/a" || bare == "nonopen" || bare == "nomaterialquestions") next + if (substr(c, 1, 1) == "<" && index(c, ">") > 0) { ph++; next } + mat++ + } + END { printf "%d %d\n", mat, ph } + ' "$1" +} + +# The scope the human authorized, in their words (intent.md). It is AUTHORITY, +# not a gate approval, and it is the only recorded place a branch publication +# can be authorized from — a `--authorized` flag an agent types is not. +sdlc_auto_scope_authorization() { # → the recorded text (may be empty) + local f=".sdlc/work/$1/intent.md" + [ -f "$f" ] || return 0 + awk '/^[ \t]*- *Scope authorization:/{sub(/^[^:]*: */,""); sub(/[ \t\r]+$/,""); print; exit}' "$f" +} +# Does that recorded scope name publishing a branch (push / PR / MR / review +# branch)? Nothing else authorizes an external effect. +sdlc_auto_scope_allows_publish() { # → 0 when it does + local t + t=$(sdlc_auto_scope_authorization "$1" | tr 'A-Z' 'a-z') + case "$t" in + ''|'<'*) return 1;; + esac + case " $t " in + *push*|*" pr "*|*"pull request"*|*"merge request"*|*" mr "*|*"review branch"*|*"feature branch"*|*"open a pr"*) + return 0;; + esac + return 1 +} + +sdlc_auto_intent_contract() { # + local f=".sdlc/work/$1/intent.md" missing="" v n + [ -f "$f" ] || { echo "absent|no intent.md"; return 0; } + # Goal: one actionable sentence, not the template placeholder + v=$(awk '/^- *Goal:/{sub(/^- *Goal: */,""); print; exit}' "$f") + case "$v" in (''|'<'*) missing="$missing Goal";; esac + v=$(awk '/^- *Scope authorization:/{sub(/^[^:]*: */,""); print; exit}' "$f") + case "$v" in (''|'<'*) missing="$missing Scope-authorization";; esac + # acceptance criteria: at least one checklist line under Success criteria + n=$(awk '/^## *Success criteria/{s=1;next} /^## /{s=0} s && /^- *\[/ && $0 !~ // {c++} END{print c+0}' "$f") + [ "$n" -ge 1 ] || missing="$missing Success-criteria" + # non-goals: at least one real bullet under Out of scope + n=$(awk '/^## *Out of scope/{s=1;next} /^## /{s=0} s && /^- / && $0 !~ /^- *')"; return 0; fi + if [ "${p:-0}" -gt 0 ]; then + echo "incomplete|## Material questions still holds $p template placeholder line(s) — write the real questions, or leave the section empty / 'none'"; return 0; fi + echo "ok|intent contract satisfied" +} + +# --- verification receipts ---------------------------------------------------- +# The recipe is .sdlc/verify.md (templates/verify.md). Receipts are written by +# tools/verify.sh from commands it executed itself, and every field a reader +# depends on is re-checked here: the log files the receipt cites must exist and +# still hash to the digests it recorded, the command digests must match the +# recipe's commands, and the number of checks must account for every configured +# one. That is CHANGE DETECTION, not authentication — a receipt says "these +# commands produced these bytes over this source", never "a trustworthy party +# ran them". An independent reviewer (roles/verifier.md) is still required. +sdlc_verify_recipe() { echo ".sdlc/verify.md"; } +sdlc_verify_receipt() { echo ".sdlc/work/$1/verify-receipt.md"; } +sdlc_verify_profile() { # → strict | advisory (default advisory; absent recipe = advisory) + local p + p=$(awk '/^profile: /{gsub(/\r/,""); print $2; exit}' "$(sdlc_verify_recipe)" 2>/dev/null || true) + case "$p" in strict) echo strict;; *) echo advisory;; esac +} +sdlc_verify_recipe_digest() { + local r; r=$(sdlc_verify_recipe) + [ -f "$r" ] && sdlc_sha256_file "$r" || echo none +} +sdlc_verify_field() { # → value, \r stripped + awk -v k="$2: " 'index($0, k) == 1 { print substr($0, length(k) + 1); exit }' "$1" 2>/dev/null | tr -d '\r' +} +# The configured checks, one per line: "\t\t". The single +# parser both tools/verify.sh (which runs them) and the receipt validation +# below (which re-derives their digests) use, so the two cannot drift apart. +sdlc_verify_recipe_checks() { # [recipe] → tab-separated lines + local r="${1:-$(sdlc_verify_recipe)}" + [ -f "$r" ] || return 0 + awk ' + function trim(x) { sub(/^[ \t]+/, "", x); sub(/[ \t\r]+$/, "", x); return x } + index($0, "check:") == 1 { + body = substr($0, 7); sub(/\r$/, "", body) + p = index(body, "|"); if (p == 0) { print "\t\t" trim(body); next } + id = trim(substr(body, 1, p - 1)); rest = substr(body, p + 1) + q = index(rest, "|"); if (q == 0) { print id "\t" trim(rest) "\t"; next } + kind = trim(substr(rest, 1, q - 1)); cmd = trim(substr(rest, q + 1)) + print id "\t" tolower(kind) "\t" cmd + }' "$r" +} +# A recipe that is malformed or still half a template must be refused BEFORE +# anything is executed: `sh -c ""` is not a verification, and the +# 60s doctor wait it burns looks like a real one in the log. +sdlc_verify_recipe_issue() { # [recipe] → "" when usable, else " " + local r="${1:-$(sdlc_verify_recipe)}" v k + [ -f "$r" ] || { echo "absent no $r"; return 0; } + v=$(sdlc_verify_field "$r" profile) + case "$v" in ''|strict|advisory) ;; *) echo "profile profile must be 'strict' or 'advisory' (found '$v')"; return 0;; esac + for k in launch doctor cleanup environment; do + v=$(sdlc_verify_field "$r" "$k") + case "$v" in '<'*) echo "placeholder the '$k:' line is still the template placeholder ($v)"; return 0;; esac + done + for k in doctor_timeout check_timeout cleanup_timeout; do + v=$(sdlc_verify_field "$r" "$k") + case "$v" in '') ;; *[!0-9]*) echo "timeout '$k: $v' must be a whole number of seconds"; return 0;; esac + done + sdlc_verify_recipe_checks "$r" | awk -F'\t' ' + BEGIN { n = 0; bad = "" } + { + n++ + id = $1; kind = $2; cmd = $3 + if (bad != "") next + if (id == "" || cmd == "") { bad = "malformed check line " n " needs '\''check: | | '\''"; next } + if (id ~ /[^a-zA-Z0-9._-]/) { bad = "id check id '\''" id "'\'' must be [a-zA-Z0-9._-]+ (it names a log file)"; next } + if (kind != "build" && kind != "unit" && kind != "lint" && kind != "runtime" && kind != "e2e") + { bad = "kind check '\''" id "'\'' has kind '\''" kind "'\'' — use build|unit|lint|runtime|e2e"; next } + if (substr(cmd, 1, 1) == "<") { bad = "placeholder check '\''" id "'\'' still holds the template placeholder (" cmd ")"; next } + if (seen[id]++) { bad = "duplicate two checks share the id '\''" id "'\''"; next } + } + END { + if (bad != "") { print bad; exit } + if (n == 0) print "empty the recipe configures no check: line — nothing would be verified" + }' +} +# "|": +# ok — every configured check ran and passed over THIS source +# fail — a configured check failed +# inconclusive — the source changed WHILE the checks ran: the result belongs +# to no single snapshot +# stale — the source or the recipe changed after the receipt +# missing — a recipe exists but no receipt does +# invalid — the receipt does not hold together: a missing or rewritten +# log, a command that is not the recipe's, checks unaccounted +# blocked — strict profile without the runtime proof it demands, a +# failed doctor, a runtime nobody owned, or a failed cleanup +# recipe — .sdlc/verify.md itself is malformed or unfilled +# unconfigured — no .sdlc/verify.md in this project +sdlc_verify_state() { # + local slug="$1" rec cur profile before after issue n conf run line id kind rc csha osha log want + [ -f "$(sdlc_verify_recipe)" ] || { echo "unconfigured|no .sdlc/verify.md (templates/verify.md) — runtime proof is not machine-checked here"; return 0; } + issue=$(sdlc_verify_recipe_issue) + [ -z "$issue" ] || { echo "recipe|.sdlc/verify.md is not usable: ${issue#* } — fix it, then re-run tools/verify.sh run $slug"; return 0; } + rec=$(sdlc_verify_receipt "$slug") + profile=$(sdlc_verify_profile) + [ -f "$rec" ] || { echo "missing|no verification receipt — run tools/verify.sh run $slug"; return 0; } + if [ "$(sdlc_field "$rec" receipt_schema || true)" != "sdlc-kit/verify-receipt@1" ]; then + echo "invalid|$rec is not a sdlc-kit/verify-receipt@1 receipt — re-run tools/verify.sh run $slug"; return 0; fi + cur=$(sdlc_source_digest 2>/dev/null || echo unbound) + before=$(sdlc_field "$rec" source_digest_before || true) + after=$(sdlc_field "$rec" source_digest_after || true) + [ -n "$before" ] || before=$(sdlc_field "$rec" source_digest || true) + [ -n "$after" ] || after="$before" + if [ -z "$before" ] || [ -z "$after" ]; then + echo "invalid|the receipt binds no source identity — re-run tools/verify.sh run $slug"; return 0; fi + # the compatibility alias must agree with the field it aliases + if [ -n "$(sdlc_field "$rec" source_digest || true)" ] && [ "$(sdlc_field "$rec" source_digest || true)" != "$after" ]; then + echo "invalid|the receipt's source_digest and source_digest_after disagree — re-run tools/verify.sh run $slug"; return 0; fi + if [ "$before" != "$after" ]; then + echo "inconclusive|the source changed while the checks ran, so the result belongs to no single snapshot — re-run tools/verify.sh run $slug"; return 0; fi + if [ "$after" != "$cur" ]; then + echo "stale|the source changed after the receipt was recorded — re-run tools/verify.sh run $slug"; return 0; fi + if [ "$(sdlc_field "$rec" recipe_digest || true)" != "$(sdlc_verify_recipe_digest)" ]; then + echo "stale|.sdlc/verify.md changed after the receipt was recorded — re-run tools/verify.sh run $slug"; return 0; fi + case "$(sdlc_field "$rec" doctor || true)" in + fail*) echo "blocked|the environment doctor command failed — no runnable environment, so nothing is verified"; return 0;; + unowned-runtime*) echo "blocked|the doctor passed but the runtime this run launched was already dead: something else answered — re-run tools/verify.sh run $slug"; return 0;; + esac + case "$(sdlc_field "$rec" cleanup || true)" in + failed*) echo "blocked|the run could not stop what it started (see $rec) — a leftover runtime makes the next result meaningless"; return 0;; + esac + case "$(sdlc_field "$rec" result || true)" in + pass) ;; + inconclusive) echo "inconclusive|the run did not reach a verdict — see $rec"; return 0;; + *) echo "fail|a configured verification check failed — see $rec"; return 0;; + esac + # every configured check must be accounted for, and its log must still be the + # one that was hashed. A receipt that cites a log nobody wrote is not evidence. + conf=$(sdlc_verify_recipe_checks | grep -c . || true) + n=$(grep -c '^check: ' "$rec" 2>/dev/null || true) + run=$(sdlc_field "$rec" checks_run || true) + if [ "${n:-0}" != "${conf:-0}" ] || [ "$(sdlc_field "$rec" checks_configured || true)" != "${conf:-0}" ]; then + echo "invalid|the receipt records ${n:-0} of ${conf:-0} configured checks — re-run tools/verify.sh run $slug"; return 0; fi + if [ "${run:-}" != "${conf:-0}" ]; then + echo "invalid|the receipt says ${run:-?} of ${conf:-0} configured checks actually ran — re-run tools/verify.sh run $slug"; return 0; fi + while IFS= read -r line; do + [ -n "$line" ] || continue + IFS='|' read -r id kind rc csha osha log </dev/null || true)" != "$osha" ]; then + echo "invalid|the log of check '$id' ($log) changed after it was recorded — re-run tools/verify.sh run $slug"; return 0; fi + done </dev/null || true) +EOF + if [ "$profile" = strict ]; then + if [ "$(sdlc_field "$rec" runtime_evidence || true)" != yes ]; then + echo "blocked|strict profile: no runtime or e2e check ran, so review-ready cannot be claimed"; return 0; fi + case "$(sdlc_field "$rec" runtime_instance || true)" in + external) + echo "blocked|strict profile: the checks ran against an EXTERNAL instance this run did not launch (--no-launch), so nothing proves it runs this source"; return 0;; + esac + if [ -n "$(sdlc_verify_field "$(sdlc_verify_recipe)" launch)" ] && \ + [ "$(sdlc_field "$rec" doctor || true)" != pass ]; then + echo "blocked|strict profile: the recipe launches a runtime but no doctor command proved the launched instance was ready and is the one under test"; return 0; fi + fi + echo "ok|receipt bound to this source ($(sdlc_field "$rec" recorded_at || true))" +} + +# --- delivery / review handoff ------------------------------------------------ +# Backward-compatible extension of templates/delivery.md: Branch, Remote, +# Handoff and Authorized-by are OPTIONAL. A delivery.md without them behaves +# exactly as it did in v0.9.0. +sdlc_auto_handoff_target() { # → review-ready | merged | deployed | local | unknown + local del="$1" h t + [ -f "$del" ] || { echo unknown; return 0; } + h=$(sdlc_delivery_field "$del" Handoff | awk '{print tolower($1)}') + case "$h" in review-ready|merged|deployed) echo "$h"; return 0;; esac + t=$(sdlc_delivery_field "$del" Target | awk '{print tolower($1)}') + case "$t" in local) echo local;; pr) echo review-ready;; deploy) echo deployed;; *) echo unknown;; esac +} + +# --- checkpoint --------------------------------------------------------------- +# The artifacts stay the authority. The checkpoint holds ONLY pending execution +# metadata: which step is in flight, how many attempts it has had, and which +# external effects already happened (so a resume never repeats one). +sdlc_checkpoint_file() { echo ".sdlc/work/$1/checkpoint.md"; } +sdlc_checkpoint_attempts() { # → n (0 when the source moved on) + local f; f=$(sdlc_checkpoint_file "$1") + [ -f "$f" ] || { echo 0; return 0; } + if [ "$(sdlc_field "$f" source_digest || true)" != "$(sdlc_source_digest 2>/dev/null || echo unbound)" ]; then + echo 0; return 0; fi + awk -v s="$2" -F' *\\| *' '/^attempt: /{ sub(/^attempt: /,""); if ($1 == s) n = $3 } END { print n + 0 }' "$f" +} +sdlc_checkpoint_state() { # → "|"; fresh | stale | none + local f; f=$(sdlc_checkpoint_file "$1") + [ -f "$f" ] || { echo "none|no checkpoint"; return 0; } + if [ "$(sdlc_field "$f" source_digest || true)" != "$(sdlc_source_digest 2>/dev/null || echo unbound)" ]; then + echo "stale|the source changed since the checkpoint — attempt counters reset, receipts invalid"; return 0; fi + echo "fresh|step $(sdlc_field "$f" step || echo -)" +} diff --git a/gates/autotest.sh b/gates/autotest.sh new file mode 100755 index 0000000..17f7c38 --- /dev/null +++ b/gates/autotest.sh @@ -0,0 +1,1101 @@ +#!/usr/bin/env bash +# autotest.sh [kit-path] — regression suite for the AUTOMATION layer +# (tools/auto.sh, tools/verify.sh, tools/handoff.sh, gates/_auto.sh). +# +# Same shape as gates/e2e.sh: throwaway git projects in its own mktemp fixture, +# the real scripts, assertions on observable results. Every "push" goes to a +# LOCAL bare repository in the same fixture — no network, no remote host, no +# `gh` call. Nothing outside the fixture is written. +# +# It asserts behavior, never prose: each case drives real commands in a real +# repository and reads what the tools actually answer. +# +# Exit 0 = every assertion held. Exit 1 = at least one FAIL (listed at the end). +set -u + +KIT="${1:-$(cd "$(dirname "$0")/.." && pwd)}" +KIT=$(cd "$KIT" 2>/dev/null && pwd) || { echo "no such kit path: ${1:-}" >&2; exit 2; } +[ -f "$KIT/tools/auto.sh" ] || { echo "not a kit with the automation layer: $KIT" >&2; exit 2; } + +BASE="${AUTOTEST_BASE:-${TMPDIR:-/tmp}}" +mkdir -p "$BASE" || exit 2 +FIX=$(mktemp -d "${BASE%/}/sdlc-auto.XXXXXX") || exit 2 +case "$FIX" in */sdlc-auto.*) ;; *) echo "refusing to use fixture $FIX" >&2; exit 2;; esac +cleanup() { case "$FIX" in */sdlc-auto.*) rm -rf "$FIX";; esac; } +[ -n "${AUTOTEST_KEEP:-}" ] || trap cleanup EXIT +echo "fixture: $FIX" +echo "kit: $KIT" +# Which sha256 tool this platform actually resolved. A fixture that hardcodes +# one the platform does not have writes a digest the kit never would, and the +# case then fails for a reason that has nothing to do with what it asserts. +echo "sha256: $(command -v shasum || command -v sha256sum || command -v openssl || echo NONE)" +echo + +PASSED=0; FAILED=0; FAILLIST="" +pass() { PASSED=$((PASSED + 1)); printf 'PASS %s\n' "$1"; } +# The whole output of a failing case is printed, line by line and unmangled. It +# used to be squashed onto one 300-character line, which on Windows cut every +# python traceback off at its first frame and hid the exception that caused the +# failure. A runaway log is bounded by lines, not by bytes, so the message that +# matters is never the part that is dropped. +fail() { FAILED=$((FAILED + 1)); FAILLIST="$FAILLIST + - $1"; printf 'FAIL %s\n' "$1" + if [ -n "${2:-}" ]; then + printf ' output:\n' + printf '%s\n' "$2" | head -n 200 | sed 's/^/ | /' + [ "$(printf '%s\n' "$2" | wc -l)" -gt 200 ] && printf ' | … (output truncated at 200 lines)\n' + fi; return 0; } +assert_exit() { local d="$1" e="$2"; shift 2; local o rc; o=$("$@" 2>&1); rc=$? + [ "$rc" = "$e" ] && pass "$d" || fail "$d (exit $rc, expected $e)" "$o"; } +assert_msg() { local d="$1" n="$2"; shift 2; local o; o=$("$@" 2>&1) + case "$o" in *"$n"*) pass "$d";; *) fail "$d (missing '$n')" "$o";; esac; } +assert_exit_msg() { local d="$1" e="$2" n="$3"; shift 3; local o rc; o=$("$@" 2>&1); rc=$? + if [ "$rc" != "$e" ]; then fail "$d (exit $rc, expected $e)" "$o" + else case "$o" in *"$n"*) pass "$d";; *) fail "$d (exit ok, message lacks '$n')" "$o";; esac; fi; } +assert_grep() { grep -q "$2" "$1" 2>/dev/null && pass "$3" || fail "$3 (no /$2/ in $1)"; } +assert_nogrep() { grep -q "$2" "$1" 2>/dev/null && fail "$3 (unexpected /$2/ in $1)" || pass "$3"; } + +auto() { "$KIT/tools/auto.sh" "$@"; } +verify() { "$KIT/tools/verify.sh" "$@"; } +handoff() { "$KIT/tools/handoff.sh" "$@"; } +gate() { "$KIT/gates/$1" "${@:2}"; } + +# ---------------------------------------------------------------- fixtures +gitinit() { + git init -q . + git symbolic-ref HEAD refs/heads/main + git config user.email auto@fixture.local + git config user.name "Autotest Fixture" + git config commit.gpgsign false +} + +# mkproj — a seeded project with a tiny runnable app +mkproj() { + local d="$1" lm="$2" + mkdir -p "$d"; ( cd "$d" && gitinit ) + ( cd "$d" && bash "$KIT/init.sh" >/dev/null ) + printf '#!/bin/sh\necho hello\n' > "$d/app.sh"; chmod +x "$d/app.sh" + awk -v lm="$lm" '/^lazymode:/{print "lazymode: " lm; next} + /^test:/{print "test: sh app.sh"; next} + /^run:/{print "run: sh app.sh"; next} + {print}' "$d/.sdlc/config.md" > "$d/.sdlc/c.tmp" + mv "$d/.sdlc/c.tmp" "$d/.sdlc/config.md" +} + +# write_intent — a compact, full-auto-ready +# intent unless a material question is passed in +write_intent() { + local d="$1" s="$2" mat="${3:-}" + mkdir -p "$d/.sdlc/work/$s" + cat > "$d/.sdlc/work/$s/intent.md" < + local d="$1" p="$2"; shift 2 + { echo "profile: $p" + echo "environment: local shell fixture" + echo "check: unit | unit | sh app.sh" + for l in "$@"; do echo "$l"; done + } > "$d/.sdlc/verify.md" +} + +# ===================================================================== +# A1 full-auto (lazymode 4) walks intent → build → ship → delivery without a +# single human ask, as long as the intent contract holds and the checks pass +# ===================================================================== +P="$FIX/a1"; mkproj "$P" 4; cd "$P" +write_intent "$P" feat-a1 +assert_exit_msg "A1a a clear intent satisfies the full-auto contract" 0 "INTENT ok" auto intent-check feat-a1 +assert_exit_msg "A1b lazymode 4: the intent gate is the agent's to record" 0 "ready intent" auto next feat-a1 +gate approve.sh intent .sdlc/work/feat-a1/intent.md --lazy --review "read app.sh and its caller" >/dev/null +assert_exit_msg "A1c after the intent gate the next action is build, not a human ask" 0 "ready build" auto next feat-a1 +write_recipe "$P" advisory "check: R1 | e2e | sh -c './app.sh | grep -q hello'" +assert_exit_msg "A1d the recipe's real commands run and the receipt is written" 0 "VERIFY ok" verify run feat-a1 +assert_grep .sdlc/work/feat-a1/verify-receipt.md '^runtime_evidence: yes' "A1e the receipt records that a real e2e check ran" +assert_grep .sdlc/work/feat-a1/scratch/verify/R1.log '\$ sh -c' "A1f the e2e log holds the command that was executed" +printf '# Evidence: feat-a1\n- R1: sh app.sh → hello\n' > .sdlc/work/feat-a1/evidence.md +assert_exit_msg "A1g with a passing receipt the ship gate is the agent's" 0 "ready ship" auto next feat-a1 +gate approve.sh ship .sdlc/work/feat-a1/evidence.md --lazy --review "read the diff" >/dev/null +assert_exit_msg "A1h delivery to a pr target stays inside the authorized scope" 0 "ready delivery" auto next feat-a1 +out=$(auto status --json) +case "$out" in *'"status": "ready"'*) pass "A1i the machine view agrees with next";; *) fail "A1i machine view" "$out";; esac +if command -v python3 >/dev/null 2>&1; then + printf '%s' "$out" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["schema"]=="sdlc-kit/auto-status@1"; f=d["features"][0]; +assert set(["slug","stage","status","next_action","blockers","source","delivery","handoff","verification","checkpoint","exit_condition"]) <= set(f)' \ + && pass "A1j status --json is valid JSON with the documented keys" || fail "A1j status --json schema" + gate status.sh --json > "$FIX/via-status.json" 2>&1 + python3 - "$FIX/via-status.json" <<'PY' && pass "A1k gates/status.sh --json is the same machine view" || fail "A1k status.sh --json" +import json,sys +d=json.load(open(sys.argv[1])) +assert d["schema"]=="sdlc-kit/auto-status@1" and d["features"][0]["slug"]=="feat-a1" +PY +else + pass "A1j/A1k skipped: no python3 to parse JSON with" +fi + +# ===================================================================== +# A2 an unresolved MATERIAL question stops the loop at every lazymode — the +# agent never guesses one away to make progress +# ===================================================================== +P="$FIX/a2"; mkproj "$P" 4; cd "$P" +write_intent "$P" feat-a2 "- which of the two greetings is the correct one?" +assert_exit_msg "A2a a material question fails the intent contract" 10 "unresolved MATERIAL" auto intent-check feat-a2 +assert_exit_msg "A2b full-auto stops for it instead of approving the gate" 10 "needs-human" auto next feat-a2 +assert_msg "A2c the blocker names the intent, not a generic stall" "intent.material" auto next feat-a2 +# resolving it in place (the trail survives) releases the loop +sed 's/- which of the two greetings is the correct one?/- which greeting? — resolved: the human chose "hello" (chat, 2026-09-17)/' \ + .sdlc/work/feat-a2/intent.md > i.tmp && mv i.tmp .sdlc/work/feat-a2/intent.md +assert_exit_msg "A2d a resolved question releases the loop" 0 "ready intent" auto next feat-a2 +# an intent without acceptance criteria / non-goals is not full-auto ready +grep -v '^- \[ \]' .sdlc/work/feat-a2/intent.md > i.tmp && mv i.tmp .sdlc/work/feat-a2/intent.md +assert_exit_msg "A2e missing acceptance criteria blocks (never a guess)" 20 "Success-criteria" auto intent-check feat-a2 +assert_exit_msg "A2f the loop reports it as blocked, with the repair" 20 "blocked" auto next feat-a2 + +# ===================================================================== +# A3 verification: a failing check, a missing receipt, and a strict profile with +# no runtime evidence each block review-ready. No pass from prose. +# ===================================================================== +P="$FIX/a3"; mkproj "$P" 4; cd "$P" +write_intent "$P" feat-a3 +gate approve.sh intent .sdlc/work/feat-a3/intent.md --lazy --review "read app.sh" >/dev/null +printf '# Evidence: feat-a3\n- R1: sh app.sh → hello (claimed)\n' > .sdlc/work/feat-a3/evidence.md +write_recipe "$P" strict +assert_exit_msg "A3a no receipt yet: the ship gate is blocked, not waived" 20 "verify.stale" auto next feat-a3 +assert_exit_msg "A3b strict without a runtime/e2e check refuses review-ready" 1 "VERIFY blocked" verify run feat-a3 +assert_exit_msg "A3c and the loop stays blocked on it" 20 "blocked" auto next feat-a3 +# a failing real check blocks too +write_recipe "$P" strict "check: R1 | e2e | sh -c './app.sh | grep -q goodbye'" +assert_exit_msg "A3d a failing e2e check fails the run" 1 "VERIFY fail" verify run feat-a3 +assert_grep .sdlc/work/feat-a3/verify-receipt.md '^result: fail' "A3e the receipt records the failure" +assert_exit_msg "A3f a failed check blocks the loop" 20 "verify.fail" auto next feat-a3 +# fixing the code makes the SAME check pass, and only then is the gate the agent's +printf '#!/bin/sh\necho goodbye\n' > app.sh +assert_exit_msg "A3g a source edit invalidates the old receipt" 1 "VERIFY stale" verify check feat-a3 +assert_exit_msg "A3h the same check now passes" 0 "VERIFY ok" verify run feat-a3 +assert_exit_msg "A3i with runtime evidence the ship gate is the agent's" 0 "ready ship" auto next feat-a3 +# a hand-written receipt proves nothing: the source digest is part of it +sed 's/^source_digest: .*/source_digest: 0000000000000000000000000000000000000000000000000000000000000000/' \ + .sdlc/work/feat-a3/verify-receipt.md > r.tmp && mv r.tmp .sdlc/work/feat-a3/verify-receipt.md +assert_exit_msg "A3j a receipt whose digests disagree with each other is invalid" 1 "VERIFY invalid" verify check feat-a3 +verify run feat-a3 >/dev/null 2>&1 +# changing the COMMANDS invalidates it as well +write_recipe "$P" strict "check: R1 | e2e | sh -c './app.sh | grep -q bye'" +assert_exit_msg "A3k a changed recipe invalidates the receipt" 1 "VERIFY stale" verify check feat-a3 +# a project with no recipe at all keeps working: the gap is reported, not faked +P="$FIX/a3b"; mkproj "$P" 4; cd "$P" +write_intent "$P" feat-a3b +gate approve.sh intent .sdlc/work/feat-a3b/intent.md --lazy --review "read app.sh" >/dev/null +assert_exit_msg "A3l no recipe: the loop runs, the unproven runtime is a named gap" 0 "ready build" auto next feat-a3b +assert_msg "A3m and the gap is visible in the machine view" "verify.unconfigured" auto status --json feat-a3b +assert_exit_msg "A3n verify check says so instead of passing" 2 "VERIFY unconfigured" verify check feat-a3b + +# ===================================================================== +# A4 the review handoff: a pushed feature branch is the exit condition, and the +# remote SHA is checked, never asserted +# ===================================================================== +P="$FIX/a4"; mkproj "$P" 4; cd "$P" +REMOTE="$FIX/a4-remote.git"; git init -q --bare "$REMOTE"; git remote add origin "$REMOTE" +write_intent "$P" feat-a4 +gate approve.sh intent .sdlc/work/feat-a4/intent.md --lazy --review "read app.sh" >/dev/null +write_recipe "$P" strict "check: R1 | e2e | sh -c './app.sh | grep -q hello'" +verify run feat-a4 >/dev/null +printf '# Evidence: feat-a4\n- R1: sh app.sh → hello\n' > .sdlc/work/feat-a4/evidence.md +gate approve.sh ship .sdlc/work/feat-a4/evidence.md --lazy --review "read the diff" >/dev/null +git checkout -q -b feat-a4 +assert_exit_msg "A4a a push without the human's authorization is refused" 1 "--authorized" handoff push feat-a4 +assert_exit_msg "A4b a force flag is refused outright" 2 "never force-pushes" handoff push feat-a4 --authorized ok --force +assert_exit_msg "A4c a protected/shared branch is never a handoff target" 1 "protected/shared branch" \ + handoff push feat-a4 --branch main --authorized "ship it" +assert_exit_msg "A4d an unborn HEAD is refused with the reason" 1 "no commit yet" \ + handoff push feat-a4 --authorized "ship it" +git add .gitignore >/dev/null; git commit -qm "chore: ignores" +assert_exit_msg "A4d2 a commit that does not contain the reviewed source is refused" 1 "does not CONTAIN" \ + handoff push feat-a4 --authorized "ship it" +git add -A >/dev/null; git commit -qm "feat: greeting" +assert_exit_msg "A4e the reviewed source is pushed to the feature branch" 0 "HANDOFF review-ready" \ + handoff push feat-a4 --authorized "push the review branch" +assert_exit_msg "A4f a second push repeats no external effect" 0 "already pushed" \ + handoff push feat-a4 --authorized "push the review branch" +assert_grep .sdlc/work/feat-a4/checkpoint.md '^effect: push|' "A4g the completed push is recorded once" +n=$(grep -c '^effect: push|' .sdlc/work/feat-a4/checkpoint.md) +[ "$n" = 1 ] && pass "A4h exactly one push effect is on record" || fail "A4h push effect recorded $n times" +SHA=$(git rev-parse HEAD) +cat > .sdlc/work/feat-a4/delivery.md < d.tmp && mv d.tmp .sdlc/work/feat-a4/delivery.md +assert_exit_msg "A4n merged without the human's authorization is refused" 1 "Authorized-by" handoff check feat-a4 +assert_exit_msg "A4o the loop asks the human for it, at lazymode 4" 10 "handoff.unauthorized" auto next feat-a4 +printf -- '- Authorized-by: "merge it after review" — human, 2026-09-17\n' >> .sdlc/work/feat-a4/delivery.md +assert_exit_msg "A4p with the authorization recorded the handoff stands" 0 "HANDOFF" handoff check feat-a4 +# a deploy target that was never delivered is a human decision, never an auto-push +P="$FIX/a4b"; mkproj "$P" 4; cd "$P" +write_intent "$P" feat-a4b +sed 's/^- Delivery target: pr/- Delivery target: deploy/' .sdlc/work/feat-a4b/intent.md > i.tmp && mv i.tmp .sdlc/work/feat-a4b/intent.md +# a deploy target trips the risk scan: --lazy needs the human's prior words +gate approve.sh intent .sdlc/work/feat-a4b/intent.md --lazy --review "read app.sh" \ + --risk-authorized "deploy feat-a4b after review" >/dev/null +printf '# Evidence: feat-a4b\n- R1: sh app.sh → hello\n' > .sdlc/work/feat-a4b/evidence.md +gate approve.sh ship .sdlc/work/feat-a4b/evidence.md --lazy --review "read the diff" >/dev/null +assert_exit_msg "A4q a deploy delivery needs its own human authorization" 10 "handoff.human" auto next feat-a4b + +# ===================================================================== +# A5 checkpoint and resume: bounded retries, reset on a source change, and the +# artifacts stay the authority +# ===================================================================== +P="$FIX/a5"; mkproj "$P" 4; cd "$P" +write_intent "$P" feat-a5 +auto checkpoint feat-a5 --set-step build.step-2 >/dev/null +assert_grep .sdlc/work/feat-a5/checkpoint.md '^step: build.step-2' "A5a the pending step is recorded" +auto checkpoint feat-a5 --attempt build.step-2 --class transient >/dev/null +auto checkpoint feat-a5 --attempt build.step-2 --class transient >/dev/null +assert_exit_msg "A5b a transient failure gets three attempts" 0 "attempt 3/3" auto checkpoint feat-a5 --attempt build.step-2 --class transient +assert_exit_msg "A5c the fourth escalates instead of looping" 20 "RETRY CAP" auto checkpoint feat-a5 --attempt build.step-2 --class transient +assert_exit_msg "A5d a deterministic failure escalates at once" 0 "attempt 1/1" auto checkpoint feat-a5 --attempt build.env --class deterministic +assert_exit_msg "A5e repeating it is capped" 20 "RETRY CAP" auto checkpoint feat-a5 --attempt build.env --class deterministic +assert_exit_msg "A5f an effect is recorded once and not repeated" 0 "effect recorded" auto checkpoint feat-a5 --effect "push|origin|feat-a5|abc123" +assert_exit_msg "A5g a resume sees it as already done" 0 "already recorded" auto checkpoint feat-a5 --effect "push|origin|feat-a5|abc123" +# a code change invalidates the attempt counters: those failures were another code's +printf '#!/bin/sh\necho changed\n' > app.sh +assert_msg "A5h a source change marks the checkpoint stale" "stale" auto checkpoint feat-a5 --show +assert_exit_msg "A5i and the retry budget starts again for the new code" 0 "attempt 1/3" auto checkpoint feat-a5 --attempt build.step-2 --class transient +assert_grep .sdlc/work/feat-a5/checkpoint.md '^effect: push|' "A5j completed external effects survive the reset" +# the checkpoint is not authority: deleting it changes no gate verdict +before=$(auto next feat-a5 2>&1) +rm -f .sdlc/work/feat-a5/checkpoint.md +after=$(auto next feat-a5 2>&1) +[ "$before" = "$after" ] && pass "A5k the artifacts, not the checkpoint, decide the next action" \ + || fail "A5k next action changed when the checkpoint was deleted" "$before // $after" + +# ===================================================================== +# A6 nothing loosened: lazymode 0 keeps its human gates, and the ship source +# binding is exactly as strict as check-gate.sh +# ===================================================================== +P="$FIX/a6"; mkproj "$P" 0; cd "$P" +write_intent "$P" feat-a6 +assert_exit_msg "A6a lazymode 0: the intent gate is a human decision" 10 "needs-human" auto next feat-a6 +assert_msg "A6b and the machine view names the gate" "gate.human.intent" auto next feat-a6 +assert_exit_msg "A6c --lazy is still refused by approve.sh at lazymode 0" 1 "keeps the 'intent' gate HUMAN" \ + gate approve.sh intent .sdlc/work/feat-a6/intent.md --lazy --review x +gate approve.sh intent .sdlc/work/feat-a6/intent.md >/dev/null +# full track at lazymode 0: the plan gate is tiered, a trip-wire keeps it human +P="$FIX/a6b"; mkproj "$P" 0; cd "$P" +mkdir -p .sdlc/work/feat-a6b +write_intent "$P" feat-a6b +sed 's/^- Track: compact.*/- Track: full/' .sdlc/work/feat-a6b/intent.md > i.tmp && mv i.tmp .sdlc/work/feat-a6b/intent.md +gate approve.sh intent .sdlc/work/feat-a6b/intent.md >/dev/null +printf '# Spec\n- R1: greeting\n' > .sdlc/work/feat-a6b/spec.md +gate approve.sh spec .sdlc/work/feat-a6b/spec.md >/dev/null +printf '# Plan\n## Gate tier\n- Tier: human — touches a migration\n' > .sdlc/work/feat-a6b/plan.md +assert_exit_msg "A6d a human-tier plan stays a human gate at lazymode 0" 10 "gate.human.plan" auto next feat-a6b +printf '# Plan\n## Gate tier\n- Tier: agent — no trip-wires\n' > .sdlc/work/feat-a6b/plan.md +assert_exit_msg "A6e a clean tier is the adversary's to record" 0 "agent-adversary" auto next feat-a6b +# source drift after the ship review: the machine view and check-gate.sh agree +P="$FIX/a6c"; mkproj "$P" 4; cd "$P" +write_intent "$P" feat-a6c +gate approve.sh intent .sdlc/work/feat-a6c/intent.md --lazy --review "read app.sh" >/dev/null +printf '# Evidence: feat-a6c\n- R1: ok\n' > .sdlc/work/feat-a6c/evidence.md +gate approve.sh ship .sdlc/work/feat-a6c/evidence.md --lazy --review "read the diff" >/dev/null +printf '#!/bin/sh\necho drifted\n' > app.sh +assert_exit_msg "A6f check-gate.sh still closes on post-review source drift" 1 "source changed after the ship review" \ + gate check-gate.sh ship .sdlc/work/feat-a6c/evidence.md +assert_exit_msg "A6g the machine view blocks on the same drift" 20 "source.drift" auto next feat-a6c +# an artifact edited after approval closes the gate in both views +P="$FIX/a6d"; mkproj "$P" 4; cd "$P" +write_intent "$P" feat-a6d +gate approve.sh intent .sdlc/work/feat-a6d/intent.md --lazy --review "read app.sh" >/dev/null +echo "a later edit" >> .sdlc/work/feat-a6d/intent.md +assert_exit_msg "A6h an edited artifact blocks the machine view too" 20 "gate.stale.intent" auto next feat-a6d + +# ===================================================================== +# A7 a v0.9.0 delivery record (no handoff fields) keeps working unchanged +# ===================================================================== +P="$FIX/a7"; mkproj "$P" 4; cd "$P" +write_intent "$P" feat-a7 +sed 's/^- Delivery target: pr/- Delivery target: local/' .sdlc/work/feat-a7/intent.md > i.tmp && mv i.tmp .sdlc/work/feat-a7/intent.md +gate approve.sh intent .sdlc/work/feat-a7/intent.md --lazy --review "read app.sh" >/dev/null +printf '# Evidence: feat-a7\n- R1: ok\n' > .sdlc/work/feat-a7/evidence.md +out=$(gate approve.sh ship .sdlc/work/feat-a7/evidence.md --lazy --review "read the diff") +DIG=$(printf '%s' "$out" | awk '/Reviewed source identity/{print $4}') +cat > .sdlc/work/feat-a7/delivery.md < — replace the Material questions body in place + awk -v body="$1" ' + /^## Material questions/ { print; print body; skip = 1; next } + /^## / { skip = 0 } + skip && $0 !~ /^## / { next } + { print }' .sdlc/work/feat-a8/intent.md > m.tmp && mv m.tmp .sdlc/work/feat-a8/intent.md +} +set_material "- unresolved: do we drop the legacy greeting endpoint?" +assert_exit_msg "A8a 'unresolved:' is not the substring 'resolved' — it blocks" 10 "unresolved MATERIAL" auto intent-check feat-a8 +set_material "- which tenant DB do we migrate? (to be resolved with the human)" +assert_exit_msg "A8b prose mentioning 'resolved' in passing does not release it" 10 "unresolved MATERIAL" auto intent-check feat-a8 +set_material "- which tenant DB? not resolved: pending the human" +assert_exit_msg "A8b2 a NEGATED marker is not a resolution — the marker is anchored" 10 "unresolved MATERIAL" auto intent-check feat-a8 +set_material "- which tenant DB? non-resolved: still open" +assert_exit_msg "A8b3 'non-resolved:' does not release it either" 10 "unresolved MATERIAL" auto intent-check feat-a8 +set_material " - do we delete the users table? (nested bullet)" +assert_exit_msg "A8c a nested bullet counts" 10 "unresolved MATERIAL" auto intent-check feat-a8 +set_material "* do we delete the users table? (asterisk bullet)" +assert_exit_msg "A8d an asterisk bullet counts" 10 "unresolved MATERIAL" auto intent-check feat-a8 +set_material "1. do we delete the users table? (numbered)" +assert_exit_msg "A8e a numbered item counts" 10 "unresolved MATERIAL" auto intent-check feat-a8 +set_material "Do we delete the users table? (prose line, no marker at all)" +assert_exit_msg "A8f a bare prose line counts (fail-closed)" 10 "unresolved MATERIAL" auto intent-check feat-a8 +assert_exit_msg "A8g and the loop stops for it at lazymode 4" 10 "intent.material" auto next feat-a8 +set_material "- do we delete the users table? — resolved: no, the human kept it (chat 2026-09-17)" +assert_exit_msg "A8h the exact resolution syntax releases it" 0 "INTENT ok" auto intent-check feat-a8 +set_material "- [resolved] which tenant DB? tenant A (chat 2026-09-17)" +assert_exit_msg "A8h2 the bracket form in the same anchored position releases it" 0 "INTENT ok" auto intent-check feat-a8 +set_material "none" +assert_exit_msg "A8i an explicit 'none' releases it" 0 "INTENT ok" auto intent-check feat-a8 +set_material "" +assert_exit_msg "A8j an empty section releases it" 0 "INTENT ok" auto intent-check feat-a8 +set_material "- " +assert_exit_msg "A8k an unfilled template placeholder is incomplete, never silently ok" 20 "placeholder" auto intent-check feat-a8 +# the template's own multi-line HTML comment is not content +set_material "PLACEHOLDER_COMMENT" +awk '{ if ($0 == "PLACEHOLDER_COMMENT") { print "" } else print }' \ + .sdlc/work/feat-a8/intent.md > m.tmp && mv m.tmp .sdlc/work/feat-a8/intent.md +assert_exit_msg "A8l an HTML comment is not content" 0 "INTENT ok" auto intent-check feat-a8 + +# ===================================================================== +# A9 (B2) the PROSE cockpit knows the same contract. This is the screen an +# agent reads: it used to say "record the intent approval" over an open +# material question while tools/auto.sh said "needs-human". +# ===================================================================== +P="$FIX/a9"; mkproj "$P" 4; cd "$P" +write_intent "$P" feat-a9 "- which of the two greetings is correct?" +assert_exit_msg "A9a the machine view stops" 10 "needs-human" auto next feat-a9 +assert_msg "A9b the cockpit names the open material question" "MATERIAL QUESTION OPEN" gate status.sh feat-a9 +out=$(gate status.sh feat-a9 2>&1) +case "$out" in + *"next → a MATERIAL question"*) pass "A9c and its next action is the human, not the lazy gate";; + *) fail "A9c the cockpit still points at approve.sh" "$out";; +esac +case "$out" in + *"next → lazy gate"*) fail "A9d the cockpit must not offer the lazy intent gate here" "$out";; + *) pass "A9d the lazy intent gate is not offered over an open question";; +esac + +# ===================================================================== +# A10 (B3) every configured check runs. A check that reads stdin used to eat +# the rest of the recipe, and the run reported "ok" over checks that never +# happened — including the one that should have failed. +# ===================================================================== +P="$FIX/a10"; mkproj "$P" 4; cd "$P" +write_intent "$P" feat-a10 +gate approve.sh intent .sdlc/work/feat-a10/intent.md --lazy --review "read app.sh" >/dev/null +{ echo "profile: advisory" + echo "check: unit | unit | sh app.sh" + echo "check: greedy | unit | cat" + echo "check: R1 | e2e | sh -c './app.sh | grep -q hello'" + echo "check: R2 | unit | false" +} > .sdlc/verify.md +assert_exit_msg "A10a a stdin-eating check does not swallow the rest of the recipe" 1 "VERIFY fail" verify run feat-a10 +n=$(grep -c '^check: ' .sdlc/work/feat-a10/verify-receipt.md) +[ "$n" = 4 ] && pass "A10b all four configured checks are on the receipt" || fail "A10b only $n of 4 checks ran" +assert_grep .sdlc/work/feat-a10/verify-receipt.md '^checks_run: 4' "A10c the receipt states how many checks ran" +assert_grep .sdlc/work/feat-a10/verify-receipt.md '^result: fail' "A10d the check that had to fail was reached" +# with the failing check fixed the same recipe passes — over ALL four checks +{ echo "profile: advisory" + echo "check: unit | unit | sh app.sh" + echo "check: greedy | unit | cat" + echo "check: R1 | e2e | sh -c './app.sh | grep -q hello'" + echo "check: R2 | unit | true" +} > .sdlc/verify.md +assert_exit_msg "A10e the fixed recipe passes over every check" 0 "(4/4)" verify run feat-a10 +# and a receipt CLAIMING a pass without accounting for every configured check +# is invalid, not ok +sed 's/^checks_run: 4/checks_run: 2/' .sdlc/work/feat-a10/verify-receipt.md > r.tmp && mv r.tmp .sdlc/work/feat-a10/verify-receipt.md +assert_exit_msg "A10f a pass that does not account for every check is invalid" 1 "VERIFY invalid" verify check feat-a10 + +# ===================================================================== +# A11 (B4) an owned runtime: launched in its own process group, proven alive, +# and stopped as a GROUP. A runtime this run did not start never counts as +# evidence about this source. +# ===================================================================== +P="$FIX/a11"; mkproj "$P" 4; cd "$P" +write_intent "$P" feat-a11 +gate approve.sh intent .sdlc/work/feat-a11/intent.md --lazy --review "read app.sh" >/dev/null +mkdir -p .sdlc/work/feat-a11/scratch +# a runtime that forks a grandchild and waits: killing the direct child alone +# leaves the grandchild holding whatever it holds +cat > server.sh <<'SH' +#!/bin/sh +( while :; do date +%s > .sdlc/work/feat-a11/scratch/served.txt; sleep 1; done ) & +echo $! > .sdlc/work/feat-a11/scratch/inner.pid +wait +SH +{ echo "profile: strict" + echo "launch: sh server.sh" + echo "doctor: test -s .sdlc/work/feat-a11/scratch/served.txt" + echo "doctor_timeout: 20" + echo "check: R1 | e2e | test -s .sdlc/work/feat-a11/scratch/served.txt" +} > .sdlc/verify.md +assert_exit_msg "A11a a launched runtime plus a passing doctor verifies" 0 "VERIFY ok" verify run feat-a11 +assert_grep .sdlc/work/feat-a11/verify-receipt.md '^launch: started' "A11b the receipt records that THIS run started the runtime" +assert_grep .sdlc/work/feat-a11/verify-receipt.md '^runtime_instance: owned' "A11c and that the instance was its own" +before=$(cat .sdlc/work/feat-a11/scratch/served.txt) +sleep 3 +after=$(cat .sdlc/work/feat-a11/scratch/served.txt) +[ "$before" = "$after" ] && pass "A11d the whole process group is gone: nothing keeps writing" \ + || fail "A11d LEAK: the grandchild survived tools/verify.sh ($before → $after)" +if [ -f .sdlc/work/feat-a11/scratch/inner.pid ]; then + ipid=$(cat .sdlc/work/feat-a11/scratch/inner.pid) + kill -0 "$ipid" 2>/dev/null && fail "A11e the forked grandchild ($ipid) is still alive" \ + || pass "A11e the forked grandchild is not alive either" +fi +# --no-launch: the checks run against something this run did not start. Under a +# strict profile that is NOT runtime proof of this source, and it says so. +rm -f .sdlc/work/feat-a11/scratch/served.txt +date +%s > .sdlc/work/feat-a11/scratch/served.txt # an "external instance" already serving +assert_exit_msg "A11f --no-launch under strict refuses to call an external instance proof" 1 "EXTERNAL" \ + verify run feat-a11 --no-launch +assert_grep .sdlc/work/feat-a11/verify-receipt.md '^runtime_instance: external' "A11g the receipt labels the instance external" +# a launch that dies while something else answers the doctor: the doctor's +# "yes" is about a process this run does not own +P="$FIX/a11b"; mkproj "$P" 4; cd "$P" +write_intent "$P" feat-a11b +gate approve.sh intent .sdlc/work/feat-a11b/intent.md --lazy --review "read app.sh" >/dev/null +mkdir -p .sdlc/work/feat-a11b/scratch +# The launched runtime must still be up for the doctor's FIRST attempt and gone +# for the one that answers. A fixed `sleep` raced that window (python's own +# start-up cost alone is most of a second on the Windows runner), so the doctor +# ends the launch itself and waits for it to be really gone: same proof, no +# clock in it. +cat > launch.sh <<'SH' +#!/bin/sh +d=.sdlc/work/feat-a11b/scratch +: > "$d/up" +i=0 +while [ ! -f "$d/stop" ] && [ "$i" -lt 300 ]; do sleep 0.2; i=$((i + 1)); done +rm -f "$d/up" +SH +cat > doctor.sh <<'SH' +#!/bin/sh +# answers only from the second attempt on — and by then the launch IS dead, +# because this is what stops it +d=.sdlc/work/feat-a11b/scratch +if [ ! -f "$d/tick" ]; then : > "$d/tick"; exit 1; fi +: > "$d/stop" +i=0 +while [ -f "$d/up" ] && [ "$i" -lt 100 ]; do sleep 0.1; i=$((i + 1)); done +[ -f "$d/up" ] && exit 1 # the launch never went away: assert nothing +sleep 1 # let the shell that wrote it finish exiting +exit 0 +SH +{ echo "profile: strict" + echo "launch: sh launch.sh" + echo "doctor: sh doctor.sh" + echo "doctor_timeout: 20" + echo "check: R1 | e2e | true" +} > .sdlc/verify.md +assert_exit_msg "A11h a doctor answered by a runtime this run did not start blocks" 1 "already dead" verify run feat-a11b +assert_grep .sdlc/work/feat-a11b/verify-receipt.md '^doctor: unowned-runtime' "A11i the receipt records the unowned runtime" +assert_exit_msg "A11j and the machine view will not call it verified" 1 "VERIFY blocked" verify check feat-a11b +# a launch that never comes up at all is a failure, not a skipped step +P="$FIX/a11c"; mkproj "$P" 4; cd "$P" +write_intent "$P" feat-a11c +gate approve.sh intent .sdlc/work/feat-a11c/intent.md --lazy --review "read app.sh" >/dev/null +{ echo "profile: strict" + echo "launch: sh -c 'echo EADDRINUSE: port 3000 already in use >&2; exit 1'" + echo "check: R1 | e2e | true" +} > .sdlc/verify.md +assert_exit_msg "A11k a launch that exits immediately fails the run" 1 "VERIFY" verify run feat-a11c +assert_grep .sdlc/work/feat-a11c/verify-receipt.md '^launch: failed' "A11l and the receipt says the launch failed" + +# ===================================================================== +# A12 (B4/N6) a check that hangs is bounded. Unattended, one hung command used +# to stop the driver for good. +# ===================================================================== +P="$FIX/a12"; mkproj "$P" 4; cd "$P" +write_intent "$P" feat-a12 +gate approve.sh intent .sdlc/work/feat-a12/intent.md --lazy --review "read app.sh" >/dev/null +{ echo "profile: advisory" + echo "check_timeout: 3" + echo "check: hang | unit | sleep 120" + echo "check: after | unit | true" +} > .sdlc/verify.md +t0=$(date +%s) +assert_exit_msg "A12a a hung check is killed at check_timeout, not waited on" 1 "TIMED OUT" verify run feat-a12 +t1=$(date +%s) +[ $((t1 - t0)) -lt 60 ] && pass "A12b the bound really was the wall clock ($((t1 - t0))s)" \ + || fail "A12b the run took $((t1 - t0))s" +assert_grep .sdlc/work/feat-a12/verify-receipt.md '^checks_run: 2' "A12c the checks after the hung one still ran" +# a half-filled recipe is refused before anything is executed +P="$FIX/a12b"; mkproj "$P" 4; cd "$P" +write_intent "$P" feat-a12b +gate approve.sh intent .sdlc/work/feat-a12b/intent.md --lazy --review "read app.sh" >/dev/null +cp "$KIT/templates/verify.md" .sdlc/verify.md +t0=$(date +%s) +assert_exit_msg "A12d an unfilled recipe is refused immediately, not run" 2 "placeholder" verify run feat-a12b +t1=$(date +%s) +[ $((t1 - t0)) -lt 30 ] && pass "A12e it does not burn the doctor timeout on a placeholder" \ + || fail "A12e the placeholder recipe took $((t1 - t0))s" +assert_exit_msg "A12f the loop reports the recipe, not a fake verification" 20 "verify.recipe" auto next feat-a12b + +# ===================================================================== +# A13 (N5) a check that writes into the tree makes the receipt describe a +# snapshot that no longer exists. Saying `ok` and then `stale` forever +# livelocked the driver. +# ===================================================================== +P="$FIX/a13"; mkproj "$P" 4; cd "$P" +write_intent "$P" feat-a13 +gate approve.sh intent .sdlc/work/feat-a13/intent.md --lazy --review "read app.sh" >/dev/null +{ echo "profile: advisory" + echo "check: cover | unit | sh -c 'date +%s%N > coverage.out'" +} > .sdlc/verify.md +assert_exit_msg "A13a a check that changes the source ends inconclusive, not ok" 1 "inconclusive" verify run feat-a13 +assert_grep .sdlc/work/feat-a13/verify-receipt.md '^source_digest_before: ' "A13b the receipt records the source before" +assert_grep .sdlc/work/feat-a13/verify-receipt.md '^source_digest_after: ' "A13c and after" +assert_exit_msg "A13d the state stays inconclusive instead of flapping ok/stale" 1 "VERIFY" verify check feat-a13 + +# ===================================================================== +# A14 (N4/N10) a receipt is change detection: a cited log that does not exist, +# or one edited afterwards, is visible. (It is NOT authentication — see the +# limitation in docs/automation.md.) +# ===================================================================== +P="$FIX/a14"; mkproj "$P" 4; cd "$P" +write_intent "$P" feat-a14 +gate approve.sh intent .sdlc/work/feat-a14/intent.md --lazy --review "read app.sh" >/dev/null +write_recipe "$P" advisory "check: R1 | e2e | sh -c './app.sh | grep -q hello'" +verify run feat-a14 >/dev/null 2>&1 +assert_exit_msg "A14a the real receipt is ok" 0 "VERIFY ok" verify check feat-a14 +mv .sdlc/work/feat-a14/scratch/verify/R1.log .sdlc/work/feat-a14/scratch/verify/R1.log.bak +assert_exit_msg "A14b a receipt citing a log that is not there is invalid" 1 "does not exist" verify check feat-a14 +mv .sdlc/work/feat-a14/scratch/verify/R1.log.bak .sdlc/work/feat-a14/scratch/verify/R1.log +echo "and everything else passed too" >> .sdlc/work/feat-a14/scratch/verify/R1.log +assert_exit_msg "A14c a log edited after the run is invalid" 1 "changed after it was recorded" verify check feat-a14 +# The digests a receipt binds are raw bytes of the artifact, whichever sha256 +# tool the platform actually has (shasum, sha256sum, openssl — Git Bash does not +# necessarily ship the first). A tool that read in TEXT mode would hash a CRLF +# file and its LF twin to the same value, i.e. change detection would stop +# detecting a change; python3 (already required by tools/verify.sh) is the +# independent reading of those bytes. +if command -v python3 >/dev/null 2>&1; then + want=$(python3 -c 'import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' .sdlc/verify.md) + got=$(bash -c '. "'"$KIT"'/gates/_common.sh"; . "'"$KIT"'/gates/_auto.sh"; sdlc_verify_recipe_digest') + [ "$want" = "$got" ] && pass "A14a2 the recipe digest is the sha256 of the recipe's actual bytes" \ + || fail "A14a2 the recipe digest is not the sha256 of the recipe's actual bytes" "bytes: $want +helper: $got +sha tool: $(command -v shasum || command -v sha256sum || command -v openssl || echo none)" +fi +# a hand-written receipt that cites logs nobody wrote. Its digests come from the +# kit's OWN helpers (as the source_digest lines above already did): hardcoding +# `shasum` here made the fixture, not the product, the thing under test — and on +# Git Bash it produced a digest the kit never would, so this case failed as +# `stale` (wrong recipe digest) long before it could reach the missing logs it +# is actually about. +P="$FIX/a14b"; mkproj "$P" 4; cd "$P" +write_intent "$P" feat-a14b +gate approve.sh intent .sdlc/work/feat-a14b/intent.md --lazy --review "read app.sh" >/dev/null +write_recipe "$P" strict "check: R1 | e2e | sh -c './app.sh | grep -q hello'" +cat > .sdlc/work/feat-a14b/verify-receipt.md </dev/null +write_recipe "$P" advisory "check: R1 | e2e | sh -c './app.sh | grep -q hello'" +verify run feat-a15 >/dev/null 2>&1 +printf '# Evidence: feat-a15\n- R1: sh app.sh → hello\n' > .sdlc/work/feat-a15/evidence.md +git checkout -q -b feat-a15 +git add -A >/dev/null; git commit -qm "feat: greeting" +gate approve.sh ship .sdlc/work/feat-a15/evidence.md --lazy --review "read the diff" >/dev/null +printf -- '- full e2e suite green on staging\n' >> .sdlc/work/feat-a15/evidence.md +assert_exit_msg "A15a check-gate.sh calls the edited artifact CLOSED" 1 "changed after it was approved" \ + gate check-gate.sh ship .sdlc/work/feat-a15/evidence.md +assert_exit_msg "A15b the machine view blocks with it" 20 "gate.stale.ship" auto next feat-a15 +assert_exit_msg "A15c and the push refuses, in check-gate.sh's own words" 1 "changed after it was approved" \ + handoff push feat-a15 --authorized "push the review branch" +[ -z "$(git ls-remote "$REMOTE" 2>/dev/null)" ] && pass "A15d no external effect happened over a closed gate" \ + || fail "A15d the remote has refs: something was pushed over a CLOSED ship gate" +# repairing the gate lets the same push through +gate approve.sh ship .sdlc/work/feat-a15/evidence.md --lazy --review "re-read the diff" >/dev/null +git add -A >/dev/null; git commit -qm "docs: evidence" +assert_exit_msg "A15e a repaired gate publishes the branch" 0 "HANDOFF review-ready" \ + handoff push feat-a15 --authorized "push the review branch" +# a verification that no longer covers this source also stops the push, on its +# own: the recipe lives under .sdlc/, so the ship source binding stays intact +write_recipe "$P" advisory "check: R1 | e2e | sh -c './app.sh | grep -q hello'" "check: R2 | unit | true" +assert_exit_msg "A15f a receipt that no longer covers the recipe stops the next push" 1 "verification stale" \ + handoff push feat-a15 --authorized "push the review branch" + +# ===================================================================== +# A16 (B6) a `pr` feature that was never pushed does not read as review-ready, +# and does not close as shipped. `Verified-by:` prose is not a check. +# ===================================================================== +P="$FIX/a16"; mkproj "$P" 4; cd "$P" +REMOTE="$FIX/a16-remote.git"; git init -q --bare "$REMOTE"; git remote add origin "$REMOTE" +write_intent "$P" feat-a16 +gate approve.sh intent .sdlc/work/feat-a16/intent.md --lazy --review "read app.sh" >/dev/null +printf '# Evidence: feat-a16\n- R1: ok\n' > .sdlc/work/feat-a16/evidence.md +git checkout -q -b feat-a16 +git add -A >/dev/null; git commit -qm "feat: greeting" +gate approve.sh ship .sdlc/work/feat-a16/evidence.md --lazy --review "read the diff" >/dev/null +SHA=$(git rev-parse HEAD) +cat > .sdlc/work/feat-a16/delivery.md <&1) +case "$out" in + *"review-ready"*) fail "A16d --no-remote-check claimed review-ready without looking" "$out";; + *) pass "A16d a network-free poll does not claim review-ready";; +esac +case "$out" in + *"close.sh"*) fail "A16e --no-remote-check suggested closing as shipped" "$out";; + *) pass "A16e nor does it suggest closing the feature";; +esac +assert_msg "A16f it names the check it did not run" "handoff.sh check" auto next feat-a16 --no-remote-check +# the prose cockpit reports the claim as a claim, never as a confirmed handoff +assert_msg "A16f2 the cockpit does not present an unchecked handoff as done" "NOT CHECKED HERE" gate status.sh feat-a16 +# a Handoff claim with no Remote/Branch at all +sed '/^- Remote: /d; /^- Branch: /d' .sdlc/work/feat-a16/delivery.md > d.tmp && mv d.tmp .sdlc/work/feat-a16/delivery.md +assert_exit_msg "A16g a handoff claim naming no branch is incomplete, not ready" 20 "handoff.incomplete" auto next feat-a16 +assert_exit_msg "A16h close.sh blocks on it too" 1 "names no Remote/Branch" gate close.sh feat-a16 shipped "delivered" +# once it really is pushed, everything lines up again +git checkout -q feat-a16 2>/dev/null || true +cat > .sdlc/work/feat-a16/delivery.md < i.tmp && mv i.tmp .sdlc/work/feat-a17/intent.md +gate approve.sh intent .sdlc/work/feat-a17/intent.md --lazy --review "read app.sh" >/dev/null +printf '# Evidence: feat-a17\n- R1: ok\n' > .sdlc/work/feat-a17/evidence.md +gate approve.sh ship .sdlc/work/feat-a17/evidence.md --lazy --review "read the diff" >/dev/null +out=$(auto next feat-a17 2>&1) +case "$out" in + *"handoff.sh push"*) fail "A17a a local delivery was told to push" "$out";; + *) pass "A17a a local target is delivered locally, not pushed";; +esac +case "$out" in + *""*) fail "A17b the loop templated an authorization for itself" "$out";; + *) pass "A17b no placeholder authorization is offered to fill in";; +esac +REMOTE="$FIX/a17-remote.git"; git init -q --bare "$REMOTE"; git remote add origin "$REMOTE" +git checkout -q -b feat-a17; git add -A >/dev/null; git commit -qm "feat: greeting" +assert_exit_msg "A17c and handoff.sh refuses to push a local delivery" 1 "target for 'feat-a17' is 'local'" \ + handoff push feat-a17 --authorized "push it" +# no delivery target recorded anywhere: a human names it, the loop does not guess +P="$FIX/a17b"; mkproj "$P" 4; cd "$P" +write_intent "$P" feat-a17b +sed '/^- Delivery target: /d' .sdlc/work/feat-a17b/intent.md > i.tmp && mv i.tmp .sdlc/work/feat-a17b/intent.md +gate approve.sh intent .sdlc/work/feat-a17b/intent.md --lazy --review "read app.sh" >/dev/null +printf '# Evidence: feat-a17b\n- R1: ok\n' > .sdlc/work/feat-a17b/evidence.md +gate approve.sh ship .sdlc/work/feat-a17b/evidence.md --lazy --review "read the diff" >/dev/null +assert_exit_msg "A17d an unknown delivery target asks the human" 10 "delivery.target-unknown" auto next feat-a17b +out=$(auto next feat-a17b 2>&1) +case "$out" in + *"handoff.sh push"*) fail "A17e an unknown target was told to push anyway" "$out";; + *) pass "A17e and never proposes an external effect on a guess";; +esac +# a pr target whose recorded scope does not authorize publishing anything +P="$FIX/a17c"; mkproj "$P" 4; cd "$P" +REMOTE="$FIX/a17c-remote.git"; git init -q --bare "$REMOTE"; git remote add origin "$REMOTE" +write_intent "$P" feat-a17c +sed 's/^- Scope authorization: .*/- Scope authorization: "have a look at the greeting bug"/' \ + .sdlc/work/feat-a17c/intent.md > i.tmp && mv i.tmp .sdlc/work/feat-a17c/intent.md +gate approve.sh intent .sdlc/work/feat-a17c/intent.md --lazy --review "read app.sh" >/dev/null +printf '# Evidence: feat-a17c\n- R1: ok\n' > .sdlc/work/feat-a17c/evidence.md +git checkout -q -b feat-a17c; git add -A >/dev/null; git commit -qm "feat: greeting" +gate approve.sh ship .sdlc/work/feat-a17c/evidence.md --lazy --review "read the diff" >/dev/null +assert_exit_msg "A17f a scope that authorizes no publication stops at the human" 10 "handoff.unauthorized-scope" auto next feat-a17c +assert_exit_msg "A17g and --authorized does not manufacture the permission" 1 "authorizes publishing" \ + handoff push feat-a17c --authorized "I hereby authorize this push" +[ -z "$(git ls-remote "$REMOTE" 2>/dev/null)" ] && pass "A17h nothing was pushed on an agent's own say-so" \ + || fail "A17h the remote has refs after an unauthorized push attempt" + +# ===================================================================== +# A18 merged/deployed are reported as pending EXTERNAL proof: a feature ref on +# a remote is not a merge commit and not a deployment. +# ===================================================================== +P="$FIX/a18"; mkproj "$P" 4; cd "$P" +REMOTE="$FIX/a18-remote.git"; git init -q --bare "$REMOTE"; git remote add origin "$REMOTE" +write_intent "$P" feat-a18 +gate approve.sh intent .sdlc/work/feat-a18/intent.md --lazy --review "read app.sh" >/dev/null +printf '# Evidence: feat-a18\n- R1: ok\n' > .sdlc/work/feat-a18/evidence.md +git checkout -q -b feat-a18; git add -A >/dev/null; git commit -qm "feat: greeting" +gate approve.sh ship .sdlc/work/feat-a18/evidence.md --lazy --review "read the diff" >/dev/null +SHA=$(git rev-parse HEAD); git push -q origin "$SHA":refs/heads/feat-a18 +cat > .sdlc/work/feat-a18/delivery.md <&1) +case "$out" in + *"NOT verified here"*) pass "A18c it says plainly what it did not check";; + *) fail "A18c the merge claim is not qualified" "$out";; +esac +assert_exit_msg "A18d close.sh repeats the same qualification" 0 "NOT verified here" \ + gate close.sh feat-a18 shipped "merged after review" + +# ===================================================================== +# A19 (N1/N8) the retry cap is a literal key, and checkpoint fields are +# validated: one step's counter never resets another's. +# ===================================================================== +P="$FIX/a19"; mkproj "$P" 4; cd "$P" +write_intent "$P" feat-a19 +auto checkpoint feat-a19 --attempt build.fix --class deterministic >/dev/null +assert_exit_msg "A19a a deterministic step is capped at one attempt" 20 "RETRY CAP" \ + auto checkpoint feat-a19 --attempt build.fix --class deterministic +auto checkpoint feat-a19 --attempt build.fiy --class deterministic >/dev/null 2>&1 || true +assert_exit_msg "A19b a near-miss step name does not reset that cap" 20 "RETRY CAP" \ + auto checkpoint feat-a19 --attempt build.fix --class deterministic +assert_exit_msg "A19c a step name that WOULD be a regex gets its own counter" 0 "attempt 1/1" \ + auto checkpoint feat-a19 --attempt 'build.fi.' --class deterministic +assert_exit_msg "A19c2 and the original cap is still in force afterwards" 20 "RETRY CAP" \ + auto checkpoint feat-a19 --attempt build.fix --class deterministic +assert_exit_msg "A19c3 a step name outside [a-zA-Z0-9._-] is refused" 1 "must be" \ + auto checkpoint feat-a19 --attempt 'build fix' --class deterministic +assert_exit_msg "A19d and so is a newline injection" 1 "must be" \ + auto checkpoint feat-a19 --set-step "build +effect: push|origin|main|deadbeef" +assert_nogrep .sdlc/work/feat-a19/checkpoint.md '^effect: push' "A19e nothing was injected into the checkpoint" + +# ===================================================================== +# A20 (N2/N3) the JSON view survives a control character, and a feature +# directory that is not a usable slug is reported, not word-split into +# features that do not exist. +# ===================================================================== +P="$FIX/a20"; mkproj "$P" 4; cd "$P" +write_intent "$P" feat-a20 +printf 'build 2/3 \033[31mred\033[0m \007 bell\n' > .sdlc/work/feat-a20/progress.md +if command -v python3 >/dev/null 2>&1; then + auto status --json > "$FIX/a20.json" 2>&1 + python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "$FIX/a20.json" \ + && pass "A20a a control character in progress.md still yields parseable JSON" \ + || fail "A20a status --json is not parseable" "$(head -c 300 "$FIX/a20.json")" +else + pass "A20a skipped: no python3" +fi +mkdir -p ".sdlc/work/feat with space" +out=$(auto status 2>&1) +case "$out" in + *"feat with space"*) pass "A20b an unusable feature directory is reported by its real name";; + *) fail "A20b the unusable directory name is not reported" "$out";; +esac +case "$out" in + *"write .sdlc/work/feat/intent.md"*) fail "A20c the name was word-split into ghost features" "$out";; + *) pass "A20c it is not word-split into features that do not exist";; +esac +assert_msg "A20c2 the prose cockpit reports it the same way" "UNUSABLE NAME" gate status.sh +assert_exit_msg "A20d and it is refused by name as a slug" 1 "not a usable feature slug" auto next "feat with space" +rmdir ".sdlc/work/feat with space" + +# ===================================================================== +# A21 the push guard rails that were already right stay right, plus remote +# drift (a review branch someone else moved). +# ===================================================================== +P="$FIX/a21"; mkproj "$P" 4; cd "$P" +REMOTE="$FIX/a21-remote.git"; git init -q --bare "$REMOTE"; git remote add origin "$REMOTE" +write_intent "$P" feat-a21 +gate approve.sh intent .sdlc/work/feat-a21/intent.md --lazy --review "read app.sh" >/dev/null +printf '# Evidence: feat-a21\n- R1: ok\n' > .sdlc/work/feat-a21/evidence.md +git checkout -q -b feat-a21; git add -A >/dev/null; git commit -qm "feat: greeting" +gate approve.sh ship .sdlc/work/feat-a21/evidence.md --lazy --review "read the diff" >/dev/null +# somebody else's commit is sitting on the review branch +git commit -q --allow-empty -m "someone else's work" +git push -q origin HEAD:refs/heads/feat-a21 +git reset -q --hard HEAD~1 +assert_exit_msg "A21a a drifted review branch stops the push instead of racing it" 1 "NOT an ancestor" \ + handoff push feat-a21 --authorized "push the review branch" +assert_exit_msg "A21b and it is still never force-pushed" 2 "never force-pushes" \ + handoff push feat-a21 --authorized "push the review branch" --force-with-lease + + +# ===================================================================== +# A22 (N5) INT stops the verification that is RUNNING. The shell used to defer +# the trap until the foreground check returned, so a signalled run kept +# going for the rest of that check and then ran the remaining ones. +# Signals are POSIX here; on Windows/Git Bash the same path runs through +# tools/_run.py (taskkill /T), which is why the helper owns the kill. +# ===================================================================== +P="$FIX/a22"; mkproj "$P" 4; cd "$P" +write_intent "$P" feat-a22 +gate approve.sh intent .sdlc/work/feat-a22/intent.md --lazy --review "read app.sh" >/dev/null +mkdir -p .sdlc/work/feat-a22/scratch +S=".sdlc/work/feat-a22/scratch" +# R1 is slow and records that it finished; R2 must never run at all +{ echo "profile: strict" + echo "check_timeout: 120" + echo "cleanup: touch $S/cleanup.ran" + echo "check: R1 | e2e | sh -c 'echo \$\$ > $S/r1.pid; sleep 90; touch $S/r1.finished'" + echo "check: R2 | unit | touch $S/r2.ran" +} > .sdlc/verify.md +# job control ON for this one launch: a shell starts an asynchronous command +# with SIGINT IGNORED, and a disposition inherited as ignored cannot be trapped. +# `set -m` gives the run its own process group and its own default dispositions, +# which is the state an interactive Ctrl-C or a supervisor's signal really finds. +set -m +"$KIT/tools/verify.sh" run feat-a22 > "$S/run.log" 2>&1 & +vpid=$! +set +m +# wait for R1 to be the running check, then interrupt the run itself +i=0; while [ ! -f "$S/r1.pid" ] && [ "$i" -lt 100 ]; do sleep 0.2; i=$((i + 1)); done +sleep 0.5 +start=$(date +%s) +kill -INT "$vpid" 2>/dev/null +vrc=0; wait "$vpid" || vrc=$? +elapsed=$(( $(date +%s) - start )) +[ "$vrc" != 0 ] && pass "A22a an interrupted run exits non-zero" \ + || fail "A22a an interrupted run must not exit 0 (exit $vrc)" +[ "$elapsed" -lt 30 ] && pass "A22b it stops promptly instead of finishing the running check" \ + || fail "A22b the trap was deferred: ${elapsed}s to leave a 90s check" +[ ! -f "$S/r1.finished" ] && pass "A22c the running check did not complete its side effect" \ + || fail "A22c the interrupted check ran to completion anyway" +[ ! -f "$S/r2.ran" ] && pass "A22d no further check ran after the signal" \ + || fail "A22d a check ran after the interruption" +[ -f "$S/cleanup.ran" ] && pass "A22e the recipe's cleanup still ran" \ + || fail "A22e cleanup was skipped on the signal path" +[ ! -f .sdlc/work/feat-a22/verify-receipt.md ] && pass "A22f no receipt claims a verdict for this source" \ + || fail "A22f an interrupted run left a receipt" +r1pid=$(cat "$S/r1.pid" 2>/dev/null || echo "") +# Asserted on every platform now. MSYS `kill` reaches the native python3 helper +# with TerminateProcess, so it cannot run its own handler and hand the kill +# down; on Windows the helper therefore holds its check in a job object that +# dies with it, which is what makes this true there too. +if [ -n "$r1pid" ]; then + sleep 1 + kill -0 "$r1pid" 2>/dev/null && { fail "A22g LEAK: the check's process ($r1pid) survived"; kill -9 "$r1pid" 2>/dev/null; } \ + || pass "A22g the check's own process group is gone" +fi +# A surviving check also keeps its log file OPEN, which on Windows is not a +# cosmetic leak: the file cannot be removed while a handle is on it ("Device or +# resource busy"), so the interrupted run leaves its own scratch directory +# undeletable. Removing the log is the portable way to ask whether anything is +# still holding it. +rm -f "$S/verify/R1.log" 2>/dev/null +[ ! -e "$S/verify/R1.log" ] && pass "A22i the interrupted run holds no handle on its own log" \ + || fail "A22i the check's log is still held open after the interruption" "$(ls -l "$S/verify" 2>&1)" +assert_msg "A22h the run says what it stopped" "INTERRUPTED" cat "$S/run.log" + +# ===================================================================== +# A23 tools/_run.py's Windows primitives, driven PORTABLY with stand-ins for +# kernel32 and tasklist. The real Win32 calls only happen on Windows CI; +# what is asserted here is the behaviour around them, which is where the +# two defects were: a job-object failure that returned False and said +# NOTHING (so the helper kept promising a containment it did not have), +# and a tasklist fallback that matched the pid as a SUBSTRING of the whole +# line, so pid 5 read as alive off somebody else's `5,432 K` memory column. +# ===================================================================== +if command -v python3 >/dev/null 2>&1; then + P="$FIX/a23"; mkdir -p "$P"; cd "$P" + cat > probe.py <<'PY' +import os, sys, types +kit = sys.argv[1]; log = sys.argv[2] +sys.path.insert(0, os.path.join(kit, "tools")) +import _run + +_run.WINDOWS = True # the Windows branches, on this machine +err = {"code": 5} # ERROR_ACCESS_DENIED +# kernel32, its constants and its structures exist only on Windows, so the ones +# the job-object path touches are stood in for here. Nothing about the FAILURE +# handling under test depends on their real contents. +_run.ctypes = types.SimpleNamespace(get_last_error=lambda: err["code"], + byref=lambda x: x, sizeof=lambda x: 144) +class _Basic: LimitFlags = 0 +class _Limits: + def __init__(self): self.BasicLimitInformation = _Basic() +_run._JOB_EXTENDED_LIMITS = _Limits +_run.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000 +_run.JobObjectExtendedLimitInformation = 9 + +class NoJob: # CreateJobObjectW refuses + def CreateJobObjectW(self, a, b): return 0 +_run._WIN = NoJob() +print("CREATE:", _run._win_bind_tree(None)) + +class NoAssign: # ... and the later call refuses + def CreateJobObjectW(self, a, b): return 7 + def SetInformationJobObject(self, *a): return 1 + def AssignProcessToJobObject(self, *a): return 0 + def CloseHandle(self, h): + err["code"] = 6 # closing a handle overwrites last error + return 1 +_run._WIN = NoAssign() +print("ASSIGN:", _run._win_bind_tree(types.SimpleNamespace(_handle=3))) + +# the real spawn path: it must REPORT and still hand back a usable child +_run._WIN = NoJob(); err["code"] = 5 +p = _run._spawn("echo child ran", log, new_group=False, bind_tree=True) +print("CHILDRC:", p.wait()) +print("LOG:", open(log).read().replace("\n", " | ")) + +# tasklist fallback: the pid is a FIELD, never a substring of the line +_run._WIN = None +real = _run.subprocess +csv_out = ('"other.exe","5432","Console","1","5,432 K"\r\n' + '"helper.exe","1234","Console","1","432 K"\r\n') +class Shim: + PIPE = real.PIPE; DEVNULL = real.DEVNULL + @staticmethod + def run(*a, **k): return types.SimpleNamespace(stdout=csv_out.encode()) +_run.subprocess = Shim +print("ALIVE5:", _run._win_alive(5), "ALIVE432:", _run._win_alive(432), + "ALIVE1234:", _run._win_alive(1234), "ALIVE5432:", _run._win_alive(5432)) +_run.subprocess = real +PY + out=$(python3 probe.py "$KIT" "$P/check.log" 2>&1); prc=$? + [ "$prc" = 0 ] || fail "A23 the portable _run.py probe itself failed (exit $prc)" "$out" + if [ "$prc" = 0 ]; then + case "$out" in + *"CREATE: CreateJobObjectW, Win32 error 5"*) pass "A23a a refused job object names the call and the Win32 error";; + *) fail "A23a the binding failure does not name CreateJobObjectW/error" "$out";; + esac + case "$out" in + *"ASSIGN: AssignProcessToJobObject, Win32 error 5"*) pass "A23b the error is read before CloseHandle overwrites it";; + *) fail "A23b the reported Win32 error is not the one that refused" "$out";; + esac + case "$out" in + *"[sdlc-kit] windows: process tree NOT bound"*) pass "A23c the failure is announced on stderr instead of being silent";; + *) fail "A23c a binding failure is still silent on stderr" "$out";; + esac + case "$out" in + *"LOG:"*"process tree NOT bound"*) pass "A23d and the run's own check log records it too";; + *) fail "A23d the check log has no record of the unbound tree" "$out";; + esac + case "$out" in + *"CHILDRC: 0"*"child ran"*) pass "A23e the check still runs: the diagnostic replaces silence, not the run";; + *) fail "A23e the check did not run after a binding failure" "$out";; + esac + case "$out" in + *"ALIVE5: False ALIVE432: False ALIVE1234: True ALIVE5432: True"*) + pass "A23f the tasklist fallback matches the PID field, not the memory column";; + *) fail "A23f a dead pid still reads as alive off another process's line" "$out";; + esac + fi +else + pass "A23 not applicable: tools/_run.py cannot run at all without python3" +fi + +echo +echo "================================================================" +echo "PASSED: $PASSED FAILED: $FAILED" +if [ "$FAILED" -gt 0 ]; then printf 'failures:%s\n' "$FAILLIST"; exit 1; fi +echo "AUTOTEST PASS" diff --git a/gates/close.sh b/gates/close.sh index fca9203..fee254a 100755 --- a/gates/close.sh +++ b/gates/close.sh @@ -207,6 +207,48 @@ EOF esac exit 1;; esac + # --- the automated review handoff (v0.10 fields), OPT-IN --------------------- + # A pre-0.10 delivery.md has no Handoff line and closes exactly as it always + # did: `Verified-by` is the human's record of a check a human ran. But a record + # that CLAIMS the automation's exit condition ("Handoff: review-ready") must + # survive the automation's own check — otherwise a line an agent wrote would + # be the only evidence that a reviewer has anything to read (AGENTS.md rule 6: + # facts are established, never asserted in prose). + d_handoff=$(sdlc_delivery_field "$del" Handoff | awk '{print tolower($1)}') + case "$d_handoff" in + review-ready|merged|deployed) + d_remote=$(sdlc_delivery_field "$del" Remote) + d_branch=$(sdlc_delivery_field "$del" Branch) + d_source=$(sdlc_delivery_field "$del" Source) + if [ -z "$d_remote" ] || [ -z "$d_branch" ]; then + echo "BLOCKED: delivery.md says 'Handoff: $d_handoff' but names no Remote/Branch," + echo " so the branch a reviewer would read cannot be identified, let alone checked." + echo " Add '- Remote: ' and '- Branch: ', or drop the Handoff" + echo " line and close this as the ordinary delivery it is." + exit 1 + fi + d_remote_sha=$(git ls-remote "$d_remote" "refs/heads/$d_branch" 2>/dev/null | awk 'NR==1{print $1}') + d_local_sha=$(git rev-parse --verify --quiet "${d_source}^{commit}" 2>/dev/null || echo none) + if [ -z "$d_remote_sha" ]; then + echo "BLOCKED: $d_remote/$d_branch does not exist on the remote." + echo " 'Handoff: $d_handoff' claims a reviewer has something to read; they have not." + echo " Push it (tools/handoff.sh push $slug --authorized \"\"), or" + echo " correct delivery.md." + exit 1 + fi + if [ "$d_remote_sha" != "$d_local_sha" ]; then + echo "BLOCKED: $d_remote/$d_branch is at $d_remote_sha, the delivered Source is $d_local_sha." + echo " The reviewer would read other code than this feature delivered." + exit 1 + fi + echo "handoff: $d_handoff — $d_remote/$d_branch @ $d_remote_sha (checked with git ls-remote)" + case "$d_handoff" in + merged|deployed) + echo " NOTE: that the branch was $d_handoff is NOT verified here — a feature ref on a" + echo " remote is not a merge commit and not a deployment. delivery.md's Verified-by" + echo " ('$d_by') is the human's record of that step, not a check this kit ran.";; + esac;; + esac echo "delivery: $d_target — verified by '$d_by'" fi diff --git a/gates/selftest.sh b/gates/selftest.sh index 8860053..c9469ff 100755 --- a/gates/selftest.sh +++ b/gates/selftest.sh @@ -101,7 +101,7 @@ echo "ok: handed-off requires and records external reference" # 10. shell scripts are LF-only — a CRLF checkout (Git for Windows default # core.autocrlf=true, without .gitattributes) makes bash reject every script -crlf=$(find "$kit" -name '*.sh' -not -path '*/.git/*' -exec awk '/\r/{print FILENAME}' {} + | sort -u) +crlf=$(find "$kit" \( -name '*.sh' -o -name '*.py' \) -not -path '*/.git/*' -exec awk '/\r/{print FILENAME}' {} + | sort -u) [ -z "$crlf" ] || { echo "FAIL: CRLF line endings — bash on Windows cannot run these:"; echo "$crlf"; exit 1; } echo "ok: shell scripts are LF-only" diff --git a/gates/status.sh b/gates/status.sh index cf99165..b7b4807 100755 --- a/gates/status.sh +++ b/gates/status.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# status.sh [--all[=]] [slug] — cockpit: where is each OPEN feature in the +# status.sh [--json] [--all[=]] [slug] — cockpit: where is each OPEN feature in the # loop, what is the next action. Closed features live in .sdlc/archive/ # (close.sh moves them); --all lists the newest 20, --all= widens that. # A slug argument finds archived features without --all. Run from the project @@ -7,7 +7,18 @@ set -euo pipefail kit_self="$(cd "$(dirname "$0")/.." && pwd)" . "$kit_self/gates/_common.sh" +. "$kit_self/gates/_auto.sh" [ -d .sdlc ] || { echo "FAIL: no .sdlc/ here. Run init.sh first, from the project root."; exit 1; } +# --json is the MACHINE view of the same state (tools/auto.sh, schema +# sdlc-kit/auto-status@1): stage · status · next action · blockers · source +# identity, for a driver that must not parse the prose below. One +# implementation, shared through gates/_common.sh, so the two cannot disagree. +# It covers OPEN features only; --all is a prose-only flag, and a closed feature +# is answered by tools/auto.sh next . +if [ "${1:-}" = --json ]; then + shift + exec "$kit_self/tools/auto.sh" status --json "$@" +fi # --all is BOUNDED by default (newest 20) so an agent that runs it does not # pull thousands of archive lines into its context; --all= widens it. all=""; cap=20 @@ -63,6 +74,14 @@ found=0 for dir in .sdlc/work/*/; do [ -d "$dir" ] || continue slug=$(basename "$dir") + # a directory name that is not a usable slug is reported as itself: it names + # no feature, and nothing here should paste it into a command + if ! sdlc_auto_valid_slug "$slug"; then + found=1 + echo "== $slug" + echo " UNUSABLE NAME: a feature directory must be [a-zA-Z0-9._-]+ — rename it; no gate is evaluated for this directory" + continue + fi [ $# -ge 1 ] && [ "$slug" != "$1" ] && continue found=1 if [ -f "${dir}CLOSED" ]; then @@ -203,6 +222,28 @@ EOF fi printf " %-8s %s\n" "$stage" "$state" done + # The full-auto intent contract (gates/_auto.sh), in the SAME words the + # machine view uses. This is the screen an agent actually reads: if it showed + # "record the intent approval" while tools/auto.sh said "a human owes an + # answer", the cockpit would walk the loop straight over an open material + # question. It overrides the next action rather than queueing behind it. + if [ -f "${dir}intent.md" ] && [ ! -f ".sdlc/approvals/${slug}.intent.approval" ]; then + ist=$(sdlc_auto_intent_contract "$slug") + case "${ist%%|*}" in + material) + printf " %-8s %s — %s\n" "intent" "MATERIAL QUESTION OPEN" "${ist#*|}" + # at lazymode 4 the intent gate is the agent's to record, so this is the + # only thing standing between an open question and an approval + if [ "$lazy" -ge 4 ]; then + next_action="a MATERIAL question in ${dir}intent.md is unanswered: take it to the human. The intent gate is NOT recorded until it is answered, lazymode 4 included (AGENTS.md rule 3a)" + fi;; + incomplete) + printf " %-8s %s — %s\n" "intent" "CONTRACT INCOMPLETE" "${ist#*|}" + if [ "$lazy" -ge 4 ]; then + next_action="complete ${dir}intent.md before the intent gate: ${ist#*|}" + fi;; + esac + fi # shipped means delivered (AGENTS.md rule 6): after the ship gate the feature # still owes a delivery record before close.sh will accept 'shipped'. if [ -f ".sdlc/approvals/${slug}.ship.approval" ]; then @@ -223,11 +264,37 @@ EOF printf " %-8s %s\n" "delivery" "recorded (${dtarget:-?}) — NOT CLOSEABLE: $ddetail" [ -z "$next_action" ] && next_action="fix the delivery record (${dir}delivery.md): $ddetail";; esac + # A delivery.md that CLAIMS the automation's exit condition is checked + # against the remote by close.sh and by tools/auto.sh. This view does not + # touch the network, so it reports the claim as a claim — never as a + # delivery that has been confirmed to be where a reviewer can read it. + dhandoff=$(sdlc_delivery_field "${dir}delivery.md" Handoff | awk '{print tolower($1)}') + case "$dhandoff" in + review-ready|merged|deployed) + dremote=$(sdlc_delivery_field "${dir}delivery.md" Remote) + dbranch=$(sdlc_delivery_field "${dir}delivery.md" Branch) + if [ -z "$dremote" ] || [ -z "$dbranch" ]; then + printf " %-8s %s\n" "handoff" "claims '$dhandoff' but names no Remote/Branch — NOT CLOSEABLE" + [ -z "$next_action" ] && next_action="add '- Remote:' and '- Branch:' to ${dir}delivery.md, or drop the Handoff line" + else + printf " %-8s %s\n" "handoff" "claims '$dhandoff' on ${dremote}/${dbranch} — NOT CHECKED HERE (no network): tools/handoff.sh check $slug" + fi;; + esac else printf " %-8s %s\n" "delivery" "— (no delivery.md)" [ -z "$next_action" ] && next_action="deliver, then record it in ${dir}delivery.md (templates/delivery.md) before close.sh shipped" fi fi + # verification receipt (tools/verify.sh): the same verdict the machine view + # reports, so a feature never looks review-ready here and blocked there. + if [ -f .sdlc/verify.md ]; then + vst=$(sdlc_verify_state "$slug") + printf " %-8s %s — %s\n" "verify" "${vst%%|*}" "${vst#*|}" + case "${vst%%|*}" in + fail|stale|missing|blocked) + [ -z "$next_action" ] && next_action="verification: ${vst#*|}";; + esac + fi # heartbeat (AGENTS.md rule 9): the live one-liner plus its age, so silence # and a dead loop look different. BSD stat first (macOS), then GNU. if [ -s "${dir}progress.md" ]; then diff --git a/init.sh b/init.sh index a821f25..1423e28 100755 --- a/init.sh +++ b/init.sh @@ -57,8 +57,11 @@ ensure_line .gitignore '.sdlc/archive/*/approvals/' # per-feature working residue. The durable record is intent.md, spec.md, # plan.md, map.md, delivery.md, evidence.md, CLOSED — they are the reason the # feature can be understood a year later, so they stay committed (AGENTS.md -# rule 7). Only the bulky, machine-regenerable residue is ignored. -for artifact in baseline.txt deviations.md harvest.md; do +# rule 7). Only the bulky, machine-regenerable residue is ignored — including +# the automation layer's two working files: checkpoint.md (pending execution +# metadata; the artifacts stay the authority) and verify-receipt.md (regenerated +# by tools/verify.sh from the source it was run against). +for artifact in baseline.txt deviations.md harvest.md checkpoint.md verify-receipt.md; do ensure_line .gitignore ".sdlc/work/*/$artifact" ensure_line .gitignore ".sdlc/archive/*/$artifact" done @@ -186,7 +189,10 @@ if git rev-parse --git-dir >/dev/null 2>&1; then fi fi -echo "Next: 1) fill .sdlc/config.md verification commands" +echo "Next: 0) OPTIONAL (required for unattended runs): copy $kit/templates/verify.md to" +echo " .sdlc/verify.md and map each requirement to the real command that proves it;" +echo " tools/verify.sh then records a receipt bound to the source it ran against" +echo " 1) fill .sdlc/config.md verification commands" echo " 2) AGENT: ask the human which lazymode level to use (0-4; default 1 is already set in .sdlc/config.md)" echo " 3) point your harness at $kit/AGENTS.md (see README)" echo " 4) start a feature: agent reads $kit/skills/1-intent/SKILL.md" diff --git a/log/changelog-v0.10.0.md b/log/changelog-v0.10.0.md new file mode 100644 index 0000000..10b9659 --- /dev/null +++ b/log/changelog-v0.10.0.md @@ -0,0 +1,58 @@ +# v0.10.0 — automation layer + +Six stages, the same gates, the same source binding. What is new is a thin, +usable layer that lets a host drive the loop without parsing prose, and a +machine-checkable record that verification actually ran (change detection — +never authentication). + +## Changes + +- Add `tools/auto.sh`: the machine entrypoint. `status --json` (schema `sdlc-kit/auto-status@1`) reports per open feature the stage, a `status` of `ready | needs-human | blocked | complete`, the next action with its command, the blockers, honest gaps, source identity, verification, delivery, handoff, checkpoint, and heartbeat. `next ` prints one line and exits 0 / 10 / 20 / 30 for those states — the whole integration surface a scheduler needs. `gates/status.sh --json` is the same output; both share `gates/_auto.sh`, which takes every gate verdict from `gates/_common.sh`, so the machine view can never be more permissive than the gates. These scripts report and record; they run no model and perform no stage, and the documentation says so wherever a "ready" appears. +- Add the full-auto intent contract (AGENTS.md rule 3, `templates/intent.md`, `tools/auto.sh intent-check`): an unattended run may act on an `intent.md` only when it carries an actionable outcome, a `Scope authorization` line in the human's words, acceptance criteria, non-goals, labelled evidence, and a `## Material questions` section with nothing unresolved. Material questions (a wrong answer changes what gets built, breaks something, or exceeds the authorized scope) block and go to the human; optional uncertainty stays under `## Open questions` as `[assumed: why]` and blocks nothing. Lazymode is unchanged: it answers *who decides*, authorization answers *may this be done at all*. +- Add the reusable verification contract (`templates/verify.md` → `.sdlc/verify.md`) and `tools/verify.sh`: profile, launch, doctor, cleanup, environment, and a requirement→command map over the project's OWN commands. `run` refuses an unfilled or malformed recipe before anything executes, deletes the previous receipt, runs EVERY configured check (stdin on `/dev/null`, own process group, bounded by `check_timeout`), keeps every log under `scratch/verify/`, and records a receipt bound to the project's whole source snapshot before and after the run, the recipe digest, and each command's and output's digest. Editing code, commands, or the recipe makes it `stale`; a cited log that is missing or was edited afterwards, or checks that do not add up to the configured ones, make it `invalid`; a source change during the run makes it `inconclusive` instead of a pass that goes stale forever. A `launch:` runtime is started in its own process group and the whole group is stopped again; if the doctor answers while that process is dead the receipt records `doctor: unowned-runtime` and blocks, and `--no-launch` labels the instance `external`, which `profile: strict` refuses as proof of this source. A failed cleanup blocks rather than leaving a runtime behind. `profile: strict` refuses review-ready without a passing `runtime` or `e2e` check, and a doctor that never comes up is NOT VERIFIED — never a silent downgrade to a green unit suite. **What the receipt is worth:** it makes "these commands never ran", "this log was edited" and "this is about other code" detectable. It is change detection, not authentication — it says nothing about who produced it — and it does not replace the fresh-context verifier. +- Add the explicit review handoff (`tools/handoff.sh`): `check` establishes the remote branch's SHA with `git ls-remote` and prints the exit condition — **review-ready** (pushed, PR-ready, human review pending) as distinct from **merged/deployed**. `push` refuses without `--authorized ""`, refuses protected or shared branches (built-in list plus `protected_branches:` in `.sdlc/config.md`), refuses `--force`/`--force-with-lease` outright, refuses a commit whose tree does not CONTAIN the reviewed source (the existing whole-source snapshot, never a patch-id shortcut), and reports `already pushed` instead of repeating the effect. Merge and deploy require `Authorized-by:` in delivery.md at every lazymode level, including 4. +- Extend `templates/delivery.md` backward-compatibly with optional `Remote`, `Branch`, `Handoff` and `Authorized-by` lines. A v0.9.0 delivery record — no `Handoff:` line — closes exactly as before. Opting IN is opting into the check: once `delivery.md` claims `Handoff: review-ready|merged|deployed`, `close.sh` requires Remote/Branch and runs `git ls-remote` itself before accepting `shipped`, so the automation's exit condition can never rest on a `Verified-by:` line nobody executed. +- Add the checkpoint (`tools/auto.sh checkpoint`, gitignored `.sdlc/work//checkpoint.md`): pending execution metadata only — the artifacts remain the authority, and deleting the checkpoint changes no gate verdict. Attempts are classified and bounded (`transient` 3, `deterministic` 1, then escalate with exit 20), a source change resets the counters and invalidates receipts, and completed external effects stay on record so a resume never pushes twice. The loop's existing caps are untouched. +- Resolve the stage instructions that stopped for a human the lazymode had already waived: skills 1 and 5 now continue after a waived gate and keep their STOP for the levels that keep the gate human. Exactly four things still stop a waived loop — work outside the authorized scope, an unresolved material question, a blocker surviving its cap, and external delivery beyond a review branch. skills/6-maintain no longer stalls on its five intake questions when there is no reachable reporter: answer them from the ticket, logs, deploy record, and code, label each claim, and carry only what is genuinely material. +- Neutralize only the LABEL of the new `- Scope authorization:` line in `tools/tripwire.sh`, so its `auth` keyword does not make every intent.md trip the security wire. The human's words after the colon are scanned like any other text; the scan stays supplemental and still clears nothing. +- `init.sh` gitignores the two new working files (`checkpoint.md`, `verify-receipt.md`) in `work/` and `archive/`, and points at `templates/verify.md`. `gates/status.sh` gained a `verify` line so the prose cockpit and the machine view agree. +- Add `gates/autotest.sh`, the automation regression suite: throwaway git projects and LOCAL bare remotes in its own fixture, no network and no `gh` call. CI runs it beside the selftest and the e2e suite on Ubuntu, macOS, and Windows (Git Bash). + +## Hardening after the first independent review + +The layer was reviewed adversarially before release; these are the fixes, all +covered by new cases in `gates/autotest.sh`. + +- **The material-question test reads content, not Markdown.** Any non-empty line under `## Material questions` blocks — nested bullets, `*` bullets, numbered items and bare prose included. Only the resolution marker (tightened to the anchored canonical form below) or an explicit `none` releases it; `unresolved:` no longer reads as resolved, and unfilled placeholders read as `incomplete`. +- **The prose cockpit knows the same contract.** `gates/status.sh` prints the intent verdict and, at lazymode 4, replaces its own next action with it — it can no longer offer the lazy intent gate over a question a human owes an answer to. +- **Bounded, isolated execution** for checks, doctor attempts and cleanup (`tools/_run.py`, python3), so a hung command cannot stop an unattended driver and a check can no longer eat the recipe off stdin. +- **`tools/handoff.sh push` runs the complete ship gate** (`gates/check-gate.sh ship`) and the verification immediately before the push and again when confirming it; it refuses a `local` target, refuses a review branch that has drifted, and requires that `intent.md`'s Scope authorization actually name a publication — a `--authorized` flag an agent types is not permission. +- **The machine view never claims a handoff it did not check.** Remote verification is on by default for a delivery claiming a remote branch; `--no-remote-check` reports `not-checked`, stays `open`, and points at `tools/handoff.sh check` instead of suggesting a close. A `Handoff:` line with no Remote/Branch is `handoff.incomplete`. `merged`/`deployed` are reported as pending EXTERNAL proof: a feature ref on a remote is not a merge commit and not a deployment. +- **Delivery targets decide the next action.** `local` records a local delivery and is never pushed; an unrecorded target asks the human instead of proposing a push; `pr` requires the recorded scope. +- Plus: C0 control characters escaped in the status JSON (`\u00XX`), literal (never regex) checkpoint step matching with validated step/effect fields, and slug validation everywhere — a directory name with a space is reported, not split into features that do not exist. + +## Hardening after the second independent review + +- **The resolution marker is anchored, not searched for.** Under `## Material questions` a line is released only by the canonical marker — `- — resolved: `, or a leading `resolved:` / `[resolved …]` once Markdown markers are peeled. A line that merely CONTAINS the word keeps blocking, so `not resolved: pending`, `non-resolved:`, `unresolved:` and `to be resolved with the PM` are all still open questions, and no list of negation words has to be maintained for that to hold. `docs/automation.md`, `templates/intent.md` and `skills/1-intent` state the same accepted form. +- **An interrupted verification stops the check that is running.** `tools/verify.sh` starts every bounded child in the background and waits on it, because a shell does not run a trap while a foreground command is still running: INT/TERM used to be deferred until the current check returned, and the remaining checks then ran anyway. Now the trap fires at once, the helper it owns is stopped (TERM, bounded wait, KILL) and takes the check's own process group with it, the recipe's cleanup still runs, no further check starts, no receipt is written, and the run exits non-zero. Nothing outside the run is signalled: an external runtime the recipe did not launch is left alone. `tools/_run.py exec` forwards INT/TERM/HUP to its child's group and exits `128+signal` (Windows: the same path through `taskkill /T`). + +## Upgrade notes + +- Nothing is required. Existing projects keep working unchanged: no `.sdlc/verify.md` means the machine view reports `verify.unconfigured` as a gap rather than a block, and delivery records without the handoff fields behave as before. +- To use the strict automated profile, copy `templates/verify.md` to `.sdlc/verify.md`, map each requirement to a real command, and set `profile: strict`. Without a passing `runtime`/`e2e` check the feature will not read as review-ready — that is the point. +- Re-run `init.sh` to pick up the two new ignore lines. It does not touch the git index. +- `- Scope authorization:` and `## Material questions` are new intent.md sections. An older intent.md without them still gates normally; only `tools/auto.sh intent-check` (and therefore an unattended run) requires them. +- The digests here are change detection, exactly like the existing ones. A receipt does not authenticate who ran anything, and a pushed branch is not a review. `docs/automation.md` §8 lists the full boundary. +- **`tools/verify.sh` needs python3** (only that script, and only for `run`/`doctor`): a POSIX shell cannot portably bound a command by wall clock or kill a process group, and stock macOS has no `timeout(1)`. Without python3 it refuses with that reason instead of running unbounded; every other script stays shell + git. All three CI runners ship python3. +- **Drivers:** `--remote-check` is now the default and is accepted as a no-op; use `--no-remote-check` for a network-free poll. `status --json` gained `unusable_feature_dirs`, and `verification.state` gained `invalid`, `inconclusive` and `recipe`. + +## Validation + +Local, on macOS (Bash 3.2) — darwin 25.6.0, arm64 — with git 2.54.0: + +- `bash gates/selftest.sh` → `SELFTEST PASS`, exit 0. +- `bash gates/e2e.sh` → `E2E PASS`, 141 assertions, 0 failures, exit 0. +- `bash gates/autotest.sh` → `AUTOTEST PASS`, 183 assertions, 0 failures, exit 0. It drives real temp git repos, LOCAL bare remotes and a real launched runtime (process-group leak included), and signals a real running verification to prove the interrupt path; every reported finding above has a case that fails without its fix. +- `bash -n` over every shell script in the repository: clean. + +The remote CI matrix (Ubuntu, macOS, Windows/Git Bash) runs all three suites on the PR and merged commit; its checks must pass before publication. diff --git a/roles/verifier.md b/roles/verifier.md index 599db3c..5929341 100644 --- a/roles/verifier.md +++ b/roles/verifier.md @@ -8,7 +8,10 @@ Inputs you receive: paths to `plan.md`, `spec.md`, the changed-file list, and Do: 1. Run the build, test, and lint commands from config.md. Record exact commands - and verdict output. + and verdict output. If the project has a `.sdlc/verify.md` recipe, run + `tools/verify.sh run `: it executes the configured commands and writes + a receipt bound to this source. The receipt records what ran; YOUR report is + still the judgement, and a receipt never substitutes for it. 2. **Exercise the change for real, end to end** — the section below. Unit tests are not a substitute for it and never stand in for it silently. 3. Check each `plan.md` **Proof** item (compact route: intent.md's Proof diff --git a/skills/1-intent/SKILL.md b/skills/1-intent/SKILL.md index a9d07b3..de7ca9a 100644 --- a/skills/1-intent/SKILL.md +++ b/skills/1-intent/SKILL.md @@ -124,7 +124,23 @@ carries the fact to every later feature. ## Write the artifact Fill `templates/intent.md` → `.sdlc/work//intent.md`. Every claim -labeled. Every open question is carried forward explicitly in its own section. +labeled. Questions are carried forward in two sections, and the split matters: + +- **`## Material questions`** — a wrong answer would change what gets built, + break something, or exceed the authorized scope. These BLOCK: they go to the + human and are never guessed away to make progress. Resolve a line in place + (`— resolved: `) so the trail survives. The + marker is anchored to that position: "not resolved: …" is not a resolution. +- **`## Open questions`** — optional uncertainty, decidable from evidence + during the work or carried as `[assumed: why]`. These block nothing. + +`- Scope authorization:` records the scope the human already authorized, in +their words. It is authority, not a gate approval (AGENTS.md rule 3): inside it +do not ask again; outside it the loop stops at every lazymode. +`tools/auto.sh intent-check ` checks this contract — actionable Goal, +scope authorization, acceptance criteria, non-goals, labelled evidence, no +unresolved material question — and an unattended run may not act on an intent +that fails it. The `Goal:` line is the reporting sentence: one plain-language sentence — no code identifiers, no jargon — that a non-technical reader understands and can copy verbatim into a status report ("teachers can re-order quiz questions"). @@ -185,13 +201,17 @@ objection, run: --review "" [--risk-authorized ""] ``` -Post the intent summary and any objections to the human as FYI, then -continue. Otherwise tell the user: +Post the intent summary and any objections to the human as FYI, then continue +to the next stage in the same run — a waived gate is not a stop (AGENTS.md +rule 3). An unresolved MATERIAL question is the exception: it stops the loop +here, at every level. + +Below lazymode 4 the intent gate is the human's. Tell the user: > Review `.sdlc/work//intent.md`. If it says exactly what you want, run: > `/gates/approve.sh intent .sdlc/work//intent.md` -STOP. After approval, continue to stage 2 (`skills/2-spec/SKILL.md`) — or, on +and STOP there. After approval, continue to stage 2 (`skills/2-spec/SKILL.md`) — or, on the compact route, straight to build (`skills/4-build/SKILL.md`) with the intent gate as its gate. Dispatch that work to a subagent only when it buys something concrete (AGENTS.md rule 5); the approved artifact, not the diff --git a/skills/4-build/SKILL.md b/skills/4-build/SKILL.md index de3a238..e8ae624 100644 --- a/skills/4-build/SKILL.md +++ b/skills/4-build/SKILL.md @@ -111,6 +111,16 @@ the configured unit suite is not a stand-in for it. The verifier changes no source and no artifact, but it may produce build/test output, logs, and its own disposable fixtures. +When the project has a `.sdlc/verify.md` recipe (templates/verify.md), run +`tools/verify.sh run ` as part of that pass: it launches what the checks +need, waits for the doctor, runs the configured build/unit/lint/runtime/e2e +commands, and records a receipt bound to this source, the recipe, and each +command's and output's digest. The receipt makes a check that never ran, a log +edited afterwards, or a result that belongs to other code DETECTABLE — it is +change detection, not authentication, not the verifier's judgement, and no +substitute for the fresh context. Quote its deciding lines in evidence.md; a code or recipe change +makes it `stale` and it must be re-run. + For a bug fix, the verifier's job includes the proof chain (AGENTS.md rule 6): the failure reproduced before the fix, the causal mechanism, the SAME reproduction passing after, and the neighbouring flows through the changed diff --git a/skills/5-ship/SKILL.md b/skills/5-ship/SKILL.md index 758d7ae..727e4d3 100644 --- a/skills/5-ship/SKILL.md +++ b/skills/5-ship/SKILL.md @@ -98,12 +98,15 @@ At lazymode ≥3 (AGENTS.md rule 3): after the adversary pass, run `/gates/approve.sh ship .sdlc/work//evidence.md --lazy --review ""` (add `--risk-authorized ""` for risky work), post the evidence summary as FYI, and continue to commit -discipline. Otherwise: +discipline in the same run: a waived gate is not a stop (AGENTS.md rule 3). +A blocker surviving round 2 of the adversary still stops the loop here. + +Below lazymode 3 the ship gate is the human's: > Review `.sdlc/work//evidence.md`, then: > `/gates/approve.sh ship .sdlc/work//evidence.md` -STOP after requesting approval. +STOP after requesting approval — and ask once, not once per artifact. ## After approval: one authorization, then deliver @@ -120,11 +123,21 @@ work: > Approve this delivery? If the human already authorized this scope — "ship it when it's green", "push -to the PR" — that IS the authorization: proceed, post the same four items as -FYI, and do not ask again. At lazymode ≥3 the whole check is autonomous: -verify the four items yourself against the rules below and post them as FYI. -A change outside the authorized scope (a different branch, an extra file, a -deploy where a PR was agreed) is a new decision and goes back to the human. +to the PR", the `- Scope authorization:` line in intent.md — that IS the +authorization: proceed, post the same four items as FYI, and do not ask again. +At lazymode ≥3 the whole check is autonomous: verify the four items yourself +against the rules below and post them as FYI. A change outside the authorized +scope (a different branch, an extra file, a deploy where a PR was agreed) is a +new decision and goes back to the human. + +**Where the unattended loop ends: a pushed feature branch.** Pushing the +reviewed commit to the ticket's own branch is the review handoff, and +`tools/handoff.sh push --authorized ""` is the safe +way to do it: it refuses protected or shared branches, never force-pushes, +refuses a commit whose tree does not CONTAIN the reviewed source, and repeats +no push that already happened. Merging that branch or deploying it is a +separate human approval, recorded as delivery.md's `Authorized-by:` — the ship +approval is not that authorization. 1. Stage named paths only: changed source files, `.sdlc/work//`, and — when changed — `.sdlc/memory/POLICY.md` and `.sdlc/config.md` @@ -150,7 +163,11 @@ deploy where a PR was agreed) is a new decision and goes back to the human. compares that commit's tree against it), the command or project tool you actually ran to check the result, and its verbatim deciding output. Examples of a real check: `gh pr view --json state,mergeStateStatus`, the deploy tool's - status output, `git log origin/ -1` after a push. For a local + status output, `git log origin/ -1` after a push, or + `tools/handoff.sh check `, which reads the remote branch's SHA with + `git ls-remote` and prints the exit condition (review-ready vs merged or + deployed). Record `Remote`, `Branch` and `Handoff` beside the usual fields + when the target is a review branch. For a local target, the passing final suite over the delivered source is the result. Never write a result you did not observe — an unverified delivery is `Confirmed: no`, and the feature closes as handed-off, not shipped. diff --git a/skills/6-maintain/SKILL.md b/skills/6-maintain/SKILL.md index e248546..73bf685 100644 --- a/skills/6-maintain/SKILL.md +++ b/skills/6-maintain/SKILL.md @@ -29,6 +29,15 @@ Ask the reporter these five questions in one message. Skip any already answered. Each answer kills hypothesis classes for free; a researcher fan-out dispatched before these answers wastes most of its budget. +**Headless, or no reachable reporter?** Do not stall on an interview nobody +will answer. Answer every question you can from the ticket text, the logs, the +deploy record, and the code — that is where most of these answers already +live — label each one `[verified: how]` or `[assumed: why]` in intent.md, and +carry only what is left. What remains is material only if a wrong answer would +change what gets built or exceed the authorized scope: those go to the human +as `## Material questions` and the loop stops for them (AGENTS.md rule 3). +Everything else is an `[assumed]` line and the diagnosis continues. + 1. Which exact control did you use? (button label / menu item / gesture) 2. What did you see immediately after? (nothing at all, a popup, an error, a partial change) — "nothing at all" and "something wrong appeared" are diff --git a/templates/delivery.md b/templates/delivery.md index 1d888c8..77ee6b4 100644 --- a/templates/delivery.md +++ b/templates/delivery.md @@ -15,6 +15,27 @@ - Confirmed: yes | no - Verified-at: YYYY-MM-DDTHH:MM:SSZ + +- Remote: +- Branch: +- Handoff: review-ready | merged | deployed + +- Authorized-by: + ## Notes + +- +- — resolved: + +## Open questions + - ## Researcher findings diff --git a/templates/verify.md b/templates/verify.md new file mode 100644 index 0000000..77009ff --- /dev/null +++ b/templates/verify.md @@ -0,0 +1,65 @@ +# Verification recipe — copy to `.sdlc/verify.md` and fill in +# +# This is the project's own verification contract, read by `tools/verify.sh`. +# It maps every requirement to the REAL command that proves it and names the +# commands that bring a runtime up and take it down again. One directive per +# line; anything else in this file is a comment. +# +# EVERY placeholder below must be replaced or the line deleted. A recipe still +# holding `<…>` is refused before anything runs — a placeholder handed to a +# shell is not a verification, and a 60s doctor wait on one looks like work. +# +# profile: strict | advisory +# strict — a feature is not review-ready until a `runtime` or `e2e` check +# has actually passed over the current source, against a runtime +# THIS run launched (`--no-launch` does not qualify), with the +# doctor confirming it. A missing runtime environment blocks; it +# never downgrades to "unit tests passed". +# advisory — the same checks run and the same receipt is written, but a +# missing runtime is reported as a gap instead of a block. +profile: advisory + +# Optional. Start whatever the runtime checks need (a server, a worker, a +# container). It is started in ITS OWN PROCESS GROUP and the whole group is +# stopped at the end — children included. If it exits immediately (a port +# already in use, a syntax error) the run fails there and says so. +launch: + +# Optional but strongly recommended, and REQUIRED by `profile: strict` whenever +# `launch:` is set. Exit 0 ONLY when the environment is really ready to be +# driven. Make it prove IDENTITY, not just liveness: have it assert the build +# or version of the instance that answers (e.g. a /health payload carrying the +# commit sha, or `--version` matching the build under test). A bare port probe +# cannot tell this run's runtime from yesterday's still holding the port — and +# `tools/verify.sh` will only tell you that the process it started is the one +# still alive, not that the thing answering is the right build. +doctor: +doctor_timeout: 60 # total seconds to wait for the doctor to come up +doctor_attempt_timeout: 30 # seconds ONE doctor attempt may take +check_timeout: 900 # seconds ONE check may take before it is killed +cleanup_timeout: 30 # seconds cleanup (and stopping the runtime) may take + +# Optional. Always runs at the end, including after a failure. If it fails or +# times out, the run is reported as blocked: a leftover runtime would make the +# next result meaningless. +cleanup: + +# Optional, free text: where these checks run, for the receipt and evidence.md. +environment: + +# The requirement → command map. One line per check: +# check: | | +# id [a-zA-Z0-9._-]+ — usually the requirement id (R1, R2, …). It names +# the log file under .sdlc/work//scratch/verify/.log. +# kind build | unit | lint | runtime | e2e +# `runtime` and `e2e` are the only kinds that count as the real run: +# the change driven through the interface a user or caller meets. +# command the project's OWN command, scoped to the change where possible. +# It runs with stdin on /dev/null, in its own process group, bounded +# by check_timeout. EVERY configured check runs, and the receipt +# records how many were configured and how many ran. +check: build | build | +check: unit | unit | +check: lint | lint | +check: R1 | e2e | +check: R2 | runtime | diff --git a/tools/_run.py b/tools/_run.py new file mode 100755 index 0000000..850b3ca --- /dev/null +++ b/tools/_run.py @@ -0,0 +1,451 @@ +#!/usr/bin/env python3 +"""_run.py — bounded process execution for tools/verify.sh. Not a user command. + +A POSIX shell cannot portably (a) put a child in its own process group, (b) kill +that whole group, or (c) bound a command by wall clock without `timeout(1)`, +which stock macOS does not ship. `tools/verify.sh` therefore delegates those +three things here. Nothing else in the kit depends on this file: the gates, +`tools/auto.sh` and `tools/handoff.sh` remain shell + git only. + +Subcommands (all take --cmd as ONE shell string, run through `sh -c`): + + exec --cmd C --log F --timeout S run C to completion, stdin 1 and row[1].strip() == str(pid): + return True + return False + h = _WIN.OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, False, pid) + if not h: + # The process is gone, unless it is merely out of reach for this account. + return ctypes.get_last_error() == ERROR_ACCESS_DENIED + try: + if _WIN.WaitForSingleObject(h, 0) == WAIT_OBJECT_0: + return False # a process handle is signalled exactly when it exits + code = wintypes.DWORD() + if _WIN.GetExitCodeProcess(h, ctypes.byref(code)): + return code.value == STILL_ACTIVE + return True + finally: + _WIN.CloseHandle(h) + + +def _report(message, log=None): + """Say it where both the operator and the run's own log will see it.""" + line = "[sdlc-kit] " + message + print(line, file=sys.stderr, flush=True) + if log: + try: + with open(log, "ab") as fh: + fh.write((line + "\n").encode("utf-8", "replace")) + except OSError: + pass + + +def _win_bind_tree(proc): + """Tie the child's whole tree to this helper's own lifetime. + + MSYS `kill` reaches a native python3 through TerminateProcess, so on the + interrupted path this helper's signal handler never runs and the check it + started used to be orphaned — still holding its log file open, which is why + the fixture teardown hit "Device or resource busy". A job object with + KILL_ON_JOB_CLOSE is the teardown Windows honours even then: when this + process dies, by any means, the OS closes the handle and the job goes with + it. Only the process this helper started is ever in that job. + + Returns None when the tree is bound, otherwise a short reason naming the + Win32 call that refused and its error code. The caller REPORTS that reason: + returning a bare False made the one interesting failure — the containment + this helper's docstring promises is not there — completely silent, so the + only symptom was the original "Device or resource busy" with nothing to + trace it to. + """ + if _WIN is None: + return "kernel32 not reachable through ctypes" + try: + job = _WIN.CreateJobObjectW(None, None) + if not job: + return "CreateJobObjectW, Win32 error %d" % ctypes.get_last_error() + info = _JOB_EXTENDED_LIMITS() + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + if not _WIN.SetInformationJobObject( + job, JobObjectExtendedLimitInformation, + ctypes.byref(info), ctypes.sizeof(info)): + # read before CloseHandle: closing a handle overwrites the last error + reason = "SetInformationJobObject, Win32 error %d" % ctypes.get_last_error() + _WIN.CloseHandle(job) + return reason + if not _WIN.AssignProcessToJobObject(job, int(proc._handle)): + reason = "AssignProcessToJobObject, Win32 error %d" % ctypes.get_last_error() + _WIN.CloseHandle(job) + return reason + _JOBS.append(job) + return None + except Exception as exc: + return "%s: %s" % (type(exc).__name__, exc) + + +def _open_log(path, append=True): + if not path: + return subprocess.DEVNULL, None + d = os.path.dirname(path) + if d: + os.makedirs(d, exist_ok=True) + fh = open(path, "ab" if append else "wb") + return fh, fh + + +def _spawn(cmd, log, new_group=True, bind_tree=False): + out, fh = _open_log(log) + kwargs = {"stdin": subprocess.DEVNULL, "stdout": out, "stderr": subprocess.STDOUT} + if new_group: + if WINDOWS: + kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + kwargs["start_new_session"] = True + try: + p = subprocess.Popen(["sh", "-c", cmd], **kwargs) + finally: + # This helper keeps no handle on the log: the child writes it, and only + # the child's death releases it. + if fh is not None: + fh.close() + # bind_tree is for the children this helper WAITS for (exec). A launched + # runtime must outlive this process, so it is never put in the job. + if bind_tree and WINDOWS: + reason = _win_bind_tree(p) + if reason is not None: + _report("windows: process tree NOT bound to this helper (%s). If this run is " + "interrupted, the command it is waiting on may keep running and keep " + "its log file open." % reason, log) + return p + + +def _kill_tree(pid, sig): + """Signal the whole process group of pid. True when the signal was sent.""" + if WINDOWS: + rc = subprocess.call( + ["taskkill", "/T", "/F", "/PID", str(pid)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return rc == 0 + try: + os.killpg(os.getpgid(pid), sig) + return True + except (ProcessLookupError, PermissionError, OSError): + try: + os.kill(pid, sig) + return True + except OSError: + return False + + +def _group_alive(pid): + # The pid is always the NATIVE pid of the process this helper started (the + # one subprocess reports and the one taskkill understands). It is never an + # MSYS/Git-Bash pid: those two number spaces do not correspond, and only + # this helper ever writes or reads the pidfile. + if WINDOWS: + return _win_alive(pid) + try: + os.killpg(os.getpgid(pid), 0) + return True + except (ProcessLookupError, PermissionError, OSError): + try: + os.kill(pid, 0) + return True + except OSError: + return False + + +def _stop(p): + """Take the child's whole group down: TERM, a bounded wait, then KILL.""" + _kill_tree(p.pid, signal.SIGTERM) + try: + p.wait(timeout=5) + except subprocess.TimeoutExpired: + _kill_tree(p.pid, SIGKILL) + try: + p.wait(timeout=5) + except subprocess.TimeoutExpired: + pass + + +def cmd_exec(a): + if a.timeout <= 0: + print("_run.py: --timeout must be a positive number of seconds", file=sys.stderr) + return 2 + p = _spawn(a.cmd, a.log, bind_tree=True) + # The command runs in its OWN group, so a signal sent to this helper (or to + # the shell's foreground group) never reaches it. Forward it explicitly: + # an interrupted run must stop the command it is running, not outlive it. + # Windows has no process groups here; taskkill inside _kill_tree does the + # same job, and CREATE_NEW_PROCESS_GROUP likewise isolates the child. + caught = {} + + def _forward(signum, _frame): + caught.setdefault("signal", signum) + _stop(p) + + installed = [] + for s in (signal.SIGINT, getattr(signal, "SIGTERM", None), getattr(signal, "SIGHUP", None)): + if s is None: + continue + try: + installed.append((s, signal.signal(s, _forward))) + except (ValueError, OSError, RuntimeError): + pass + try: + try: + rc = p.wait(timeout=a.timeout) + except subprocess.TimeoutExpired: + rc = None + if caught.get("signal"): + if a.log: + with open(a.log, "a") as fh: + fh.write("\n[sdlc-kit] interrupted (signal %d) — process group terminated\n" + % caught["signal"]) + return 128 + caught["signal"] + if rc is not None: + return rc + finally: + for s, previous in installed: + try: + signal.signal(s, previous) + except (ValueError, OSError, RuntimeError): + pass + # Timed out: take the whole group down, gently first. + _stop(p) + if a.log: + with open(a.log, "a") as fh: + fh.write("\n[sdlc-kit] timed out after %gs — process group terminated\n" % a.timeout) + return TIMEOUT_RC + + +def cmd_launch(a): + p = _spawn(a.cmd, a.log) + with open(a.pidfile, "w") as fh: + fh.write("%d\n" % p.pid) + # Handshake: a launch command that dies immediately (a syntax error, a port + # already in use) must be reported as `failed` here, not discovered later as + # "something on that port answered the doctor". + deadline = time.time() + max(a.settle, 0.0) + while True: + rc = p.poll() + if rc is not None: + print("launch exited immediately with status %d" % rc, file=sys.stderr) + return 1 + if time.time() >= deadline: + break + time.sleep(0.1) + return 0 if _group_alive(p.pid) else 1 + + +def _read_pid(path): + try: + with open(path) as fh: + return int(fh.read().strip()) + except (OSError, ValueError): + return None + + +def cmd_alive(a): + pid = _read_pid(a.pidfile) + if pid is None: + return 2 + return 0 if _group_alive(pid) else 1 + + +def cmd_stop(a): + pid = _read_pid(a.pidfile) + if pid is None: + return 2 + if not _group_alive(pid): + return 0 + _kill_tree(pid, signal.SIGTERM) + deadline = time.time() + max(a.timeout, 1) + while time.time() < deadline: + if not _group_alive(pid): + return 0 + time.sleep(0.2) + _kill_tree(pid, SIGKILL) + deadline = time.time() + 5 + while time.time() < deadline: + if not _group_alive(pid): + return 0 + time.sleep(0.2) + print("process group %d survived TERM and KILL" % pid, file=sys.stderr) + return 1 + + +def main(argv): + ap = argparse.ArgumentParser(add_help=True) + sub = ap.add_subparsers(dest="sub") + e = sub.add_parser("exec"); e.add_argument("--cmd", required=True) + e.add_argument("--log", default=""); e.add_argument("--timeout", type=float, required=True) + e.set_defaults(fn=cmd_exec) + l = sub.add_parser("launch"); l.add_argument("--cmd", required=True) + l.add_argument("--log", default=""); l.add_argument("--pidfile", required=True) + l.add_argument("--settle", type=float, default=0.5) + l.set_defaults(fn=cmd_launch) + v = sub.add_parser("alive"); v.add_argument("--pidfile", required=True) + v.set_defaults(fn=cmd_alive) + s = sub.add_parser("stop"); s.add_argument("--pidfile", required=True) + s.add_argument("--timeout", type=float, default=15) + s.set_defaults(fn=cmd_stop) + a = ap.parse_args(argv) + if not getattr(a, "fn", None): + ap.print_usage(sys.stderr) + return 2 + return a.fn(a) + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tools/auto.sh b/tools/auto.sh new file mode 100755 index 0000000..fd11ff2 --- /dev/null +++ b/tools/auto.sh @@ -0,0 +1,608 @@ +#!/usr/bin/env bash +# auto.sh — the machine entrypoint of sdlc-kit. Run from the project root. +# +# tools/auto.sh status [--json] [slug] machine state of the open features +# tools/auto.sh next one line + an exit code for a driver +# tools/auto.sh intent-check the full-auto intent contract +# tools/auto.sh checkpoint … pending execution metadata (resume) +# +# WHAT THIS IS: a reporter over the artifacts and approval records that already +# exist, plus a place to record attempts and completed external effects. Every +# gate verdict is taken from gates/_common.sh — the same functions check-gate.sh +# and close.sh use — so this view can never be more permissive than the gates. +# +# WHAT THIS IS NOT: an agent. It runs no model, writes no artifact, and performs +# no stage. `status: ready` means "the next action is one this project's +# lazymode lets an agent take" — an LLM under the stage skills still does it. +# +# Exit codes of `next` (for a host scheduler such as Symphony): +# 0 ready · 10 needs-human · 20 blocked · 30 complete · 1 usage/environment +set -euo pipefail +kit="$(cd "$(dirname "$0")/.." && pwd)" +. "$kit/gates/_common.sh" +. "$kit/gates/_auto.sh" + +usage() { + cat >&2 <<'EOF' +usage (from the project root): + tools/auto.sh status [--json] [--no-remote-check] [slug] + tools/auto.sh next [--no-remote-check] + tools/auto.sh intent-check + tools/auto.sh checkpoint [--show | --set-step | --attempt --class + | --effect "|" | --clear] +EOF + exit 1 +} +[ $# -ge 1 ] || usage +cmd="$1"; shift +[ -d .sdlc ] || { echo "FAIL: no .sdlc/ here. Run init.sh first, from the project root." >&2; exit 1; } + +# Remote verification is ON by default for a delivery that claims a remote +# branch: a handoff the loop cannot see is not a handoff. `--no-remote-check` +# is for a poll that must touch no network — it then reports +# `remote_verdict: not-checked` and refuses to call anything review-ready. +# `--remote-check` is kept as an explicit no-op so existing drivers keep working. +REMOTE_CHECK=1 +JSON="" +slug="" +while [ $# -gt 0 ]; do + case "$1" in + --json) JSON=1;; + --remote-check) REMOTE_CHECK=1;; + --no-remote-check) REMOTE_CHECK="";; + --*) break;; + *) slug="$1";; + esac + shift +done + +# An unknown flag must not be swallowed silently: a driver that asked for +# something this version does not implement has to hear about it. +case "$cmd" in + status|next|intent-check) [ $# -eq 0 ] || { echo "FAIL: unknown option '$1'" >&2; usage; };; +esac + +# ---------------------------------------------------------------- evaluation +# Every field of the machine view is set here, once, by one decision table. +EV_STAGE=""; EV_STATUS=""; EV_NEXT_KIND=""; EV_NEXT_CMD=""; EV_NEXT_TEXT="" +EV_BLOCKERS=""; EV_GAPS=""; EV_EXIT=""; EV_TRACK="" +EV_SRC_STATE=""; EV_SRC_WANT=""; EV_SRC_NOW="" +EV_VERIFY=""; EV_VERIFY_DETAIL=""; EV_INTENT=""; EV_INTENT_DETAIL="" +EV_DELIVERY=""; EV_DELIVERY_DETAIL=""; EV_DELIVERY_TARGET=""; EV_HANDOFF="" +EV_REMOTE=""; EV_BRANCH=""; EV_REMOTE_SHA=""; EV_REMOTE_VERDICT="" +EV_CHECKPOINT=""; EV_CHECKPOINT_DETAIL=""; EV_CHECKPOINT_STEP="" + +add_blocker() { EV_BLOCKERS="$EV_BLOCKERS$1|$2 +"; } +add_gap() { EV_GAPS="$EV_GAPS$1|$2 +"; } +set_next() { EV_NEXT_KIND="$1"; EV_NEXT_CMD="$2"; EV_NEXT_TEXT="$3"; } + +skill_dir_for() { case "$1" in + intent) echo skills/1-intent;; spec) echo skills/2-spec;; plan) echo skills/3-plan;; + ship) echo skills/5-ship;; *) echo skills;; esac; } + +planned_target() { # → local | pr | deploy | unknown (what the loop agreed on) + local dir=".sdlc/work/$1" v="" + if [ -f "$dir/delivery.md" ]; then + v=$(sdlc_delivery_field "$dir/delivery.md" Target | awk '{print tolower($1)}') + fi + if [ -z "$v" ] && [ -f "$dir/intent.md" ]; then + v=$(awk '/^- *Delivery target:/{sub(/^[^:]*: */,""); print tolower($1); exit}' "$dir/intent.md") + fi + if [ -z "$v" ] && [ -f "$dir/spec.md" ]; then + v=$(awk '/[Rr]elease procedure/{ if (match(tolower($0), /local|deploy|pr/)) { print substr(tolower($0), RSTART, RLENGTH); exit } }' "$dir/spec.md") + fi + case "$v" in local|pr|deploy) printf '%s\n' "$v";; *) echo unknown;; esac +} + +evaluate() { # + local s="$1" dir=".sdlc/work/$1" st detail stage rec loop art tier scope delivered_sha + EV_STAGE=""; EV_STATUS=""; EV_BLOCKERS=""; EV_GAPS=""; EV_EXIT=open + EV_NEXT_KIND=""; EV_NEXT_CMD=""; EV_NEXT_TEXT="" + EV_SRC_STATE=""; EV_SRC_WANT=""; EV_SRC_NOW="" + EV_VERIFY=""; EV_VERIFY_DETAIL=""; EV_INTENT=""; EV_INTENT_DETAIL="" + EV_DELIVERY=""; EV_DELIVERY_DETAIL=""; EV_DELIVERY_TARGET=""; EV_HANDOFF="" + EV_REMOTE=""; EV_BRANCH=""; EV_REMOTE_SHA=""; EV_REMOTE_VERDICT="not-checked" + EV_TRACK=$(sdlc_auto_track "$s") + EV_DELIVERY_TARGET=$(planned_target "$s") + + st=$(sdlc_checkpoint_state "$s"); EV_CHECKPOINT="${st%%|*}"; EV_CHECKPOINT_DETAIL="${st#*|}" + EV_CHECKPOINT_STEP=$(sdlc_field "$(sdlc_checkpoint_file "$s")" step 2>/dev/null || true) + + st=$(sdlc_auto_intent_contract "$s"); EV_INTENT="${st%%|*}"; EV_INTENT_DETAIL="${st#*|}" + st=$(sdlc_verify_state "$s"); EV_VERIFY="${st%%|*}"; EV_VERIFY_DETAIL="${st#*|}" + + if [ -f "$dir/CLOSED" ]; then + EV_STAGE=closed; EV_STATUS=complete + EV_EXIT=$(sdlc_auto_handoff_target "$dir/delivery.md") + case "$EV_EXIT" in unknown) EV_EXIT=closed;; esac + set_next none "" "closed: $(grep '^state: ' "$dir/CLOSED" | cut -d' ' -f2-)" + return 0 + fi + + # map-first and legacy-compressed features: the next action is not an artifact + if [ -f "$dir/map.md" ] && [ ! -f "$dir/intent.md" ]; then + EV_STAGE=map; EV_STATUS=ready + set_next write "" "resolve the top Unknown in $dir/map.md (skills/1-intent 'Chart a map first')" + return 0 + fi + if [ ! -f "$dir/intent.md" ] && [ -f "$dir/plan.md" ]; then + EV_STAGE=intent; EV_STATUS=ready + set_next write "" "legacy compressed feature: write $dir/intent.md (Track: compact), then pass the intent gate" + return 0 + fi + + loop="intent spec plan ship" + [ "$EV_TRACK" = compact ] && loop="intent ship" + for stage in $loop; do + art="$dir/$(sdlc_auto_artifact_for "$stage")" + rec=".sdlc/approvals/${s}.${stage}.approval" + st=$(sdlc_auto_stage_state "$s" "$stage"); detail="${st#*|}"; st="${st%%|*}" + case "$st" in + absent) + if [ "$stage" = ship ]; then + # evidence.md missing means the work itself is still open: build and + # the independent verification come before the ship artifact + EV_STAGE=build + case "$EV_VERIFY" in + fail|invalid) EV_STATUS=blocked; add_blocker verify.fail "$EV_VERIFY_DETAIL" + set_next verify "tools/verify.sh run $s" "the verification is not satisfied over this source — fix it, then re-run";; + blocked) EV_STATUS=blocked; add_blocker verify.environment "$EV_VERIFY_DETAIL" + set_next human "" "no runnable verification environment: $EV_VERIFY_DETAIL";; + recipe) EV_STATUS=blocked; add_blocker verify.recipe "$EV_VERIFY_DETAIL" + set_next write "" "$EV_VERIFY_DETAIL";; + missing|stale|inconclusive) + EV_STATUS=ready + set_next verify "tools/verify.sh run $s" "build per skills/4-build, then record the verification receipt";; + unconfigured) + EV_STATUS=ready; add_gap verify.unconfigured "$EV_VERIFY_DETAIL" + set_next build "" "build and verify per skills/4-build + roles/verifier.md, then write $art";; + ok) EV_STATUS=ready + set_next write "" "write $art (templates/evidence.md), quoting the receipt's deciding lines";; + esac + else + EV_STAGE="$stage"; EV_STATUS=ready + set_next write "" "write $art (see $(skill_dir_for "$stage"))" + fi + return 0;; + stale) + EV_STAGE="$stage"; EV_STATUS=blocked + add_blocker "gate.stale.$stage" "$detail" + set_next approve "" "$detail" + return 0;; + pending) + EV_STAGE="$stage" + # the intent contract decides whether an unattended run may act at all + if [ "$stage" = intent ]; then + case "$EV_INTENT" in + material) EV_STATUS=needs-human; add_blocker intent.material "$EV_INTENT_DETAIL" + set_next human "" "answer the MATERIAL question(s) in $art, then the intent gate" + return 0;; + incomplete) EV_STATUS=blocked; add_blocker intent.incomplete "$EV_INTENT_DETAIL" + set_next write "" "complete $art: $EV_INTENT_DETAIL" + return 0;; + esac + fi + if [ "$stage" = ship ]; then + case "$EV_VERIFY" in + fail|invalid) EV_STATUS=blocked; add_blocker verify.fail "$EV_VERIFY_DETAIL" + set_next verify "tools/verify.sh run $s" "$EV_VERIFY_DETAIL"; return 0;; + blocked) EV_STATUS=blocked; add_blocker verify.environment "$EV_VERIFY_DETAIL" + set_next human "" "$EV_VERIFY_DETAIL"; return 0;; + recipe) EV_STATUS=blocked; add_blocker verify.recipe "$EV_VERIFY_DETAIL" + set_next write "" "$EV_VERIFY_DETAIL"; return 0;; + missing|stale|inconclusive) + EV_STATUS=blocked; add_blocker verify.stale "$EV_VERIFY_DETAIL" + set_next verify "tools/verify.sh run $s" "$EV_VERIFY_DETAIL"; return 0;; + unconfigured) add_gap verify.unconfigured "$EV_VERIFY_DETAIL";; + esac + fi + if [ "$LAZY" -ge "$(sdlc_auto_lazy_min "$stage")" ]; then + EV_STATUS=ready + set_next approve "gates/approve.sh $stage $art --lazy --review \"\"" \ + "lazymode $LAZY waives the $stage human gate: review the affected code and behavior, then record the approval" + elif [ "$stage" = plan ]; then + tier=$(grep -qiE '^- *[Tt]ier: *human' "$art" && echo human || echo agent) + if [ "$tier" = human ]; then + EV_STATUS=needs-human; add_blocker gate.human.plan "plan.md declares Tier: human (a trip-wire) — the plan gate is a human decision" + set_next human "gates/approve.sh plan $art" "show the human the plan's Human summary and trip-wires" + else + EV_STATUS=ready + set_next approve "gates/approve.sh plan $art --agent-adversary" "tiered plan gate: after a clean adversary review" + fi + else + EV_STATUS=needs-human; add_blocker "gate.human.$stage" "lazymode $LAZY keeps the $stage gate human" + set_next human "gates/approve.sh $stage $art" "ask the human to approve $art" + fi + return 0;; + approved) ;; + esac + if [ "$stage" = ship ]; then + read -r EV_SRC_STATE EV_SRC_WANT EV_SRC_NOW <" into its own command + # line is not authorization (AGENTS.md rule 3). + case "$EV_DELIVERY_TARGET" in + local) + EV_STATUS=ready + set_next deliver "" "local delivery: run the agreed proof here and record it in $dir/delivery.md (templates/delivery.md). Nothing is pushed — a local target has no remote review branch.";; + pr) + if sdlc_auto_scope_allows_publish "$s"; then + scope=$(sdlc_auto_scope_authorization "$s") + EV_STATUS=ready + set_next deliver "tools/handoff.sh push $s --authorized \"$scope\"" \ + "publish the review branch within the scope intent.md records, then write $dir/delivery.md (Target, Source, Remote, Branch, Handoff: review-ready)" + else + EV_STATUS=needs-human + add_blocker handoff.unauthorized-scope "the delivery target is 'pr' but intent.md's Scope authorization does not name publishing a branch (push / PR / review branch) — an agent may not authorize an external effect for itself" + set_next human "" "ask the human whether this may be pushed for review; record their words in intent.md's '- Scope authorization:' line, re-run the intent gate, then deliver" + fi;; + deploy) + EV_STATUS=needs-human + add_blocker handoff.human "a deploy delivery leaves the review boundary: it needs its own human authorization, whatever the lazymode (AGENTS.md rule 3)" + set_next human "" "ask the human to authorize the deploy; the loop may push the feature branch and stop there";; + *) + EV_STATUS=needs-human + add_blocker delivery.target-unknown "no delivery target is recorded (intent.md '- Delivery target:' / delivery.md 'Target:'), so what 'shipped' has to prove is unknown — the loop does not pick one, and never pushes on a guess" + set_next human "" "ask the human which delivery this is (local | pr | deploy), record it in intent.md, then deliver";; + esac + return 0 + fi + st=$(sdlc_delivery_issue "$dir/delivery.md" "$EV_SRC_STATE" "$EV_SRC_WANT" "$EV_SRC_NOW") + EV_DELIVERY="${st%% *}"; EV_DELIVERY_DETAIL="${st#* }" + EV_HANDOFF=$(sdlc_auto_handoff_target "$dir/delivery.md") + EV_BRANCH=$(sdlc_delivery_field "$dir/delivery.md" Branch) + EV_REMOTE=$(sdlc_delivery_field "$dir/delivery.md" Remote) + case "$EV_DELIVERY" in + ok|unbound) ;; + *) EV_STATUS=blocked; add_blocker "delivery.$EV_DELIVERY" "$EV_DELIVERY_DETAIL" + set_next deliver "" "fix the delivery record ($dir/delivery.md): $EV_DELIVERY_DETAIL" + return 0;; + esac + case "$EV_HANDOFF" in + merged|deployed) + if [ -z "$(sdlc_delivery_field "$dir/delivery.md" Authorized-by)" ]; then + EV_STATUS=needs-human + add_blocker handoff.unauthorized "Handoff: $EV_HANDOFF without an Authorized-by line — merge and deploy need a separately bound human approval" + set_next human "" "record the human's authorization in $dir/delivery.md (Authorized-by:), or downgrade the handoff to review-ready" + return 0 + fi + # An Authorized-by line is a RECORD of the human's words, not proof that + # the merge or the deployment happened: nothing in this repository can see + # a merge commit on someone else's branch or a running deployment. + add_gap handoff.external-proof "'$EV_HANDOFF' is not verified by this kit: a feature ref on a remote is not a merge commit and not a deployment. The proof of that step is external (the human's, or the deployment system's) and belongs in delivery.md's Verified-by/Evidence lines";; + esac + # A handoff that claims a remote branch must NAME one. Without Remote and + # Branch there is nothing to check, and 'review-ready' would be prose. + case "$EV_HANDOFF" in + review-ready|merged|deployed) + if [ -z "$EV_BRANCH" ] || [ -z "$EV_REMOTE" ]; then + EV_STATUS=blocked + add_blocker handoff.incomplete "Handoff: $EV_HANDOFF but $dir/delivery.md names no Remote/Branch — the reviewer's target cannot be verified, so it is not review-ready" + set_next deliver "" "add '- Remote: ' and '- Branch: ' to $dir/delivery.md (templates/delivery.md), then re-check" + return 0 + fi + delivered_sha=$(git rev-parse "$(sdlc_delivery_field "$dir/delivery.md" Source)^{commit}" 2>/dev/null || echo none) + if [ -z "$REMOTE_CHECK" ]; then + # network-free poll: say exactly that, and claim nothing + EV_REMOTE_VERDICT=not-checked + EV_EXIT=open + EV_STATUS=ready + add_gap handoff.not-checked "--no-remote-check: $EV_REMOTE/$EV_BRANCH was not contacted, so this run knows nothing about the review branch and does not call it review-ready" + set_next deliver "tools/handoff.sh check $s" \ + "verify the review branch against the remote before anything is closed as shipped" + return 0 + fi + EV_REMOTE_SHA=$(git ls-remote "$EV_REMOTE" "refs/heads/$EV_BRANCH" 2>/dev/null | awk 'NR==1{print $1}') + if [ -z "$EV_REMOTE_SHA" ]; then + EV_REMOTE_VERDICT=absent; EV_STATUS=blocked; EV_EXIT=open + add_blocker handoff.remote-absent "$EV_REMOTE/$EV_BRANCH does not exist on the remote — the review target was never pushed, whatever delivery.md says" + set_next deliver "" "push the feature branch (tools/handoff.sh push $s), then re-check" + return 0 + elif [ "$EV_REMOTE_SHA" != "$delivered_sha" ]; then + EV_REMOTE_VERDICT=mismatch; EV_STATUS=blocked; EV_EXIT=open + add_blocker handoff.remote-mismatch "$EV_REMOTE/$EV_BRANCH is at $EV_REMOTE_SHA, not the delivered commit — the reviewer would read other code" + set_next deliver "" "push the delivered commit to $EV_REMOTE/$EV_BRANCH, or correct delivery.md" + return 0 + else + EV_REMOTE_VERDICT=match + fi;; + esac + EV_EXIT="$EV_HANDOFF" + EV_STATUS=ready + set_next close "gates/close.sh $s shipped \"\"" \ + "delivery confirmed (${EV_HANDOFF}) — close the feature; human review of the branch happens outside this loop" +} + +# ---------------------------------------------------------------- rendering +LAZY=$(sdlc_auto_lazymode) + +heartbeat_line() { [ -s ".sdlc/work/$1/progress.md" ] && head -n1 ".sdlc/work/$1/progress.md" | tr -d '\r' || true; } +heartbeat_age() { # → seconds or empty + local f=".sdlc/work/$1/progress.md" mt + [ -s "$f" ] || return 0 + mt=$(stat -f %m "$f" 2>/dev/null || stat -c %Y "$f" 2>/dev/null || true) + case "$mt" in (''|*[!0-9]*) return 0;; esac + echo $(( $(date +%s) - mt )) +} + +emit_blockers_json() { # + local first=1 line code detail + printf '[' + printf '%s' "$1" | while IFS= read -r line; do + [ -n "$line" ] || continue + code="${line%%|*}"; detail="${line#*|}" + [ $first -eq 1 ] || printf ',' + first=0 + printf '{"code":%s,"detail":%s}' "$(sdlc_json_str "$code")" "$(sdlc_json_str "$detail")" + done + printf ']' +} + +feature_json() { # + local s="$1" hb age + hb=$(heartbeat_line "$s"); age=$(heartbeat_age "$s") + printf ' {\n' + printf ' "slug": %s,\n' "$(sdlc_json_str "$s")" + printf ' "track": %s,\n' "$(sdlc_json_str "$EV_TRACK")" + printf ' "stage": %s,\n' "$(sdlc_json_str "$EV_STAGE")" + printf ' "status": %s,\n' "$(sdlc_json_str "$EV_STATUS")" + printf ' "exit_condition": %s,\n' "$(sdlc_json_str "$EV_EXIT")" + printf ' "next_action": {"kind": %s, "command": %s, "text": %s},\n' \ + "$(sdlc_json_str "$EV_NEXT_KIND")" "$(sdlc_json_str "$EV_NEXT_CMD")" "$(sdlc_json_str "$EV_NEXT_TEXT")" + printf ' "blockers": '; emit_blockers_json "$EV_BLOCKERS"; printf ',\n' + printf ' "gaps": '; emit_blockers_json "$EV_GAPS"; printf ',\n' + printf ' "intent_contract": {"state": %s, "detail": %s},\n' \ + "$(sdlc_json_str "$EV_INTENT")" "$(sdlc_json_str "$EV_INTENT_DETAIL")" + printf ' "verification": {"state": %s, "profile": %s, "detail": %s, "receipt": %s},\n' \ + "$(sdlc_json_str "$EV_VERIFY")" "$(sdlc_json_str "$(sdlc_verify_profile)")" \ + "$(sdlc_json_str "$EV_VERIFY_DETAIL")" "$(sdlc_json_str "$(sdlc_verify_receipt "$s")")" + printf ' "source": {"state": %s, "reviewed_digest": %s, "current_digest": %s},\n' \ + "$(sdlc_json_str "${EV_SRC_STATE:-unknown}")" "$(sdlc_json_str "${EV_SRC_WANT:-}")" "$(sdlc_json_str "${EV_SRC_NOW:-}")" + printf ' "delivery": {"state": %s, "target": %s, "detail": %s},\n' \ + "$(sdlc_json_str "${EV_DELIVERY:-none}")" "$(sdlc_json_str "${EV_DELIVERY_TARGET:-unknown}")" "$(sdlc_json_str "${EV_DELIVERY_DETAIL:-}")" + printf ' "handoff": {"target": %s, "remote": %s, "branch": %s, "remote_sha": %s, "remote_verdict": %s},\n' \ + "$(sdlc_json_str "${EV_HANDOFF:-unknown}")" "$(sdlc_json_str "${EV_REMOTE:-}")" "$(sdlc_json_str "${EV_BRANCH:-}")" \ + "$(sdlc_json_str "${EV_REMOTE_SHA:-}")" "$(sdlc_json_str "${EV_REMOTE_VERDICT:-not-checked}")" + printf ' "checkpoint": {"state": %s, "step": %s, "detail": %s},\n' \ + "$(sdlc_json_str "$EV_CHECKPOINT")" "$(sdlc_json_str "${EV_CHECKPOINT_STEP:-}")" "$(sdlc_json_str "$EV_CHECKPOINT_DETAIL")" + printf ' "heartbeat": {"line": %s, "age_seconds": %s}\n' \ + "$(sdlc_json_str "$hb")" "${age:-null}" + printf ' }' +} + +feature_text() { # + local line + printf '%s\t%s\t%s\t%s\n' "$1" "$EV_STAGE" "$EV_STATUS" "$EV_NEXT_TEXT" + printf '%s' "$EV_BLOCKERS" | while IFS= read -r line; do + [ -n "$line" ] && printf ' blocker: %s — %s\n' "${line%%|*}" "${line#*|}" + done + [ -n "$EV_NEXT_CMD" ] && printf ' command: %s\n' "$EV_NEXT_CMD" || true +} + +# Feature directory names, one per line, VALIDATED. A name with a space used to +# be word-split by the caller into several features that do not exist, and the +# driver was told to write an intent.md for each of them. An unusable name is +# now reported as itself, once, and evaluated for nothing. +list_slugs() { + local d n + for d in .sdlc/work/*/; do + [ -d "$d" ] || continue + n=${d#.sdlc/work/}; n=${n%/} + sdlc_auto_valid_slug "$n" || continue + [ -n "$slug" ] && [ "$n" != "$slug" ] && continue + printf '%s\n' "$n" + done +} +list_invalid_slugs() { + local d n + for d in .sdlc/work/*/; do + [ -d "$d" ] || continue + n=${d#.sdlc/work/}; n=${n%/} + sdlc_auto_valid_slug "$n" && continue + printf '%s\n' "$n" + done +} + +status_code() { case "$1" in ready) echo 0;; needs-human) echo 10;; blocked) echo 20;; complete) echo 30;; *) echo 1;; esac; } + +case "$cmd" in + status) + if [ -n "$JSON" ]; then + printf '{\n' + printf ' "schema": "sdlc-kit/auto-status@1",\n' + printf ' "kit_version": %s,\n' "$(sdlc_json_str "$(sdlc_auto_kit_version "$kit")")" + printf ' "generated_at": %s,\n' "$(sdlc_json_str "$(sdlc_auto_now)")" + printf ' "project_root": %s,\n' "$(sdlc_json_str "$(pwd)")" + printf ' "lazymode": %s,\n' "$LAZY" + printf ' "source_digest": %s,\n' "$(sdlc_json_str "$(sdlc_source_digest 2>/dev/null || echo unbound)")" + printf ' "git_head": %s,\n' "$(sdlc_json_str "$(git rev-parse --verify --quiet HEAD 2>/dev/null || echo none)")" + printf ' "features": [\n' + first=1 + while IFS= read -r s <&3; do + [ -n "$s" ] || continue + evaluate "$s" + [ $first -eq 1 ] || printf ',\n' + first=0 + feature_json "$s" + done 3<&2; exit 1; } + # a closed feature has been archived (close.sh): a driver polling one after + # the close must read `complete`, not an error + if [ ! -d ".sdlc/work/$slug" ] && [ -d ".sdlc/archive/$slug" ]; then + exitc=$(sdlc_auto_handoff_target ".sdlc/archive/$slug/delivery.md") + case "$exitc" in unknown) exitc=closed;; esac + printf 'complete closed %s :: archived (%s)\n' "$exitc" \ + "$(grep '^state: ' ".sdlc/archive/$slug/CLOSED" 2>/dev/null | cut -d' ' -f2- || echo '?')" + exit 30 + fi + [ -d ".sdlc/work/$slug" ] || { echo "FAIL: no open feature '.sdlc/work/$slug'" >&2; exit 1; } + evaluate "$slug" + printf '%s %s %s :: %s\n' "$EV_STATUS" "$EV_STAGE" "${EV_EXIT}" "${EV_NEXT_CMD:-$EV_NEXT_TEXT}" + printf '%s' "$EV_BLOCKERS" | while IFS= read -r l; do [ -n "$l" ] && printf 'blocker: %s — %s\n' "${l%%|*}" "${l#*|}"; done + exit "$(status_code "$EV_STATUS")" + ;; + intent-check) + [ -n "$slug" ] || usage + sdlc_auto_valid_slug "$slug" || { echo "FAIL: '$slug' is not a usable feature slug ([a-zA-Z0-9._-]+)" >&2; exit 1; } + st=$(sdlc_auto_intent_contract "$slug") + printf 'INTENT %s: %s\n' "${st%%|*}" "${st#*|}" + case "${st%%|*}" in ok) exit 0;; material) exit 10;; *) exit 20;; esac + ;; + checkpoint) + [ -n "$slug" ] || usage + sdlc_auto_valid_slug "$slug" || { echo "FAIL: '$slug' is not a usable feature slug ([a-zA-Z0-9._-]+)" >&2; exit 1; } + [ -d ".sdlc/work/$slug" ] || { echo "FAIL: no open feature '.sdlc/work/$slug'" >&2; exit 1; } + f=$(sdlc_checkpoint_file "$slug") + action=show; step=""; class=""; effect="" + while [ $# -gt 0 ]; do + case "$1" in + --show) action=show;; + --clear) action=clear;; + --set-step) [ $# -ge 2 ] || usage; action=set; step="$2"; shift;; + --attempt) [ $# -ge 2 ] || usage; action=attempt; step="$2"; shift;; + --class) [ $# -ge 2 ] || usage; class="$2"; shift;; + --effect) [ $# -ge 2 ] || usage; action=effect; effect="$2"; shift;; + *) usage;; + esac + shift + done + # checkpoint.md is a line-oriented record read back by this script. A value + # carrying a newline (or a field separator) would inject arbitrary fields + # into it, so the two fields an agent supplies are validated, not trusted. + case "$action" in + set|attempt) + case "$step" in + ''|*[!a-zA-Z0-9._-]*) + echo "FAIL: step names must be [a-zA-Z0-9._-]+ (got: '$step')" >&2; exit 1;; + esac;; + esac + if [ "$action" = effect ]; then + case "$effect" in + *'|'*) ;; + *) echo "FAIL: --effect needs \"|\"" >&2; exit 1;; + esac + if [ "$(printf '%s' "$effect" | wc -l | tr -d ' ')" != 0 ]; then + echo "FAIL: --effect must be a single line" >&2; exit 1 + fi + case "$effect" in + *"$(printf '\t')"*|*"$(printf '\r')"*) + echo "FAIL: --effect must not contain tabs or carriage returns" >&2; exit 1;; + esac + case "${effect%%|*}" in + ''|*[!a-zA-Z0-9._-]*) + echo "FAIL: the effect kind must be [a-zA-Z0-9._-]+ (got: '${effect%%|*}')" >&2; exit 1;; + esac + fi + cur_src=$(sdlc_source_digest 2>/dev/null || echo unbound) + # a checkpoint written against other source is not evidence about this one: + # the counters reset and the file is rewritten, so a resume never replays a + # verdict that belonged to code nobody has any more + if [ -f "$f" ] && [ "$(sdlc_field "$f" source_digest || true)" != "$cur_src" ]; then + if [ "$action" != show ]; then + grep -v '^attempt: ' "$f" > "$f.tmp" 2>/dev/null || true + awk -v d="$cur_src" '/^source_digest: /{print "source_digest: " d; next} {print}' "$f.tmp" > "$f" && rm -f "$f.tmp" + fi + fi + case "$action" in + show) + if [ -f "$f" ]; then cat "$f"; else echo "no checkpoint for $slug"; fi + st=$(sdlc_checkpoint_state "$slug"); printf 'checkpoint %s: %s\n' "${st%%|*}" "${st#*|}";; + clear) rm -f "$f"; echo "checkpoint cleared for $slug";; + set) + [ -f "$f" ] || printf 'checkpoint_schema: sdlc-kit/checkpoint@1\nsource_digest: %s\n' "$cur_src" > "$f" + grep -v '^step: ' "$f" > "$f.tmp" || true; mv "$f.tmp" "$f" + grep -v '^updated_at: ' "$f" > "$f.tmp" || true; mv "$f.tmp" "$f" + printf 'step: %s\nupdated_at: %s\n' "$step" "$(sdlc_auto_now)" >> "$f" + echo "checkpoint step: $step";; + attempt) + case "$class" in transient|deterministic) ;; *) echo "FAIL: --attempt needs --class transient|deterministic" >&2; exit 1;; esac + [ -f "$f" ] || printf 'checkpoint_schema: sdlc-kit/checkpoint@1\nsource_digest: %s\n' "$cur_src" > "$f" + n=$(sdlc_checkpoint_attempts "$slug" "$step") + n=$((n + 1)) + # caps mirror the loop's existing ones: a deterministic failure repeats + # deterministically, so it escalates at once; a transient one gets three + cap=3; [ "$class" = deterministic ] && cap=1 + # LITERAL, never a regex: `build.fi.` used to match and delete the + # counter of `build.fix`, which reset the retry cap of another step. + # Same comparison sdlc_checkpoint_attempts reads them back with. + awk -v s="$step" -F' *\\| *' ' + { line = $0 + if (index(line, "attempt: ") == 1) { + body = substr(line, 10); n = split(body, parts, / *\| */) + if (parts[1] == s) next + } + print line }' "$f" > "$f.tmp" && mv "$f.tmp" "$f" + printf 'attempt: %s | %s | %s\n' "$step" "$class" "$n" >> "$f" + grep -v '^updated_at: ' "$f" > "$f.tmp" || true; mv "$f.tmp" "$f" + printf 'updated_at: %s\n' "$(sdlc_auto_now)" >> "$f" + if [ "$n" -gt "$cap" ]; then + echo "RETRY CAP: '$step' has $n $class attempts (cap $cap) — stop retrying and escalate to the human" + exit 20 + fi + echo "attempt $n/$cap ($class) for $step";; + effect) + [ -f "$f" ] || printf 'checkpoint_schema: sdlc-kit/checkpoint@1\nsource_digest: %s\n' "$cur_src" > "$f" + case "$effect" in + *"|"*) ;; + *) echo "FAIL: --effect needs \"|\"" >&2; exit 1;; + esac + if grep -qxF "effect: $effect" "$f"; then + echo "effect already recorded (not repeated): $effect" + else + printf 'effect: %s\n' "$effect" >> "$f" + echo "effect recorded: $effect" + fi;; + esac + ;; + *) usage;; +esac diff --git a/tools/handoff.sh b/tools/handoff.sh new file mode 100755 index 0000000..26fcafd --- /dev/null +++ b/tools/handoff.sh @@ -0,0 +1,298 @@ +#!/usr/bin/env bash +# handoff.sh — the review handoff: get the reviewed source onto a feature branch +# a human can review, and prove it is there. Run from the project root. +# +# tools/handoff.sh check [--remote ] [--branch ] +# tools/handoff.sh push --authorized "" [--remote ] [--branch ] +# +# The boundary this script draws is the point of the whole automation layer: +# +# review-ready — the feature branch exists on the remote at the delivered +# commit, and that commit CONTAINS the source the ship review +# saw. A human review of that branch is the next step. +# merged/deployed — NOT this script's business. Merging a feature branch or +# deploying it leaves the review boundary, so delivery.md must +# carry `Authorized-by:` with the human's own words for it, at +# every lazymode level (AGENTS.md rule 3 "Autonomy is not +# authority"). `check` refuses to call it done without one. +# +# Guard rails on `push`, all of them checked IMMEDIATELY BEFORE the side effect: +# +# * the complete ship gate — gates/check-gate.sh ship, the same program the +# agent runs by hand: the approval must bind this artifact, its upstreams +# and the reviewed source. A ship gate that is CLOSED for any reason +# (an evidence.md edited after approval, source drift, an unbound record) +# refuses the push, in check-gate.sh's own words; +# * the verification receipt — a configured recipe must read `ok`; +# * AUTHORITY — `--authorized` is required, AND the scope the human recorded +# in intent.md (`- Scope authorization:`) must actually name publishing a +# branch. A flag an agent types is not permission; neither is a line an +# agent wrote into delivery.md. Both are records, not authentication (L2). +# * a `local` delivery target is never pushed; +# * protected/shared branches are refused (`protected_branches:` in +# .sdlc/config.md plus a built-in list), --force/--force-with-lease are +# refused outright, and a remote branch that has drifted away from this +# history (not an ancestor of HEAD) stops the push instead of racing it. +# +# A push whose remote branch is already at this commit is reported and NOT +# repeated, so a resume cannot double-fire an external effect. +# +# Exit: 0 ok · 1 refusal, mismatch, or failed push · 2 usage/environment. +set -uo pipefail +kit="$(cd "$(dirname "$0")/.." && pwd)" +. "$kit/gates/_common.sh" +. "$kit/gates/_auto.sh" + +usage() { + cat >&2 <<'EOF' +usage (from the project root): + tools/handoff.sh check [--remote ] [--branch ] + tools/handoff.sh push --authorized "" [--remote ] [--branch ] +EOF + exit 2 +} +[ $# -ge 2 ] || usage +cmd="$1"; slug="$2"; shift 2 +remote=""; branch=""; authorized="" +while [ $# -gt 0 ]; do + case "$1" in + --remote) [ $# -ge 2 ] || usage; remote="$2"; shift;; + --branch) [ $# -ge 2 ] || usage; branch="$2"; shift;; + --authorized) [ $# -ge 2 ] || usage; authorized="$2"; shift;; + --force|--force-with-lease|-f) + echo "FAIL: this script never force-pushes. A rejected push means the remote branch moved —" >&2 + echo " look at what is there and take it to the human." >&2; exit 2;; + *) usage;; + esac + shift +done +[ -d .sdlc ] || { echo "FAIL: no .sdlc/ here." >&2; exit 2; } +sdlc_auto_valid_slug "$slug" || { echo "FAIL: '$slug' is not a usable feature slug ([a-zA-Z0-9._-]+)" >&2; exit 2; } +[ -d ".sdlc/work/$slug" ] || { echo "FAIL: no open feature '.sdlc/work/$slug'" >&2; exit 2; } +git rev-parse --git-dir >/dev/null 2>&1 || { echo "FAIL: not a git repository — no branch handoff is possible here." >&2; exit 2; } + +del=".sdlc/work/$slug/delivery.md" +evidence=".sdlc/work/$slug/evidence.md" +shiprec=".sdlc/approvals/${slug}.ship.approval" + +# The COMPLETE ship gate, not a subset of it: the same script, the same output. +# Run immediately before the external effect and immediately before confirming +# one, so nothing can change between the verdict and the act. +ship_gate() { # → 0 open; prints check-gate.sh's own refusal otherwise + local out rc + [ -f "$evidence" ] || { echo "the ship gate is CLOSED: $evidence does not exist."; return 1; } + out=$("$kit/gates/check-gate.sh" ship "$evidence" 2>&1); rc=$? + [ "$rc" = 0 ] && return 0 + printf '%s\n' "$out" + return 1 +} +# The verification receipt, by the same rule the loop and the cockpit use. +verify_gate() { # → 0 when nothing is owed + local st; st=$(sdlc_verify_state "$slug") + case "${st%%|*}" in + ok|unconfigured) return 0;; + *) printf 'verification %s: %s\n' "${st%%|*}" "${st#*|}"; return 1;; + esac +} +[ -n "$remote" ] || { [ -f "$del" ] && remote=$(sdlc_delivery_field "$del" Remote) || true; } +[ -n "$branch" ] || { [ -f "$del" ] && branch=$(sdlc_delivery_field "$del" Branch) || true; } + +# Shared branches are never a handoff target: a feature branch is. +protected_default="main master develop development trunk release production prod staging" +protected_extra=$(awk '/^protected_branches: /{sub(/^[^:]*: */,""); gsub(/,/," "); print; exit}' .sdlc/config.md 2>/dev/null | tr -d '\r') +is_protected() { # + local b="$1" p + for p in $protected_default $protected_extra; do [ "$b" = "$p" ] && return 0; done + case "$b" in release/*|releases/*|prod/*|production/*|hotfix/main*) return 0;; esac + return 1 +} + +reviewed_digest() { sdlc_field "$shiprec" code_digest 2>/dev/null || true; } +contains_reviewed() { # → 0 when its tree IS the reviewed source + local rev="$1" want tree + want=$(reviewed_digest) + [ -n "$want" ] && [ "$want" != none ] || return 2 + tree=$(sdlc_tree_entries "$rev" 2>/dev/null) || return 3 + [ "$(printf '%s\n' "$tree" | sdlc_entries_unsupported)" = 0 ] || return 3 + [ "$(printf '%s\n' "$tree" | sdlc_entries_digest)" = "$want" ] +} + +case "$cmd" in + check) + [ -f "$del" ] || { echo "HANDOFF none: no $del yet — nothing has been delivered."; exit 1; } + target=$(sdlc_delivery_field "$del" Target | awk '{print tolower($1)}') + handoff=$(sdlc_auto_handoff_target "$del") + src=$(sdlc_delivery_field "$del" Source) + external_claim="" + case "$handoff" in + merged|deployed) + if [ -z "$(sdlc_delivery_field "$del" Authorized-by)" ]; then + echo "HANDOFF unauthorized: '$handoff' needs the human's own authorization in $del (Authorized-by:)." + echo " Merging or deploying is a separate decision from the ship approval, at every lazymode." + exit 1 + fi + # Being on the remote feature ref proves the branch is there. It proves + # NOTHING about a merge or a deployment: those happen outside this + # repository, and this script cannot see them. + external_claim="$handoff";; + esac + if [ "$target" = local ]; then + echo "HANDOFF local: a local delivery has no remote review branch (exit condition: local)." + exit 0 + fi + [ -n "$remote" ] && [ -n "$branch" ] || { + echo "HANDOFF incomplete: $del names no Branch/Remote, so the reviewer's target cannot be verified." + echo " Add '- Remote: ' and '- Branch: ' (templates/delivery.md)." + exit 1; } + git cat-file -e "${src}^{commit}" 2>/dev/null || { + echo "HANDOFF unknown-source: delivery Source '$src' is not a commit in this repository."; exit 1; } + local_sha=$(git rev-parse "${src}^{commit}") + remote_sha=$(git ls-remote "$remote" "refs/heads/$branch" 2>/dev/null | awk 'NR==1{print $1}') + [ -n "$remote_sha" ] || { echo "HANDOFF absent: $remote/$branch does not exist — the reviewer has nothing to read."; exit 1; } + if [ "$remote_sha" != "$local_sha" ]; then + echo "HANDOFF mismatch: $remote/$branch is at $remote_sha, the delivered commit is $local_sha." + echo " The reviewer would read other code than the one this feature delivered." + exit 1 + fi + contains_reviewed "$local_sha"; rc=$? + case $rc in + 0) ;; + 2) echo "HANDOFF unbound: no ship approval with a source binding — re-run the ship review."; exit 1;; + 3) echo "HANDOFF unreadable: the tree of $local_sha could not be read or hashed."; exit 1;; + *) echo "HANDOFF source-content: $local_sha does not CONTAIN the source the ship review saw."; exit 1;; + esac + # confirming is a claim too: the whole ship gate and the verification are + # re-checked here, so `review-ready` can never be printed over a gate that + # check-gate.sh calls CLOSED + if ! out=$(ship_gate); then + echo "HANDOFF blocked: the ship gate is not open, so nothing here is review-ready." + printf '%s\n' "$out" + exit 1 + fi + if ! out=$(verify_gate); then + echo "HANDOFF blocked: the verification this project configured is not satisfied." + printf '%s\n' "$out" + exit 1 + fi + if [ -n "$external_claim" ]; then + echo "HANDOFF $external_claim: PENDING EXTERNAL PROOF." + echo " Verified here: $remote/$branch @ $local_sha contains the reviewed source, and" + echo " $del records a human authorization for '$external_claim'." + echo " NOT verified here: that the branch was actually $external_claim. A feature ref on a" + echo " remote is not a merge commit on the target branch and is not a deployment." + echo " That proof is the human's (or the deployment system's) to provide, in $del." + exit 0 + fi + echo "HANDOFF review-ready: $remote/$branch @ $local_sha contains the reviewed source." + echo " exit condition: review-ready (human review pending; NOT merged, NOT deployed)." + exit 0;; + + push) + [ -n "$authorized" ] || { + echo "FAIL: pushing leaves this repository. Re-run with --authorized \"\"" >&2 + echo " naming the scope they authorized (AGENTS.md rule 3)." >&2; exit 1; } + [ -f "$shiprec" ] || { echo "FAIL: no ship approval for '$slug' — nothing has been reviewed to hand off." >&2; exit 1; } + # AUTHORITY: the human's recorded scope, not the flag. --authorized carries + # the words; intent.md is where they were recorded before the work started. + if ! sdlc_auto_scope_allows_publish "$slug"; then + scope=$(sdlc_auto_scope_authorization "$slug") + echo "FAIL: nothing on record authorizes publishing a branch for '$slug'." >&2 + echo " intent.md's '- Scope authorization:' reads: ${scope:-(absent)}" >&2 + echo " A --authorized flag an agent types is not permission, and neither is a line an" >&2 + echo " agent writes into delivery.md. Take it to the human, record their words in" >&2 + echo " intent.md's Scope authorization (naming the push / PR / review branch), re-run" >&2 + echo " the intent gate, then push (AGENTS.md rule 3)." >&2 + exit 1 + fi + # a delivery the loop agreed to keep local never leaves the repository + dtarget="" + if [ -f "$del" ]; then dtarget=$(sdlc_delivery_field "$del" Target | awk '{print tolower($1)}'); fi + if [ -z "$dtarget" ] && [ -f ".sdlc/work/$slug/intent.md" ]; then + dtarget=$(awk '/^[ \t]*- *Delivery target:/{sub(/^[^:]*: */,""); print tolower($1); exit}' ".sdlc/work/$slug/intent.md") + fi + if [ "$dtarget" = local ]; then + echo "FAIL: the agreed delivery target for '$slug' is 'local' — a local delivery has no" >&2 + echo " remote review branch. Record it in $del instead of pushing, or take a change of" >&2 + echo " target to the human first." >&2 + exit 1 + fi + # the whole ship gate, in check-gate.sh's own words, immediately before the effect + if ! out=$(ship_gate); then + echo "FAIL: the ship gate is not open, so there is nothing reviewed to publish." >&2 + printf '%s\n' "$out" >&2 + exit 1 + fi + if ! out=$(verify_gate); then + echo "FAIL: the verification this project configured is not satisfied — do not publish it yet." >&2 + printf '%s\n' "$out" >&2 + exit 1 + fi + read -r st want now <&2; exit 1; } + [ -n "$remote" ] || remote=origin + git remote get-url "$remote" >/dev/null 2>&1 || { echo "FAIL: no such remote: $remote" >&2; exit 2; } + [ -n "$branch" ] || branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true) + [ -n "$branch" ] || { echo "FAIL: detached HEAD and no --branch: name the feature branch explicitly." >&2; exit 2; } + if is_protected "$branch"; then + echo "FAIL: '$branch' is a protected/shared branch. This loop pushes a FEATURE branch for review;" >&2 + echo " merging into a shared branch is the human's decision (AGENTS.md rule 3)." >&2 + exit 1 + fi + head_sha=$(git rev-parse --verify --quiet HEAD) || { + echo "FAIL: this repository has no commit yet — commit the reviewed source before the handoff." >&2; exit 1; } + contains_reviewed "$head_sha"; rc=$? + case $rc in + 0) ;; + 2) echo "FAIL: the ship approval binds no source identity — nothing can be proven about this commit." >&2; exit 1;; + 3) echo "FAIL: the tree of HEAD could not be read or hashed." >&2; exit 1;; + *) echo "FAIL: HEAD ($head_sha) does not CONTAIN the source the ship review saw. Commit the reviewed" >&2 + echo " bytes, or re-run the ship review over what is really there." >&2; exit 1;; + esac + remote_sha=$(git ls-remote "$remote" "refs/heads/$branch" 2>/dev/null | awk 'NR==1{print $1}') + if [ "$remote_sha" = "$head_sha" ]; then + echo "already pushed: $remote/$branch is at $head_sha — no external effect repeated." + echo "HANDOFF review-ready: $remote/$branch @ $head_sha" + exit 0 + fi + # Remote drift: the feature branch moved somewhere this history does not + # contain (a rebase, a colleague, another agent). git would reject it, but + # a rejection read as "retry" is how force-pushes happen. Stop first, and say + # what is there. + if [ -n "$remote_sha" ]; then + if ! git cat-file -e "${remote_sha}^{commit}" 2>/dev/null; then + echo "FAIL: $remote/$branch is at $remote_sha, a commit this repository does not have." >&2 + echo " Fetch it and look at what is there before anything is pushed over it." >&2 + exit 1 + fi + if ! git merge-base --is-ancestor "$remote_sha" "$head_sha" 2>/dev/null; then + echo "FAIL: $remote/$branch is at $remote_sha, which is NOT an ancestor of HEAD ($head_sha)." >&2 + echo " The review branch drifted: pushing would either be rejected or lose what is there." >&2 + echo " This script never force-pushes — take the divergence to the human." >&2 + exit 1 + fi + fi + echo "pushing $head_sha to $remote/$branch (authorized: $authorized)" + if ! git push "$remote" "HEAD:refs/heads/$branch"; then + echo "FAIL: the push was rejected. It is NOT retried with --force: look at what is on the remote." >&2 + exit 1 + fi + remote_sha=$(git ls-remote "$remote" "refs/heads/$branch" 2>/dev/null | awk 'NR==1{print $1}') + [ "$remote_sha" = "$head_sha" ] || { + echo "FAIL: after the push $remote/$branch is at '${remote_sha:-nothing}', not $head_sha." >&2; exit 1; } + ck=$(sdlc_checkpoint_file "$slug") + [ -f "$ck" ] || printf 'checkpoint_schema: sdlc-kit/checkpoint@1\nsource_digest: %s\n' "$(sdlc_source_digest 2>/dev/null || echo unbound)" > "$ck" + grep -qxF "effect: push|$remote|$branch|$head_sha" "$ck" || printf 'effect: push|%s|%s|%s\n' "$remote" "$branch" "$head_sha" >> "$ck" + if ! out=$(ship_gate); then + echo "PUSHED, BUT NOT REVIEW-READY: the ship gate closed between the push and this line." >&2 + printf '%s\n' "$out" >&2 + echo " $remote/$branch is at $head_sha. Repair the gate before anyone reviews it." >&2 + exit 1 + fi + echo "HANDOFF review-ready: $remote/$branch @ $head_sha contains the reviewed source." + echo " Record it in $del: Target, Source: $head_sha, Remote: $remote, Branch: $branch, Handoff: review-ready." + exit 0;; + + *) usage;; +esac diff --git a/tools/tripwire.sh b/tools/tripwire.sh index 51826a3..daa7ea9 100755 --- a/tools/tripwire.sh +++ b/tools/tripwire.sh @@ -9,10 +9,22 @@ set -euo pipefail { [ $# -eq 1 ] && [ -f "$1" ]; } || { echo "usage: tripwire.sh "; exit 1; } plan="$1" +# The kit's own `- Scope authorization:` line (templates/intent.md) names WHO +# authorized the work; the label itself is not evidence of security-sensitive +# work, and matching "auth" in it would make every intent.md trip the security +# wire. Only the LABEL is neutralized — the human's words after the colon are +# scanned like any other text, and line numbers stay the file's own. +scanfile="$plan" +tmpscan="" +if grep -qiE '^- *scope authorization:' "$plan" 2>/dev/null; then + tmpscan=$(mktemp) && trap 'rm -f "$tmpscan"' EXIT + sed 's/^\(- *[Ss]cope \)[Aa]uthorization:/\1mandate:/' "$plan" > "$tmpscan" + scanfile="$tmpscan" +fi hits=0 scan() { #