diff --git a/.github/workflows/selftest.yml b/.github/workflows/selftest.yml index 6269a7c..05c4d05 100644 --- a/.github/workflows/selftest.yml +++ b/.github/workflows/selftest.yml @@ -1,11 +1,9 @@ name: selftest on: - # branch work is tested once, on its PR; push covers the delivery targets - # (main and version tags) that no PR run precedes - push: - branches: [main] - tags: ['v*'] + # One smoke test, a few seconds: it runs on pull requests because branch + # protection requires its three checks to merge; by hand from the Actions tab. pull_request: + workflow_dispatch: permissions: contents: read env: @@ -32,28 +30,6 @@ 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 - # The record store: where it lives, how it refuses, how it is read back. - # It exits 2 when the filesystem cannot create symlinks — on Windows that - # would be the MSYS setting above failing, which CI must not hide. - # It runs FIRST because it is the shortest of the four and the most - # platform-sensitive: a Windows-only failure surfaces in about a minute - # instead of after the long suites have run. All four still run, and the - # job fails if any of them does. - - name: knowledge (record store and retrieval, local fixtures only) - run: bash gates/knowledge-test.sh - shell: bash - - name: selftest (gate mechanics) + - name: selftest (smoke test, a few seconds) 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/AGENTS.md b/AGENTS.md index 140977c..428ba86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,8 +27,8 @@ Stage names double as gate names: `gates/check-gate.sh spec .sdlc/work/ **Two routes, one contract.** There is no third shape. - **Compact** — small and well understood. `intent.md` is the single work - artifact and carries the files to change, the proof, the risk, and the - delivery target (templates/intent.md). Flow: intent (gate) → build → ship + artifact and carries the files to change, the proof, the risk, the + baseline, and the delivery target (templates/intent.md). Flow: intent (gate) → build → ship → close. No spec, no plan, and none is ever demanded of it. Ship keeps its full adversary review — the only review that diff gets. Criteria: skills/1-intent. @@ -139,20 +139,41 @@ archives the feature to `.sdlc/archive//`. never guessed away; optional uncertainty is carried as `[assumed: why]` and blocks nothing. `tools/auto.sh intent-check ` reports the verdict (section rules: templates/intent.md; full text: docs/automation.md §3). -4. **Keep memory bounded.** At each stage start read - `.sdlc/memory/POLICY.md` (human-declared hard rules), - `.sdlc/memory/INDEX.md` (lessons; 50 lines max), `.sdlc/memory/DOMAIN.md` - (terms, verified facts, constraints; 100 lines max), and the feature's - own `harvest.md` if present, then open lesson files whose tags match the - task. Stage skills do not repeat this. DOMAIN over its limit: split by - subdomain, leave pointer lines. INDEX over its limit: merge +4. **Keep memory bounded, and put each thing in one place.** At each stage + start read `.sdlc/memory/POLICY.md`, `INDEX.md`, `DOMAIN.md`, the area + page(s) the task touches (`tools/kb.sh show `), and the feature's + own `harvest.md` if present; then open lesson files whose tags match the + task. Stage skills do not repeat this. + + | What | Where | Written by | + |---|---|---| + | A product's business rule (정책) — "a submitted answer cannot be edited" | `memory/areas/.md` Business rules, numbered P1… (templates/area.md) | close merge, shipped only | + | What changed in an area, when | the area page's History, one line per feature | close merge, shipped only | + | A fact that holds for one area only | the area page's How it works | close merge | + | A term, or a system fact/constraint that spans areas | `memory/DOMAIN.md` (100 lines max) | close merge | + | A trap and the correct move | `memory/lessons/-.md` + one `INDEX.md` line (50 lines max) | close merge | + | A hard rule for agents, in the human's words | `memory/POLICY.md` | only on the human's word | + | The story of one feature, for a reader | `work//summary.md` (templates/summary.md) | any stage, kept current | + + A **product area** is what users navigate by: for a web app one menu, + named by its menu path (`학습 > 평가 > 제출`); otherwise a module, API, + job, or CLI command — not the `--area` knowledge folder of rule 7. + summary.md's `Area:` line and the page's `Menu:` line use the same words, + which is how `kb.sh` links them. DOMAIN over its limit: move + area-specific facts to their area page. INDEX over its limit: merge near-duplicates, drop superseded entries, replace promoted ones (skills/6-maintain). - **INDEX.md, DOMAIN.md, and lessons/ have one writer: the close step.** - Mid-loop, stages and researchers append candidates — one line each — to + **memory/ has one writer: the close step** (POLICY.md aside). Mid-loop, + stages and researchers append candidates — one line each — to `.sdlc/work//harvest.md` (templates/harvest.md). At close, merge - harvest into lessons/INDEX/DOMAIN and delete it; `close.sh` blocks while - it exists. A feature idle 30 days or more may have its harvest merged the + every candidate into its row above — creating an area page from the + template when none exists, numbering new rules, adding the feature's + History line to each area it changed — then delete harvest.md. Business + rules and History describe what the product DOES, so they merge only when + the feature closes `shipped`: any other close drops them (summary.md keeps + the story), and a stale merge leaves them in harvest.md for that close; + `close.sh` blocks while it + exists. A feature idle 30 days or more may have its harvest merged the same way WITHOUT closing — one merge at a time, in the owning checkout (`tools/kb.sh harvest` lists them); nothing in `kb.sh` writes `memory/`. **Recency wins on merge, three guards.** A contradicting candidate @@ -165,7 +186,8 @@ archives the feature to `.sdlc/archive//`. **POLICY.md is written only on the human's word**: transcribe a hard rule they state in chat with the date and their words; never add, soften, or remove one on your own judgment. The adversary treats a violation as - blocking. + blocking. A business rule is never a POLICY.md line: it goes on its area + page. Never read the whole `.sdlc/archive/` into context: use `tools/kb.sh search ""` / `show ` (rule 7), a targeted `ls`/`grep`, or a single slug lookup. @@ -261,7 +283,8 @@ archives the feature to `.sdlc/archive//`. **Records are read back, not just written**, through `tools/kb.sh`: `search ""` (bounded, literal, open and closed features plus memory), `show ` (summary.md first, then goal, delivery, lessons, - paths), `harvest` (unmerged candidates), `index` (contents page). + paths) or `show ` (its page and the features that changed + it), `harvest` (unmerged candidates), `index` (contents page). `--area ` covers every store in that folder, including features whose checkout is gone. Exit 0 found · 1 nothing · 2 usage/refusal. 8. **Speak plainly.** Every report, gate request, and question starts with diff --git a/README.ko.md b/README.ko.md index 21e8732..5b52b32 100644 --- a/README.ko.md +++ b/README.ko.md @@ -74,7 +74,7 @@ Intent → spec → plan → build → evidence → maintain. 사람 승인 게 | 계획이 채팅 안에만 존재 | 기록(`intent.md`, `spec.md`, `plan.md`, `evidence.md`, `delivery.md`)을 애플리케이션 히스토리 밖의 저장소에 남기고 나중에 `tools/kb.sh`로 찾음 | | 작성자가 자기 검사를 직접 실행 | 작성자 컨텍스트가 없는 verifier와 adversary가 리뷰 | | 승인이 사라지는 채팅 메시지 | 승인 기록이 단계, 산출물, 시각, 모드를 담고 `.sdlc/approvals/`에 파일로 남음 | -| 실패한 시도는 잊힌 컨텍스트가 됨 | 교훈은 상한 있는 인덱스로, 확인된 사실은 `DOMAIN.md`로 | +| 실패한 시도는 잊힌 컨텍스트가 됨 | 교훈은 상한 있는 인덱스로, 업무 정책은 제품 영역 페이지로, 확인된 사실은 `DOMAIN.md`로 | | 만능 워커 하나가 전부 수행 | 로컬 QA, 리뷰어, 브라우저, API, DB 전문 에이전트가 있으면 역할 계약을 그쪽에 위임 | | "끝났다"가 모호함 | 모든 실행이 `shipped`, `abandoned`, `dead-end`, `handed-off` 중 하나로 종결되고, `shipped`는 승인만으로는 부족하며 검증된 전달 기록을 요구 | @@ -144,7 +144,8 @@ agent APPROVED: intent of claims-status (.sdlc/work/claims-status/intent.md) ├── memory/ │ ├── POLICY.md # 사람이 선언한 하드 룰, 에이전트는 전사만 │ ├── INDEX.md # 교훈 포인터, 50줄 이하 -│ ├── DOMAIN.md # 용어 · 확인된 사실 · 제약 +│ ├── DOMAIN.md # 여러 제품 영역에 걸친 용어 · 사실 · 제약 +│ ├── areas/.md # 제품 영역(웹앱은 메뉴)마다 한 장: 업무 정책 P1… · 동작 · 변경 이력 │ └── lessons/-.md ├── work// # 열린 피처만 │ ├── origin.md # 요청 당시의 티켓 · 기획서 스냅샷 — intent 게이트가 결합 @@ -156,7 +157,7 @@ agent APPROVED: intent of claims-status (.sdlc/work/claims-status/intent.md) │ ├── deviations.md # 빌드 중 편차 기록 │ ├── progress.md # 하트비트: 살아있는 한 줄 (규칙 9) │ ├── baseline.txt # 브라운필드의 변경 전 동작 -│ ├── summary.md # 읽는 사람용 페이지: 문제·원인·변경·결과·교훈·태그, 승인에 묶이지 않아 계속 갱신 +│ ├── summary.md # 읽는 사람용 페이지: 제품 영역 · 무엇이 문제였나 · Before → After · 확인 방법 · 기억할 점, 승인에 묶이지 않아 계속 갱신 │ ├── harvest.md # 루프 중 교훈·도메인 후보, close에서 병합(그 전에도 kb.sh show / harvest로 읽힘) │ └── scratch/ # 대용량 로그 · 캡처 · 트레이스 └── archive// # 닫힌 피처, close.sh가 여기로 옮김 @@ -168,7 +169,9 @@ agent APPROVED: intent of claims-status (.sdlc/work/claims-status/intent.md) **기록을 어디에 둘지는 사용자가 정합니다.** 기본값은 프로젝트 작업 사본 안이고, `init.sh . --area ~/knowledge`를 쓰면 사용자가 고른 폴더 아래 `/<단위이름>-<체크아웃 식별자>/`에 저장하고 `.sdlc`를 그곳으로 연결합니다. 체크아웃마다 저장소가 하나씩이므로 워크트리 두 개가 승인 상태를 공유하는 일이 없습니다. 영역이 프로젝트 안에 있거나 프로젝트가 영역 안에 있을 때, 다른 체크아웃이 이미 그 저장소를 소유할 때, 실제 `.sdlc` 디렉터리가 이미 있을 때(자동으로 옮기지 않습니다), 링크를 만들 수 없을 때는 아무것도 쓰지 않고 분명히 실패합니다. 이 소유권은 init 시점뿐 아니라 실행 시점에도 다시 확인합니다. `/PROJECT`에 적힌 체크아웃이 지금 실행 중인 체크아웃과 다르면 `check-gate.sh`, `approve.sh`, `close.sh`, `status.sh`, `tools/auto.sh`, `tools/verify.sh`, `tools/handoff.sh`가 판정을 내리거나 상태를 쓰기 전에 거부하므로, 심볼릭 링크를 그대로 복사한 작업 사본(`cp -R`, rsync, 대부분의 백업 복원)이 다른 체크아웃의 게이트를 열거나 그 피처를 닫을 수 없습니다. 읽기는 이 제약을 받지 않아 `tools/kb.sh show|search|list`는 그대로 쓸 수 있고, 소유권을 자동으로 옮기거나 다시 묶는 일은 없습니다. 어느 쪽을 고르든 **저장소 백업은 사용자의 몫입니다.** git이 더 이상 대신해 주지 않습니다. -**기록을 다시 읽는 도구는 `tools/kb.sh`입니다.** `index`는 목차 페이지를 다시 만듭니다(`init.sh`와 `close.sh`가 자동으로 실행합니다). 페이지는 상태·날짜·태그를 담은 개요 표(최신순), 아직 close가 병합하지 않은 harvest 목록, 피처별 절 순서입니다. `show `는 피처 하나를 요약본으로 보여줍니다. 목표, `summary.md`(문제·원인·변경·결과·교훈을 담는, 계속 갱신하도록 만든 유일한 기록), 배포 상태, 병합되지 않은 harvest 후보, 교훈 제목이 먼저 나오고 파일 경로는 마지막입니다. `search "<문자열>"`은 열린 피처와 닫힌 피처, 지속 메모리를 대상으로 출력량을 제한한 문자열 검색을 하고, `harvest [--stale <일수>]`는 harvest.md가 아직 memory/에 들어가지 않은 열린 피처를 유휴 기간과 함께 나열합니다(유휴 상태가 오래된 피처는 close 없이 병합할 수 있습니다 — AGENTS.md 규칙 4). `--area <폴더>`를 붙이면 그 폴더 안의 모든 저장소를 대상으로 같은 일을 하며, 원래 체크아웃이 사라진 피처도 읽을 수 있습니다. 저장소 config.md에 `index_style: obsidian`을 적으면 Obsidian 볼트용 frontmatter와 인라인 `#태그`를 덧붙입니다. 생성 시각은 절대 쓰지 않으므로 내용이 같으면 diff도 생기지 않습니다. 종료 코드는 `0` 찾음, `1` 없음, `2` 사용법 오류 또는 거부입니다. +**지식은 제품 영역별로 정리됩니다.** 웹앱이면 제품 영역은 메뉴 하나이고 메뉴 경로(`학습 > 평가 > 제출`)로 부릅니다. 다른 소프트웨어는 모듈, API, 배치 작업, CLI 명령입니다. 제품 영역마다 `memory/areas/.md` 한 장이 있고, 여기에 개발자가 아니어도 읽을 수 있는 문장으로 쓴 업무 정책(P1, P2…), 동작 방식, 그 영역을 바꾼 피처별 이력 한 줄이 담깁니다. 피처의 `summary.md`는 `Area:` 줄로 제품 영역을 적고, spec은 어떤 정책을 유지하거나 바꾸는지 밝히고, Side effects 검증자는 건드리지 않아야 할 정책이 그대로인지 다시 확인하며, close 병합이 새로 생기거나 바뀐 정책을 페이지에 올립니다. `tools/kb.sh show "학습 > 평가 > 제출"`(또는 페이지 파일 이름)은 그 페이지와 그 제품 영역을 바꾼 피처를 함께 보여줍니다. + +**기록을 다시 읽는 도구는 `tools/kb.sh`입니다.** `index`는 목차 페이지를 다시 만듭니다(`init.sh`와 `close.sh`가 자동으로 실행합니다). 페이지는 정책 수·마지막 변경·피처를 담은 제품 영역 표, 상태·날짜·제품 영역·태그를 담은 개요 표(최신순), 아직 close가 병합하지 않은 harvest 목록, 피처별 절 순서입니다. `show `는 피처 하나를 요약본으로 보여줍니다. 목표, `summary.md`(제품 영역·무엇이 문제였나·Before → After·확인 방법을 담는, 계속 갱신하도록 만든 유일한 기록), 배포 상태, 병합되지 않은 harvest 후보, 교훈 제목이 먼저 나오고 파일 경로는 마지막입니다. `search "<문자열>"`은 열린 피처와 닫힌 피처, 지속 메모리를 대상으로 출력량을 제한한 문자열 검색을 하고, `harvest [--stale <일수>]`는 harvest.md가 아직 memory/에 들어가지 않은 열린 피처를 유휴 기간과 함께 나열합니다(유휴 상태가 오래된 피처는 close 없이 병합할 수 있습니다 — AGENTS.md 규칙 4). `--area <폴더>`를 붙이면 그 폴더 안의 모든 저장소를 대상으로 같은 일을 하며, 원래 체크아웃이 사라진 피처도 읽을 수 있습니다. 저장소 config.md에 `index_style: obsidian`을 적으면 Obsidian 볼트용 frontmatter와 인라인 `#태그`를 덧붙입니다. 생성 시각은 절대 쓰지 않으므로 내용이 같으면 diff도 생기지 않습니다. 종료 코드는 `0` 찾음, `1` 없음, `2` 사용법 오류 또는 거부입니다. 공개 sdlc-kit 저장소는 프레임워크만 담습니다. 기록은 작성된 자리, 즉 프로젝트 작업 사본이나 사용자가 고른 영역에 남아 그대로 읽힙니다. @@ -231,10 +234,7 @@ abandoned나 dead-end는 교훈이 없으면 닫히지 않습니다(lazymode 3 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 원격, 네트워크 없음) -gates/knowledge-test.sh # 기록 저장 위치와 재검색을 자체 픽스처에서 검사 +gates/selftest.sh # 스모크 테스트: 스크립트 문법, 스킬 메타데이터, 게이트 동작 (몇 초) ``` 예시: @@ -334,7 +334,7 @@ init.sh 멱등 프로젝트 시드 .gitattributes LF 고정, Windows 클론에서도 스크립트 생존 skills/1-6/ 단계별 지시서 roles/ verifier · adversary · researcher 계약 -gates/ approve · check · close · status · stats · selftest · e2e · autotest (공용 헬퍼 _common.sh, _auto.sh 포함) +gates/ approve · check · close · status · stats · selftest (공용 헬퍼 _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 랜딩 페이지 @@ -344,24 +344,14 @@ docs/automation.md 기계 계약: status JSON, 영수증, 핸드오프, 체크 ## 킷 검증 ```bash -./gates/selftest.sh # 게이트 동작 -./gates/e2e.sh # 자체 일회용 픽스처에서 루프 전체 -./gates/autotest.sh # 자체 일회용 픽스처에서 자동화 계층 -./gates/knowledge-test.sh # 기록이 저장되는 위치와 다시 찾는 방법 +./gates/selftest.sh # 몇 초 ``` -셀프테스트는 게이트 상태와 경로·내용 결합(다른 경로 재사용, 경로 이탈, 심볼릭 링크, 결합 이전 기록은 모두 닫힌 상태로 실패), 단계명 인젝션, 경로 이탈 거부, delegated와 lazy 승인 및 그 리뷰·위험 허가 기록, 컴팩트 루트와 승격 시 재승인, 전달 기록을 요구하는 `shipped` 종결, `refcheck.sh`의 드리프트 감지, 종결 시 교훈 요구, 이중 종결 거부, 종결 시 아카이브(승인 기록 이동과 status 범위 포함), YAML 프런트매터 파싱, 전체 스크립트의 LF 줄 끝을 검사합니다. 여기에 엔드투엔드 워크플로 픽스처 두 가지 — 컴팩트 버그 수정의 intent부터 전달 종결까지, 그리고 그 주변 실패 경로 — 가 함께 돌고, 리뷰 전에 이미 커밋된 작업의 소스 결합과 `pr` 전달의 커밋 포함 여부 검사도 포함됩니다. - -`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)에서 네 스위트를 모두 실행합니다. - -`gates/knowledge-test.sh`는 저장소 자체를 검사합니다. 루트에 고정된 무시 규칙, 사용자가 고른 폴더에 묶인 외부 영역(공백과 비ASCII 경로 포함), 체크아웃마다 분리되는 저장소, 그리고 모든 거부 경로를 확인합니다. 영역이 프로젝트 안에 있는 경우, 프로젝트가 영역 안에 있는 경우, 다른 체크아웃이 소유한 저장소, 저장소가 아닌 디렉터리, 자동으로 옮기지 않는 실제 `.sdlc`, 다른 곳을 가리키는 링크, 쓸 수 없는 영역이 여기에 해당합니다. 이어서 링크를 통해 피처 하나를 승인·변조·ship·전달·종결까지 돌려 게이트 동작이 그대로인지 확인하고, 재검색도 검사합니다. close 시점의 목차 갱신, `show`, 출력이 제한된 문자열 `search`, `-`로 시작하는 질의, 사람이 쓴 페이지를 덮어쓰지 않는 동작, 피처 심볼릭 링크를 따라가지 않는 동작, 그리고 체크아웃을 삭제한 뒤에도 `--area`로 기록을 읽는 동작입니다. 심볼릭 링크를 만들 수 없는 파일 시스템에서는 외부 영역 항목을 조용히 건너뛰지 않고 NOT VERIFIED로 보고합니다. +대부분이 지침 문서인 킷이라 스모크 테스트 하나만 둡니다. 모든 스크립트가 문법 오류 없이 LF로 +저장돼 있는지, 모든 SKILL.md의 frontmatter가 올바른지, 게이트가 승인된 내용에서만 열리고 그 +내용이나 상위 산출물이 바뀌면 닫히는지, lazymode가 설정 단계를 넘지 않는지, `dead-end`에는 +교훈이 필요하고 `shipped`에는 ship 승인과 확인된 전달 기록이 필요한지, 그리고 UTF-8 로케일에서도 +지식이 제 제품 영역에 정리되는지 확인합니다. CI는 PR과 수동 실행 때만 돌립니다. ## 이것이 아닌 것 diff --git a/README.md b/README.md index 7dc6a54..f025bd4 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Mid-loop, lesson and domain candidates stage in the feature's own `harvest.md`; | Keeps the plan inside one chat | Writes the record — `intent.md`, `spec.md`, `plan.md`, `evidence.md`, `delivery.md` — to a store you can search later (`tools/kb.sh`), out of the application's git history | | Author runs its own checks | A fresh-context verifier and adversary review the work without the author's context | | Approval is a chat message that disappears | Approval records name the stage, artifact, time, and mode, and stay on disk in `.sdlc/approvals/` | -| Failed attempt becomes forgotten context | Lessons go to a bounded index; durable facts go to `DOMAIN.md` | +| Failed attempt becomes forgotten context | Lessons go to a bounded index; business rules go to their product area's page; durable facts go to `DOMAIN.md` | | One generic worker does everything | Roles map onto local QA, reviewer, browser, API, or DB specialists when available | | "Done" is ambiguous | Every run closes as `shipped`, `abandoned`, `dead-end`, or `handed-off` — and `shipped` requires a verified delivery record, not just an approval | @@ -144,7 +144,8 @@ Per feature, inside the **target project**: ├── memory/ │ ├── POLICY.md # human-declared hard rules; agents transcribe only │ ├── INDEX.md # ≤50 lines of lesson pointers -│ ├── DOMAIN.md # terms · verified facts · constraints +│ ├── DOMAIN.md # terms · facts and constraints that span areas +│ ├── areas/.md # one per product area (web app: one menu): business rules P1… · how it works · history │ └── lessons/-.md ├── work// # OPEN features only │ ├── origin.md # the ticket / 기획서 as requested — bound by the intent gate @@ -156,7 +157,7 @@ Per feature, inside the **target project**: │ ├── deviations.md # build-time differences │ ├── progress.md # heartbeat: ONE live line (rule 9) │ ├── baseline.txt # brownfield behavior before the change -│ ├── summary.md # the reader's page: Problem/Cause/Change/Result/Lesson, Tags; kept current, bound by no approval +│ ├── summary.md # the reader's page: Area · What was wrong · Before → After · How to check · Remember; kept current, bound by no approval │ ├── harvest.md # mid-loop lesson/domain candidates; merged at close (readable before: kb.sh show / harvest) │ └── scratch/ # bulk logs, captures, traces └── archive// # closed features; close.sh moves them here @@ -168,7 +169,9 @@ Per feature, inside the **target project**: **Where the records live is your choice.** By default they sit in the project's working copy. `init.sh . --area ~/knowledge` puts them in a folder you choose instead — `/-/`, with `.sdlc` linked to it, one store per checkout so two worktrees never share approvals. The area is refused if it sits inside the project (or the project inside it), if another checkout already owns that store, if a real `.sdlc` directory is already there (nothing is ever relocated for you), or if the link cannot be made. That ownership is re-checked at RUNTIME, not only at init: `check-gate.sh`, `approve.sh`, `close.sh`, `status.sh`, `tools/auto.sh`, `tools/verify.sh` and `tools/handoff.sh` refuse before any verdict or write when `/PROJECT` names a different checkout, so a copied working copy (`cp -R`, rsync and most restores keep the symlink) can neither open another checkout's gate nor close its features. Reading is never bound that way: `tools/kb.sh show|search|list` still works, and nothing is ever re-bound or moved for you. Whichever you choose, **the store is yours to back up** — git no longer does it for you. -**Reading the records back** is `tools/kb.sh`: `index` regenerates the contents page (`init.sh` and `close.sh` do it for you) — an overview table by state, date and tags, newest first, the harvests no close has merged yet, then one section per feature; `show ` prints one feature as a digest — goal, its `summary.md` (Problem/Cause/Change/Result/Lesson, the one record meant to be kept current), delivery, unmerged harvest candidates, lesson titles, then the paths; `search ""` does a bounded literal search over open and closed features plus durable memory; `harvest [--stale ]` lists open features whose harvest.md is not in memory yet, with idle time (a stale one may be merged without closing — AGENTS.md rule 4); and `--area ` does any of these across every store in that folder — including features whose checkout no longer exists. `index_style: obsidian` in the store's config.md adds frontmatter and inline `#tags` for a vault; no timestamp is ever written, so an unchanged page produces no diff. Exit codes: `0` found, `1` nothing found, `2` usage error or refusal. +**Knowledge is filed by product area.** For a web app an area is one menu, named by its menu path (`학습 > 평가 > 제출`); for other software a module, API, job, or CLI command. Each area has one page, `memory/areas/.md`: its business rules numbered P1, P2… in sentences a non-developer can read, how it works, and a history line per feature that changed it. A feature's `summary.md` names its area on an `Area:` line, the spec states which rules it keeps or changes, the Side effects verifier re-checks the rules it should not have touched, and the close merge files new or changed rules on the page. `tools/kb.sh show "학습 > 평가 > 제출"` (or the page's file name) prints the page with the features that changed it. + +**Reading the records back** is `tools/kb.sh`: `index` regenerates the contents page (`init.sh` and `close.sh` do it for you) — the product areas with their rule count, last change and features, an overview table by state, date, area and tags, newest first, the harvests no close has merged yet, then one section per feature; `show ` prints one feature as a digest — goal, its `summary.md` (area, what was wrong, before → after, how to check, the one record meant to be kept current), delivery, unmerged harvest candidates, lesson titles, then the paths; `search ""` does a bounded literal search over open and closed features plus durable memory; `harvest [--stale ]` lists open features whose harvest.md is not in memory yet, with idle time (a stale one may be merged without closing — AGENTS.md rule 4); and `--area ` does any of these across every store in that folder — including features whose checkout no longer exists. `index_style: obsidian` in the store's config.md adds frontmatter and inline `#tags` for a vault; no timestamp is ever written, so an unchanged page produces no diff. Exit codes: `0` found, `1` nothing found, `2` usage error or refusal. The public sdlc-kit repository stays framework-only. The records live and stay readable where they were written — in the project's working copy, or in the area you chose. @@ -231,9 +234,7 @@ When the incident cannot be reproduced, fresh-context adversaries recount the sc 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) +gates/selftest.sh # smoke test: scripts parse, skill metadata, gate mechanics (seconds) ``` Example: @@ -335,7 +336,7 @@ 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 · autotest (+ _common.sh, _auto.sh) +gates/ approve · check · close · status · stats · selftest (+ _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 @@ -345,31 +346,15 @@ docs/automation.md the machine contract: status JSON, receipts, handoff, checkp ## Verify the kit ```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 -./gates/knowledge-test.sh # where records live and how they are found again +./gates/selftest.sh # a few seconds ``` -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). - -`gates/knowledge-test.sh` covers the store itself: the anchored ignore rule, an external area bound to a chosen folder (spaces and non-ASCII included), one store per checkout, and every refusal — an area inside the project, a project inside the area, a store another checkout owns, a directory that is not a store, a real `.sdlc` that is never relocated, a link pointing somewhere else, an unwritable area. It then runs a feature through the link (approve, tamper, ship, deliver, close) to prove the gates are unchanged, and checks retrieval: the contents page refreshed at close, `show`, bounded literal `search`, a query starting with `-`, a user-authored page that is never clobbered, a feature symlink that is never followed, and records still readable through `--area` after the checkout they came from is deleted. Where the filesystem cannot create a symlink the external-area cases are reported as NOT VERIFIED rather than skipped silently. +One smoke test for a kit that is mostly instructions: every script parses and is +LF-only, every SKILL.md has valid frontmatter, a gate opens only for the approved +bytes and closes when they or an upstream artifact change, lazymode never goes +beyond its level, `dead-end` needs a lesson and `shipped` needs a ship approval +plus a confirmed delivery, and knowledge is filed under its own product area +under a UTF-8 locale. CI runs it on pull requests and by hand. ## What this is not diff --git a/SKILL.md b/SKILL.md index e3b92c5..c281f5b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -31,15 +31,15 @@ they do it under this contract (gates, artifacts, memory), not beside it. | Request looks like | Do | |---|---| -| any feature, fix, or change request, however worded | New slug. Read `skills/1-intent/SKILL.md`. Small and well understood → compact route (one work artifact: intent → build → ship); anything ambiguous, broad, or risky → full route; oversized → map first. | +| any feature or change request, however worded — a fix whose cause is already known included | New slug. Read `skills/1-intent/SKILL.md`. Small and well understood → compact route (one work artifact: intent → build → ship); anything ambiguous, broad, or risky → full route; oversized → map first. | | "explain X" / "why does Y happen" / read-only audit | Answer it. No slug, no artifacts, no gates. Propose stage 1 only if the human wants the change made. | | 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. | -| "has this been done before" / "why is X like this" / debugging an old area | `tools/kb.sh search ""` then `tools/kb.sh show ` (AGENTS.md rule 7). | +| "has this been done before" / "why is X like this" / "what are the rules for " | `tools/kb.sh search ""`, then `tools/kb.sh show ` or `show `; `tools/kb.sh index` lists the areas (AGENTS.md rule 7). | | "what did we learn that is not in memory yet" / many open features, few closes | `tools/kb.sh harvest [--stale ]`; a stale one may be merged without closing (AGENTS.md rule 4). Contents page: `tools/kb.sh index` (`index_style: obsidian` in config.md for a vault). | | a host/scheduler drives the loop, or you need machine state | `gates/status.sh --json`, `tools/auto.sh next `; contract: `docs/automation.md`. | | 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`. | +| something is broken and the cause is not known — bug report, incident, alert | Read `skills/6-maintain/SKILL.md` first: it diagnoses, then writes the intent. Takes precedence over the first row. | | "we're done / drop this / dead end" for a feature | `gates/close.sh "reason"` — what each needs: AGENTS.md "Every feature ends in a terminal state". | | project has no `.sdlc/` yet | Run `/init.sh` from the project root (records are gitignored; `--area ` keeps them in a folder the human names instead); ask the human which lazymode level they want (0–4, default 1; AGENTS.md rule 3) and set it in `.sdlc/config.md`; fill the config commands; then stage 1. | diff --git a/VERSION b/VERSION index 4a29f93..86dd09a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.14.0 +v0.15.0 diff --git a/gates/autotest.sh b/gates/autotest.sh deleted file mode 100755 index 3701bf3..0000000 --- a/gates/autotest.sh +++ /dev/null @@ -1,1138 +0,0 @@ -#!/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 - -# ===================================================================== -# A24 the build fix loop cap is machine-readable: deviations.md's round lines -# decide, and an exhausted loop is a human decision at lazymode 4 -# ===================================================================== -P="$FIX/a24"; mkproj "$P" 4; cd "$P" -write_intent "$P" feat-a24 -gate approve.sh intent .sdlc/work/feat-a24/intent.md --lazy --review "read app.sh" >/dev/null -assert_exit_msg "A24a no fix loop recorded: build is ready" 0 "ready build" auto next feat-a24 -DV=.sdlc/work/feat-a24/deviations.md -printf '# Deviations: feat-a24\n## Fix loop\n- round 1/3: E2E · accepted F1 · declined none · re-check: open: F1 still fails\n' > "$DV" -assert_exit_msg "A24b an open round below the cap keeps build ready" 0 "ready build" auto next feat-a24 -printf -- '- round 2/3: E2E · accepted F1 · declined none · re-check: open: F1 still fails\n- round 3/3: E2E · accepted F1 · declined none · re-check: open: F1 still fails\n' >> "$DV" -assert_exit_msg "A24c round 3 still open is fixloop.exhausted: needs-human, lazymode 4 included" 10 "fixloop.exhausted" auto next feat-a24 -out=$(gate status.sh feat-a24 2>&1) -case "$out" in *"FIX LOOP EXHAUSTED"*) pass "A24d the cockpit says the same";; *) fail "A24d cockpit hides the exhausted loop" "$out";; esac -printf '# Deviations: feat-a24\n## Fix loop\n- round 1/3: E2E · accepted F1 · declined none · re-check: open: F1 still fails\n- round 2/3: E2E · accepted F1 · declined none · re-check: open: F1 still fails\n- round 3/3: E2E · accepted F1 · declined none · re-check: resolved\n' > "$DV" -assert_exit_msg "A24e round 3 resolved in place: build is ready again" 0 "ready build" auto next feat-a24 -printf -- '- round 4/3: E2E · accepted F2 · declined none · re-check: pending\n' >> "$DV" -assert_exit_msg "A24f a round past the cap is exhausted whatever its re-check says" 10 "fixloop.exhausted" auto next feat-a24 -out=$(auto status --json) -case "$out" in *'"fix_loop": {"state": "exhausted"'*) pass "A24g the machine view carries fix_loop";; *) fail "A24g fix_loop missing from JSON" "$out";; esac -printf '# Evidence: feat-a24\n- R1: sh app.sh → hello\n' > .sdlc/work/feat-a24/evidence.md -assert_exit_msg "A24h evidence.md over an exhausted loop does not make the lazy ship gate ready" 10 "fixloop.exhausted" auto next feat-a24 - -# ===================================================================== -# A25 `data` is a receipted check kind: a read-only consistency query runs and -# is recorded, and never counts as runtime evidence -# ===================================================================== -P="$FIX/a25"; mkproj "$P" 4; cd "$P" -write_intent "$P" feat-a25 -write_recipe "$P" advisory "check: D1 | data | sh -c 'test 1 -eq 1'" -assert_exit_msg "A25a a data check runs under the recipe" 0 "VERIFY ok" verify run feat-a25 -assert_grep .sdlc/work/feat-a25/verify-receipt.md '^check: D1 | data | 0 |' "A25b the receipt records the data check" -assert_grep .sdlc/work/feat-a25/verify-receipt.md '^runtime_evidence: no' "A25c a data check is not runtime evidence" -write_recipe "$P" advisory "check: D1 | data | sh -c 'test 1 -eq 2'" -assert_exit_msg "A25d a failing data check fails the run" 1 "VERIFY fail" verify run feat-a25 - -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 56d98c6..4dffef9 100755 --- a/gates/close.sh +++ b/gates/close.sh @@ -4,7 +4,7 @@ # The reason MUST contain the external ticket/PR reference (e.g. A20-1240). # Terminal state for a feature. Human decision; --delegated per AGENTS.md rule 3. # A dead-ended feature must leave MORE knowledge behind than it consumed: -# closing requires a lesson (dead-end/abandoned) and a DOMAIN.md harvest check. +# closing requires a lesson (dead-end/abandoned) and a merged harvest.md. # `shipped` requires a DELIVERY: the ship approval must still bind the reviewed # evidence AND the reviewed source snapshot, and delivery.md must record a # confirmed result whose Source is that source — a commit that contains it, or @@ -269,8 +269,9 @@ fi # harvest.md means lesson/domain candidates were never merged into memory/. if [ -f "$dir/harvest.md" ]; then echo "BLOCKED: unmerged harvest: $dir/harvest.md" - echo " Merge it into .sdlc/memory/ (lesson files + INDEX.md lines + DOMAIN.md" - echo " facts), delete the file, then re-run. Close is the single-writer moment." + echo " Merge it into .sdlc/memory/ (area pages: rules + history; DOMAIN.md facts;" + echo " lesson files + INDEX.md lines — AGENTS.md rule 4), delete the file, then" + echo " re-run. Close is the single-writer moment." exit 1 fi @@ -364,7 +365,7 @@ if [ -f .sdlc/memory/INDEX.md ]; then fi fi -echo "Reminder: harvest durable facts into .sdlc/memory/DOMAIN.md." +echo "Reminder: file what this feature taught in .sdlc/memory/ — area pages, DOMAIN.md, lessons (AGENTS.md rule 4)." if [ -n "$in_git" ]; then # Records are gitignored, so there is normally nothing to stage but the # .gitignore rule itself. A project seeded by an older kit may still TRACK diff --git a/gates/e2e.sh b/gates/e2e.sh deleted file mode 100755 index 60492a6..0000000 --- a/gates/e2e.sh +++ /dev/null @@ -1,1004 +0,0 @@ -#!/usr/bin/env bash -# e2e.sh [kit-path] — end-to-end integration suite for sdlc-kit. -# -# Builds throwaway git projects in its OWN mktemp fixture, drives the loop with -# the kit's real scripts (init/approve/check-gate/status/close/refcheck), and -# asserts on observable results — the loop as a user meets it, not the internals -# (gates/selftest.sh covers those unit-level; this suite never runs it — CI and -# the release check run both, and one suite hiding inside the other only makes a -# failure report twice). Nothing outside the fixture is -# written and no network, remote, or `gh` call is ever made: `pr` and `deploy` -# deliveries are exercised through the local fixture, which is all close.sh -# inspects. Bash 3.2 compatible (no associative arrays, no mapfile, no [[ =~ ]]). -# -# The kit path defaults to this script's own kit, so the suite is relocatable. -# E2E_BASE= puts the fixture somewhere else; E2E_KEEP=1 keeps it. -# -# 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/init.sh" ] || { echo "not a kit: $KIT" >&2; exit 2; } - -BASE="${E2E_BASE:-${TMPDIR:-/tmp}}" -mkdir -p "$BASE" || exit 2 -FIX=$(mktemp -d "${BASE%/}/sdlc-e2e.XXXXXX") || exit 2 -case "$FIX" in */sdlc-e2e.*) ;; *) echo "refusing to use fixture $FIX" >&2; exit 2;; esac -cleanup() { case "$FIX" in */sdlc-e2e.*) rm -rf "$FIX";; esac; } -[ -n "${E2E_KEEP:-}" ] || trap cleanup EXIT -echo "fixture: $FIX" -echo "kit: $KIT" -echo - -PASSED=0; FAILED=0; FAILLIST="" -pass() { PASSED=$((PASSED + 1)); printf 'PASS %s\n' "$1"; } -fail() { FAILED=$((FAILED + 1)); FAILLIST="$FAILLIST - - $1"; printf 'FAIL %s\n' "$1"; [ -n "${2:-}" ] && printf ' output: %s\n' "$(printf '%s' "$2" | tr '\n' '|' | cut -c1-300)"; return 0; } - -# assert_ok — command must succeed -assert_ok() { local d="$1"; shift; local o rc; o=$("$@" 2>&1); rc=$? - [ $rc -eq 0 ] && pass "$d" || fail "$d (exit $rc)" "$o"; } -# assert_ok_msg — must succeed AND print needle -assert_ok_msg() { local d="$1" n="$2"; shift 2; local o rc; o=$("$@" 2>&1); rc=$? - if [ $rc -ne 0 ]; then fail "$d (exit $rc, expected 0)" "$o" - else case "$o" in *"$n"*) pass "$d";; *) fail "$d (missing '$n')" "$o";; esac; fi; } -# assert_fail_msg — must fail AND explain with needle -assert_fail_msg() { local d="$1" n="$2"; shift 2; local o rc; o=$("$@" 2>&1); rc=$? - if [ $rc -eq 0 ]; then fail "$d (succeeded, expected refusal)" "$o" - else case "$o" in *"$n"*) pass "$d";; *) fail "$d (refused, but message lacks '$n')" "$o";; esac; fi; } -# assert_exit -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_file() { [ -f "$1" ] && pass "$2" || fail "$2 (missing file $1)"; } -assert_nofile() { [ -e "$1" ] && fail "$2 (unexpected $1)" || pass "$2"; } -assert_grep() { grep -q "$2" "$1" 2>/dev/null && pass "$3" || fail "$3 (no /$2/ in $1)"; } -# mklink — a fixture that claims to be a symlink must BE one. -# Git Bash's default MSYS mode makes `ln -s` COPY instead of link, which would -# turn a symlink assertion into a false PASS; CI sets -# MSYS=winsymlinks:nativestrict. A link we cannot create stops the suite: it is -# a setup failure, not a soft assertion. -mklink() { ln -s "$1" "$2" 2>/dev/null - [ -L "$2" ] || { fail "setup: $2 is not a real symlink (Windows: MSYS=winsymlinks:nativestrict)"; exit 1; }; } - -# Every fixture repo is local, disposable, and deterministic: a fixed identity, a -# fixed initial branch name (older git defaults to master), no signing hook. -gitinit() { - git init -q . - git symbolic-ref HEAD refs/heads/main - git config user.email e2e@fixture.local - git config user.name "E2E Fixture" - git config commit.gpgsign false -} -sdlc() { "$KIT/gates/$1" "${@:2}"; } # bash 3.2 supports ${@:2} -sha256of() { # — same tools the kit itself falls back through - if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' - elif command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' - else openssl dgst -sha256 "$1" | awk '{print $NF}'; fi -} - -# ---------------------------------------------------------------- fixture app -# A tiny runnable app + its own test runner, so "before/after" proof is real. -make_app() { # - cat > "$1/app.sh" <<'APP' -#!/bin/sh -# search — print matching lines of data.txt -search() { - q="$1" - if [ -z "$q" ]; then return 0; fi # empty query matches nothing - grep -F -- "$q" data.txt || true -} -case "${1:-}" in - search) search "${2:-}";; - *) echo "usage: app.sh search " >&2; exit 2;; -esac -APP - chmod +x "$1/app.sh" - printf 'alpha one\nbeta two\nalpha three\n' > "$1/data.txt" - cat > "$1/test_app.sh" <<'T' -#!/bin/sh -# test runner: every case prints PASS/FAIL; exit 1 on any failure -fails=0 -check() { # - if [ "$2" = "$3" ]; then echo "PASS $1"; else - echo "FAIL $1: expected [$2] got [$3]"; fails=$((fails+1)); fi -} -check search-alpha "alpha one -alpha three" "$(./app.sh search alpha)" -check search-none "" "$(./app.sh search zzz)" -[ "$fails" = 0 ] && { echo "ALL PASS"; exit 0; } || { echo "$fails FAILURE(S)"; exit 1; } -T - chmod +x "$1/test_app.sh" -} - -fill_config() { # - awk -v lm="$2" ' - /^lazymode:/ { print "lazymode: " lm; next } - /^test:/ { print "test: ./test_app.sh"; next } - /^lint:/ { print "lint: sh -n app.sh"; next } - /^run:/ { print "run: ./app.sh search alpha"; next } - { print }' "$1/.sdlc/config.md" > "$1/.sdlc/config.tmp" - mv "$1/.sdlc/config.tmp" "$1/.sdlc/config.md" -} - -echo "=============== A. compact route: a real feature, gates, delivery, close" -A="$FIX/proj-compact"; mkdir -p "$A"; cd "$A" -gitinit; make_app "$A" -git add -A; git commit -qm "init: search app with tests" -assert_ok_msg "A1 init.sh seeds .sdlc/" "Seeded .sdlc/" bash "$KIT/init.sh" . -fill_config "$A" 0 -assert_ok "A2 baseline suite green before the feature" ./test_app.sh - -mkdir -p .sdlc/work/add-count/scratch -cat > .sdlc/work/add-count/intent.md <<'EOF' -# Intent: add-count -- Goal: someone searching sees how many results came back. -- Date: 2026-09-15 -- Type: brownfield -- Track: compact — one file and one existing test file, revert is one commit -- Requested by: QA fixture human - -## Success criteria -- [ ] `./test_app.sh` passes with a case asserting the count line - -## Compact route -- Files: app.sh (search), test_app.sh (new case) -- Proof: ./test_app.sh — "ALL PASS" means results and count both correct -- Risk: search output only; single revert; nothing else reads app.sh -- Delivery target: local - -## Out of scope / must not change -- the matching behavior itself -EOF -assert_fail_msg "A3 build gate is CLOSED before the intent approval" "GATE CLOSED" \ - sdlc check-gate.sh intent .sdlc/work/add-count/intent.md -# fixture human authorization (chat): "approve add-count intent, local only" -assert_ok "A4 intent approved --delegated on the fixture human's explicit word" \ - sdlc approve.sh intent .sdlc/work/add-count/intent.md --delegated -assert_ok_msg "A5 intent gate OPEN after approval" "GATE OPEN" \ - sdlc check-gate.sh intent .sdlc/work/add-count/intent.md -assert_grep .sdlc/approvals/add-count.intent.approval '^track: compact' "A6 approval froze the compact verdict" -assert_grep .sdlc/approvals/add-count.intent.approval '^mode: delegated-chat' "A6b delegated identity recorded" -assert_grep .sdlc/approvals/add-count.intent.approval '^runner: agent' "A6c agent runner recorded" -out=$(sdlc status.sh add-count 2>&1) -case "$out" in *"(compact)"*) pass "A7 status marks the compact route";; *) fail "A7 compact marker" "$out";; esac -case "$out" in *" spec "*) fail "A7b status still asks for spec on the compact route" "$out";; *) pass "A7b no spec stage demanded";; esac - -# --- build: test BEFORE (must fail), then the fix, then test AFTER -cat > test_app.sh <<'T' -#!/bin/sh -fails=0 -check() { if [ "$2" = "$3" ]; then echo "PASS $1"; else echo "FAIL $1: expected [$2] got [$3]"; fails=$((fails+1)); fi; } -check search-alpha "alpha one -alpha three -count: 2" "$(./app.sh search alpha)" -check search-none "count: 0" "$(./app.sh search zzz)" -check count-line "count: 2" "$(./app.sh search alpha | tail -n1)" -[ "$fails" = 0 ] && { echo "ALL PASS"; exit 0; } || { echo "$fails FAILURE(S)"; exit 1; } -T -chmod +x test_app.sh -./test_app.sh > .sdlc/work/add-count/scratch/before.log 2>&1 -rc=$? -[ $rc -ne 0 ] && pass "A8 the new test FAILS against the old code (exit $rc) — real before-proof" \ - || fail "A8 the new test passed before the change; it cannot fail" -assert_grep .sdlc/work/add-count/scratch/before.log 'FAIL count-line' "A8b before.log holds the observed failure" - -# implement -cat > app.sh <<'APP' -#!/bin/sh -# search — print matching lines of data.txt, then the result count -search() { - q="$1" - if [ -z "$q" ]; then echo "count: 0"; return 0; fi - out=$(grep -F -- "$q" data.txt || true) - [ -n "$out" ] && printf '%s\n' "$out" - if [ -z "$out" ]; then echo "count: 0"; else printf 'count: %s\n' "$(printf '%s\n' "$out" | wc -l | tr -d ' ')"; fi -} -case "${1:-}" in - search) search "${2:-}";; - *) echo "usage: app.sh search " >&2; exit 2;; -esac -APP -chmod +x app.sh -assert_ok "A9 full suite green AFTER the change (one full run at the end)" ./test_app.sh -./test_app.sh > .sdlc/work/add-count/scratch/after.log 2>&1 -assert_grep .sdlc/work/add-count/scratch/after.log 'ALL PASS' "A9b after.log holds the passing output" - -cat > .sdlc/work/add-count/evidence.md <<'EOF' -# Evidence: add-count -## Verification -- Command: ./test_app.sh → `ALL PASS` (full output: scratch/after.log) -- Before the change the new case failed: `FAIL count-line: expected [count: 2] got [alpha three]` - (full output: scratch/before.log) -## Regression -- Baseline vs after: the two pre-existing cases were updated for the intended - output change (count line) and pass; matching behavior unchanged. -EOF -assert_ok "A10 ship approved --delegated (fixture human: 'ship it locally')" \ - sdlc approve.sh ship .sdlc/work/add-count/evidence.md --delegated -assert_grep .sdlc/approvals/add-count.ship.source ' app.sh$' "A11 ship binds the reviewed app.sh" -assert_grep .sdlc/approvals/add-count.ship.source ' test_app.sh$' "A11b ship binds the reviewed test file" -assert_grep .sdlc/approvals/add-count.ship.approval '^code_files: [1-9]' "A11d the bound source set is not empty" -assert_grep .sdlc/approvals/add-count.ship.approval '^code_digest: [0-9a-f]' "A11c reviewed code digest recorded" -assert_grep .sdlc/approvals/add-count.ship.approval '^upstream_intent: [0-9a-f]' "A11e ship binds the upstream intent" -if grep -qE '^upstream_(spec|plan):' .sdlc/approvals/add-count.ship.approval; then - fail "A11f compact ship approval invented a spec/plan binding" -else pass "A11f the compact route binds no spec/plan at ship — none is ever demanded of it"; fi -out=$(sdlc status.sh add-count 2>&1) -case "$out" in *"no delivery.md"*) pass "A12 status asks for the delivery record after the ship gate";; - *) fail "A12 delivery row missing" "$out";; esac -assert_fail_msg "A13 close shipped BLOCKED with no delivery record" "requires a delivery record" \ - sdlc close.sh add-count shipped "count line delivered" - -# staging + committing the reviewed content must NOT invalidate the approval. -# The .sdlc paths are ignored now (one `/.sdlc` rule), so naming them here would -# make `git add` exit 1 and print a hint block — noise that would also hide a -# real ignored-path error. Only source is staged, which is all the ship -# approval binds anyway. -git add app.sh test_app.sh .gitignore -git commit -qm "feat(search): show the result count" -SHA=$(git rev-parse HEAD) -assert_ok_msg "A14 ship gate survives staging+commit of the reviewed content" "GATE OPEN" \ - sdlc check-gate.sh ship .sdlc/work/add-count/evidence.md -cat > .sdlc/work/add-count/delivery.md <&1) -case "$out" in *"add-count"*"[CLOSED: shipped]"*) pass "A19 status --all lists the archived feature";; - *) fail "A19 archived feature not listed" "$out";; esac - -# --- the records are KNOWLEDGE, not source: a clone of the application does -# not carry them, and they stay findable where they live instead -git add -A .gitignore 2>/dev/null -git commit -qm "chore: ignore the record store" >/dev/null 2>&1 -git clone -q "$A" "$FIX/clone-compact" -C="$FIX/clone-compact" -assert_nofile "$C/.sdlc" "A20 a fresh clone carries no records (/.sdlc is ignored)" -assert_ok_msg "A20b git confirms the store is ignored" ".sdlc" git -C "$A" check-ignore -v .sdlc -assert_file "$A/.sdlc/archive/add-count/intent.md" "A21 intent.md stays in the store where it was written" -assert_file "$A/.sdlc/archive/add-count/evidence.md" "A22 evidence.md stays in the store" -assert_file "$A/.sdlc/archive/add-count/delivery.md" "A22b delivery.md stays in the store" -assert_file "$A/.sdlc/archive/add-count/CLOSED" "A23 CLOSED stays in the store" -assert_file "$A/.sdlc/archive/add-count/approvals/add-count.ship.approval" \ - "A24 approval records stay with the archived feature" -assert_file "$A/.sdlc/archive/add-count/scratch/after.log" "A25 the cited scratch log is still there" -# and the closed feature is RETRIEVABLE, which is the point of keeping it -assert_ok_msg "A26 close refreshed the contents page" "add-count" cat "$A/.sdlc/README.md" -assert_ok_msg "A27 kb.sh show finds the archived feature" "archive/add-count" \ - bash "$KIT/tools/kb.sh" show add-count --store "$A/.sdlc" -assert_ok_msg "A28 kb.sh search finds the delivered evidence" "evidence.md" \ - bash "$KIT/tools/kb.sh" search --store "$A/.sdlc" "ALL PASS" - -echo -echo "=============== B. full route: a reproduced bug fix, spec+plan gates" -B="$FIX/proj-full"; mkdir -p "$B"; cd "$B" -gitinit; make_app "$B" -# plant the bug: an empty query returns every line -cat > app.sh <<'APP' -#!/bin/sh -search() { grep -F -- "$1" data.txt || true; } # BUG: empty query matches all -case "${1:-}" in - search) search "${2:-}";; - *) echo "usage: app.sh search " >&2; exit 2;; -esac -APP -chmod +x app.sh -git add -A; git commit -qm "init: search app (with the empty-query bug)" -bash "$KIT/init.sh" . >/dev/null -fill_config "$B" 0 -mkdir -p .sdlc/work/fix-empty-query/scratch -# reproduce FIRST, before writing anything else -./app.sh search "" > .sdlc/work/fix-empty-query/scratch/repro-before.log 2>&1 -n=$(wc -l < .sdlc/work/fix-empty-query/scratch/repro-before.log | tr -d ' ') -[ "$n" = 3 ] && pass "B1 bug reproduced before any change (empty query returned $n lines)" \ - || fail "B1 bug did not reproduce (got $n lines, expected 3)" -cat > .sdlc/work/fix-empty-query/origin.md <<'EOF' -# Origin: fix-empty-query -- Ref: QA-7 (fixture ticket) -- Read with: fixture -## Text -An empty search must print nothing. -EOF -cat > .sdlc/work/fix-empty-query/intent.md <<'EOF' -# Intent: fix-empty-query -- Goal: an empty search box no longer dumps every record. -- Date: 2026-09-15 -- Type: brownfield -- Track: full — the blast radius of the matcher is not yet known -- Requested by: QA fixture human -## Success criteria -- [ ] `./app.sh search ""` prints nothing; `./test_app.sh` passes -EOF -assert_ok "B2 intent approved (fixture human)" sdlc approve.sh intent .sdlc/work/fix-empty-query/intent.md --delegated -assert_grep .sdlc/approvals/fix-empty-query.intent.approval '^upstream_origin: [0-9a-f]' "B2b the intent approval binds the origin snapshot" -assert_fail_msg "B3 spec gate closed before its own approval" "GATE CLOSED" \ - sdlc check-gate.sh spec .sdlc/work/fix-empty-query/spec.md -cat > .sdlc/work/fix-empty-query/spec.md <<'EOF' -# Spec: fix-empty-query -- AS-IS: search("") runs `grep -F ""`, which matches every line of data.txt (repro: scratch/repro-before.log). -- TO-BE: search("") returns no lines and exits 0. -- Stays untouched: non-empty queries keep their exact current output. -- Release procedure: local (commit on this branch, suite green). -EOF -assert_ok "B4 spec approved on top of the approved intent" sdlc approve.sh spec .sdlc/work/fix-empty-query/spec.md --delegated -assert_grep .sdlc/approvals/fix-empty-query.spec.approval '^upstream_intent: [0-9a-f]' "B4b spec binds the upstream intent digest" -cat > .sdlc/work/fix-empty-query/plan.md <<'EOF' -# Plan: fix-empty-query -- Gate tier: agent -1. app.sh search(): return early when the query is empty. Proof: ./app.sh search "" prints nothing. -2. test_app.sh: add the empty-query case. Proof: ./test_app.sh → ALL PASS. -EOF -assert_ok "B5 plan approved --agent-adversary (tiered, no trip-wire)" \ - sdlc approve.sh plan .sdlc/work/fix-empty-query/plan.md --agent-adversary -assert_ok_msg "B6 build gate open on the approved plan" "GATE OPEN" \ - sdlc check-gate.sh plan .sdlc/work/fix-empty-query/plan.md -out=$(sdlc status.sh fix-empty-query 2>&1) -case "$out" in *"intent "*"spec "*"plan "*) pass "B7 status resumes the full route with all four stages";; - *) fail "B7 full-route status rows missing" "$out";; esac - -# failing test first (test-before proof), then the fix -cat > test_app.sh <<'T' -#!/bin/sh -fails=0 -check() { if [ "$2" = "$3" ]; then echo "PASS $1"; else echo "FAIL $1: expected [$2] got [$3]"; fails=$((fails+1)); fi; } -check search-alpha "alpha one -alpha three" "$(./app.sh search alpha)" -check empty-query "" "$(./app.sh search '')" -[ "$fails" = 0 ] && { echo "ALL PASS"; exit 0; } || { echo "$fails FAILURE(S)"; exit 1; } -T -chmod +x test_app.sh -./test_app.sh > .sdlc/work/fix-empty-query/scratch/test-before.log 2>&1 -rc=$?; [ $rc -ne 0 ] && pass "B8 regression test fails against the unfixed code" || fail "B8 regression test passed before the fix" -assert_grep .sdlc/work/fix-empty-query/scratch/test-before.log 'FAIL empty-query' "B8b before-log names the failing case" -cat > app.sh <<'APP' -#!/bin/sh -search() { - [ -z "$1" ] && return 0 # an empty query matches nothing - grep -F -- "$1" data.txt || true -} -case "${1:-}" in - search) search "${2:-}";; - *) echo "usage: app.sh search " >&2; exit 2;; -esac -APP -chmod +x app.sh -assert_ok "B9 same reproduction passes after the fix (full suite)" ./test_app.sh -./test_app.sh > .sdlc/work/fix-empty-query/scratch/test-after.log 2>&1 -cat > .sdlc/work/fix-empty-query/evidence.md <<'EOF' -# Evidence: fix-empty-query -## Verification -- Command: ./test_app.sh → ALL PASS (scratch/test-after.log) -## Bug proof -- Before: `./app.sh search ""` → printed all 3 data lines (scratch/repro-before.log); - `./test_app.sh` → `FAIL empty-query` (scratch/test-before.log) -- Mechanism: `grep -F ""` matches every line, so an empty query fell through to grep. -- After: the SAME commands → empty output, `ALL PASS` (scratch/test-after.log) -- Adjacent flows: non-empty query (search alpha) unchanged; unknown-word query still empty. -- Intermittent? no — deterministic. -## Regression -- Baseline vs after: clean, non-empty query output byte-identical. -EOF -assert_ok "B10 ship approved" sdlc approve.sh ship .sdlc/work/fix-empty-query/evidence.md --delegated -DIG=$(awk '/^code_digest: /{print $2}' .sdlc/approvals/fix-empty-query.ship.approval) - -# B10a-B10h the full-route ship approval binds the WHOLE upstream chain. spec.md -# and plan.md live under .sdlc/, which the source snapshot excludes, so nothing -# else in the kit would notice a rewrite of the decision the ship was granted on. -assert_grep .sdlc/approvals/fix-empty-query.ship.approval '^upstream_spec: [0-9a-f]' \ - "B10a the full-route ship approval binds the upstream spec" -assert_grep .sdlc/approvals/fix-empty-query.ship.approval '^upstream_plan: [0-9a-f]' \ - "B10b the full-route ship approval binds the upstream plan" -cp .sdlc/work/fix-empty-query/spec.md "$FIX/spec.keep" -cp .sdlc/work/fix-empty-query/plan.md "$FIX/plan.keep" -cat > .sdlc/work/fix-empty-query/delivery.md <> .sdlc/work/fix-empty-query/spec.md -assert_fail_msg "B10c a spec.md edited after the ship review blocks 'shipped'" \ - "spec.md changed after the ship review" sdlc close.sh fix-empty-query shipped "delivered" -assert_fail_msg "B10d the ship gate itself closes over the rewritten spec" "GATE CLOSED" \ - sdlc check-gate.sh ship .sdlc/work/fix-empty-query/evidence.md -out=$(sdlc status.sh fix-empty-query 2>&1) -case "$out" in *"spec.md changed since approval"*) pass "B10e status says the same thing on the ship row";; - *) fail "B10e status hides the upstream spec rewrite" "$out";; esac -cp "$FIX/spec.keep" .sdlc/work/fix-empty-query/spec.md -assert_ok_msg "B10f the restored spec.md reopens the ship gate" "GATE OPEN" \ - sdlc check-gate.sh ship .sdlc/work/fix-empty-query/evidence.md -rm -f .sdlc/work/fix-empty-query/plan.md -assert_fail_msg "B10g a plan.md deleted after the ship review blocks 'shipped'" \ - "part of the approved ship basis and is now missing" sdlc close.sh fix-empty-query shipped "delivered" -cp "$FIX/plan.keep" .sdlc/work/fix-empty-query/plan.md -assert_ok_msg "B10h the restored upstream chain reopens the ship gate" "GATE OPEN" \ - sdlc check-gate.sh ship .sdlc/work/fix-empty-query/evidence.md -assert_grep .sdlc/approvals/fix-empty-query.ship.approval '^upstream_origin: [0-9a-f]' \ - "B10i the full-route ship approval binds the origin snapshot" -cp .sdlc/work/fix-empty-query/origin.md "$FIX/origin.keep" -echo "- also export every record (ticket edited after the review)" >> .sdlc/work/fix-empty-query/origin.md -assert_fail_msg "B10j an origin.md edited after the ship review blocks 'shipped'" \ - "origin.md changed after the ship review" sdlc close.sh fix-empty-query shipped "delivered" -cp "$FIX/origin.keep" .sdlc/work/fix-empty-query/origin.md -assert_ok_msg "B10k the restored origin reopens the ship gate" "GATE OPEN" \ - sdlc check-gate.sh ship .sdlc/work/fix-empty-query/evidence.md -cat > .sdlc/work/fix-empty-query/delivery.md </dev/null -git clone -q "$B" "$FIX/clone-full" -assert_nofile "$FIX/clone-full/.sdlc" "B13 the clone carries the code, not the records" -assert_ok_msg "B13b the full route's spec is retrievable from the store" "spec.md" \ - bash "$KIT/tools/kb.sh" show fix-empty-query --store "$B/.sdlc" - -echo -echo "=============== C. negative scenarios: none may pass or ship" -C2="$FIX/proj-neg"; mkdir -p "$C2"; cd "$C2" -gitinit; make_app "$C2"; git add -A; git commit -qm init -bash "$KIT/init.sh" . >/dev/null; fill_config "$C2" 0 - -# C1 approved artifact edited after approval -mkdir -p .sdlc/work/neg-one -printf -- '- Track: full\ngoal: neg one\n' > .sdlc/work/neg-one/intent.md -sdlc approve.sh intent .sdlc/work/neg-one/intent.md --delegated >/dev/null -echo "scope creep added after the human approved" >> .sdlc/work/neg-one/intent.md -assert_fail_msg "C1 edited-after-approval artifact closes its gate" "changed after it was approved" \ - sdlc check-gate.sh intent .sdlc/work/neg-one/intent.md -out=$(sdlc status.sh neg-one 2>&1) -case "$out" in *STALE*) pass "C1b status reports the stale binding in the same words";; *) fail "C1b status hides the stale binding" "$out";; esac - -# C2 upstream edit invalidates a downstream gate -mkdir -p .sdlc/work/neg-two -printf -- '- Track: full\ngoal: neg two\n' > .sdlc/work/neg-two/intent.md -echo "spec" > .sdlc/work/neg-two/spec.md -sdlc approve.sh intent .sdlc/work/neg-two/intent.md --delegated >/dev/null -sdlc approve.sh spec .sdlc/work/neg-two/spec.md --delegated >/dev/null -echo "requirement changed" >> .sdlc/work/neg-two/intent.md -assert_fail_msg "C2 upstream intent edit closes the spec gate" "changed after 'spec' was approved" \ - sdlc check-gate.sh spec .sdlc/work/neg-two/spec.md - -# C3 wrong artifact path / stage-artifact mismatch / outside the project / symlink -mkdir -p .sdlc/work/neg-three -echo goal > .sdlc/work/neg-three/intent.md -echo ev > .sdlc/work/neg-three/evidence.md -assert_fail_msg "C3 ship gate refuses intent.md as its artifact" "gate binds evidence.md" \ - sdlc approve.sh ship .sdlc/work/neg-three/intent.md --delegated -mkdir -p "$FIX/outside/neg-three"; cp .sdlc/work/neg-three/intent.md "$FIX/outside/neg-three/intent.md" -assert_fail_msg "C3b artifact outside .sdlc/work refused" "must live in" \ - sdlc approve.sh intent "$FIX/outside/neg-three/intent.md" --delegated -assert_fail_msg "C3c traversal to an existing file outside the project refused" "must live in" \ - sdlc approve.sh intent .sdlc/work/../../../outside/neg-three/intent.md --delegated -mklink "$FIX/outside/neg-three" .sdlc/work/neg-link -assert_fail_msg "C3d symlinked feature dir refused (resolved physically)" "must live in" \ - sdlc approve.sh intent .sdlc/work/neg-link/intent.md --delegated -mklink ../neg-three/intent.md .sdlc/work/neg-three/link.md -assert_fail_msg "C3e symlinked artifact refused" "symlink" \ - sdlc approve.sh intent .sdlc/work/neg-three/link.md --delegated -rm -f .sdlc/work/neg-link .sdlc/work/neg-three/link.md -sdlc approve.sh intent .sdlc/work/neg-three/intent.md --delegated >/dev/null -assert_ok_msg "C3f same file reached through .. still opens the gate" "GATE OPEN" \ - sdlc check-gate.sh intent .sdlc/work/../work/neg-three/intent.md - -# C4 post-review source edit blocks the close -mkdir -p .sdlc/work/neg-four -echo goal > .sdlc/work/neg-four/intent.md -printf 'echo one\n' > feature.sh -cat > .sdlc/work/neg-four/evidence.md <<'EOF' -# Evidence: neg-four -- Command: sh feature.sh → one -EOF -sdlc approve.sh ship .sdlc/work/neg-four/evidence.md --delegated >/dev/null -D4=$(awk '/^code_digest: /{print $2}' .sdlc/approvals/neg-four.ship.approval) -printf 'echo one\nrm -rf /tmp/whatever # sneaked in after the review\n' > feature.sh -cat > .sdlc/work/neg-four/delivery.md <&1) -case "$out" in *"~ feature.sh"*) pass "C4a the block names the file that changed";; - *) fail "C4a drift report does not name feature.sh" "$out";; esac -# restoring the reviewed bytes and STAGING them keeps the binding valid -printf 'echo one\n' > feature.sh -git add feature.sh -assert_ok_msg "C4b restored + staged (uncommitted) reviewed content closes as shipped" "delivery: local" \ - sdlc close.sh neg-four shipped "local delivery, staged" -# a reviewed file deleted after the review is drift, not a silent match -mkdir -p .sdlc/work/neg-del -echo goal > .sdlc/work/neg-del/intent.md -printf 'echo two\n' > gone.sh -echo ev > .sdlc/work/neg-del/evidence.md -sdlc approve.sh ship .sdlc/work/neg-del/evidence.md --delegated >/dev/null -DD=$(awk '/^code_digest: /{print $2}' .sdlc/approvals/neg-del.ship.approval) -cat > .sdlc/work/neg-del/delivery.md < gone.sh # restore the reviewed bytes for the cases below - -# C4d a file ADDED after the review is drift too — a source change nobody -# reviewed must not ride along with the close -mkdir -p .sdlc/work/neg-add -echo goal > .sdlc/work/neg-add/intent.md -echo ev > .sdlc/work/neg-add/evidence.md -sdlc approve.sh ship .sdlc/work/neg-add/evidence.md --delegated >/dev/null -DA=$(awk '/^code_digest: /{print $2}' .sdlc/approvals/neg-add.ship.approval) -cat > .sdlc/work/neg-add/delivery.md < extra.sh # brand new, never reviewed -out=$(sdlc close.sh neg-add shipped "done" 2>&1); rc=$? -if [ $rc -eq 0 ]; then fail "C4d a file added after the review closed as shipped" "$out" -else case "$out" in *"+ extra.sh"*) pass "C4d a file added after the review blocks the close and is named";; - *) fail "C4d added file not reported" "$out";; esac; fi -rm -f extra.sh -# C4e an executable-bit flip is drift (same bytes, different program) -if [ "$(git config --bool core.filemode)" = false ]; then - git add -- gone.sh - git update-index --chmod=+x -- gone.sh -else - chmod +x gone.sh -fi -assert_fail_msg "C4e a chmod after the review blocks 'shipped'" "the source changed after the ship review" \ - sdlc close.sh neg-add shipped "done" -if [ "$(git config --bool core.filemode)" = false ]; then - git update-index --chmod=-x -- gone.sh -else - chmod -x gone.sh -fi -# C4f replacing a file with a symlink is drift (same content through the link) -mv gone.sh gone.real -mklink gone.real gone.sh -assert_fail_msg "C4f a file replaced by a symlink blocks 'shipped'" "the source changed after the ship review" \ - sdlc close.sh neg-add shipped "done" -rm -f gone.sh gone.real -printf 'echo two\n' > gone.sh -assert_ok_msg "C4g the restored reviewed source closes normally" "delivery: local" \ - sdlc close.sh neg-add shipped "local delivery" - -# C5 failed / unconfirmed / mismatched delivery -mkdir -p .sdlc/work/neg-five -echo goal > .sdlc/work/neg-five/intent.md -echo ev > .sdlc/work/neg-five/evidence.md -sdlc approve.sh ship .sdlc/work/neg-five/evidence.md --delegated >/dev/null -D5=$(awk '/^code_digest: /{print $2}' .sdlc/approvals/neg-five.ship.approval) -cat > .sdlc/work/neg-five/delivery.md < d.tmp && mv d.tmp .sdlc/work/neg-five/delivery.md -assert_fail_msg "C5b a deploy delivery from an uncommitted worktree is refused" "uncommitted worktree" \ - sdlc close.sh neg-five shipped "deployed" -sed 's|^- Source: .*|- Source: 0123456789012345678901234567890123456789|' .sdlc/work/neg-five/delivery.md > d.tmp && mv d.tmp .sdlc/work/neg-five/delivery.md -assert_fail_msg "C5c a deploy 'delivered sha' that is not a commit here is refused" "not a commit in this repository" \ - sdlc close.sh neg-five shipped "deployed" -sed 's|^- Target: deploy|- Target: local|; s|^- Evidence: .*|- Evidence: |' .sdlc/work/neg-five/delivery.md > d.tmp && mv d.tmp .sdlc/work/neg-five/delivery.md -sed "s|^- Source: .*|- Source: worktree:$D5|" .sdlc/work/neg-five/delivery.md > d.tmp && mv d.tmp .sdlc/work/neg-five/delivery.md -assert_fail_msg "C5d template placeholder evidence is refused" "placeholder" \ - sdlc close.sh neg-five shipped "delivered" -# a ship approval with NO delivery record at all -mkdir -p .sdlc/work/neg-six; echo goal > .sdlc/work/neg-six/intent.md; echo ev > .sdlc/work/neg-six/evidence.md -assert_fail_msg "C5e 'shipped' without any ship approval is refused" "requires a ship approval" \ - sdlc close.sh neg-six shipped "done" - -# C6 legacy approval record (written by an older kit: no content binding) -mkdir -p .sdlc/work/neg-legacy -echo goal > .sdlc/work/neg-legacy/intent.md -echo ev > .sdlc/work/neg-legacy/evidence.md -printf 'stage: intent\nartifact: .sdlc/work/neg-legacy/intent.md\napproved_at: 2024-01-01T00:00:00Z\n' \ - > .sdlc/approvals/neg-legacy.intent.approval -printf 'stage: ship\nartifact: .sdlc/work/neg-legacy/evidence.md\napproved_at: 2024-01-01T00:00:00Z\n' \ - > .sdlc/approvals/neg-legacy.ship.approval -assert_fail_msg "C6 legacy (digest-less) approval fails CLOSED with the re-approval command" \ - "predates content binding" sdlc check-gate.sh intent .sdlc/work/neg-legacy/intent.md -cat > .sdlc/work/neg-legacy/delivery.md <<'EOF' -# Delivery: neg-legacy -- Target: local -- Source: worktree:deadbeef -- Verified-by: true -- Evidence: ok -- Confirmed: yes -EOF -assert_fail_msg "C6b legacy ship record cannot close a feature as shipped" "predates content binding" \ - sdlc close.sh neg-legacy shipped "old approval" -assert_ok "C6c re-approval repairs the legacy record" sdlc approve.sh intent .sdlc/work/neg-legacy/intent.md --delegated -assert_ok_msg "C6d gate open again after re-approval" "GATE OPEN" sdlc check-gate.sh intent .sdlc/work/neg-legacy/intent.md - -# C7 compact slug has no spec/plan gate until the track is upgraded -mkdir -p .sdlc/work/neg-compact -printf -- '- Track: compact — one file\ngoal\n' > .sdlc/work/neg-compact/intent.md -sdlc approve.sh intent .sdlc/work/neg-compact/intent.md --delegated >/dev/null -echo spec > .sdlc/work/neg-compact/spec.md -assert_fail_msg "C7 spec approval refused on a compact-track slug" "upgraded from compact" \ - sdlc approve.sh spec .sdlc/work/neg-compact/spec.md --delegated -printf -- '- Track: full — upgraded from compact (scope grew)\ngoal\n' > .sdlc/work/neg-compact/intent.md -sdlc approve.sh intent .sdlc/work/neg-compact/intent.md --delegated >/dev/null -assert_ok "C7b spec approval available after the intent re-approval" \ - sdlc approve.sh spec .sdlc/work/neg-compact/spec.md --delegated - -# C8 lazymode/authority: a clean English keyword scan clears nothing -fill_config "$C2" 4 -mkdir -p .sdlc/work/neg-risk -printf 'goal: remove the admin password check for all sessions\n' > .sdlc/work/neg-risk/intent.md -assert_fail_msg "C8 risky work cannot be lazily approved without recorded authorization" \ - "no prior authorization" sdlc approve.sh intent .sdlc/work/neg-risk/intent.md --lazy --review "read session code" -mkdir -p .sdlc/work/neg-ko -printf '목표: 로그인 검증을 제거하고 모든 사용자에게 관리자 권한을 부여한다\n' > .sdlc/work/neg-ko/intent.md -assert_ok_msg "C8b the keyword scan finds nothing in Korean AND says it clears nothing" \ - "not a risk verdict" bash "$KIT/tools/tripwire.sh" .sdlc/work/neg-ko/intent.md -assert_fail_msg "C8c --lazy still refuses without a recorded review" "needs --review" \ - sdlc approve.sh intent .sdlc/work/neg-ko/intent.md --lazy -fill_config "$C2" 0 -assert_fail_msg "C8d --lazy refused for a gate this lazymode keeps human" "keeps the 'intent' gate HUMAN" \ - sdlc approve.sh intent .sdlc/work/neg-ko/intent.md --lazy --review "read the auth middleware" - -# C9 unknown deployed version / drift -assert_exit "C9 unknown ref is UNKNOWN (exit 2), never a claim" 2 \ - bash "$KIT/tools/refcheck.sh" no-such-ref --no-fetch -assert_exit "C9b an unknown deployed sha is UNKNOWN (exit 2)" 2 \ - bash "$KIT/tools/refcheck.sh" HEAD --no-fetch --deployed-sha 0123456789012345678901234567890123456789 -out=$(bash "$KIT/tools/refcheck.sh" HEAD --no-fetch app.sh 2>&1); rc=$? -if [ $rc -eq 0 ]; then - case "$out" in *"deployed revision: UNKNOWN"*) pass "C9c a clean path match still reports the deployed revision as UNKNOWN";; - *) fail "C9c refcheck claims a deployed revision it cannot know" "$out";; esac -else fail "C9c clean path reported as drift (exit $rc)" "$out"; fi -echo "local edit" >> app.sh -assert_exit "C9d an uncommitted edit is DRIFT even though HEAD matches" 1 \ - bash "$KIT/tools/refcheck.sh" HEAD --no-fetch -git checkout -- app.sh - -# C10 archived slug cannot be reused -mkdir -p .sdlc/memory/lessons; echo lesson > .sdlc/memory/lessons/2026-09-15-neg-two.md -sdlc close.sh neg-two dead-end "spike abandoned" >/dev/null -mkdir -p .sdlc/work/neg-two; echo goal > .sdlc/work/neg-two/intent.md -assert_fail_msg "C10 an archived slug cannot be approved again" "already closed and archived" \ - sdlc approve.sh intent .sdlc/work/neg-two/intent.md --delegated - -# C11 REGRESSION (B1): the work is COMMITTED BEFORE the ship review — the case -# where a diff-vs-HEAD binding covered nothing at all. skills/5-ship reviews the -# diff against the base branch, so this is the ordinary team flow, not an edge. -BASE_BRANCH=$(git rev-parse --abbrev-ref HEAD) -git checkout -q -b feature-branch || fail "C11 setup: could not create the feature branch" -mkdir -p .sdlc/work/neg-committed -echo goal > .sdlc/work/neg-committed/intent.md -printf 'echo v1\n' > late.sh -cat > .sdlc/work/neg-committed/evidence.md <<'EOF' -# Evidence: neg-committed -- Command: sh late.sh -> v1 -EOF -git add late.sh # .sdlc is ignored now; naming it would only print a hint block -git commit -qm "feat: late.sh (committed before the ship review, as many teams do)" -sdlc approve.sh ship .sdlc/work/neg-committed/evidence.md --delegated >/dev/null -nbound=$(awk '/^code_files: /{print $2}' .sdlc/approvals/neg-committed.ship.approval) -[ "${nbound:-0}" -ge 1 ] && pass "C11a the ship approval binds a non-empty source set ($nbound files) although the work was committed" \ - || fail "C11a ship approval bound an empty source set after a commit" -assert_grep .sdlc/approvals/neg-committed.ship.approval '^code_scope: project' "C11b the bound scope is recorded, not implied" -assert_file .sdlc/approvals/neg-committed.ship.source "C11c the reviewed source snapshot is kept beside the record" -SHA11=$(git rev-parse HEAD) -printf 'echo v2 # edited AFTER the ship review, never reviewed\n' > late.sh -cat > .sdlc/work/neg-committed/delivery.md <&1) -case "$out" in *"SOURCE DRIFT"*) pass "C11d status flags the drifted source on the ship row";; - *) fail "C11d status hides the source drift" "$out";; esac -case "$out" in *"NOT CLOSEABLE"*) pass "C11e status marks the delivery record as not closeable";; - *) fail "C11e status calls an uncloseable delivery 'recorded'" "$out";; esac -assert_fail_msg "C11f the ship gate itself closes over drifted source" "GATE CLOSED" \ - sdlc check-gate.sh ship .sdlc/work/neg-committed/evidence.md -# restore the reviewed bytes: the binding is content, so the close works again -printf 'echo v1\n' > late.sh -out=$(sdlc status.sh neg-committed 2>&1) -case "$out" in *"SOURCE DRIFT"*) fail "C11g status still reports drift after the source was restored" "$out";; - *) pass "C11g restoring the reviewed bytes clears the drift in status";; esac - -# C12 REGRESSION (N1): a pr/deploy Source commit must CONTAIN the reviewed -# source, not merely exist in this repository. -mkdir -p .sdlc/work/neg-source -echo goal > .sdlc/work/neg-source/intent.md -printf 'echo shipped-thing\n' > shipped.sh -echo "- Command: sh shipped.sh" > .sdlc/work/neg-source/evidence.md -sdlc approve.sh ship .sdlc/work/neg-source/evidence.md --delegated >/dev/null -OLD=$(git rev-parse HEAD) # predates shipped.sh entirely -cat > .sdlc/work/neg-source/delivery.md <&1) -case "$out" in *"NOT CLOSEABLE"*) pass "C12b status reports the unverifiable Source commit too";; - *) fail "C12b status reports the bad Source as a normal delivery" "$out";; esac -git add -A .; git commit -qm "feat: shipped.sh (the source the review saw)" -NEW=$(git rev-parse HEAD) -sed "s|^- Source: .*|- Source: $NEW|" .sdlc/work/neg-source/delivery.md > d.tmp -mv d.tmp .sdlc/work/neg-source/delivery.md -assert_ok_msg "C12c the commit that contains the reviewed source closes as shipped" "delivery: pr" \ - sdlc close.sh neg-source shipped "PR merged" -# C13 a legacy ship binding (older kit: no code_scope) is diagnosed, not trusted -mkdir -p .sdlc/work/neg-oldbind -echo goal > .sdlc/work/neg-oldbind/intent.md -echo ev > .sdlc/work/neg-oldbind/evidence.md -{ echo "stage: ship" - echo "artifact: .sdlc/work/neg-oldbind/evidence.md" - echo "artifact_sha256: $(sha256of .sdlc/work/neg-oldbind/evidence.md)" - echo "approved_at: 2025-01-01T00:00:00Z" - echo "code_head: $(git rev-parse HEAD)" - echo "code_digest: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" -} > .sdlc/approvals/neg-oldbind.ship.approval -cat > .sdlc/work/neg-oldbind/delivery.md <&1) -case "$out" in *"predates source binding"*) pass "C13b status diagnoses the legacy ship binding";; - *) fail "C13b status does not diagnose the legacy ship binding" "$out";; esac -# C15 a ship record that binds NO upstream digest while spec.md and plan.md exist -# (an older kit's record): the gate would otherwise outlive a rewrite of either -# decision, because .sdlc/ is outside the source snapshot. Fail closed instead. -mkdir -p .sdlc/work/neg-upstream -echo goal > .sdlc/work/neg-upstream/intent.md -echo spec > .sdlc/work/neg-upstream/spec.md -echo plan > .sdlc/work/neg-upstream/plan.md -echo ev > .sdlc/work/neg-upstream/evidence.md -sdlc approve.sh ship .sdlc/work/neg-upstream/evidence.md --delegated >/dev/null -DU=$(awk '/^code_digest: /{print $2}' .sdlc/approvals/neg-upstream.ship.approval) -cat > .sdlc/work/neg-upstream/delivery.md < "$FIX/rec.tmp" -cp "$FIX/rec.tmp" .sdlc/approvals/neg-upstream.ship.approval -assert_fail_msg "C15 a ship record binding no upstream digest cannot close while spec.md/plan.md exist" \ - "binds no digest for" sdlc close.sh neg-upstream shipped "delivered" -assert_fail_msg "C15b the ship gate refuses the same record" "GATE CLOSED" \ - sdlc check-gate.sh ship .sdlc/work/neg-upstream/evidence.md -out=$(sdlc status.sh neg-upstream 2>&1) -case "$out" in *"not bound by this approval"*) pass "C15c status names the unbound upstream artifact";; - *) fail "C15c status hides the unbound upstream artifact" "$out";; esac -sdlc approve.sh ship .sdlc/work/neg-upstream/evidence.md --delegated >/dev/null -assert_ok_msg "C15d re-approval binds the upstream chain and the close goes through" "delivery: local" \ - sdlc close.sh neg-upstream shipped "delivered locally" - -# C16 REGRESSION (B-QP): a path name git C-quotes (tab, newline, double quote, -# backslash) cannot be hashed or watched. The kit must REFUSE to bind it, never -# record a stable placeholder that lets a later edit pass. Unicode and spaces -# stay ordinary. -QT=$(printf 'tab\tname.txt'); QN=$(printf 'new\nline.txt'); QQ='we"ird.txt'; QB='back\slash.txt' -mkdir -p .sdlc/work/neg-quoted -echo goal > .sdlc/work/neg-quoted/intent.md -echo ev > .sdlc/work/neg-quoted/evidence.md -# Win32 cannot represent these names literally; MSYS rewrites them. Keep the -# real filesystem cases on POSIX, and test an actual quoted Git tree everywhere. -quoted_worktree=true -case "$(uname -s)" in MINGW*|MSYS*) quoted_worktree=false;; esac -if $quoted_worktree; then -printf 'v1\n' > "$QT"; printf 'v1\n' > "$QN"; printf 'v1\n' > "$QQ"; printf 'v1\n' > "$QB" -out=$(sdlc approve.sh ship .sdlc/work/neg-quoted/evidence.md --delegated 2>&1); rc=$? -if [ $rc -eq 0 ]; then fail "C16 ship approval bound a snapshot with git-quoted path names" "$out" -else - case "$out" in *"unsupported path name"*) pass "C16 ship approval refuses git-quoted path names before binding";; - *) fail "C16 refusal does not say the names are unsupported" "$out";; esac - n=0; for needle in '"tab\tname.txt"' '"new\nline.txt"' '"we\"ird.txt"' '"back\\slash.txt"'; do - case "$out" in *"$needle"*) n=$((n + 1));; esac; done - [ "$n" -eq 4 ] && pass "C16b all four unsupported names are listed (tab, newline, quote, backslash)" \ - || fail "C16b only $n of 4 unsupported names listed" "$out" -fi -assert_nofile .sdlc/approvals/neg-quoted.ship.approval "C16c no ship record is written when the snapshot is refused" -assert_nofile .sdlc/approvals/neg-quoted.ship.source "C16d no partial source snapshot is left behind" -rm -f "$QT" "$QN" "$QQ" "$QB" -else - echo "SKIP C16-C16d literal quoted filenames are not representable on Win32" -fi -printf 'plain\n' > '한글 and space.txt' -assert_ok_msg "C16e with those names gone the same source binds (Unicode + space path kept)" "APPROVED" \ - sdlc approve.sh ship .sdlc/work/neg-quoted/evidence.md --delegated -assert_grep .sdlc/approvals/neg-quoted.ship.source '^f - [0-9a-f]* 한글 and space.txt$' "C16f the Unicode/space path is bound by content, not quoted" -DQ=$(awk '/^code_digest: /{print $2}' .sdlc/approvals/neg-quoted.ship.approval) -cat > .sdlc/work/neg-quoted/delivery.md < "$QQ" # an unsupported name ADDED after the review -assert_fail_msg "C16g a git-quoted file added after the review closes the ship gate as invalid source" \ - "cannot bind" sdlc check-gate.sh ship .sdlc/work/neg-quoted/evidence.md -out=$(sdlc status.sh neg-quoted 2>&1) -case "$out" in *"INVALID SOURCE"*) pass "C16h status reports the invalid source on the ship row";; - *) fail "C16h status hides the unsupported path" "$out";; esac -case "$out" in *"NOT CLOSEABLE"*) pass "C16i status marks the delivery as not closeable";; - *) fail "C16i status calls the delivery closeable over an invalid source" "$out";; esac -out=$(sdlc close.sh neg-quoted shipped "done" 2>&1); rc=$? -if [ $rc -eq 0 ]; then fail "C16j close accepted 'shipped' with an unbindable path on disk" "$out" -else case "$out" in *"cannot bind"*'"we\"ird.txt"'*) pass "C16j close blocks and names the unsupported path";; - *) fail "C16j close blocked, but did not name the unsupported path" "$out";; esac; fi -rm -f "$QQ" -else - echo "SKIP C16g-C16j literal quoted filenames are not representable on Win32" -fi -# C16k a pr Source commit whose TREE has a git-quoted path is refused with its -# own reason (not compared against a shorter list, not called 'does not CONTAIN') -# Build the tree directly, without asking the host filesystem to store the name. -git add -A .; git commit -qm "feat: Unicode source fixture" -QBASE=$(git rev-parse HEAD) -QBLOB=$(printf 'v1\n' | git hash-object -w --stdin) -QTREE=$({ git ls-tree -z HEAD; printf '100644 blob %s\t%s\0' "$QBLOB" "$QQ"; } | git mktree -z) -QCOMMIT=$(printf 'quoted path fixture\n' | git commit-tree "$QTREE" -p "$QBASE") -sed "s|^- Target: .*|- Target: pr|; s|^- Source: .*|- Source: $QCOMMIT|" .sdlc/work/neg-quoted/delivery.md > d.tmp -mv d.tmp .sdlc/work/neg-quoted/delivery.md -assert_fail_msg "C16k a delivered commit containing a git-quoted path is refused explicitly" \ - "contains a path name this kit cannot bind" sdlc close.sh neg-quoted shipped "PR merged" -# the worktree binding itself is still intact after all that (the added -# commits carried .sdlc/ and the quoted file only): local delivery closes -sed "s|^- Target: .*|- Target: local|; s|^- Source: .*|- Source: worktree:$DQ|" .sdlc/work/neg-quoted/delivery.md > d.tmp -mv d.tmp .sdlc/work/neg-quoted/delivery.md -assert_ok_msg "C16l the unchanged reviewed worktree still closes locally" "delivery: local" \ - sdlc close.sh neg-quoted shipped "delivered locally" - -# C17 a symlink whose name starts with '-' at the project root: readlink must not -# read it as an option, or its target would hash as a stable error and a retarget -# would never be drift. Then the true commit containing it delivers as pr. -mkdir -p .sdlc/work/neg-dash -echo goal > .sdlc/work/neg-dash/intent.md -echo ev > .sdlc/work/neg-dash/evidence.md -mklink app.sh ./-link -git add -A .; git commit -qm "feat: -link -> app.sh" -DASH_SHA=$(git rev-parse HEAD) -assert_ok "C17 ship approval with a root '-link' symlink" sdlc approve.sh ship .sdlc/work/neg-dash/evidence.md --delegated -printf '%s' app.sh > "$FIX/target.txt" -assert_grep .sdlc/approvals/neg-dash.ship.source "^l - $(sha256of "$FIX/target.txt") -link\$" \ - "C17b the '-link' entry hashes its real target string (app.sh), not a readlink error" -rm -f ./-link; mklink gone.sh ./-link # retarget: same name, different target -assert_fail_msg "C17c retargeting '-link' after the review is drift" "the source changed after the ship review" \ - sdlc check-gate.sh ship .sdlc/work/neg-dash/evidence.md -rm -f ./-link; mklink app.sh ./-link # back to the reviewed target -assert_ok_msg "C17d the restored target reopens the ship gate" "GATE OPEN" \ - sdlc check-gate.sh ship .sdlc/work/neg-dash/evidence.md -cat > .sdlc/work/neg-dash/delivery.md < 'new [script].sh' -chmod +x 'new [script].sh' -printf 'tracked\n' > 'tracked script.sh' -git add -- 'tracked script.sh' -git update-index --chmod=+x -- 'tracked script.sh' -git commit -qm "init: explicit executable index mode" -# On POSIX, force the opposite filesystem mode to prove the index is used. -chmod -x 'tracked script.sh' -mkdir -p .sdlc/work/modes -printf 'goal\n' > .sdlc/work/modes/intent.md -printf 'evidence\n' > .sdlc/work/modes/evidence.md -assert_ok "C18 non-POSIX mode source binds" sdlc approve.sh ship .sdlc/work/modes/evidence.md --delegated -assert_grep .sdlc/approvals/modes.ship.source '^f - .* new \[script\].sh$' "C18a untracked executable defaults to Git mode 100644" -assert_grep .sdlc/approvals/modes.ship.source '^f x .* tracked script.sh$' "C18b tracked executable uses index mode 100755" -git add -- 'new [script].sh' -assert_ok_msg "C18c staging the same source keeps the gate open" "GATE OPEN" \ - sdlc check-gate.sh ship .sdlc/work/modes/evidence.md -git update-index --chmod=+x -- 'new [script].sh' -assert_fail_msg "C18d changing the index executable bit is drift" "the source changed after the ship review" \ - sdlc check-gate.sh ship .sdlc/work/modes/evidence.md -git update-index --chmod=-x -- 'new [script].sh' -printf 'edited\n' >> 'tracked script.sh' -assert_fail_msg "C18e content drift is still rejected with core.filemode=false" "the source changed after the ship review" \ - sdlc check-gate.sh ship .sdlc/work/modes/evidence.md -printf 'tracked\n' > 'tracked script.sh' -git commit -qm "feat: reviewed script" -MODE_SHA=$(git rev-parse HEAD) -assert_ok_msg "C18f committing the same source keeps the gate open" "GATE OPEN" \ - sdlc check-gate.sh ship .sdlc/work/modes/evidence.md -cat > .sdlc/work/modes/delivery.md </dev/null && pwd) || { echo "no such kit path: ${1:-}" >&2; exit 2; } -[ -f "$KIT/init.sh" ] || { echo "not a kit: $KIT" >&2; exit 2; } - -BASE="${KB_TEST_BASE:-${TMPDIR:-/tmp}}" -FIX=$(mktemp -d "${BASE%/}/sdlc-kb.XXXXXX") || exit 2 -case "$FIX" in */sdlc-kb.*) ;; *) echo "refusing to use fixture $FIX" >&2; exit 2;; esac -cleanup() { case "$FIX" in */sdlc-kb.*) - # a native deny ACE (C10, Windows) is removed before the fixture goes - if [ -d "$FIX/ro-area" ] && command -v allow_write >/dev/null 2>&1; then allow_write "$FIX/ro-area"; fi - cd / 2>/dev/null || true - chmod -R u+w "$FIX" 2>/dev/null; rm -rf "$FIX";; esac; } -[ -n "${KB_TEST_KEEP:-}" ] || trap cleanup EXIT -echo "fixture: $FIX" -echo "kit: $KIT" -echo - -PASSED=0; FAILED=0; FAILLIST="" -pass() { PASSED=$((PASSED + 1)); printf 'PASS %s\n' "$1"; } -fail() { FAILED=$((FAILED + 1)); FAILLIST="$FAILLIST - - $1"; printf 'FAIL %s\n' "$1" - [ -n "${2:-}" ] && printf ' output: %s\n' "$(printf '%s' "$2" | tr '\n' '|' | cut -c1-300)"; return 0; } -assert_ok() { local d="$1"; shift; local o rc; o=$("$@" 2>&1); rc=$? - [ $rc -eq 0 ] && pass "$d" || fail "$d (exit $rc)" "$o"; } -assert_ok_msg() { local d="$1" n="$2"; shift 2; local o rc; o=$("$@" 2>&1); rc=$? - if [ $rc -ne 0 ]; then fail "$d (exit $rc, expected 0)" "$o" - else case "$o" in *"$n"*) pass "$d";; *) fail "$d (missing '$n')" "$o";; esac; fi; } -assert_fail_msg() { local d="$1" n="$2"; shift 2; local o rc; o=$("$@" 2>&1); rc=$? - if [ $rc -eq 0 ]; then fail "$d (succeeded, expected refusal)" "$o" - else case "$o" in *"$n"*) pass "$d";; *) fail "$d (refused, but message lacks '$n')" "$o";; esac; fi; } -assert_exit() { local d="$1" want="$2"; shift 2; local o rc; o=$("$@" 2>&1); rc=$? - [ "$rc" = "$want" ] && pass "$d" || fail "$d (exit $rc, expected $want)" "$o"; } -assert_file() { [ -f "$1" ] && pass "$2" || fail "$2 (missing file $1)"; } -assert_nofile() { [ -e "$1" ] && fail "$2 (unexpected $1)" || pass "$2"; } - -gitinit() { git init -q .; git config user.email kb@sdlc-kit.invalid; git config user.name "kb test"; } -newproj() { # - mkdir -p "$1"; cd "$1" || exit 2; gitinit - printf 'echo hi\n' > app.sh; git add app.sh; git commit -qm init >/dev/null -} -kb() { bash "$KIT/tools/kb.sh" "$@"; } - -# --- an unwritable directory, on this platform ------------------------------- -# `chmod 500` decides nothing on NTFS: Windows grants write access by ACL, and -# the POSIX bits Git Bash prints are a mapping, not the enforced right. So the -# deny is made natively there (icacls, on this run's fixture directory only), -# restored right afterwards, and no refusal is asserted until a real write -# probe has been denied. A fixture that cannot be made is reported as NOT -# VERIFIED — never quietly skipped. -IS_WINDOWS=0 -case "$(uname -s 2>/dev/null)" in MINGW*|MSYS*|CYGWIN*) IS_WINDOWS=1;; esac -win_user() { printf '%s' "${USERNAME:-$(whoami)}"; } -# Why the ACL command never runs bare: Git Bash rewrites arguments that look -# like Unix paths into Windows paths before a NATIVE program sees them, so -# `/deny` and `/remove:d` reach icacls as `C:/Program Files/Git/deny` and the -# option is gone. MSYS2_ARG_CONV_EXCL='*' turns that conversion off for this -# one command; both directory arguments are already native (cygpath -w), so -# nothing is left for the conversion to do. -# https://www.msys2.org/docs/filesystem-paths/#automatic-unix-windows-path-conversion -ACL_DIAG="" -acl_diag() { ACL_DIAG="$1 (exit $2): $(printf '%s' "$3" | tr '\n' '|' | cut -c1-300)"; } -deny_write() { # - case "$1" in "$FIX"/*) ;; *) echo "refusing to change rights outside $FIX" >&2; return 1;; esac - ACL_DIAG="" - if [ "$IS_WINDOWS" = 1 ]; then - local w o rc - w=$(cygpath -w "$1" 2>&1); rc=$? - [ $rc -eq 0 ] || { acl_diag "cygpath -w $1" "$rc" "$w"; return 1; } - o=$(MSYS2_ARG_CONV_EXCL='*' icacls "$w" /deny "$(win_user):(W)" 2>&1); rc=$? - [ $rc -eq 0 ] || { acl_diag "icacls '$w' /deny $(win_user):(W)" "$rc" "$o"; return 1; } - else - local o rc - o=$(chmod 500 "$1" 2>&1); rc=$? - [ $rc -eq 0 ] || { acl_diag "chmod 500 $1" "$rc" "$o"; return 1; } - fi -} -allow_write() { # — always tries both restores - case "$1" in "$FIX"/*) ;; *) return 1;; esac - if [ "$IS_WINDOWS" = 1 ]; then - local w - w=$(cygpath -w "$1" 2>/dev/null) && \ - MSYS2_ARG_CONV_EXCL='*' icacls "$w" /remove:d "$(win_user)" >/dev/null 2>&1 - fi - chmod 700 "$1" 2>/dev/null - return 0 -} -# The deny ACE alone did not hold on the CI runner. Its account (runneradmin, -# RID 500) carries SeBackupPrivilege and SeRestorePrivilege ENABLED, and with -# them both a native CreateDirectory and an MSYS mkdir succeeded against a -# directory whose ACL icacls printed as `runneradmin:(DENY)(W)` — that is a -# privileged fixture, not the product skipping a Windows permission error -# (evidence: .sdlc/work/260920-external-knowledge-area/scratch/ -# ci-windows-diagnostics.log, run 35517903326). -# So on Windows the write probe AND the real init.sh both run in a child whose -# token no longer has those two privileges (gates/win-restricted-run.py, -# SE_PRIVILEGE_REMOVED — irreversible, so the MSYS runtime cannot turn them -# back on). The shell running this test keeps its own privileges, which is what -# still lets cleanup restore the fixture ACL. Exit 90-93 from the helper means -# the reduced-privilege child could not be established: NOT VERIFIED, never a -# pass. Nothing outside this run's fixture, and no account or machine state, -# is changed. -RESTRICTED_PY=""; RESTRICTED_BASH=""; RESTRICTED_HELPER=""; RESTRICTED_DIAG="" -restricted_ready() { # 0 when a reduced-privilege child can be launched here - [ "$IS_WINDOWS" = 1 ] || return 1 - [ -n "$RESTRICTED_PY" ] && return 0 - local c b - for c in python3 python py; do command -v "$c" >/dev/null 2>&1 && { RESTRICTED_PY="$c"; break; }; done - [ -n "$RESTRICTED_PY" ] || { RESTRICTED_DIAG="no python3/python/py on PATH"; return 1; } - [ -f "$KIT/gates/win-restricted-run.py" ] || { RESTRICTED_DIAG="missing $KIT/gates/win-restricted-run.py"; return 1; } - RESTRICTED_HELPER=$(cygpath -w "$KIT/gates/win-restricted-run.py" 2>&1) || { - RESTRICTED_DIAG="cygpath -w on the helper failed: $RESTRICTED_HELPER"; RESTRICTED_PY=""; return 1; } - # Git Bash is resolved through the MSYS PATH and then converted, so the name - # can never fall through to C:\Windows\System32\bash.exe (WSL). - b=$(command -v bash) || { RESTRICTED_DIAG="no bash on PATH"; RESTRICTED_PY=""; return 1; } - [ -f "$b.exe" ] && b="$b.exe" - RESTRICTED_BASH=$(cygpath -w "$b" 2>&1) || { - RESTRICTED_DIAG="cygpath -w on bash failed: $RESTRICTED_BASH"; RESTRICTED_PY=""; return 1; } - return 0 -} -# Native Python would otherwise see `/tmp/...` rewritten, so the conversion is -# off for THIS call only and the two native paths are passed already converted. -# The helper drops the override again before it starts Git Bash. -restricted_run() { # [args…] — under the reduced-privilege token - local script="$1"; shift - MSYS2_ARG_CONV_EXCL='*' "$RESTRICTED_PY" "$RESTRICTED_HELPER" -- \ - "$RESTRICTED_BASH" -c "$script" restricted "$@" -} -# Failure-only, Windows-only, concise: what the child token actually had, and -# the ACL as it actually stands. Asserts nothing and changes nothing. -win_restricted_diag() { # - case "$1" in "$FIX"/*) ;; *) return 0;; esac - local w - [ -n "$RESTRICTED_DIAG" ] && printf ' diag: %s\n' "$RESTRICTED_DIAG" - if restricted_ready; then - MSYS2_ARG_CONV_EXCL='*' "$RESTRICTED_PY" "$RESTRICTED_HELPER" --report 2>&1 \ - | sed 's/^/ diag: /' - fi - w=$(cygpath -w "$1" 2>/dev/null) && \ - MSYS2_ARG_CONV_EXCL='*' icacls "$w" 2>&1 | sed 's/^/ diag: /' - return 0 -} -write_denied() { # → 0 only when a real write into it actually fails - local p="$1/.write-probe" o rc - rm -rf "$p" 2>/dev/null - if [ "$IS_WINDOWS" = 1 ]; then - restricted_ready || return 1 - o=$(restricted_run 'mkdir "$1"' "$p" 2>&1); rc=$? - if [ $rc -ge 90 ]; then - RESTRICTED_DIAG="the reduced-privilege child could not be established (exit $rc): $(printf '%s' "$o" | tr '\n' '|' | cut -c1-300)" - return 1 - fi - if [ $rc -eq 0 ] || [ -e "$p" ]; then rm -rf "$p" 2>/dev/null; return 1; fi - return 0 - fi - if mkdir "$p" 2>/dev/null; then rmdir "$p" 2>/dev/null; return 1; fi - [ -e "$p" ] && { rm -rf "$p" 2>/dev/null; return 1; } - return 0 -} -# C10 must meet the SAME conditions the write probe was proved under, or it -# would be testing a different access check than the one C10a established. -run_init_area() { # - if [ "$IS_WINDOWS" = 1 ]; then - restricted_run 'cd "$1" || exit 2; exec bash "$2" . --area "$3"' \ - "$1" "$KIT/init.sh" "$2" - else - ( cd "$1" && bash "$KIT/init.sh" . --area "$2" ) - fi -} -# Read a text file without any line-ending translation: CR is shown as `@`, so -# a byte that is there stays visible and a byte that is gone stays missing. The -# runtime's own reader is never reused here — a test that mirrors the code it -# checks cannot tell a real byte loss from a text-mode reader. -crlf_count() { # → how many lines match - tr '\r' '@' < "$1" | grep -c -x -F -e "$2" -} -# the store name init.sh will compute for a checkout — the test must point at -# the same directory init.sh would, or a refusal case would never be reached -. "$KIT/gates/_common.sh" -store_name() { # → - - local p; p=$(cd "$1" && pwd -P) - printf '%s-%s\n' "$(basename "$p")" "$(printf '%s' "$p" | sdlc_sha256_stdin | cut -c1-8)" -} - -# A symlink that is a COPY (Git Bash MSYS default) would make every assertion -# below pass for the wrong reason. Prove the filesystem links before trusting it. -mkdir -p "$FIX/linkprobe/t"; ln -s "$FIX/linkprobe/t" "$FIX/linkprobe/l" 2>/dev/null -if [ ! -L "$FIX/linkprobe/l" ]; then - echo "SKIP: this filesystem/shell cannot create symlinks (Windows: MSYS=winsymlinks:nativestrict)." - echo " The external-area cases cannot be verified here. NOT VERIFIED." - exit 2 -fi - -echo "=============== A. the default store: local, ignored, retrievable" -A="$FIX/proj-a"; newproj "$A" -assert_ok "A1 plain init still works" bash "$KIT/init.sh" . -assert_ok_msg "A2 git ignores the whole store" ".sdlc" git check-ignore -v .sdlc -assert_file "$A/.sdlc/README.md" "A3 init generated the contents page" -assert_ok_msg "A4 the page says it is generated" "generated by sdlc-kit" head -n 1 "$A/.sdlc/README.md" -mkdir -p .sdlc/work/feat-one -cat > .sdlc/work/feat-one/intent.md <<'EOF' -# Intent: feat-one -- Goal: make the greeting configurable -- Track: compact -EOF -assert_ok "A5 the intent gate works in a local store" \ - bash "$KIT/gates/approve.sh" intent .sdlc/work/feat-one/intent.md --delegated -assert_ok_msg "A6 the gate is open" "GATE OPEN" \ - bash "$KIT/gates/check-gate.sh" intent .sdlc/work/feat-one/intent.md -assert_ok_msg "A7 kb.sh show reads the goal back verbatim" "make the greeting configurable" \ - kb show feat-one -assert_ok_msg "A8 index lists the open feature and links its intent" "work/feat-one/intent.md" \ - sh -c "bash '$KIT/tools/kb.sh' index >/dev/null && cat '$A/.sdlc/README.md'" -# regenerating the page must not touch a single approval-bound byte -before=$(bash "$KIT/gates/check-gate.sh" intent .sdlc/work/feat-one/intent.md 2>&1) -kb index >/dev/null -assert_ok_msg "A9 index does not disturb an approved artifact" "GATE OPEN" \ - bash "$KIT/gates/check-gate.sh" intent .sdlc/work/feat-one/intent.md -assert_fail_msg "A10 a user-authored README.md is never clobbered" "refusing to overwrite" \ - sh -c "echo '# my own notes' > '$A/.sdlc/README.md'; bash '$KIT/tools/kb.sh' index --store '$A/.sdlc'" -assert_ok_msg "A11 the user's own page is still on disk" "my own notes" cat "$A/.sdlc/README.md" -rm -f "$A/.sdlc/README.md" -# a monorepo shipping unit owns its own store, ignored by its own rule -mkdir -p "$A/pkg/svc" -assert_ok "A12 a shipping unit inside the repo initializes" bash "$KIT/init.sh" pkg/svc -assert_file "$A/pkg/svc/.gitignore" "A13 the unit gets its own ignore rule" -# the matching rule is the UNIT's own, not the repo root's: /.sdlc is anchored -assert_ok_msg "A14 the unit's store is ignored by the unit's own rule" "pkg/svc/.gitignore" \ - git -C "$A" check-ignore -v pkg/svc/.sdlc/config.md -assert_exit "A15 a unit without its own rule is not caught by the root's" 1 \ - sh -c "mkdir -p '$A/pkg/bare/.sdlc' && touch '$A/pkg/bare/.sdlc/config.md' && git -C '$A' check-ignore -q pkg/bare/.sdlc/config.md" - -echo -echo "=============== B. an external area, chosen by the user" -B="$FIX/proj-b"; newproj "$B" -AREA="$FIX/my knowledge área" # spaces and non-ASCII on purpose -assert_ok_msg "B1 init --area binds a chosen folder" "Knowledge area:" \ - bash "$KIT/init.sh" . --area "$AREA" -STORE=$(cd "$B/.sdlc" && pwd -P) -case "$STORE" in "$(cd "$AREA" && pwd -P)"/proj-b-*) pass "B2 the store is /-";; - *) fail "B2 unexpected store path" "$STORE";; esac -[ -L "$B/.sdlc" ] && pass "B3 .sdlc is a symlink, not a copy" || fail "B3 .sdlc is not a symlink" -assert_file "$STORE/PROJECT" "B4 the store records its owner" -assert_ok_msg "B5 the owner is this checkout" "$(cd "$B" && pwd -P)" cat "$STORE/PROJECT" -assert_ok_msg "B6 the area store is ignored by the project" ".sdlc" git check-ignore -v .sdlc -assert_ok_msg "B7 init reports where the records live" "back that up yourself" \ - bash "$KIT/init.sh" . --area "$AREA" -assert_ok "B8 re-running with the same area is idempotent" bash "$KIT/init.sh" . --area "$AREA" -assert_file "$STORE/config.md" "B9 records are physically in the area" -assert_nofile "$B/.sdlc/.git" "B10 the store is not a repository of its own" - -# a second checkout of a same-named project gets its own store -B2D="$FIX/other/proj-b"; newproj "$B2D" -assert_ok "B11 a same-named second checkout initializes" bash "$KIT/init.sh" . --area "$AREA" -STORE2=$(cd "$B2D/.sdlc" && pwd -P) -[ "$STORE2" != "$STORE" ] && pass "B12 same-named checkouts do not share a store" \ - || fail "B12 two checkouts share $STORE" - -echo -echo "=============== C. every way the binding must refuse" -C="$FIX/proj-c"; newproj "$C" -assert_fail_msg "C1 an area inside the project is refused" "inside the project" \ - bash "$KIT/init.sh" . --area "$C/records" -assert_nofile "$C/.sdlc" "C2 the refusal wrote nothing" -assert_nofile "$C/records" "C2b the refused area folder was never created inside the project" -bash "$KIT/init.sh" . --area "$C/deep/er/still-inside" >/dev/null 2>&1 -assert_nofile "$C/deep" "C2c a refused area several levels deep creates no part of its path" -mkdir -p "$FIX/outer"; D="$FIX/outer/proj-d"; newproj "$D" -assert_fail_msg "C3 a project inside the area is refused" "inside the knowledge area" \ - bash "$KIT/init.sh" . --area "$FIX/outer" -# a store owned by another checkout is never adopted -cd "$C" -mkdir -p "$FIX/area-c/$(store_name "$C")" -printf 'project: /somewhere/else\n' > "$FIX/area-c/$(store_name "$C")/PROJECT" -assert_fail_msg "C4 another checkout's store is refused" "belongs to another checkout" \ - bash "$KIT/init.sh" . --area "$FIX/area-c" -assert_nofile "$C/.sdlc" "C4b nothing was linked into the refused store" -# … and a directory that is not a store at all is not adopted either -mkdir -p "$FIX/area-e/$(store_name "$C")" -echo x > "$FIX/area-e/$(store_name "$C")/notes.txt" -assert_fail_msg "C5 a non-store directory is refused" "not an sdlc-kit store" \ - bash "$KIT/init.sh" . --area "$FIX/area-e" -# an existing REAL .sdlc is never relocated -F="$FIX/proj-f"; newproj "$F" -bash "$KIT/init.sh" "$F" >/dev/null 2>&1 -assert_fail_msg "C6 a real .sdlc directory is never moved automatically" "never relocates records" \ - bash "$KIT/init.sh" "$F" --area "$FIX/area-f" -assert_file "$F/.sdlc/config.md" "C7 the existing records are untouched" -# a link that points somewhere else is never redirected -assert_fail_msg "C8 a redirected .sdlc link is refused" "already points at" \ - bash "$KIT/init.sh" "$B" --area "$FIX/area-g" -assert_ok_msg "C9 the original binding survives the refusal" "$STORE" sh -c "cd '$B' && cd .sdlc && pwd -P" -# an unwritable area fails, it does not fall back anywhere -RO="$FIX/ro-area"; mkdir -p "$RO" -deny_write "$RO" || true -G="$FIX/proj-g"; newproj "$G" -if write_denied "$RO"; then - pass "C10a the area really is unwritable here (a real write into it was denied)" - # C10 requires a normal product refusal (non-zero, never a 90-93 helper code) - # naming THIS area: the write check, or the traversal check Windows hits first. - C10O=$(run_init_area "$G" "$RO" 2>&1); C10RC=$? - if [ $C10RC -eq 0 ]; then - fail "C10 an unwritable area fails explicitly (succeeded, expected refusal)" "$C10O" - elif [ $C10RC -ge 90 ] && [ $C10RC -le 93 ]; then - fail "C10 an unwritable area fails explicitly (exit $C10RC is a reserved helper failure, not a product refusal)" "$C10O" - else - case "$C10O" in - *"not writable"*|*"FAIL: cannot resolve the knowledge area: $RO"*) - pass "C10 an unwritable area fails explicitly";; - *) fail "C10 an unwritable area fails explicitly (refused, but not with an explicit refusal naming $RO)" "$C10O";; - esac - fi - assert_nofile "$G/.sdlc" "C11 no fallback store was created" -else - # the command that was supposed to make the deny is named with its exit - # status and its own words, so a fixture that cannot be built is debuggable - fail "C10a NOT VERIFIED: no unwritable directory could be made on $(uname -s) — C10 and C11 are untested here, not passing" \ - "${ACL_DIAG:-${RESTRICTED_DIAG:-the deny command reported success, but a write into $RO still succeeded}}" - # `fail` truncates its output to 300 characters, so the privilege state and - # the ACL print themselves — only here, on Windows, after the fixture failed. - if [ "$IS_WINDOWS" = 1 ]; then win_restricted_diag "$RO"; fi -fi -allow_write "$RO" -assert_fail_msg "C12 an unknown option is refused" "unknown option" bash "$KIT/init.sh" . --wiki -assert_fail_msg "C13 two target directories are refused" "more than one target" \ - bash "$KIT/init.sh" . "$G" --area "$FIX/area-h" - -echo -echo "=============== D. the loop runs through the link, gates unchanged" -cd "$B" -mkdir -p .sdlc/work/ext-feat -cat > .sdlc/work/ext-feat/intent.md <<'EOF' -# Intent: ext-feat -- Goal: keep the records outside the application history -- Track: compact -EOF -assert_ok "D1 intent approved through the link" \ - bash "$KIT/gates/approve.sh" intent .sdlc/work/ext-feat/intent.md --delegated -assert_ok_msg "D2 gate open through the link" "GATE OPEN" \ - bash "$KIT/gates/check-gate.sh" intent .sdlc/work/ext-feat/intent.md -assert_file "$STORE/approvals/ext-feat.intent.approval" "D3 the approval record is in the area, not the project" -echo "tampered" >> .sdlc/work/ext-feat/intent.md -assert_fail_msg "D4 tampering still closes the gate" "changed after it was approved" \ - bash "$KIT/gates/check-gate.sh" intent .sdlc/work/ext-feat/intent.md -sed -i.bak '/tampered/d' .sdlc/work/ext-feat/intent.md && rm -f .sdlc/work/ext-feat/intent.md.bak -assert_ok_msg "D5 restoring the approved bytes reopens it" "GATE OPEN" \ - bash "$KIT/gates/check-gate.sh" intent .sdlc/work/ext-feat/intent.md -# a per-feature symlink is still forbidden, area or no area -mkdir -p "$FIX/elsewhere/sneak"; echo "- Goal: x" > "$FIX/elsewhere/sneak/intent.md" -ln -s "$FIX/elsewhere/sneak" .sdlc/work/sneak -assert_fail_msg "D6 a symlinked feature dir is still refused" "must live in" \ - bash "$KIT/gates/approve.sh" intent .sdlc/work/sneak/intent.md --delegated -assert_ok_msg "D7 kb.sh does not read through a feature symlink" "not read" \ - sh -c "bash '$KIT/tools/kb.sh' show sneak --store '$B/.sdlc' 2>&1; true" -rm -f .sdlc/work/sneak -# the ship snapshot must not bind the .sdlc link itself -printf 'echo hello\n' > app.sh -echo "- Command: sh app.sh -> hello" > .sdlc/work/ext-feat/evidence.md -assert_ok "D8 ship approval taken with the store linked" \ - bash "$KIT/gates/approve.sh" ship .sdlc/work/ext-feat/evidence.md --delegated -if grep -q ' \.sdlc$' "$STORE/approvals/ext-feat.ship.source"; then - fail "D9 the source snapshot bound the .sdlc link" -else pass "D9 the source snapshot excludes the .sdlc link"; fi -git add -A; git commit -qm "feat: greet" >/dev/null -assert_ok_msg "D10 committing the reviewed source keeps the ship gate open" "GATE OPEN" \ - bash "$KIT/gates/check-gate.sh" ship .sdlc/work/ext-feat/evidence.md -SHA=$(git rev-parse HEAD) -cat > .sdlc/work/ext-feat/delivery.md <&1; true" -# long lines are truncated, not dumped -mkdir -p .sdlc/work/longline -awk 'BEGIN { printf "- Goal: "; for (i = 0; i < 400; i++) printf "x"; print " needle-long" }' > .sdlc/work/longline/intent.md -LONG=$(kb search "needle-long" | awk '{ print length($0) }' | sort -rn | head -1) -[ "${LONG:-9999}" -le 260 ] && pass "E10 a very long matching line is truncated" \ - || fail "E10 an unbounded line was printed (${LONG} chars)" -# area-wide: every owned store, and nothing else -assert_ok_msg "E11 --area searches across stores" "ext-feat" \ - bash "$KIT/tools/kb.sh" search --area "$AREA" "keep the records" -mkdir -p "$AREA/not-a-store/work/x" -echo "- Goal: keep the records secret" > "$AREA/not-a-store/work/x/intent.md" -assert_exit "E12 --area never reads an unowned directory" 1 \ - sh -c "bash '$KIT/tools/kb.sh' search --area '$AREA' 'keep the records secret' 2>/dev/null" -ln -s "$FIX/elsewhere" "$AREA/linked-store" -assert_exit "E13 --area does not follow a symlink out of the area" 1 \ - sh -c "bash '$KIT/tools/kb.sh' list --area '$AREA' 2>/dev/null | grep -q linked-store" -assert_ok_msg "E14 list names the stores and their features" "ext-feat" \ - bash "$KIT/tools/kb.sh" list --area "$AREA" -# The point of the area: the checkout can be gone and the knowledge stays. -# Leave the checkout BEFORE deleting it. A process whose working directory has -# been removed cannot resolve its own cwd on Windows, and `find` then fails for -# the whole run — which would break retrieval here for a reason that has -# nothing to do with the records. The deletion itself is still asserted, and so -# are the retained bytes: the same query must come back with the same content. -EVID="$STORE/archive/ext-feat/evidence.md" -EV_BEFORE=$(sdlc_sha256_file "$EVID") -SEARCH_BEFORE=$(bash "$KIT/tools/kb.sh" search --area "$AREA" "sh app.sh" 2>&1) -cd "$FIX" || exit 2 -rm -rf "$B" -assert_nofile "$B" "E15a the checkout the records came from is really gone" -assert_ok_msg "E15 knowledge outlives the checkout it came from" "ext-feat" \ - bash "$KIT/tools/kb.sh" show ext-feat --area "$AREA" -assert_ok_msg "E16 and stays searchable" "evidence.md" \ - bash "$KIT/tools/kb.sh" search --area "$AREA" "sh app.sh" -[ "$(sdlc_sha256_file "$EVID")" = "$EV_BEFORE" ] \ - && pass "E16a the retained evidence bytes are unchanged by the deletion" \ - || fail "E16a the evidence file changed when the checkout was deleted" "$EVID" -[ "$(bash "$KIT/tools/kb.sh" search --area "$AREA" "sh app.sh" 2>&1)" = "$SEARCH_BEFORE" ] \ - && pass "E16b the same query returns the same records as before the deletion" \ - || fail "E16b retrieval changed after the deletion" "$SEARCH_BEFORE" - -echo -echo "=============== F. a COPY of a checkout never uses the original's store" -# cp -R, rsync, tar without --dereference and most backup restores preserve a -# symlink, so a copied project resolves .sdlc into the ORIGINAL's store. The -# copy owns nothing there: no gate verdict, no approval, no close — and the -# original must come out of the attempt byte-for-byte unchanged. -H="$FIX/proj-h"; newproj "$H" -AREA_H="$FIX/area-owner" -assert_ok "F0 the original checkout binds its own store" bash "$KIT/init.sh" . --area "$AREA_H" -HSTORE=$(cd "$H/.sdlc" && pwd -P) -mkdir -p .sdlc/work/owned -cat > .sdlc/work/owned/intent.md <<'EOF' -# Intent: owned -- Goal: work that belongs to the original checkout -- Track: compact -EOF -bash "$KIT/gates/approve.sh" intent .sdlc/work/owned/intent.md --delegated >/dev/null -REC="$HSTORE/approvals/owned.intent.approval" -REC_BEFORE=$(sdlc_sha256_file "$REC") -cd "$FIX" || exit 2 -cp -R "$H" "$FIX/proj-h-copy" -COPY="$FIX/proj-h-copy"; cd "$COPY" || exit 2 -if [ "$(cd .sdlc && pwd -P)" = "$HSTORE" ]; then - pass "F1 the copy really does resolve into the original's store (the risk is real)" -else fail "F1 the copy does not reach the original store — the rest of F proves nothing"; fi -assert_fail_msg "F2 check-gate refuses from the copy" "belong to another checkout" \ - bash "$KIT/gates/check-gate.sh" intent .sdlc/work/owned/intent.md -assert_fail_msg "F3 approve refuses from the copy" "belong to another checkout" \ - bash "$KIT/gates/approve.sh" spec .sdlc/work/owned/intent.md --delegated -assert_fail_msg "F4 close refuses from the copy" "belong to another checkout" \ - bash "$KIT/gates/close.sh" owned abandoned "closed from a COPY of the checkout" -assert_fail_msg "F5 status refuses from the copy" "belong to another checkout" \ - bash "$KIT/gates/status.sh" -assert_fail_msg "F6 the machine view refuses too (--json goes through auto.sh)" "belong to another checkout" \ - bash "$KIT/tools/auto.sh" status --json -assert_fail_msg "F7 verify.sh refuses from the copy" "belong to another checkout" \ - bash "$KIT/tools/verify.sh" check owned -assert_fail_msg "F8 handoff.sh refuses from the copy" "belong to another checkout" \ - bash "$KIT/tools/handoff.sh" check owned -assert_fail_msg "F9 an ordinary init re-run does not bypass the owner check" "belong to another checkout" \ - bash "$KIT/init.sh" . -assert_fail_msg "F10 nor does re-running it with --area" "belong to another checkout" \ - bash "$KIT/init.sh" . --area "$AREA_H" -assert_fail_msg "F11 regenerating the contents page is a write, and is refused" "belong to another checkout" \ - bash "$KIT/tools/kb.sh" index -# reading is never bound to a checkout: that is how knowledge outlives one -assert_ok_msg "F12 read-only retrieval still works from anywhere" "work that belongs to the original" \ - bash "$KIT/tools/kb.sh" search --area "$AREA_H" "work that belongs to the original" -# … and the original is untouched by every refusal above -assert_file "$HSTORE/work/owned/intent.md" "F13 the original's feature is still open, not archived" -assert_nofile "$HSTORE/archive/owned" "F14 nothing of the original was archived" -if [ "$(sdlc_sha256_file "$REC")" = "$REC_BEFORE" ]; then - pass "F15 the original's approval record is byte-identical" -else fail "F15 the original's approval record changed"; fi -cd "$H" || exit 2 -assert_ok_msg "F16 the owning checkout still passes its own gate" "GATE OPEN" \ - bash "$KIT/gates/check-gate.sh" intent .sdlc/work/owned/intent.md -# an external store with no ownership record is not assumed to be anyone's -mv "$HSTORE/PROJECT" "$HSTORE/PROJECT.bak" -assert_fail_msg "F17 an external store with no PROJECT record fails explicitly" "no ownership record" \ - bash "$KIT/gates/check-gate.sh" intent .sdlc/work/owned/intent.md -printf 'unit: x\n' > "$HSTORE/PROJECT" -assert_fail_msg "F18 a PROJECT without a readable owner line fails explicitly" "owner of these records is unknown" \ - bash "$KIT/gates/status.sh" -mv "$HSTORE/PROJECT.bak" "$HSTORE/PROJECT" -assert_ok_msg "F19 restoring the record restores the gate" "GATE OPEN" \ - bash "$KIT/gates/check-gate.sh" intent .sdlc/work/owned/intent.md - -echo -echo "=============== G. the ignore cleanup, and readable store names" -# A Windows-authored .gitignore stores every line with a trailing CR. The -# obsolete kit rules must still go, and every other byte — CR included — stays. -CR="$FIX/proj-crlf"; newproj "$CR" -printf 'keepme\r\n.sdlc/approvals/\r\nbuild/\r\n' > .gitignore -assert_ok_msg "G1 init reports the obsolete CRLF rule as removed" "removed obsolete kit ignore" \ - bash "$KIT/init.sh" . -# Counted byte by byte (crlf_count: CR is read as `@`), because the failure to -# catch here is a LOST CR, and a reader that drops CR itself would call the -# loss a pass — or an intact file a failure. -gi_state() { # → <'/.sdlc', LF only> - printf '%d%d%d%d' \ - "$(crlf_count .gitignore 'keepme@')" \ - "$(crlf_count .gitignore 'build/@')" \ - "$(( $(crlf_count .gitignore '.sdlc/approvals/@') + $(crlf_count .gitignore '.sdlc/approvals/') ))" \ - "$(crlf_count .gitignore '/.sdlc')" -} -G=$(gi_state) -[ "$G" = "1101" ] && pass "G2 CRLF: obsolete rule gone, /.sdlc added once, user lines kept with their CR" \ - || { fail "G2 CRLF cleanup wrong (keepme/build/obsolete/count = $G)" "$(od -c .gitignore | tr '\n' ' ')" - printf ' bytes on disk:\n'; od -c .gitignore | sed 's/^/ /'; } -# Diagnostic, never an assertion: awk's view of this file against its bytes. -# A disagreement means awk translates line endings on this platform (text -# mode), which is exactly what can make an intact file look byte-damaged — the -# reason nothing above reads .gitignore through awk. -AWK_CR=$(awk '/\r$/ { n += 1 } END { print n + 0 }' .gitignore 2>/dev/null) -BYTE_CR=$(tr '\r' '@' < .gitignore | grep -c '@$') -[ "${AWK_CR:-x}" = "$BYTE_CR" ] \ - || printf 'note: awk sees %s CR-terminated line(s), the bytes have %s — awk is in text mode here\n' \ - "${AWK_CR:-?}" "$BYTE_CR" -assert_ok "G3 a second run over the cleaned CRLF file changes nothing more" bash "$KIT/init.sh" . -G=$(gi_state) -[ "$G" = "1101" ] && pass "G4 still exactly one /.sdlc rule, and the CRs are still there" \ - || { fail "G4 the second run changed the file (keepme/build/obsolete/count = $G)" - printf ' bytes on disk:\n'; od -c .gitignore | sed 's/^/ /'; } -assert_exit "G5 the git index was never touched" 1 \ - sh -c "git -C '$CR' diff --cached --quiet; test \$? -ne 0" -# a non-ASCII checkout keeps a readable store name (only path-hostile bytes go) -U="$FIX/지식 프로젝트"; newproj "$U" -assert_ok "G6 a Unicode-named checkout initializes" bash "$KIT/init.sh" . --area "$FIX/area-u" -USTORE=$(cd "$U/.sdlc" && pwd -P) -case "$(basename "$USTORE")" in - (*지식-프로젝트-*) pass "G7 the store name stays readable: $(basename "$USTORE")";; - (*) fail "G7 the Unicode name was collapsed" "$(basename "$USTORE")";; -esac -assert_ok_msg "G8 the store records the kit version that seeded it" "kit_version:" cat "$USTORE/PROJECT" -assert_ok_msg "G9 the readable name carries into retrieval output" "지식-프로젝트" \ - bash "$KIT/tools/kb.sh" list --area "$FIX/area-u" - -echo -echo "=============== H. a human can read it: digest, overview, unmerged knowledge" -# A store with two open features and one closed. Dates and tags come from the -# records only; nothing is inferred from mtime except the idle time of `harvest`. -HP="$FIX/hproj"; newproj "$HP" -assert_ok "H0 the fixture project initializes" bash "$KIT/init.sh" . -mkdir -p .sdlc/work/h-new .sdlc/work/h-old .sdlc/work/h-quiet .sdlc/archive/h-done .sdlc/memory/lessons -cat > .sdlc/work/h-new/intent.md <<'EOF' -# Intent: h-new -- Goal: let a teacher export the roster as CSV -- Date: 2026-09-18 -- Track: compact -EOF -cat > .sdlc/work/h-new/summary.md <<'EOF' -# Summary: h-new -- Tags: roster, export -- Problem: the roster page has no export, so teachers retype names into spreadsheets -- Cause: not known yet -- Change: add a CSV endpoint behind the existing roster query -- Result: not delivered -- Lesson: none -EOF -cat > .sdlc/work/h-new/harvest.md <<'EOF' -# Harvest: h-new -## Domain candidates - -- the roster query already filters by term — [verified: RosterMapper.xml:40 — 2026-09-18] -## Lesson candidates -- [export,encoding] Excel needs a BOM to open UTF-8 CSV as UTF-8 -EOF -cat > .sdlc/work/h-old/intent.md <<'EOF' -# Intent: h-old -- Goal: 학생 현황에서 학습 이력이 있는 학생이 '-'로 보이는 문제를 고쳐 교사가 실제 점수를 볼 수 있게 한다. 이 문장은 표에서 잘려야 할 만큼 길게 이어진다. 그리고 더 이어진다. -- Date: 2026-08-01 -EOF -printf '# Intent: h-quiet\n- Goal: nothing to harvest here\n- Date: 2026-08-15\n' > .sdlc/work/h-quiet/intent.md -printf '# Intent: h-done\n- Goal: an archived one\n- Date: 2026-07-01\n' > .sdlc/archive/h-done/intent.md -printf 'state: shipped\nreason: done\nclosed_at: 2026-07-02T10:00:00Z\n' > .sdlc/archive/h-done/CLOSED -printf '# Lesson: a BOM makes Excel read UTF-8\n- Feature: h-new\n' > .sdlc/memory/lessons/2026-09-18-h-new-bom.md -# show — the digest -assert_ok_msg "H1 show prints the reader's summary before the paths" "teachers retype names" kb show h-new -assert_ok_msg "H2 show prints the unmerged harvest candidates" "Excel needs a BOM" kb show h-new -assert_ok_msg "H3 show prints a lesson's title, not only its file" "a BOM makes Excel read UTF-8" kb show h-new -assert_ok_msg "H4 show lists documents relative to the store" "work/h-new/summary.md" kb show h-new -assert_ok_msg "H5 show falls back to the H1 when an intent has no Goal line" "h-quiet" \ - sh -c "sed -i.bak '/^- Goal/d' .sdlc/work/h-quiet/intent.md && rm -f .sdlc/work/h-quiet/intent.md.bak && bash '$KIT/tools/kb.sh' show h-quiet" -SHOW=$(kb show h-new) -case "$SHOW" in *"Summary:"*"Documents:"*) pass "H6 the digest comes before the document list";; *) fail "H6 the document list is not last" "$SHOW";; esac -# index — overview table, newest first, tags, unmerged harvests -assert_ok "H7 index regenerates the page" kb index -PAGE=.sdlc/README.md -assert_ok_msg "H8 the page opens with an overview table" "| Feature | State | Date | Tags | Goal |" cat "$PAGE" -assert_ok_msg "H9 the table carries the tags a reader browses by" "roster, export" cat "$PAGE" -NEW_AT=$(grep -n '^| \[h-new\]' "$PAGE" | head -1 | cut -d: -f1); OLD_AT=$(grep -n '^| \[h-old\]' "$PAGE" | head -1 | cut -d: -f1) -if [ -n "$NEW_AT" ] && [ -n "$OLD_AT" ] && [ "$NEW_AT" -lt "$OLD_AT" ]; then pass "H10 open features are listed newest first" -else fail "H10 the newer feature is not listed first (new@${NEW_AT:-?} old@${OLD_AT:-?})"; fi -assert_ok_msg "H11 the closed feature carries its close date" "closed 2026-07-02" cat "$PAGE" -assert_ok_msg "H12 unmerged harvests have their own section" "## Knowledge not merged yet" cat "$PAGE" -assert_ok_msg "H13 the section names the feature and shows the candidate" "Excel needs a BOM" cat "$PAGE" -assert_exit "H14 a feature without a harvest is not listed as unmerged" 1 \ - sh -c "awk '/^## Knowledge not merged yet/,/^## Open features/' '$PAGE' | grep -q h-quiet" -assert_ok_msg "H15 the page still lists the summary of a feature" "teachers retype names" cat "$PAGE" -# the long goal is cut at a character boundary — never a broken glyph -CELL=$(grep '^| \[h-old\]' "$PAGE" | head -1) -case "$CELL" in *"…"*) pass "H16 a long goal is truncated in the table";; *) fail "H16 the long goal was not truncated" "$CELL";; esac -if command -v python3 >/dev/null 2>&1; then - if printf '%s' "$CELL" | python3 -c 'import sys; sys.stdin.buffer.read().decode("utf-8")' 2>/dev/null; then - pass "H17 the truncated cell is still valid UTF-8" - else fail "H17 the truncation split a multibyte character" "$CELL"; fi -else echo "NOT VERIFIED H17 (no python3 to check UTF-8 validity)"; fi -# regenerating an unchanged store changes nothing (no timestamp in the page) -cp "$PAGE" "$FIX/page-before"; kb index >/dev/null -if cmp -s "$PAGE" "$FIX/page-before"; then pass "H18 regenerating an unchanged store yields identical bytes" -else fail "H18 the page changed without the records changing" "$(diff "$FIX/page-before" "$PAGE" | head -5)"; fi -# harvest — the trigger -assert_ok_msg "H19 harvest lists the open feature holding candidates" "h-new" kb harvest -assert_exit "H20 harvest exits 0 when something is unmerged" 0 bash "$KIT/tools/kb.sh" harvest -assert_ok_msg "H21 --stale 0 marks it as mergeable without closing" "STALE" kb harvest --stale 0 -assert_ok_msg "H22 a fresh harvest is reported active under the default window" "active" kb harvest -assert_exit "H23 harvest exits 1 for a store with nothing unmerged" 1 \ - sh -c "mkdir -p '$FIX/hempty/work/x' && echo '# Intent: x' > '$FIX/hempty/work/x/intent.md' && bash '$KIT/tools/kb.sh' harvest --store '$FIX/hempty'" -assert_fail_msg "H24 harvest takes no positional argument" "takes no argument" bash "$KIT/tools/kb.sh" harvest h-new -assert_fail_msg "H25 --stale wants a number" "number of days" bash "$KIT/tools/kb.sh" harvest --stale soon -# obsidian — a store setting (config.md), so init.sh and close.sh keep the style; -# it changes the generated page only -printf 'index_style: obsidian # tools/kb.sh index\n' >> .sdlc/config.md -assert_ok_msg "H26 index_style: obsidian in config.md selects the style" "(obsidian)" kb index -assert_ok_msg "H27 the page opens with frontmatter" "---" head -n 1 "$PAGE" -assert_ok_msg "H28 the frontmatter tags the page" "tags: [sdlc-kit, knowledge]" head -n 5 "$PAGE" -assert_ok_msg "H29 feature tags become inline #tags" "#roster #export" cat "$PAGE" -assert_ok_msg "H30 links stay relative markdown" "](work/h-new/intent.md)" cat "$PAGE" -assert_ok "H31 a frontmatter page is still recognized as generated" kb index -sed -i.bak '/^index_style:/d' .sdlc/config.md && rm -f .sdlc/config.md.bak -assert_ok_msg "H32 removing the setting regenerates a plain page" "(plain)" kb index -assert_exit "H33 the plain page has no frontmatter" 1 sh -c "head -n 1 '$PAGE' | grep -q '^---'" -assert_fail_msg "H34 there is no style flag — one setting, one place" "unknown option" bash "$KIT/tools/kb.sh" index --obsidian -assert_ok_msg "H36 records under work/ were not written by any of this" "not delivered" cat .sdlc/work/h-new/summary.md -assert_nofile ".sdlc/work/h-new/README.md" "H37 no page was written inside a feature directory" -cd "$FIX" || exit 2 - -echo -echo "================================================================" -printf 'PASSED: %s FAILED: %s\n' "$PASSED" "$FAILED" -if [ "$FAILED" -gt 0 ]; then printf 'failures:%s\n' "$FAILLIST"; echo "KNOWLEDGE-TEST FAIL"; exit 1; fi -echo "KNOWLEDGE-TEST PASS" diff --git a/gates/selftest.sh b/gates/selftest.sh index 31af94b..aa602c0 100755 --- a/gates/selftest.sh +++ b/gates/selftest.sh @@ -1,763 +1,72 @@ #!/usr/bin/env bash -# selftest.sh — proves the gate mechanism works: approve→open, unapproved→closed. +# selftest.sh — the kit's one smoke test: scripts parse, skill metadata is valid, +# and the gate mechanics that make the loop trustworthy still hold. Runs in +# seconds in a throwaway git repo; touches nothing outside it. set -euo pipefail kit="$(cd "$(dirname "$0")/.." && pwd)" tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT -cd "$tmp"; mkdir -p .sdlc/work/feat-a -git init -q . # close.sh's .gitignore handling is git-repo-only +cd "$tmp"; git init -q . +fail() { echo "FAIL: $*"; exit 1; } +closed() { "$kit/gates/check-gate.sh" "$1" "$2" >/dev/null 2>&1 && fail "$3" || true; } -# mklink — a fixture that claims to be a symlink must BE one. -# Git Bash's default MSYS mode makes `ln -s` COPY instead of link, which would -# turn a security assertion into a false PASS; CI sets -# MSYS=winsymlinks:nativestrict. A link we cannot create is a setup failure. -mklink() { - ln -s "$1" "$2" || { - echo "FAIL: setup — cannot create symlink $2 -> $1 (Windows: MSYS=winsymlinks:nativestrict)"; exit 1; } - [ -L "$2" ] || { - echo "FAIL: setup — $2 is a copy, not a symlink (Windows: MSYS=winsymlinks:nativestrict)"; exit 1; } -} - -a=.sdlc/work/feat-a/intent.md -echo "goal: test" > "$a" - -# 1. gate closed before approval -if "$kit/gates/check-gate.sh" intent "$a" >/dev/null 2>&1; then - echo "FAIL: gate open without approval"; exit 1; fi -echo "ok: gate closed before approval" - -# 2. approve → gate open -"$kit/gates/approve.sh" intent "$a" >/dev/null -"$kit/gates/check-gate.sh" intent "$a" >/dev/null -echo "ok: gate open after approval" - -# 3. edit after approval → gate CLOSES (the approval binds the artifact's content) -cp "$a" "$a.orig" -echo "later edit" >> "$a" -out=$("$kit/gates/check-gate.sh" intent "$a" 2>&1) && { echo "FAIL: gate stayed open after the approved artifact changed"; exit 1; } -case "$out" in (*"changed after it was approved"*) ;; (*) echo "FAIL: content-drift message missing: $out"; exit 1;; esac -cp "$a.orig" "$a"; rm -f "$a.orig" -"$kit/gates/check-gate.sh" intent "$a" >/dev/null || { echo "FAIL: gate not open again for the approved content"; exit 1; } -echo "ok: post-approval edit closes the gate; approved content reopens it" - -# 4. stage name injection rejected -if "$kit/gates/approve.sh" "../../etc/pwn" "$a" >/dev/null 2>&1; then - echo "FAIL: path-traversal stage name accepted"; exit 1; fi -echo "ok: invalid stage name rejected" - -# 5. artifact outside a feature dir rejected -echo x > bare.md -if "$kit/gates/approve.sh" intent bare.md >/dev/null 2>&1; then - echo "FAIL: bare-path artifact accepted (slug '.')"; exit 1; fi -echo "ok: bare-path artifact rejected" - -# 6. approved artifact missing → closed WITH a message (never silent) -mv "$a" "$a.bak" -out=$("$kit/gates/check-gate.sh" intent "$a" 2>&1) && { echo "FAIL: gate open on missing artifact"; exit 1; } -case "$out" in (*"GATE CLOSED"*) echo "ok: missing artifact closed with message";; - (*) echo "FAIL: missing artifact closed SILENTLY"; exit 1;; esac -mv "$a.bak" "$a" - -# 7. --delegated: works at every gated stage, always recorded (with the agent runner) -"$kit/gates/approve.sh" intent "$a" --delegated >/dev/null -grep -q '^mode: delegated-chat' .sdlc/approvals/feat-a.intent.approval || { echo "FAIL: delegated mode not recorded"; exit 1; } -grep -q '^runner: agent' .sdlc/approvals/feat-a.intent.approval || { echo "FAIL: agent runner not recorded for delegated"; exit 1; } -grep -q '^artifact_sha256: [0-9a-f]\{64\}$' .sdlc/approvals/feat-a.intent.approval || { echo "FAIL: approval does not bind the artifact digest"; exit 1; } -grep -qx 'artifact: .sdlc/work/feat-a/intent.md' .sdlc/approvals/feat-a.intent.approval || { echo "FAIL: approval does not bind the canonical path"; exit 1; } -echo "spec body" > .sdlc/work/feat-a/spec.md -"$kit/gates/approve.sh" spec .sdlc/work/feat-a/spec.md --delegated >/dev/null -grep -q '^mode: delegated-chat' .sdlc/approvals/feat-a.spec.approval || { echo "FAIL: delegated mode not recorded for spec"; exit 1; } -grep -q '^upstream_intent: [0-9a-f]\{64\}$' .sdlc/approvals/feat-a.spec.approval || { echo "FAIL: spec approval does not bind the upstream intent"; exit 1; } -# the gate binds ONE artifact per stage: a spec approval over intent.md is refused -if "$kit/gates/approve.sh" spec "$a" --delegated >/dev/null 2>&1; then - echo "FAIL: spec gate accepted intent.md as its artifact"; exit 1; fi -echo "ok: delegated approval recorded, path/digest/upstream bound, artifact allowlisted" - -# 8. close mechanism: dead-end blocked without lesson, allowed with, idempotent-refused, -# and the feature + its approvals archive out of work/ -mkdir -p .sdlc/memory/lessons -if "$kit/gates/close.sh" feat-a dead-end "test reason" >/dev/null 2>&1; then - echo "FAIL: dead-end close allowed without a lesson"; exit 1; fi -echo "lesson" > .sdlc/memory/lessons/2020-01-01-feat-a.md -mkdir -p .sdlc/work/feat-a/scratch && echo bulk > .sdlc/work/feat-a/scratch/dump.log -out=$("$kit/gates/close.sh" feat-a dead-end "test reason") -grep -q '^state: dead-end' .sdlc/archive/feat-a/CLOSED || { echo "FAIL: CLOSED record wrong or not archived"; exit 1; } -[ -d .sdlc/work/feat-a ] && { echo "FAIL: closed feature still under work/"; exit 1; } -[ -f .sdlc/archive/feat-a/approvals/feat-a.intent.approval ] || { echo "FAIL: approvals not archived with the feature"; exit 1; } -ls .sdlc/approvals/feat-a.*.approval >/dev/null 2>&1 && { echo "FAIL: approvals left behind in .sdlc/approvals/"; exit 1; } -grep -qxF '/.sdlc' .gitignore || { echo "FAIL: the archived record is not gitignored on close"; exit 1; } -case "$out" in (*"scratch/ still has files"*) ;; (*) echo "FAIL: leftover scratch not flagged at close"; exit 1;; esac -if "$kit/gates/close.sh" feat-a abandoned "again" >/dev/null 2>&1; then - echo "FAIL: double close allowed"; exit 1; fi -echo "ok: close requires lesson, archives feature+approvals, refuses double close" - -# 9. handed-off: blocked without external reference, allowed with key/URL, no lesson required -mkdir -p .sdlc/work/feat-b -if "$kit/gates/close.sh" feat-b handed-off "sent to another team" >/dev/null 2>&1; then - echo "FAIL: handed-off close allowed without an external reference"; exit 1; fi -"$kit/gates/close.sh" feat-b handed-off "tracking continues in A20-1240" >/dev/null -grep -q '^state: handed-off' .sdlc/archive/feat-b/CLOSED || { echo "FAIL: handed-off state not recorded"; exit 1; } -grep -q '^reason: tracking continues in A20-1240' .sdlc/archive/feat-b/CLOSED || { echo "FAIL: handed-off reference not recorded"; exit 1; } -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 +# 1. every script parses; scripts are LF-only (a CRLF checkout breaks bash on Windows) +for f in "$kit"/init.sh "$kit"/gates/*.sh "$kit"/tools/*.sh; do bash -n "$f" || fail "syntax: $f"; done 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" - -# 11. every SKILL.md frontmatter parses as YAML (guards the 'Triggers:' colon trap) -py="" -for c in python3 python py; do - command -v "$c" >/dev/null 2>&1 || continue - # -c '' rejects the Windows Store alias stub, which resolves but never runs - "$c" -c '' >/dev/null 2>&1 && { py="$c"; break; } +[ -z "$crlf" ] || fail "CRLF line endings: $crlf" +echo "ok: scripts parse and are LF-only" + +# 2. every SKILL.md frontmatter has name and description (guards the 'Triggers:' colon trap) +for p in "$kit"/SKILL.md "$kit"/skills/*/SKILL.md; do + head -1 "$p" | grep -qx -- '---' || fail "no frontmatter: $p" + awk '/^---$/{n++; next} n==1' "$p" | grep -q '^name: ' || fail "no name: $p" + awk '/^---$/{n++; next} n==1' "$p" | grep -q '^description: "' || fail "description must be a quoted string: $p" done -if [ -n "$py" ]; then - if ! "$py" - "$kit" <<'PYEOF' -import sys, glob, os -failed = [] -for p in glob.glob(os.path.join(sys.argv[1], '**/SKILL.md'), recursive=True): - text = open(p, encoding='utf-8').read() # SKILL.md is UTF-8; Windows defaults to cp1252 - if not text.startswith('---'): - failed.append(f'{p}: no frontmatter'); continue - fm = text.split('---')[1] - try: - import yaml - d = yaml.safe_load(fm) - assert isinstance(d, dict) and 'name' in d and 'description' in d - except ImportError: - for line in fm.strip().split('\n'): - if line and not line.startswith((' ', '#')) and ': ' in line: - v = line.split(': ', 1)[1] - if ': ' in v and not v.startswith(('"', "'", '|', '>')): - failed.append(f'{p}: unquoted colon in value: {line[:60]}') - except Exception as e: - failed.append(f'{p}: {e}') -if failed: - print('\n'.join(failed)); sys.exit(1) -PYEOF - then - echo "FAIL: SKILL.md frontmatter invalid (see above)"; exit 1 - fi - echo "ok: all SKILL.md frontmatter valid" -else - echo "skip: no working python found, frontmatter check skipped" -fi - -# 12. --agent-adversary: recorded for plan, rejected for every other stage -mkdir -p .sdlc/work/feat-c -p=.sdlc/work/feat-c/plan.md -echo "plan: test" > "$p" -"$kit/gates/approve.sh" plan "$p" --agent-adversary >/dev/null -grep -q '^mode: agent-adversary' .sdlc/approvals/feat-c.plan.approval || { echo "FAIL: agent-adversary mode not recorded"; exit 1; } -grep -q '^runner: agent' .sdlc/approvals/feat-c.plan.approval || { echo "FAIL: agent runner not recorded for agent-adversary"; exit 1; } -"$kit/gates/check-gate.sh" plan "$p" >/dev/null -if "$kit/gates/approve.sh" spec "$p" --agent-adversary >/dev/null 2>&1; then - echo "FAIL: agent-adversary accepted for a non-plan stage"; exit 1; fi -echo "ok: agent-adversary approval is plan-only and recorded" - -# 13. status.sh renders every state without crashing -mkdir -p .sdlc/work/feat-d -out=$("$kit/gates/status.sh" feat-d) || { echo "FAIL: status.sh crashed on artifact-less feature"; exit 1; } -case "$out" in (*"write intent.md"*) ;; (*) echo "FAIL: wrong next action for empty feature"; exit 1;; esac -echo i > .sdlc/work/feat-d/intent.md; echo s > .sdlc/work/feat-d/spec.md; echo p > .sdlc/work/feat-d/plan.md -"$kit/gates/approve.sh" intent .sdlc/work/feat-d/intent.md --delegated >/dev/null -"$kit/gates/approve.sh" spec .sdlc/work/feat-d/spec.md --delegated >/dev/null -out=$("$kit/gates/status.sh" feat-d) || { echo "FAIL: status.sh crashed mid-run"; exit 1; } -case "$out" in (*"plan gate (tiered)"*) ;; (*) echo "FAIL: tiered plan hint missing"; exit 1;; esac -"$kit/gates/approve.sh" plan .sdlc/work/feat-d/plan.md --agent-adversary >/dev/null -out=$("$kit/gates/status.sh" feat-d) || { echo "FAIL: status.sh crashed after tier approval"; exit 1; } -case "$out" in (*"agent-adversary"*) ;; (*) echo "FAIL: agent-adversary mode not shown"; exit 1;; esac -out=$("$kit/gates/status.sh") || { echo "FAIL: status.sh crashed on full run"; exit 1; } -case "$out" in (*"[CLOSED:"*) echo "FAIL: archived feature leaked into the default run"; exit 1;; (*) ;; esac -out=$("$kit/gates/status.sh" --all) || { echo "FAIL: status.sh crashed with --all"; exit 1; } -case "$out" in (*"[CLOSED: dead-end]"*"(archived)"*) ;; (*) echo "FAIL: --all does not render archived features"; exit 1;; esac -out=$("$kit/gates/status.sh" feat-a) || { echo "FAIL: status.sh crashed on an archived slug"; exit 1; } -case "$out" in (*"[CLOSED: dead-end]"*) ;; (*) echo "FAIL: archived slug not found by name"; exit 1;; esac -echo "ok: status.sh renders empty, tiered, approved, and archived states" - -# 14. upstream chaining: editing intent after spec approval CLOSES the spec gate -"$kit/gates/check-gate.sh" spec .sdlc/work/feat-d/spec.md >/dev/null -cp .sdlc/work/feat-d/intent.md .sdlc/work/feat-d/intent.md.orig -echo "tweak" >> .sdlc/work/feat-d/intent.md -out=$("$kit/gates/check-gate.sh" spec .sdlc/work/feat-d/spec.md 2>&1) && { echo "FAIL: spec gate survived an upstream intent rewrite"; exit 1; } -case "$out" in (*"intent.md changed after"*) ;; (*) echo "FAIL: upstream-drift message missing: $out"; exit 1;; esac -mv .sdlc/work/feat-d/intent.md.orig .sdlc/work/feat-d/intent.md -"$kit/gates/check-gate.sh" spec .sdlc/work/feat-d/spec.md >/dev/null || { echo "FAIL: spec gate not open after the upstream was restored"; exit 1; } -echo "ok: upstream edit closes the downstream gate" - -# 14b. origin.md (the ticket / 기획서 snapshot) is bound by the intent gate and -# every gate downstream of it, and is never a gate of its own -mkdir -p .sdlc/work/feat-o -echo "ticket A20-1: users can export" > .sdlc/work/feat-o/origin.md -echo i > .sdlc/work/feat-o/intent.md; echo s > .sdlc/work/feat-o/spec.md -"$kit/gates/approve.sh" intent .sdlc/work/feat-o/intent.md --delegated >/dev/null -grep -q '^upstream_origin: [0-9a-f]' .sdlc/approvals/feat-o.intent.approval || { echo "FAIL: intent approval does not bind origin.md"; exit 1; } -"$kit/gates/approve.sh" spec .sdlc/work/feat-o/spec.md --delegated >/dev/null -echo "edited after approval" >> .sdlc/work/feat-o/origin.md -out=$("$kit/gates/check-gate.sh" spec .sdlc/work/feat-o/spec.md 2>&1) && { echo "FAIL: spec gate survived an origin.md rewrite"; exit 1; } -case "$out" in (*"origin.md changed after"*"re-approve intent, then spec"*) ;; (*) echo "FAIL: origin-drift message wrong: $out"; exit 1;; esac -if "$kit/gates/approve.sh" origin .sdlc/work/feat-o/origin.md --delegated >/dev/null 2>&1; then - echo "FAIL: 'origin' accepted as a gate"; exit 1; fi -mkdir -p .sdlc/work/feat-o2; echo i > .sdlc/work/feat-o2/intent.md -"$kit/gates/approve.sh" intent .sdlc/work/feat-o2/intent.md --delegated >/dev/null -echo "late snapshot" > .sdlc/work/feat-o2/origin.md -out=$("$kit/gates/check-gate.sh" intent .sdlc/work/feat-o2/intent.md 2>&1) && { echo "FAIL: intent gate survived an origin.md written after the approval"; exit 1; } -case "$out" in (*"binds no digest for origin.md"*) ;; (*) echo "FAIL: unbound-origin message wrong: $out"; exit 1;; esac -echo "ok: origin.md is bound by intent and every downstream gate, never a gate itself" - -# 15. tripwire.sh: flags risky plans, stays quiet on clean ones -tw=.sdlc/work/feat-d/tw.md -printf 'step 1: run ALTER TABLE users\nstep 2: edit Dockerfile\n' > "$tw" -out=$("$kit/tools/tripwire.sh" "$tw") -case "$out" in (*"TRIP-WIRE?"*) ;; (*) echo "FAIL: tripwire missed a migration"; exit 1;; esac -printf 'step 1: rename a local variable\n' > "$tw" -out=$("$kit/tools/tripwire.sh" "$tw") -case "$out" in (*"no trip-wire candidates"*) ;; (*) echo "FAIL: tripwire false positive on a clean plan"; exit 1;; esac -echo "ok: tripwire flags risk and stays quiet on clean plans" - -# 16. close.sh prints a promotion reminder for lesson tags repeating 3+ times -mkdir -p .sdlc/work/feat-e -echo "goal" > .sdlc/work/feat-e/intent.md -echo "lesson" > .sdlc/memory/lessons/2020-01-02-feat-e.md -cat >> .sdlc/memory/INDEX.md <<'EOF' -- [async, gate] one → lessons/a.md -- [async] two → lessons/b.md -- [async, test] three → lessons/c.md -EOF -out=$("$kit/gates/close.sh" feat-e dead-end "test promote" 2>&1) -case "$out" in (*"PROMOTE:"*async*) echo "ok: repeated lesson tag triggers promotion reminder";; - (*) echo "FAIL: no promotion reminder for repeated tag"; exit 1;; esac +echo "ok: SKILL.md frontmatter" -# 17. --lazy: refused with no lazymode / below the level, accepted at the level, recorded, shown -mkdir -p .sdlc/work/feat-f -fi2=.sdlc/work/feat-f/intent.md; fs=.sdlc/work/feat-f/spec.md; fp=.sdlc/work/feat-f/plan.md -echo "goal" > "$fi2"; echo "spec" > "$fs"; echo "plan" > "$fp" -rm -f .sdlc/config.md -if "$kit/gates/approve.sh" plan "$fp" --lazy --review "read the diff" >/dev/null 2>&1; then - echo "FAIL: lazy approval accepted without lazymode in config"; exit 1; fi -printf 'lazymode: 1\n' > .sdlc/config.md -if "$kit/gates/approve.sh" spec "$fs" --lazy --review "read the spec" >/dev/null 2>&1; then - echo "FAIL: lazy spec approval accepted at lazymode 1"; exit 1; fi -if "$kit/gates/approve.sh" plan "$fp" --lazy >/dev/null 2>&1; then - echo "FAIL: lazy approval accepted without --review"; exit 1; fi -"$kit/gates/approve.sh" plan "$fp" --lazy --review "read plan and the code it touches" >/dev/null -grep -q '^review: read plan and the code it touches$' .sdlc/approvals/feat-f.plan.approval || { echo "FAIL: review note not recorded"; exit 1; } -grep -q '^mode: lazy' .sdlc/approvals/feat-f.plan.approval || { echo "FAIL: lazy mode not recorded"; exit 1; } -grep -q '^runner: agent' .sdlc/approvals/feat-f.plan.approval || { echo "FAIL: agent runner not recorded for lazy"; exit 1; } -printf 'lazymode: 4\n' > .sdlc/config.md -"$kit/gates/approve.sh" intent "$fi2" --lazy --review "read the handler and its callers" >/dev/null -"$kit/gates/approve.sh" spec "$fs" --lazy --review "read the spec against the code" >/dev/null -echo "evidence" > .sdlc/work/feat-f/evidence.md # ship PENDING so status must hint --lazy -out=$("$kit/gates/status.sh" feat-f) || { echo "FAIL: status.sh crashed with lazymode set"; exit 1; } -case "$out" in (*"lazymode: 4"*) ;; (*) echo "FAIL: lazymode not shown in status"; exit 1;; esac -case "$out" in (*"· lazy"*) ;; (*) echo "FAIL: lazy approval mode not shown in status"; exit 1;; esac -case "$out" in (*"--lazy"*) ;; (*) echo "FAIL: pending ship gate should hint --lazy at lazymode 4"; exit 1;; esac -if "$kit/gates/approve.sh" build "$fp" --lazy --review r >/dev/null 2>&1; then - echo "FAIL: --lazy accepted for an unknown stage"; exit 1; fi -printf 'lazymode: 10\n' > .sdlc/config.md # out of range must fail CLOSED -if "$kit/gates/approve.sh" ship .sdlc/work/feat-f/evidence.md --lazy --review r >/dev/null 2>&1; then - echo "FAIL: out-of-range lazymode failed OPEN"; exit 1; fi -printf 'lazymode: 4\r\n' > .sdlc/config.md # CRLF-saved config must still parse -"$kit/gates/approve.sh" ship .sdlc/work/feat-f/evidence.md --lazy --review "diff reviewed" >/dev/null || { echo "FAIL: CRLF lazymode config not parsed"; exit 1; } -echo "ok: lazy approval enforces the lazymode level, range, and CRLF, is recorded, and shows in status" - -# 18. lazymode >=3 waives the lesson requirement on non-shipped closes; below 3 keeps it -mkdir -p .sdlc/work/feat-g .sdlc/work/feat-h -echo "goal" > .sdlc/work/feat-g/intent.md; echo "goal" > .sdlc/work/feat-h/intent.md -printf 'lazymode: 3\n' > .sdlc/config.md -"$kit/gates/close.sh" feat-g dead-end "spike did not pan out" >/dev/null || { echo "FAIL: lazymode 3 close still demands a lesson"; exit 1; } -printf 'lazymode: 2\n' > .sdlc/config.md -if "$kit/gates/close.sh" feat-h dead-end "no lesson here" >/dev/null 2>&1; then - echo "FAIL: lazymode 2 close allowed without a lesson"; exit 1; fi -echo "ok: lazymode >=3 waives the lesson, below 3 still requires it" - -# 19. archived slugs are single-use: approve.sh refuses them -mkdir -p .sdlc/work/feat-a && echo again > .sdlc/work/feat-a/intent.md # feat-a archived in test 8 -if "$kit/gates/approve.sh" intent .sdlc/work/feat-a/intent.md >/dev/null 2>&1; then - echo "FAIL: approve accepted a slug that is already archived"; exit 1; fi -rm -rf .sdlc/work/feat-a -echo "ok: approve refuses an archived slug" - -# 20. interrupted close resumes without rewriting CLOSED, but re-runs the -# checks (CLOSED is agent-writable) and refuses a state mismatch -printf 'lazymode: 0\n' > .sdlc/config.md -mkdir -p .sdlc/work/feat-j -printf 'state: abandoned\nreason: r\n' > .sdlc/work/feat-j/CLOSED -if "$kit/gates/close.sh" feat-j dead-end "r" >/dev/null 2>&1; then - echo "FAIL: resume accepted a state that contradicts CLOSED"; exit 1; fi -if "$kit/gates/close.sh" feat-j abandoned "r" >/dev/null 2>&1; then - echo "FAIL: resume skipped the lesson check"; exit 1; fi -echo "lesson" > .sdlc/memory/lessons/2020-01-03-feat-j.md -out=$("$kit/gates/close.sh" feat-j abandoned "r") || { echo "FAIL: legit resume failed"; exit 1; } -case "$out" in (*"resuming"*) ;; (*) echo "FAIL: resume not announced"; exit 1;; esac -grep -q '^reason: r$' .sdlc/archive/feat-j/CLOSED || { echo "FAIL: resume rewrote CLOSED"; exit 1; } -echo "ok: resume archives, re-runs checks, refuses state mismatch" - -# 21. map-first feature: next action points at map.md, not intent.md -mkdir -p .sdlc/work/feat-k -echo m > .sdlc/work/feat-k/map.md -out=$("$kit/gates/status.sh" feat-k) || { echo "FAIL: status.sh crashed on a map-only feature"; exit 1; } -case "$out" in (*"map.md"*) ;; (*) echo "FAIL: map-first next action missing"; exit 1;; esac -echo "ok: map-first feature routes to the map, not intent.md" - -# 22. archive listings are bounded: --all caps at 20 with a pointer line, -# --all= widens/narrows, stats.sh default notes the truncation -for i in $(seq 1 20); do - d=".sdlc/archive/bulk-$i"; mkdir -p "$d" - printf 'state: shipped\nreason: r\n' > "$d/CLOSED" -done # + features archived by earlier tests → well over the cap -out=$("$kit/gates/status.sh" --all) -n=$(printf '%s\n' "$out" | grep -c '(archived)') || true -[ "$n" -eq 20 ] || { echo "FAIL: --all showed $n archived, expected cap 20"; exit 1; } -case "$out" in (*"more archived"*) ;; (*) echo "FAIL: --all missing the truncation pointer"; exit 1;; esac -out=$("$kit/gates/status.sh" --all=3) -n=$(printf '%s\n' "$out" | grep -c '(archived)') || true -[ "$n" -eq 3 ] || { echo "FAIL: --all=3 showed $n archived"; exit 1; } -out=$("$kit/gates/stats.sh") -case "$out" in (*"most recently closed"*) ;; (*) echo "FAIL: stats.sh default not bounded/noted"; exit 1;; esac -case "$out" in (*"included only with --all"*) ;; (*) echo "FAIL: stats.sh re-approval scope note missing"; exit 1;; esac -echo "ok: archive listings bounded by default, widened only explicitly" - -# 23. compact route: status skips spec/plan, marks the feature, routes to build; -# 'micro' still parses as the older spelling of the same verdict -mkdir -p .sdlc/work/feat-m -printf -- '- Track: compact — two known files, existing test\ngoal\n' > .sdlc/work/feat-m/intent.md -"$kit/gates/approve.sh" intent .sdlc/work/feat-m/intent.md --delegated >/dev/null -grep -q '^track: compact' .sdlc/approvals/feat-m.intent.approval || { echo "FAIL: compact track not recorded"; exit 1; } -out=$("$kit/gates/status.sh" feat-m) || { echo "FAIL: status.sh crashed on a compact feature"; exit 1; } -case "$out" in (*"(compact)"*) ;; (*) echo "FAIL: compact marker missing"; exit 1;; esac -case "$out" in (*" spec "*) echo "FAIL: compact feature still shows a spec stage"; exit 1;; (*) ;; esac -case "$out" in (*"write evidence.md"*) ;; (*) echo "FAIL: compact next action should be build/ship"; exit 1;; esac -# a compact slug has no spec or plan gate: approving one demands the upgrade first -echo s > .sdlc/work/feat-m/spec.md -out=$("$kit/gates/approve.sh" spec .sdlc/work/feat-m/spec.md --delegated 2>&1) && { echo "FAIL: spec approved on a compact-track slug"; exit 1; } -case "$out" in (*"upgraded from compact"*) ;; (*) echo "FAIL: no upgrade instruction on the refused spec approval"; exit 1;; esac -# upgrade: re-approve intent as full, then the spec gate is available again -printf -- '- Track: full — upgraded from compact (scope grew)\ngoal\n' > .sdlc/work/feat-m/intent.md -"$kit/gates/approve.sh" intent .sdlc/work/feat-m/intent.md --delegated >/dev/null -"$kit/gates/approve.sh" spec .sdlc/work/feat-m/spec.md --delegated >/dev/null || { echo "FAIL: spec approval still refused after the track upgrade"; exit 1; } -out=$("$kit/gates/status.sh" feat-m) || { echo "FAIL: status.sh crashed on healed compact"; exit 1; } -case "$out" in (*"(compact)"*) echo "FAIL: spec.md present but still rendered compact"; exit 1;; (*) ;; esac -rm .sdlc/work/feat-m/spec.md -mkdir -p .sdlc/work/feat-p # 'microservice-…' must NOT read as the compact track -printf -- '- Track: microservice-split\ngoal\n' > .sdlc/work/feat-p/intent.md -out=$("$kit/gates/status.sh" feat-p) || { echo "FAIL: status.sh crashed on feat-p"; exit 1; } -case "$out" in (*"(compact)"*) echo "FAIL: 'microservice…' misread as the compact track"; exit 1;; (*) ;; esac -mkdir -p .sdlc/work/feat-mi # older spelling still parses -printf -- '- Track: micro — legacy spelling\ngoal\n' > .sdlc/work/feat-mi/intent.md -"$kit/gates/approve.sh" intent .sdlc/work/feat-mi/intent.md --delegated >/dev/null -grep -q '^track_spelling: micro' .sdlc/approvals/feat-mi.intent.approval || { echo "FAIL: legacy micro spelling not recorded"; exit 1; } -out=$("$kit/gates/status.sh" feat-mi) || { echo "FAIL: status.sh crashed on a legacy micro feature"; exit 1; } -case "$out" in (*"(compact)"*) ;; (*) echo "FAIL: legacy micro spelling not treated as compact"; exit 1;; esac -echo "ok: compact route skips spec/plan, upgrade revalidates intent, 'micro' still parses" - -# 24. shipped requires the ship approval AND a confirmed delivery record; -# unmerged harvest blocks close -mkdir -p .sdlc/work/feat-o -echo goal > .sdlc/work/feat-o/intent.md -echo evidence > .sdlc/work/feat-o/evidence.md -echo "- [tag] candidate" > .sdlc/work/feat-o/harvest.md -if "$kit/gates/close.sh" feat-o shipped "done" >/dev/null 2>&1; then - echo "FAIL: shipped close allowed without a ship approval"; exit 1; fi -"$kit/gates/approve.sh" ship .sdlc/work/feat-o/evidence.md >/dev/null -if "$kit/gates/close.sh" feat-o shipped "done" >/dev/null 2>&1; then - echo "FAIL: close allowed with an unmerged harvest.md"; exit 1; fi -rm .sdlc/work/feat-o/harvest.md -out=$("$kit/gates/close.sh" feat-o shipped "done" 2>&1) && { echo "FAIL: shipped close allowed with no delivery record"; exit 1; } -case "$out" in (*"requires a delivery record"*) ;; (*) echo "FAIL: missing-delivery message wrong: $out"; exit 1;; esac -codeid=$(awk '/^code_digest: /{print $2}' .sdlc/approvals/feat-o.ship.approval) -cat > .sdlc/work/feat-o/delivery.md <&1) && { echo "FAIL: unconfirmed delivery accepted as shipped"; exit 1; } -case "$out" in (*"Confirmed"*) ;; (*) echo "FAIL: unconfirmed-delivery message wrong: $out"; exit 1;; esac -sed 's/^- Confirmed: no/- Confirmed: yes/; s|^- Source: .*|- Source: worktree:deadbeef|' .sdlc/work/feat-o/delivery.md > .sdlc/work/feat-o/delivery.tmp -mv .sdlc/work/feat-o/delivery.tmp .sdlc/work/feat-o/delivery.md -out=$("$kit/gates/close.sh" feat-o shipped "done" 2>&1) && { echo "FAIL: delivery with a mismatched source accepted"; exit 1; } -case "$out" in (*"Source does not match the current source identity"*) ;; (*) echo "FAIL: source-mismatch message wrong: $out"; exit 1;; esac -sed "s|^- Source: .*|- Source: worktree:$codeid|" .sdlc/work/feat-o/delivery.md > .sdlc/work/feat-o/delivery.tmp -mv .sdlc/work/feat-o/delivery.tmp .sdlc/work/feat-o/delivery.md -out=$("$kit/gates/close.sh" feat-o shipped "done") || { echo "FAIL: valid delivery still rejected"; exit 1; } -case "$out" in (*"delivery: local"*) ;; (*) echo "FAIL: delivery not reported at close"; exit 1;; esac -echo "ok: shipped needs approval + confirmed, source-matching delivery; harvest blocks until merged" - -# 25. approvals stranded between the two archive mvs are swept on the next close attempt -echo "stage: intent" > .sdlc/approvals/feat-o.intent.approval # feat-o already archived in test 24 -out=$("$kit/gates/close.sh" feat-o shipped "again" 2>&1) && { echo "FAIL: double close of archived slug allowed"; exit 1; } -case "$out" in (*"swept stranded approval"*) ;; (*) echo "FAIL: stranded approval not swept"; exit 1;; esac -[ -f .sdlc/archive/feat-o/approvals/feat-o.intent.approval ] || { echo "FAIL: swept approval not in archive"; exit 1; } -echo "ok: stranded approvals are swept into the archive" - -# 26. re-approval leaves a .history trail and stats counts it -mkdir -p .sdlc/work/feat-q -echo plan > .sdlc/work/feat-q/plan.md -"$kit/gates/approve.sh" plan .sdlc/work/feat-q/plan.md --agent-adversary >/dev/null -"$kit/gates/approve.sh" plan .sdlc/work/feat-q/plan.md --agent-adversary >/dev/null -[ -f .sdlc/approvals/feat-q.plan.approval.history ] || { echo "FAIL: no .history on re-approval"; exit 1; } -out=$("$kit/gates/stats.sh") || { echo "FAIL: stats.sh crashed"; exit 1; } -case "$out" in (*"feat-q.plan: 2 approvals"*) ;; (*) echo "FAIL: stats missed the re-approval"; exit 1;; esac -case "$out" in (*"agent-adversary"*) ;; (*) echo "FAIL: stats hides agent-run approval modes"; exit 1;; esac -echo "ok: re-approvals leave a history trail; stats reports modes honestly" - -# 27. Track verdict frozen at approval: post-approval micro flip is flagged, not honored -mkdir -p .sdlc/work/feat-r -printf -- '- Track: full\ngoal\n' > .sdlc/work/feat-r/intent.md -"$kit/gates/approve.sh" intent .sdlc/work/feat-r/intent.md --delegated >/dev/null -grep -q '^track: full' .sdlc/approvals/feat-r.intent.approval || { echo "FAIL: track not recorded in approval"; exit 1; } -printf -- '- Track: compact — flipped after approval\ngoal\n' > .sdlc/work/feat-r/intent.md -out=$("$kit/gates/status.sh" feat-r) || { echo "FAIL: status crashed on track mismatch"; exit 1; } -case "$out" in (*"(compact)"*) echo "FAIL: post-approval compact flip honored"; exit 1;; (*) ;; esac -case "$out" in (*"re-approve intent"*) ;; (*) echo "FAIL: track mismatch not flagged"; exit 1;; esac -echo "ok: intent approval freezes the Track verdict" - -# 28. init.sh ignores the WHOLE record store with one anchored rule, is -# idempotent, drops the narrower rules older kit versions issued, and flags -# paths a previous kit version already tracked -( - mkdir -p "$tmp/init-probe"; cd "$tmp/init-probe"; git init -q . - mkdir -p .sdlc/work/feat-x - echo e > .sdlc/work/feat-x/evidence.md - git add -A - # seeded by an older kit: narrower rules plus one rule of the USER's own - printf '%s\n' '.sdlc/work/*/spec.md' '.sdlc/approvals/' 'build/' > .gitignore - out=$("$kit/init.sh" .) - grep -qxF '/.sdlc' .gitignore || { echo "FAIL: init.sh does not ignore /.sdlc"; exit 1; } - grep -qxF 'build/' .gitignore || { echo "FAIL: init.sh dropped the user's own ignore rule"; exit 1; } - # every kit-owned narrower rule is subsumed and removed - for line in '.sdlc/work/*/spec.md' '.sdlc/approvals/' '.sdlc/work/*/scratch/' \ - '.sdlc/archive/*/progress.md'; do - grep -qxF "$line" .gitignore && { echo "FAIL: init.sh still issues the subsumed $line"; exit 1; } - done - case "$out" in (*"removed obsolete kit ignore"*) ;; - (*) echo "FAIL: init.sh did not report removing the obsolete ignores"; exit 1;; esac - before=$(wc -l < .gitignore) - "$kit/init.sh" . >/dev/null - [ "$(wc -l < .gitignore)" = "$before" ] || { echo "FAIL: init.sh .gitignore is not idempotent"; exit 1; } - # --no-index: check-ignore skips paths already in the index, and evidence.md - # was staged above on purpose — we are testing the rules, not the index - for p in .sdlc .sdlc/work/feat-x/intent.md .sdlc/work/feat-x/evidence.md \ - .sdlc/memory/DOMAIN.md .sdlc/config.md .sdlc/work/feat-x/scratch/dump.log \ - .sdlc/approvals/feat-x.intent.approval; do - if ! git check-ignore --no-index -q "$p"; then - echo "FAIL: $p is not gitignored — records must stay out of the application's history"; exit 1; fi - done - # anchored: another shipping unit's store is NOT caught by this project's rule - mkdir -p pkg/.sdlc/work/feat-y - if git check-ignore --no-index -q pkg/.sdlc/work/feat-y/intent.md; then - echo "FAIL: /.sdlc is not anchored — it caught a nested shipping unit's store"; exit 1; fi - # init.sh must never touch the git index: evidence.md was staged before the - # run and must still be staged after it (untracking is the human's call) - git ls-files | grep -q '^\.sdlc/work/feat-x/evidence\.md$' || { - echo "FAIL: init.sh mutated the git index"; exit 1; } - case "$out" in (*"tracked file(s) now match .gitignore"*) ;; - (*) echo "FAIL: init.sh did not flag the already-tracked record"; exit 1;; esac -) || exit 1 -echo "ok: init.sh ignores the whole record store, anchored, idempotent, index untouched" - -# 29. approvals are bound to a PATH, not a bare slug: a same-slug feature dir -# somewhere else cannot reuse the approval, and a symlinked dir is refused -mkdir -p .sdlc/work/marker -echo "goal" > .sdlc/work/marker/intent.md -"$kit/gates/approve.sh" intent .sdlc/work/marker/intent.md >/dev/null -"$kit/gates/check-gate.sh" intent .sdlc/work/marker/intent.md >/dev/null -mkdir -p elsewhere/marker -cp .sdlc/work/marker/intent.md elsewhere/marker/intent.md -if "$kit/gates/check-gate.sh" intent elsewhere/marker/intent.md >/dev/null 2>&1; then - echo "FAIL: an approval opened the gate for a same-slug dir outside .sdlc/work/"; exit 1; fi -if "$kit/gates/approve.sh" intent elsewhere/marker/intent.md >/dev/null 2>&1; then - echo "FAIL: approve accepted an artifact outside .sdlc/work/"; exit 1; fi -# a traversal that resolves to the SAME canonical file is fine, not a hole -"$kit/gates/check-gate.sh" intent .sdlc/work/../work/marker/intent.md >/dev/null \ - || { echo "FAIL: canonical path rejected when reached via .."; exit 1; } -# a traversal that escapes is not -if "$kit/gates/check-gate.sh" intent .sdlc/work/../../elsewhere/marker/intent.md >/dev/null 2>&1; then - echo "FAIL: traversal out of the project accepted"; exit 1; fi -mklink "$tmp/elsewhere/marker" .sdlc/work/linked -if "$kit/gates/approve.sh" intent .sdlc/work/linked/intent.md >/dev/null 2>&1; then - echo "FAIL: symlinked feature dir accepted"; exit 1; fi -rm -f .sdlc/work/linked -mklink ../marker/intent.md .sdlc/work/marker/link.md -if "$kit/gates/approve.sh" intent .sdlc/work/marker/link.md >/dev/null 2>&1; then - echo "FAIL: symlinked artifact accepted"; exit 1; fi -rm -f .sdlc/work/marker/link.md -echo "ok: approvals bind a canonical path; cross-path, traversal, and symlinks refused" - -# 30. an approval record written by an older kit (no content binding) fails CLOSED -printf 'stage: intent\nartifact: .sdlc/work/marker/intent.md\napproved_at: 2020-01-01T00:00:00Z\n' \ - > .sdlc/approvals/marker.intent.approval -out=$("$kit/gates/check-gate.sh" intent .sdlc/work/marker/intent.md 2>&1) && { - echo "FAIL: legacy approval marker still opens the gate"; exit 1; } -case "$out" in (*"predates content binding"*"approve.sh intent"*) ;; - (*) echo "FAIL: legacy marker message is not actionable: $out"; exit 1;; esac -out=$("$kit/gates/status.sh" marker) || { echo "FAIL: status crashed on a legacy marker"; exit 1; } -case "$out" in (*"STALE"*) ;; (*) echo "FAIL: status does not flag the stale approval"; exit 1;; esac -"$kit/gates/approve.sh" intent .sdlc/work/marker/intent.md >/dev/null -"$kit/gates/check-gate.sh" intent .sdlc/work/marker/intent.md >/dev/null -echo "ok: pre-binding approval records fail closed with the re-approval command" - -# 31. lazymode moves the checkpoint, not the review: --review is mandatory, and -# risky work needs recorded authorization. A clean keyword scan clears nothing. -mkdir -p .sdlc/work/feat-risk -ri=.sdlc/work/feat-risk/intent.md -printf 'goal: drop the password check for admin sessions\n' > "$ri" -printf 'lazymode: 4\n' > .sdlc/config.md -if "$kit/gates/approve.sh" intent "$ri" --lazy --review "read the session code" >/dev/null 2>&1; then - echo "FAIL: risky intent lazily approved without recorded authorization"; exit 1; fi -"$kit/gates/approve.sh" intent "$ri" --lazy --review "read auth/session.js and its callers" \ - --risk-authorized "human 2026-09-15: yes, remove that check" >/dev/null -grep -q '^risk_authority: human 2026-09-15' .sdlc/approvals/feat-risk.intent.approval || { - echo "FAIL: risk authorization not recorded"; exit 1; } -# non-English risky text: the scan finds nothing, and that clears NOTHING — -# the review requirement is unchanged, which is what the record must show -mkdir -p .sdlc/work/feat-ko -ki=.sdlc/work/feat-ko/intent.md -printf '목표: 로그인 검증을 제거하고 모든 사용자에게 관리자 권한을 부여한다\n' > "$ki" -out=$("$kit/tools/tripwire.sh" "$ki") -case "$out" in (*"no trip-wire candidates"*) ;; (*) echo "FAIL: tripwire fixture changed"; exit 1;; esac -case "$out" in (*"not a risk verdict"*) ;; (*) echo "FAIL: tripwire clean output does not disclaim authority"; exit 1;; esac -if "$kit/gates/approve.sh" intent "$ki" --lazy >/dev/null 2>&1; then - echo "FAIL: clean keyword scan let a lazy approval skip the review"; exit 1; fi -"$kit/gates/approve.sh" intent "$ki" --lazy --review "read the auth middleware; change is scoped to the test fixture" >/dev/null -grep -q '^review: ' .sdlc/approvals/feat-ko.intent.approval || { echo "FAIL: review note not recorded"; exit 1; } -echo "ok: --lazy records a real review; risky work needs authorization; a clean scan authorizes nothing" - -# 32. a feature from the OLD compressed loop (plan.md, no intent.md) gets a -# documented continuation path instead of a silently lost gate -mkdir -p .sdlc/work/feat-legacy -echo "mini plan" > .sdlc/work/feat-legacy/plan.md -"$kit/gates/approve.sh" plan .sdlc/work/feat-legacy/plan.md --agent-adversary >/dev/null -out=$("$kit/gates/status.sh" feat-legacy) || { echo "FAIL: status crashed on a legacy compressed feature"; exit 1; } -case "$out" in (*"LEGACY COMPRESSED"*) ;; (*) echo "FAIL: legacy compressed feature not detected"; exit 1;; esac -case "$out" in (*"write intent.md"*) ;; (*) echo "FAIL: no continuation path for legacy compressed work"; exit 1;; esac -echo "ok: pre-compact compressed work keeps an explicit continuation path" - -# 33. refcheck.sh: a matching HEAD proves nothing about the files on disk -( - mkdir -p "$tmp/refprobe"; cd "$tmp/refprobe"; git init -q . - git config user.email t@example.com; git config user.name t - echo "v1" > app.txt; echo "cfg" > cfg.txt - git add app.txt cfg.txt; git commit -qm init - git branch deploy-ref - # clean tree, HEAD == ref → OK - out=$("$kit/tools/refcheck.sh" deploy-ref --no-fetch) || { echo "FAIL: clean tree reported as drift"; exit 1; } - case "$out" in (*"OK:"*) ;; (*) echo "FAIL: clean tree not OK: $out"; exit 1;; esac - case "$out" in (*"deployed revision: UNKNOWN"*) ;; - (*) echo "FAIL: refcheck claims to know the deployed revision without evidence"; exit 1;; esac - # unstaged edit, HEAD still == ref → DRIFT (this is the bug that shipped in 0.8.0) - echo "hotfix" >> app.txt - out=$("$kit/tools/refcheck.sh" deploy-ref --no-fetch 2>&1) && { - echo "FAIL: dirty working tree reported as matching the deploy ref"; exit 1; } - case "$out" in (*"DRIFT"*app.txt*) ;; (*) echo "FAIL: drifted file not named: $out"; exit 1;; esac - # staged, not committed → still drift - git add app.txt - "$kit/tools/refcheck.sh" deploy-ref --no-fetch >/dev/null 2>&1 && { - echo "FAIL: staged-only change reported as matching"; exit 1; } - git commit -qm hotfix - # committed → HEAD moved; the named path really differs from the ref - out=$("$kit/tools/refcheck.sh" deploy-ref --no-fetch app.txt 2>&1) && { - echo "FAIL: committed divergence reported as matching"; exit 1; } - case "$out" in (*"~ app.txt"*) ;; (*) echo "FAIL: content comparison missing for app.txt: $out"; exit 1;; esac - # a path that did NOT change is reported clean by CONTENT, not by commit count - out=$("$kit/tools/refcheck.sh" deploy-ref --no-fetch cfg.txt) || { - echo "FAIL: unchanged path reported as drift"; exit 1; } - case "$out" in (*"OK:"*cfg.txt*) ;; (*) echo "FAIL: unchanged path not confirmed by content: $out"; exit 1;; esac - # untracked new file counts as drift for the paths it covers - echo new > extra.txt - "$kit/tools/refcheck.sh" deploy-ref --no-fetch >/dev/null 2>&1 && { - echo "FAIL: untracked file ignored"; exit 1; } - rm extra.txt - # unknown ref and an unknown deployed sha are UNKNOWN (exit 2), never a claim - out=$("$kit/tools/refcheck.sh" no-such-ref --no-fetch 2>&1); rc=$? - [ "$rc" = 2 ] || { echo "FAIL: unknown ref exit $rc, expected 2"; exit 1; } - case "$out" in (*"UNKNOWN"*) ;; (*) echo "FAIL: unknown ref not reported as unknown"; exit 1;; esac - out=$("$kit/tools/refcheck.sh" deploy-ref --no-fetch --deployed-sha 0123456789012345678901234567890123456789 2>&1); rc=$? - [ "$rc" = 2 ] || { echo "FAIL: unknown deployed sha exit $rc, expected 2"; exit 1; } - # a supplied deployed sha is what gets compared, and it is labelled as such - sha=$(git rev-parse HEAD) - out=$("$kit/tools/refcheck.sh" deploy-ref --no-fetch --deployed-sha "$sha" cfg.txt) || { - echo "FAIL: comparison against the supplied deployed sha failed"; exit 1; } - case "$out" in (*"deployed revision: $sha"*) ;; (*) echo "FAIL: deployed sha not reported: $out"; exit 1;; esac -) || exit 1 -echo "ok: refcheck compares worktree content, separates ref from deployment, fails to UNKNOWN" - -# 34. workflow scenario: a compact bug fix from intent to a delivered close, -# in a repo with real commits — staging and committing must NOT invalidate -# the ship approval, but an edit after the review must. -( - mkdir -p "$tmp/flow"; cd "$tmp/flow"; git init -q . - git config user.email t@example.com; git config user.name t - printf 'def login(user):\n return user.valid\n' > app.py - git add app.py; git commit -qm init - "$kit/init.sh" . >/dev/null - mkdir -p .sdlc/work/fix-login .sdlc/memory/lessons - cat > .sdlc/work/fix-login/intent.md <<'EOF' -# Intent: fix-login -- Goal: users with a trailing space in their name can log in again. -- Track: compact — one file, existing test covers it -## Compact route -- Files: app.py (login) -- Proof: python3 -c "import app" -- Risk: login path only; single revert -- Delivery target: local -EOF - # gate first: build is not authorized before the intent gate - "$kit/gates/check-gate.sh" intent .sdlc/work/fix-login/intent.md >/dev/null 2>&1 && { - echo "FAIL: compact build gate open before approval"; exit 1; } - "$kit/gates/approve.sh" intent .sdlc/work/fix-login/intent.md --delegated >/dev/null - "$kit/gates/check-gate.sh" intent .sdlc/work/fix-login/intent.md >/dev/null || { - echo "FAIL: intent gate closed after approval"; exit 1; } - out=$("$kit/gates/status.sh" fix-login) - case "$out" in (*"(compact)"*) ;; (*) echo "FAIL: scenario feature not on the compact route"; exit 1;; esac - case "$out" in (*" spec "*) echo "FAIL: compact scenario still lists a spec stage"; exit 1;; (*) ;; esac - # build - printf 'def login(user):\n return user.valid and user.name.strip() != ""\n' > app.py - cat > .sdlc/work/fix-login/evidence.md <<'EOF' -# Evidence: fix-login -## Bug proof -- Before: repro → AttributeError -- Mechanism: name was compared unstripped -- After: same repro → pass -- Adjacent flows: signup → pass -EOF - "$kit/gates/approve.sh" ship .sdlc/work/fix-login/evidence.md --delegated >/dev/null - codeid=$(awk '/^code_digest: /{print $2}' .sdlc/approvals/fix-login.ship.approval) - grep -q '^code_scope: project' .sdlc/approvals/fix-login.ship.approval || { - echo "FAIL: ship approval did not record the source scope"; exit 1; } - grep -q ' app.py$' .sdlc/approvals/fix-login.ship.source || { - echo "FAIL: ship source snapshot does not contain the reviewed file"; exit 1; } - out=$("$kit/gates/status.sh" fix-login) - case "$out" in (*"no delivery.md"*) ;; (*) echo "FAIL: status does not ask for the delivery record"; exit 1;; esac - # staging + committing the REVIEWED content keeps the approval valid - git add app.py .gitignore # .sdlc is ignored: records never enter the index - git commit -qm "fix(login): strip the name before validating" - sha=$(git rev-parse HEAD) - cat > .sdlc/work/fix-login/delivery.md </dev/null || { - echo "FAIL: committing the reviewed content invalidated the delivery"; exit 1; } - [ -f .sdlc/archive/fix-login/CLOSED ] || { echo "FAIL: scenario feature not archived"; exit 1; } - # second feature: an edit AFTER the ship review must block the close - mkdir -p .sdlc/work/fix-two - echo "goal" > .sdlc/work/fix-two/intent.md - echo "evidence" > .sdlc/work/fix-two/evidence.md - printf 'def helper():\n return 1\n' > helper.py - "$kit/gates/approve.sh" ship .sdlc/work/fix-two/evidence.md --delegated >/dev/null - printf 'def helper():\n return 2 # sneaked in after the review\n' > helper.py - cat > .sdlc/work/fix-two/delivery.md <&1) && { - echo "FAIL: close accepted code edited after the ship review"; exit 1; } - case "$out" in (*"the source changed after the ship review"*) ;; (*) echo "FAIL: post-review edit message wrong: $out"; exit 1;; esac - case "$out" in (*"~ helper.py"*) ;; (*) echo "FAIL: drift report does not name the changed file: $out"; exit 1;; esac - # rewriting evidence.md after its approval is caught too - mkdir -p .sdlc/work/fix-three - echo "goal" > .sdlc/work/fix-three/intent.md - echo "evidence" > .sdlc/work/fix-three/evidence.md - "$kit/gates/approve.sh" ship .sdlc/work/fix-three/evidence.md --delegated >/dev/null - echo "rewritten after approval" >> .sdlc/work/fix-three/evidence.md - cat > .sdlc/work/fix-three/delivery.md <&1) && { - echo "FAIL: close accepted evidence rewritten after its approval"; exit 1; } - case "$out" in (*"changed after the ship approval"*) ;; (*) echo "FAIL: evidence-drift message wrong: $out"; exit 1;; esac - # a remote target may not be delivered from an uncommitted worktree - sed 's/^- Target: local/- Target: pr/' .sdlc/work/fix-three/delivery.md > .sdlc/work/fix-three/d.tmp - mv .sdlc/work/fix-three/d.tmp .sdlc/work/fix-three/delivery.md - out=$("$kit/gates/close.sh" fix-three shipped "done" 2>&1) && { - echo "FAIL: a pr delivery from an uncommitted worktree was accepted"; exit 1; } - case "$out" in (*"changed after the ship approval"*|*"cannot come from an uncommitted worktree"*) ;; - (*) echo "FAIL: pr-from-worktree message wrong: $out"; exit 1;; esac -) || exit 1 -echo "ok: compact scenario ships through delivery; commits keep, edits break, the ship binding" - -# 35. the ship binding covers work that was COMMITTED BEFORE the review, and a -# pr/deploy Source commit must CONTAIN the reviewed source, not merely exist. -( - mkdir -p "$tmp/committed"; cd "$tmp/committed"; git init -q . - git config user.email t@example.com; git config user.name t - printf 'echo v1\n' > late.sh - git add late.sh; git commit -qm base - old=$(git rev-parse HEAD) - "$kit/init.sh" . >/dev/null - mkdir -p .sdlc/work/committed-first - echo goal > .sdlc/work/committed-first/intent.md - printf 'echo v2\n' > late.sh - echo "- Command: sh late.sh -> v2" > .sdlc/work/committed-first/evidence.md - git add -A .; git commit -qm "feat: v2 (committed BEFORE the ship review)" - head=$(git rev-parse HEAD) - "$kit/gates/approve.sh" ship .sdlc/work/committed-first/evidence.md --delegated >/dev/null - n=$(awk '/^code_files: /{print $2}' .sdlc/approvals/committed-first.ship.approval) - [ "${n:-0}" -ge 1 ] || { echo "FAIL: ship approval bound an empty source set after a commit"; exit 1; } - # a Source commit that predates the reviewed source is refused - cat > .sdlc/work/committed-first/delivery.md <&1) && { - echo "FAIL: close accepted a Source commit that does not contain the reviewed source"; exit 1; } - case "$out" in (*"does not CONTAIN the reviewed source"*) ;; - (*) echo "FAIL: stale-source-commit message wrong: $out"; exit 1;; esac - # the commit that does contain it closes - sed "s|^- Source: .*|- Source: $head|" .sdlc/work/committed-first/delivery.md > d.tmp - mv d.tmp .sdlc/work/committed-first/delivery.md - "$kit/gates/close.sh" committed-first shipped "delivered" >/dev/null || { - echo "FAIL: the commit that contains the reviewed source was refused"; exit 1; } - # second feature: an edit after the review of ALREADY COMMITTED work is drift - mkdir -p .sdlc/work/committed-two - echo goal > .sdlc/work/committed-two/intent.md - echo ev > .sdlc/work/committed-two/evidence.md - # nothing to commit: the archive and the new feature's records are ignored, - # so HEAD still holds exactly the reviewed source - "$kit/gates/approve.sh" ship .sdlc/work/committed-two/evidence.md --delegated >/dev/null - printf 'echo HACKED\n' > late.sh # never reviewed by anyone - sha=$(git rev-parse HEAD) - cat > .sdlc/work/committed-two/delivery.md <&1) && { - echo "FAIL: post-review edit of committed work closed as shipped (B1)"; exit 1; } - case "$out" in (*"the source changed after the ship review"*) ;; - (*) echo "FAIL: committed-work drift message wrong: $out"; exit 1;; esac - out=$("$kit/gates/status.sh" committed-two 2>&1) - case "$out" in (*"SOURCE DRIFT"*) ;; (*) echo "FAIL: status hides the source drift: $out"; exit 1;; esac - case "$out" in (*"NOT CLOSEABLE"*) ;; (*) echo "FAIL: status calls an uncloseable delivery closeable: $out"; exit 1;; esac - out=$("$kit/gates/check-gate.sh" ship .sdlc/work/committed-two/evidence.md 2>&1) && { - echo "FAIL: ship gate stayed open over drifted source"; exit 1; } - case "$out" in (*"GATE CLOSED"*) ;; (*) echo "FAIL: ship gate drift message wrong: $out"; exit 1;; esac -) || exit 1 -echo "ok: work committed before the review is bound; a Source commit must contain it; status agrees" +# 3. a gate opens only for the approved bytes, and an upstream edit closes the gate below it +"$kit/init.sh" . >/dev/null +d=.sdlc/work/feat-a; mkdir -p "$d"; a=$d/intent.md; s=$d/spec.md +echo "goal" > "$a" +closed intent "$a" "gate open without approval" +"$kit/gates/approve.sh" intent "$a" --delegated >/dev/null +"$kit/gates/check-gate.sh" intent "$a" >/dev/null || fail "gate closed after approval" +echo "spec" > "$s"; "$kit/gates/approve.sh" spec "$s" --delegated >/dev/null +echo "edit" >> "$a" +closed intent "$a" "gate stayed open after the approved artifact changed" +closed spec "$s" "spec gate survived an upstream intent edit" +"$kit/gates/approve.sh" "../../etc/pwn" "$a" >/dev/null 2>&1 && fail "path-traversal stage name accepted" +echo "ok: gates bind content and upstream; bad stage names refused" + +# 4. lazymode moves who decides, never beyond the configured level +d=.sdlc/work/feat-b; mkdir -p "$d"; echo spec > "$d/spec.md"; echo plan > "$d/plan.md" +sed -i.bak 's/^lazymode: .*/lazymode: 1/' .sdlc/config.md && rm -f .sdlc/config.md.bak +"$kit/gates/approve.sh" spec "$d/spec.md" --lazy --review r >/dev/null 2>&1 && fail "lazy spec approval accepted at lazymode 1" +"$kit/gates/approve.sh" plan "$d/plan.md" --lazy >/dev/null 2>&1 && fail "lazy approval accepted without --review" +"$kit/gates/approve.sh" plan "$d/plan.md" --lazy --review "read the plan" >/dev/null || fail "lazy plan approval refused at lazymode 1" +echo "ok: lazymode enforced" + +# 5. close: dead-end needs a lesson; shipped needs a ship approval and a confirmed delivery +"$kit/gates/close.sh" feat-b dead-end "tried" >/dev/null 2>&1 && fail "dead-end closed without a lesson" +d=.sdlc/work/feat-c; mkdir -p "$d"; echo goal > "$d/intent.md"; echo evidence > "$d/evidence.md" +"$kit/gates/close.sh" feat-c shipped "done" >/dev/null 2>&1 && fail "shipped without a ship approval" +"$kit/gates/approve.sh" ship "$d/evidence.md" >/dev/null +"$kit/gates/close.sh" feat-c shipped "done" >/dev/null 2>&1 && fail "shipped without a delivery record" +src=$(awk '/^code_digest: /{print $2}' .sdlc/approvals/feat-c.ship.approval) +printf -- '- Target: local\n- Source: worktree:%s\n- Verified-by: selftest\n- Evidence: ok\n- Confirmed: yes\n' "$src" > "$d/delivery.md" +"$kit/gates/close.sh" feat-c shipped "done" >/dev/null || fail "valid delivery refused" +[ -f .sdlc/archive/feat-c/CLOSED ] || fail "shipped feature not archived" +echo "ok: close requires its proof" + +# 6. knowledge retrieval files features under their own product area — under a +# UTF-8 locale too (macOS awk once collated Hangul menu paths as equal) +mkdir -p .sdlc/work/f1 .sdlc/work/f2 .sdlc/memory/areas +printf -- '- Area: 명단 > 내보내기\n' > .sdlc/work/f1/summary.md +printf -- '- Area: 결제 > 환불\n' > .sdlc/work/f2/summary.md +printf -- '# Area: 명단 > 내보내기\n- Menu: 명단 > 내보내기\n## Business rules (정책)\n- P1: 현재 학기만\n' > .sdlc/memory/areas/roster.md +LC_ALL=en_US.UTF-8 bash "$kit/tools/kb.sh" index >/dev/null +grep -qF '| [명단 > 내보내기](memory/areas/roster.md) | 1 | — | f1 |' .sdlc/README.md || fail "area table wrong: $(grep '명단' .sdlc/README.md)" +out=$(bash "$kit/tools/kb.sh" show "명단 > 내보내기") +case "$out" in (*"P1: 현재 학기만"*) ;; (*) fail "show did not print the rule: $out";; esac +echo "ok: knowledge by product area" echo "SELFTEST PASS" diff --git a/gates/win-restricted-run.py b/gates/win-restricted-run.py deleted file mode 100755 index dfaf48b..0000000 --- a/gates/win-restricted-run.py +++ /dev/null @@ -1,254 +0,0 @@ -#!/usr/bin/env python3 -"""Run ONE command with SeBackupPrivilege and SeRestorePrivilege REMOVED. - -TEST-ONLY helper for gates/knowledge-test.sh (C10a/C10). Nothing the kit ships -at runtime uses it, and it is never imported. - -Why it exists -------------- -The C10 fixture needs a directory the current user really cannot write into. -On NTFS that is an explicit deny ACE, and gates/knowledge-test.sh sets one with -icacls. On the GitHub windows-latest runner that was not enough: the CI account -(runneradmin, RID 500) holds SeBackupPrivilege and SeRestorePrivilege ENABLED, -and both a native CreateDirectory and an MSYS mkdir succeeded against a -directory carrying `runneradmin:(DENY)(W)` (evidence: -.sdlc/work/260920-external-knowledge-area/scratch/ci-windows-diagnostics.log, -run 35517903326). A privileged token is a property of the CI fixture, not proof -that the product skips Windows permission errors. - -So the write probe and the real init.sh run in a child whose token no longer -carries those two privileges. - -Why SE_PRIVILEGE_REMOVED and not "disable" ------------------------------------------- -AdjustTokenPrivileges with SE_PRIVILEGE_REMOVED (0x4) deletes the privilege -from the token instead of clearing its enabled bit; the docs call that -irreversible, so the MSYS runtime in the child cannot enable it again at -startup the way it can with a merely disabled privilege. CreateProcess gives -the child a copy of this process's primary token, so the removal is inherited. -No impersonation, no restricted-token construction, and no machine or account -state is touched: the edit applies to THIS helper process only, and dies with -it. The shell that launched the helper keeps its own privileges, which is what -lets the test restore the fixture ACL afterwards. - - AdjustTokenPrivileges / SE_PRIVILEGE_REMOVED: - https://learn.microsoft.com/windows/win32/api/securitybaseapi/nf-securitybaseapi-adjusttokenprivileges - TOKEN_PRIVILEGES: - https://learn.microsoft.com/windows/win32/api/winnt/ns-winnt-token_privileges - Privilege constants (SE_BACKUP_NAME, SE_RESTORE_NAME): - https://learn.microsoft.com/windows/win32/secauthz/privilege-constants - -Usage ------ - win-restricted-run.py --report print the privileges before and - after the removal, run nothing - win-restricted-run.py -- [args...] remove, prove removed, then run - -Exit status ------------ - normal path; output is the command's own - 90 not Windows, or unusable arguments - 91 the token could not be opened or adjusted - 92 a privilege is STILL on the token after the removal (never run anything) - 93 the command could not be started -Codes 90-93 mean "this proves nothing", not "the command failed": the caller -reports NOT VERIFIED on them. -""" - -import os -import subprocess -import sys - -TARGETS = ("SeBackupPrivilege", "SeRestorePrivilege") - -E_USAGE, E_TOKEN, E_STILL_THERE, E_SPAWN = 90, 91, 92, 93 - -TOKEN_ADJUST_PRIVILEGES = 0x0020 -TOKEN_QUERY = 0x0008 -SE_PRIVILEGE_ENABLED = 0x00000002 -SE_PRIVILEGE_REMOVED = 0x00000004 -TokenPrivileges = 3 -ERROR_INSUFFICIENT_BUFFER = 122 - - -def die(code, msg): - sys.stderr.write("win-restricted-run: %s\n" % msg) - sys.exit(code) - - -if sys.platform != "win32": - die(E_USAGE, "this helper only means anything on native Windows Python " - "(sys.platform=%r)" % sys.platform) - -import ctypes # noqa: E402 (only reachable on Windows) -from ctypes import wintypes # noqa: E402 - -advapi32 = ctypes.WinDLL("advapi32", use_last_error=True) -kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) - - -class LUID(ctypes.Structure): - _fields_ = [("LowPart", wintypes.DWORD), ("HighPart", wintypes.LONG)] - - -class LUID_AND_ATTRIBUTES(ctypes.Structure): - _fields_ = [("Luid", LUID), ("Attributes", wintypes.DWORD)] - - -# Prototypes are declared, not left to ctypes' defaults: GetCurrentProcess -# returns the pseudo-handle (HANDLE)-1, and a default c_int return would be -# truncated on 64-bit before OpenProcessToken ever sees it. -kernel32.GetCurrentProcess.restype = wintypes.HANDLE -kernel32.GetCurrentProcess.argtypes = [] -advapi32.OpenProcessToken.restype = wintypes.BOOL -advapi32.OpenProcessToken.argtypes = [wintypes.HANDLE, wintypes.DWORD, - ctypes.POINTER(wintypes.HANDLE)] -advapi32.LookupPrivilegeValueW.restype = wintypes.BOOL -advapi32.LookupPrivilegeNameW.restype = wintypes.BOOL -advapi32.GetTokenInformation.restype = wintypes.BOOL -advapi32.AdjustTokenPrivileges.restype = wintypes.BOOL - - -def token_privileges_struct(count): - class TOKEN_PRIVILEGES(ctypes.Structure): - _fields_ = [("PrivilegeCount", wintypes.DWORD), - ("Privileges", LUID_AND_ATTRIBUTES * count)] - return TOKEN_PRIVILEGES - - -def win_error(what): - err = ctypes.get_last_error() - return "%s failed (GetLastError=%d: %s)" % ( - what, err, ctypes.FormatError(err).strip()) - - -def open_own_token(): - handle = wintypes.HANDLE() - ok = advapi32.OpenProcessToken( - kernel32.GetCurrentProcess(), - TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, - ctypes.byref(handle)) - if not ok: - die(E_TOKEN, win_error("OpenProcessToken")) - return handle - - -def lookup_luid(name): - luid = LUID() - if not advapi32.LookupPrivilegeValueW(None, name, ctypes.byref(luid)): - die(E_TOKEN, win_error("LookupPrivilegeValueW(%s)" % name)) - return luid - - -def privilege_name(luid): - """The privilege's real name, or death. Never a placeholder: the absence - proof is keyed on these names, so an unnamed entry would silently read as - 'the target is gone' when the lookup merely failed.""" - size = wintypes.DWORD(0) - ctypes.set_last_error(0) - ok = advapi32.LookupPrivilegeNameW(None, ctypes.byref(luid), None, - ctypes.byref(size)) - # The sizing call MUST fail with ERROR_INSUFFICIENT_BUFFER and MUST fill in - # a length; anything else (success, or another error) means we cannot trust - # the name we are about to key the map on. - if ok or ctypes.get_last_error() != ERROR_INSUFFICIENT_BUFFER \ - or not size.value: - die(E_TOKEN, win_error("LookupPrivilegeNameW sizing")) - buf = ctypes.create_unicode_buffer(size.value + 1) - size = wintypes.DWORD(len(buf)) - ctypes.set_last_error(0) - if not advapi32.LookupPrivilegeNameW(None, ctypes.byref(luid), buf, - ctypes.byref(size)): - die(E_TOKEN, win_error("LookupPrivilegeNameW")) - return buf.value - - -def current_privileges(token): - """{name: 'Enabled'|'Disabled'} for every privilege still on the token.""" - size = wintypes.DWORD(0) - advapi32.GetTokenInformation(token, TokenPrivileges, None, 0, - ctypes.byref(size)) - # The sizing call is EXPECTED to fail with ERROR_INSUFFICIENT_BUFFER; only - # a size it never filled in is a real failure. The second call decides. - if not size.value: - die(E_TOKEN, win_error("GetTokenInformation(TokenPrivileges) sizing")) - buf = ctypes.create_string_buffer(size.value) - if not advapi32.GetTokenInformation(token, TokenPrivileges, buf, - size, ctypes.byref(size)): - die(E_TOKEN, win_error("GetTokenInformation(TokenPrivileges)")) - count = ctypes.cast(buf, ctypes.POINTER(wintypes.DWORD))[0] - found = {} - if count: - privs = ctypes.cast( - buf, ctypes.POINTER(token_privileges_struct(count)) - )[0].Privileges - for i in range(count): - state = "Enabled" if privs[i].Attributes & SE_PRIVILEGE_ENABLED \ - else "Disabled" - found[privilege_name(privs[i].Luid)] = state - return found - - -def remove_targets(token): - tp = token_privileges_struct(len(TARGETS))() - tp.PrivilegeCount = len(TARGETS) - for i, name in enumerate(TARGETS): - tp.Privileges[i].Luid = lookup_luid(name) - tp.Privileges[i].Attributes = SE_PRIVILEGE_REMOVED - ctypes.set_last_error(0) - ok = advapi32.AdjustTokenPrivileges(token, False, ctypes.byref(tp), 0, - None, None) - err = ctypes.get_last_error() - # A privilege the token never had is not an error here: ERROR_NOT_ALL_ - # ASSIGNED (1300) with a successful call still leaves it absent, which is - # the state being asked for. The check below is what decides either way. - if not ok and err not in (0, 1300): - die(E_TOKEN, win_error("AdjustTokenPrivileges")) - - -def describe(state): - return " ".join("%s=%s" % (n, state.get(n, "")) for n in TARGETS) - - -def main(argv): - token = open_own_token() - before = current_privileges(token) - - if argv[:1] == ["--report"]: - if len(argv) != 1: - die(E_USAGE, "--report takes no other argument") - remove_targets(token) - after = current_privileges(token) - print("parent token (inherited): %s" % describe(before)) - print("child token (after removal): %s" % describe(after)) - return 0 - - if argv[:1] != ["--"] or len(argv) < 2: - die(E_USAGE, "usage: win-restricted-run.py --report | -- [args]") - command = argv[1:] - - remove_targets(token) - after = current_privileges(token) - still = [n for n in TARGETS if n in after] - if still: - die(E_STILL_THERE, - "NOT VERIFIED: %s still on this token after SE_PRIVILEGE_REMOVED " - "(before: %s) — nothing was run" - % (", ".join(still), describe(before))) - - # The caller reaches native Python with MSYS2_ARG_CONV_EXCL='*' so that its - # POSIX arguments survive. That override is for THAT call only: the child - # is a Git Bash that runs native git, and it must keep the normal - # conversion rules. - env = dict(os.environ) - env.pop("MSYS2_ARG_CONV_EXCL", None) - try: - # stdout/stderr are inherited, so the caller reads the command's own - # output and its own exit status, with nothing added or swallowed. - return subprocess.call(command, env=env) - except OSError as e: - die(E_SPAWN, "could not start %r: %s" % (command[0], e)) - - -if __name__ == "__main__": - sys.exit(main(sys.argv[1:])) diff --git a/init.sh b/init.sh index 53cc3f4..ca585fb 100755 --- a/init.sh +++ b/init.sh @@ -212,7 +212,7 @@ if [ -n "$area" ]; then echo "Knowledge area: $store (linked as $project_phys/.sdlc)" fi -mkdir -p .sdlc/work .sdlc/approvals .sdlc/memory/lessons +mkdir -p .sdlc/work .sdlc/approvals .sdlc/memory/lessons .sdlc/memory/areas # Append a line to a project file unless it is already there, verbatim. # tr -d '\r': a CRLF file would never match, re-appending the line each run. @@ -302,8 +302,8 @@ EOF EOF [ -f .sdlc/memory/DOMAIN.md ] || cat > .sdlc/memory/DOMAIN.md <<'EOF' -# Domain knowledge — how THIS system works (≤100 lines; over → split by -# subdomain into memory/domain/.md and keep one pointer line here) +# Domain knowledge — how THIS system works: terms and facts that span areas +# (≤100 lines; over → move area-specific facts to memory/areas/.md) # ONE writer: the close step. Mid-loop candidates stage in the feature's # work//harvest.md and merge here at close (AGENTS.md rule 4). # Facts carry [verified: how — YYYY-MM-DD]. Recency wins: a merge candidate diff --git a/log/changelog-v0.15.0.md b/log/changelog-v0.15.0.md new file mode 100644 index 0000000..423f474 --- /dev/null +++ b/log/changelog-v0.15.0.md @@ -0,0 +1,121 @@ +# v0.15.0 — knowledge filed by product area, with its business rules + +Records were filed by feature and date. Nobody could ask "what are the rules +for the submit screen?" without knowing which slug changed it. In real stores +`DOMAIN.md` had turned into long evidence sentences with file paths and +hashes, not something a person reads. `summary.md` had five one-line fields +and no store had used it. This release files knowledge the way people +navigate a product, by area (for a web app, by menu), and gives business +rules (정책) one home. Gates, approvals, the `--json` schema, and lazymode are +unchanged. + +## Changes + +- **Product area pages** (`templates/area.md` → `memory/areas/.md`). + There is one page per area. For a web app an area is one menu, named by its + menu path (`학습 > 평가 > 제출`); for other software it is a module, API, + job, or CLI command. Each page has: + - `Menu:`, `Where:`, and `Aliases:` lines; + - **Business rules (정책)**, numbered P1… and written as testable sentences + a non-developer can read, each with its source and the feature that set + it (P-numbers are never reused, and a retired rule stays struck through); + - a short How it works; + - a History line per feature that changed the area. + + `init.sh` seeds `memory/areas/`. The page replaces DOMAIN.md's old + "split into `memory/domain/.md`" overflow rule, so there is one + concept instead of two. +- **What goes where, in one table** (AGENTS.md rule 4). Business rules go to + the area page, area history to the page's History, cross-area terms and + facts to DOMAIN.md, traps to lessons, agent rules to POLICY.md (only on the + human's word, and never a business rule), and one feature's story to + summary.md. Every row except POLICY.md and summary.md is written only by + the close merge, which creates a missing area page from the template. +- **The loop uses the pages**: + - Intent names the area and cites the P-numbers the request touches. + - The spec's AS-IS names each rule it keeps, changes, or retires. + - The Side effects verifier re-checks the rules the change did not set out + to change. + - Ship writes every set, changed, or retired rule and a history line as area + candidates (a new `Area candidates` section in `templates/harvest.md`). +- **summary.md reads like a note to a colleague**: a Goal sentence as its + title, then `Area`, `Tags`, and `Status` (only what delivery.md confirms), + followed by *What was wrong*, *Before → After*, *How to check*, and + *Remember*. +- **`tools/kb.sh`**: + - `index` opens with a **Product areas** table (live-rule count, last + change, features, and areas a feature names that have no page yet). + - The overview table gains an **Area** column. + - `show ` falls back to a product area — the page's file name, its + exact menu path, or an area features name with no page yet — and prints + it with the features that name it. An `Area:` line may use the menu path + or the file name. + - `search` already covered `memory/`, so business rules are found by their + words. + - An area name containing `/` or starting with `.` never resolves to a file. + - A record heading with nothing under it is no longer printed, so an + unfinished summary shows only what is known. Summaries print up to 20 + lines (was 12). + - Messages: a `show` miss reads "no feature or product area", `show` with + no argument asks for "a feature slug or a product area", an `--area` miss + reads "no such knowledge folder (--area)", the usage text lists + `show `, the contents page's Search line names + `show `, and the harvest and close hints name the area + pages. +- **`kb.sh` runs with byte semantics (`LC_ALL=C`).** Under a UTF-8 locale, + macOS awk compares strings by collation, so different Hangul menu paths + compared equal and every feature was filed under every area. Found by macOS + CI (en_US.UTF-8). +- **Rules and history describe what shipped.** They merge only on a + `shipped` close; any other close drops them, and a stale merge leaves + them in harvest.md for that close. The merge + gives a new rule the next number its page never used. A fact about one area + goes to that page's How it works. The spec template gains a **Business + rules touched** section, and the spec adversary and the verifier read the + area pages. +- **E2E lens findings from v0.14.0, fixed**: + - The compact route gains a `Baseline:` line (build reads it). + - Probe 8 covers any outbound call, not only UI. + - evidence.md and the ship authorization name the compact-route source. + - The router states that a broken-and-undiagnosed report goes to stage 6 + ahead of the generic change row. + - The compact criterion now explains why optional open questions still + disqualify it: the compact route has no spec to answer them. + +## Compatibility + +Older summaries (Problem/Cause/… bullets) still print, now up to 20 lines. A store with +no `memory/areas/` and no `Area:` lines gets no Product areas section. The +overview table's header gains a column; `gates/knowledge-test.sh` H8 is +updated for that deliberate change. + +## Validation + +- `bash gates/knowledge-test.sh` → `KNOWLEDGE-TEST PASS`, 152, under the + en_US.UTF-8, ko_KR.UTF-8 and C locales. H38–H45 are new and cover one + contract each: area row, page-less area, `show` by menu path, `show` of a + page-less area, path walk, unfilled summary, filled section, and Area by + file name. H38, H39 and H45 fail on the pre-fix kb.sh under en_US.UTF-8. +- Three fresh-context verifier lenses (E2E, Side effects, Intent match). + Round 1 found the unfilled-template leak (blocking) and ~20 minor gaps. A + round-2 re-check found every one resolved (PASS), including against the old + kb.sh on seven real stores (only named differences, byte-identical + regeneration, unchanged exit codes). It also found four minor new gaps + (usage line, changelog wording, stale-merge rule loss, researcher + destination), and those were fixed. +- `bash gates/selftest.sh` → `SELFTEST PASS` +- `bash gates/e2e.sh` → `E2E PASS`, 152 · `bash gates/autotest.sh` → `AUTOTEST PASS`, 195 + +## Tests: one smoke test + +The kit is mostly instructions, and four suites (about 3,800 lines, several +minutes per run) mostly re-checked wording. `gates/e2e.sh`, +`gates/autotest.sh`, `gates/knowledge-test.sh`, and +`gates/win-restricted-run.py` are removed. `gates/selftest.sh` is now one +smoke test (~75 lines, a few seconds) covering: scripts parse and are +LF-only, SKILL.md frontmatter, gates bind content and upstream, lazymode +limits, close proof (lesson, ship approval, confirmed delivery), and +product-area filing under a UTF-8 locale. CI runs only that: on pull +requests (branch protection requires its checks) and by hand, no longer on +every push to main or on tags. + diff --git a/roles/researcher.md b/roles/researcher.md index 343a802..a2c03a8 100644 --- a/roles/researcher.md +++ b/roles/researcher.md @@ -34,10 +34,12 @@ Report format (target: 60 lines or fewer): - Claims checked: "" → CONFIRMED/REFUTED () - Unknowns: - Domain candidates: + span areas (.sdlc/memory/DOMAIN.md); one line each with [verified: how]> +- Area candidates: ] …`, one line each with [verified: how]> ``` -The dispatcher appends Domain candidates to the feature's +The dispatcher appends Domain and Area candidates to the feature's `.sdlc/work//harvest.md` (AGENTS.md rule 4). A candidate that contradicts an existing DOMAIN.md entry supersedes it — mark it `supersedes: ` so the close merge replaces the old line instead of keeping both. diff --git a/roles/verifier.md b/roles/verifier.md index b6c4314..d32e1b0 100644 --- a/roles/verifier.md +++ b/roles/verifier.md @@ -6,8 +6,10 @@ dispatcher names yours; the lenses run in parallel, fresh context each. Inputs: intent.md and origin.md (the snapshot of the ticket / 기획서 the intent gate bound; absent when the request had no origin beyond the chat), -spec.md and plan.md (compact route: intent.md only), the changed-file list, -`.sdlc/config.md` commands, and `baseline.txt` when it exists. +spec.md and plan.md (compact route: intent.md only), summary.md (its `Area:` +line names the area pages to read with `tools/kb.sh show `), the +changed-file list, `.sdlc/config.md` commands, and `baseline.txt` when it +exists. ## Lens 1 — E2E: does the change work where the user meets it? @@ -39,8 +41,10 @@ spec.md and plan.md (compact route: intent.md only), the changed-file list, Assume the feature works and look for what it broke, skewed, or left behind: 1. Brownfield: rerun the baseline commands and diff against `baseline.txt`; - check every "stays untouched" item (spec.md U-items) and the neighbouring - flows that share the changed code or data. Name them; no quota. + check every "stays untouched" item (spec.md U-items), every business + rule on the touched area pages that the change did not set out to change + (`tools/kb.sh show `), and the neighbouring flows that share the + changed code or data. Name them; no quota. 2. **Data consistency.** Start from plan.md's **Data touched** list (compact route: intent.md's Risk line) and add any shape the diff touches that it missed — a missed shape is itself a finding. Follow each one to its other diff --git a/skills/1-intent/SKILL.md b/skills/1-intent/SKILL.md index cb46ace..e697ff9 100644 --- a/skills/1-intent/SKILL.md +++ b/skills/1-intent/SKILL.md @@ -129,11 +129,13 @@ feature. Write `summary.md` beside it (templates/summary.md) — the page a human reads instead of the stage files, printed first by `tools/kb.sh show` and the -contents page. Fill `Tags` (the domain areas a reader browses by), `Problem`, -and `Cause`/`Change` as far as they are known; leave `Result` at "not -delivered". No approval binds summary.md, so later stages keep it true -(skills/5-ship sets Result and Lesson). Ten lines, plain language, each -claim pointing at the record that proves it. +contents page. Name the product area(s) on its `Area:` line — for a web app +the menu path a user clicks, in the exact words of the existing area page +(`tools/kb.sh index` lists them), or as the UI labels it when no page exists +yet. Open the page with `tools/kb.sh show ` and cite the business rules +(P-numbers) the request touches in intent.md's Evidence section. Fill What was wrong and Before → After as far as they are +known; `Status: not delivered`. No approval binds summary.md, so later +stages keep it true (skills/5-ship finishes it). When the request has an origin — a ticket, a 기획서, an incident — snapshot it FIRST as `.sdlc/work//origin.md` (templates/origin.md): the intent @@ -178,7 +180,8 @@ no plan, and nothing downstream may demand one. Take it only when ALL hold: - the change is bounded and you can name the single revert that undoes it; - the probes named the exact files and symbols to change; - success is checkable by an existing command from `.sdlc/config.md`; -- intent.md has no open questions; +- intent.md has no open questions at all — optional ones included: on the + full route the spec answers them, and the compact route has no spec; - the work is inside what the human has already authorized — no data loss, public API change, security path, or migration outside that scope (AGENTS.md rule 3 "Autonomy is not authority"). A clean `tools/tripwire.sh` @@ -187,10 +190,11 @@ no plan, and nothing downstream may demand one. Take it only when ALL hold: A single "maybe" means **full**. Ambiguity, breadth, and risk are exactly what the spec and plan gates exist for. -A compact intent.md carries what spec and plan would have carried, in four +A compact intent.md carries what spec and plan would have carried, in five extra lines (templates/intent.md): **Files** to change, **Proof** command, -**Risk** and its blast radius, **Delivery target** (local | pr | deploy). -Without those four it is not compact-ready — write them or go full. +**Risk** and its blast radius, **Baseline** (brownfield), **Delivery +target** (local | pr | deploy). Without those five it is not compact-ready — +write them or go full. Record the verdict in the `Track:` line with the reasons (`- Track: compact — two known files, existing test covers it`) BEFORE the diff --git a/skills/2-spec/SKILL.md b/skills/2-spec/SKILL.md index 6044aee..32d94de 100644 --- a/skills/2-spec/SKILL.md +++ b/skills/2-spec/SKILL.md @@ -41,9 +41,10 @@ Fill `templates/spec.md`. Rules: and serialization end to end. - Behavior as AS-IS → TO-BE pairs (template table). Brownfield AS-IS comes from explorer or browser evidence with file:line or capture references. Use - observations, not memory. The pair format is also how the change is - presented to the human at the gate: what happens today, what will happen - after. + observations, not memory. The business rules on the area page (P-numbers) + are AS-IS too: name each one this change keeps, changes, or retires. The + pair format is also how the change is presented to the human at the gate: + what happens today, what will happen after. - Brownfield: include a **"What stays untouched"** section with testable statements about behavior that must survive. This becomes the regression baseline. - **Ask for constraints the code does not show.** Ownership boundaries, @@ -62,8 +63,8 @@ gate, not whether the spec is reviewed (AGENTS.md rule 3). Include `tools/tripwire.sh` output over the draft as evidence. Dispatch a fresh-context adversary (`roles/adversary.md`) with ONLY: -intent.md, draft spec.md, `.sdlc/memory/POLICY.md` if present, and the -researcher report if any. It checks intent mismatch, wrong data shapes, +intent.md, draft spec.md, `.sdlc/memory/POLICY.md` if present, the area +pages summary.md names, and the researcher report if any. It checks intent mismatch, wrong data shapes, missing edge cases, scope creep, untestable requirements, and policy violations. diff --git a/skills/4-build/SKILL.md b/skills/4-build/SKILL.md index 69450ad..8ddb538 100644 --- a/skills/4-build/SKILL.md +++ b/skills/4-build/SKILL.md @@ -16,12 +16,13 @@ Heartbeat throughout: AGENTS.md rule 9. Compact-route features (intent.md `Track: compact`, older spelling `micro`) have no spec or plan and never need one: check `gates/check-gate.sh intent .sdlc/work//intent.md` instead, and - treat intent.md's Compact route section (Files · Proof · Risk · Delivery + treat intent.md's Compact route section (Files · Proof · Risk · Baseline · Delivery target) plus its success criteria as the plan. 2. Read plan.md and spec.md (compact route: intent.md), and memory (AGENTS.md rule 4). 3. **Brownfield: capture the regression baseline before editing.** Run the - baseline commands from plan.md and save output to + baseline commands from plan.md (compact route: intent.md's Baseline line) + and save output to `.sdlc/work//baseline.txt`. Without a baseline, you cannot prove that existing behavior stayed unchanged. diff --git a/skills/5-ship/SKILL.md b/skills/5-ship/SKILL.md index 0808927..0fb0661 100644 --- a/skills/5-ship/SKILL.md +++ b/skills/5-ship/SKILL.md @@ -65,12 +65,16 @@ what would help the next agent — but only what a future run could REUSE no lesson. Write what there is into the feature's `.sdlc/work//harvest.md` (lesson candidates in the skill 6 format; durable terms, verified facts, and constraints as domain candidates; -AGENTS.md rule 4). Then bring the feature's -`summary.md` up to date: `Result` says what delivery.md confirms (a pushed -review branch is not a deployment), `Lesson` is the one line worth -remembering, `Cause`/`Change` match what was actually found and built. -summary.md is bound by no approval; `tools/kb.sh show` prints it first, so -a stale one misleads every later reader. Domain facts describe the system; lessons describe mistakes. If a +AGENTS.md rule 4). **Every business rule this feature set, changed, or +retired is an area candidate** — the rule in one testable sentence, its +source (the origin, the spec R-item or compact intent O-item), the P-number +it changes or retires — plus one +history line per area it changed. Then finish the feature's `summary.md`: +`Status` says only what delivery.md confirms (a pushed review branch is not +a deployment), Before → After and How to check match what was actually +built and proven, Remember holds the one thing worth knowing next time. +`tools/kb.sh show` prints it first, so a stale one misleads every later +reader. Domain facts describe the system; lessons describe mistakes. If a stage skill should have prevented a mistake, add `promote: skills/` to the lesson candidate. A tag that appears three or more times in INDEX.md must be promoted: propose the stage-skill change to the human (`close.sh` @@ -103,7 +107,7 @@ work: > - staged files: > - final diff: > - commit message: -> - delivery target: +> - delivery target: > Approve this delivery? If the human already authorized this scope — "ship it when it's green", "push diff --git a/skills/6-maintain/SKILL.md b/skills/6-maintain/SKILL.md index cddf52d..9953939 100644 --- a/skills/6-maintain/SKILL.md +++ b/skills/6-maintain/SKILL.md @@ -40,6 +40,9 @@ Everything else is an `[assumed]` line and the diagnosis continues. request sent, the command run, the job or schedule that fired) 2. What happened, and what was expected instead? (nothing at all, an error, a wrong result, a partial change, slow) — each is a different bug class. + The area page's business rules (`tools/kb.sh show `) are the first + reference for "expected"; a report that contradicts one is a question + about the rule, not yet a bug. 3. Where and when? (environment, URL or host, version, time window — this picks the deployed ref and the log window) 4. As whom? (account, role, tenant, client — permissions and data scope @@ -146,7 +149,9 @@ one contract"). There is no separate compressed loop. `.sdlc/work//` directory so prior approvals stay intact, and write ONE work artifact: `intent.md` with `- Track: compact`, carrying the reproduction and diagnosis as verified evidence plus the Compact route - section (Files · Proof · Risk · Delivery target). Pass the intent gate, + section (Files · Proof · Risk · Baseline · Delivery target), and a + summary.md naming the product area (skills/1-intent "Write the + artifact"). Pass the intent gate, then build and verify under skill 4, then ship. No spec.md, no plan.md, and nothing downstream asks for one. - **Full route.** Use it for everything else — an unclear cause, a wide blast diff --git a/skills/6-maintain/probes.md b/skills/6-maintain/probes.md index 3610a41..b7d9774 100644 --- a/skills/6-maintain/probes.md +++ b/skills/6-maintain/probes.md @@ -57,9 +57,10 @@ Trace the claimed error to the fix site. A lower layer that catches its own errors and returns false/null cannot reject an upper await; a try/catch there fixes nothing. Prove the error can REACH the handler you are editing. -## 8. Timeout audit (when the symptom is "nothing happened") +## 8. Timeout audit (when the symptom is "nothing happened" or "hangs") - grep -n 'timeout' + grep -n -i 'timeout' -An awaited call with no timeout that precedes the visible action (popup, -navigation) explains a dead-looking UI better than most crash theories. +A call with no timeout that precedes the expected effect (a popup, a +response, the next job step) explains a hang or a silent no-op better than +most crash theories. diff --git a/templates/area.md b/templates/area.md new file mode 100644 index 0000000..a03db05 --- /dev/null +++ b/templates/area.md @@ -0,0 +1,32 @@ +# Area: 평가 > 제출> + + + +- Menu: +- Where: +- Aliases: + +## Business rules (정책) + + +## How it works + + +## History + diff --git a/templates/evidence.md b/templates/evidence.md index f06ab59..1dfd86d 100644 --- a/templates/evidence.md +++ b/templates/evidence.md @@ -1,6 +1,6 @@ # Evidence: -- From: plan.md (approved YYYY-MM-DD) +- From: plan.md (approved YYYY-MM-DD) | intent.md (compact route, approved YYYY-MM-DD) - Diff: - Origin: : unchanged | drifted: | unreachable> diff --git a/templates/harvest.md b/templates/harvest.md index 37f68ae..6245a32 100644 --- a/templates/harvest.md +++ b/templates/harvest.md @@ -1,17 +1,19 @@ # Harvest: - + + +## Area candidates + ## Domain candidates - diff --git a/templates/intent.md b/templates/intent.md index 537147c..df86a52 100644 --- a/templates/intent.md +++ b/templates/intent.md @@ -2,9 +2,9 @@ - Goal: - + - Date: YYYY-MM-DD - Type: greenfield | brownfield - Track: full (default) | compact — @@ -42,6 +42,7 @@ else the human's. Name a command or test where possible.> - Files: - Proof: - Risk: +- Baseline: - Delivery target: local | pr | deploy ## Out of scope / must not change diff --git a/templates/spec.md b/templates/spec.md index 7e794a4..2681b48 100644 --- a/templates/spec.md +++ b/templates/spec.md @@ -33,6 +33,9 @@ cases where they apply.> |---|------|------------------|-------| | B1 | | | | +## Business rules touched +- P1: kept · P3: changed → R2 · P4: retired → R5 · new rule → R6 + ## What stays untouched - U1: ; checked by diff --git a/templates/summary.md b/templates/summary.md index 8c953dc..d96b11b 100644 --- a/templates/summary.md +++ b/templates/summary.md @@ -1,18 +1,35 @@ -# Summary: +# + page print it before anything else, so a person reads this instead of six + stage files. No approval binds it, so it stays TRUE as the work moves: + written with the intent, revised when build learns better, finished at + ship from what delivery.md and evidence.md prove. + Write for a colleague who was not there: the team's own language + (headings may be translated), short sentences, what a user sees rather + than code. It STATES; the records PROVE — point at them (evidence.md §n, + delivery.md) instead of pasting output. Twenty lines at most. + Guidance lives in comments like this one: a section with no text yet is + left out of `kb.sh show`, so an unfinished summary prints only what is + known. --> -- Tags: -- Problem: -- Cause: -- Change: -- Result: -- Lesson: +- Area: +- Tags: +- Status: not delivered + + +## What was wrong + + +## Before → After + + +## How to check + + +## Remember + diff --git a/tools/kb.sh b/tools/kb.sh index 7fdef6b..0a6e67a 100755 --- a/tools/kb.sh +++ b/tools/kb.sh @@ -5,7 +5,8 @@ # # kb.sh index [--store ] regenerate the contents page # kb.sh list [--store | --area ] -# kb.sh show [--store | --area ] one feature as a digest +# kb.sh show [--store | --area ] +# one feature as a digest, or one area page # kb.sh search [--store | --area ] [--limit N] # kb.sh harvest [--store | --area ] [--stale ] # open features whose harvest.md is not in memory/ yet @@ -22,9 +23,12 @@ # progress.md (heartbeat), baseline.txt, checkpoint.md, verify-receipt.md. # # `show` is a digest — goal, summary.md (templates/summary.md), delivery, -# unmerged harvest.md candidates, lesson titles, then the paths. `index` opens -# with an overview table (state, date, tags) newest first and lists the harvests -# no close has merged yet. `index_style: obsidian` in the store's config.md adds +# unmerged harvest.md candidates, lesson titles, then the paths. A name that is +# no feature is looked up as a product area: memory/areas/.md, or the page +# whose Menu line reads exactly (templates/area.md), printed with the +# features whose summary.md names it. `index` opens with the product areas +# (business-rule count, last change, features), then an overview table (state, +# date, area, tags) newest first, and lists the harvests no close has merged yet. `index_style: obsidian` in the store's config.md adds # YAML frontmatter and inline #tags to the page; links stay relative markdown. # No timestamp is written into the page: an unchanged store yields no diff. # @@ -36,10 +40,15 @@ # no unmerged harvest), 2 usage error or refusal (missing store, unowned path, # unwritable page). set -u +# Byte semantics everywhere: under a UTF-8 locale macOS awk compares strings by +# collation, and Hangul menu paths then compare EQUAL to each other — every +# feature would be filed under every product area. Records are matched as bytes. +LC_ALL=C; export LC_ALL MARKER='' LINE_MAX=200 -SUMMARY_MAX=12 # lines of summary.md shown by `show` / `index` +SUMMARY_MAX=20 # lines of summary.md shown by `show` / `index` (templates/summary.md: twenty at most) +AREA_MAX=40 # lines of an area page shown by `show` HARVEST_MAX=10 # harvest.md lines shown by `show` HARVEST_INDEX_MAX=6 # harvest.md lines shown per feature on the contents page TABLE_GOAL_MAX=120 # bytes of the goal kept in the overview table (≈40 CJK / 120 ASCII characters) @@ -49,7 +58,7 @@ TABLE_GOAL_MAX=120 # bytes of the goal kept in the overview table (≈40 CJK / SEP=$(printf '\037') usage() { - sed -n '3,11p' "$0" | sed 's/^# \{0,1\}//' >&2 + sed -n '3,12p' "$0" | sed 's/^# \{0,1\}//' >&2 exit 2 } @@ -66,7 +75,7 @@ kb_label() { # → the name results are reported under kb_stores() { # local store="$1" area="$2" d n=0 if [ -n "$area" ]; then - [ -d "$area" ] || { echo "FAIL: no such area: $area" >&2; return 2; } + [ -d "$area" ] || { echo "FAIL: no such knowledge folder (--area): $area" >&2; return 2; } for d in "$area"/*; do [ -d "$d" ] || continue [ -L "$d" ] && continue # never follow a link out of the area @@ -120,13 +129,18 @@ kb_tags() { # → "tag, tag" — summary.md's Tags line (else the intent's [ -n "$t" ] || return 0 printf '%s' "$t" | tr -d '[]' | tr ',' '\n' | awk '{ gsub(/^[ \t]+|[ \t]+$/, ""); if ($0 != "") { if (n++) printf ", "; printf "%s", $0 } }' } +kb_areas() { # → the product areas summary.md names, one per line + kb_get "$1/summary.md" Area | tr ',' '\n' | awk '{ gsub(/^[ \t]+|[ \t]+$/, ""); if ($0 != "") print }' +} +kb_join() { awk '{ if (n++) printf ", "; printf "%s", $0 }'; } # lines on stdin → "a, b" kb_hashtags() { # "tag, tag" → "#tag #tag" for Obsidian's tag pane (spaces inside a tag become -) printf '%s' "$1" | tr ',' '\n' | awk '{ gsub(/^[ \t]+|[ \t]+$/, ""); gsub(/[ \t]+/, "-"); if ($0 != "") { if (n++) printf " "; printf "#%s", $0 } }' } # The readable body of a record: template comments (single- and multi-line), # blank lines and unfilled `` lines are dropped; the H1 is dropped; # `## X` (and deeper headings) become `X:` labels, so a record embedded in the -# contents page cannot hijack its outline. +# contents page cannot hijack its outline. A heading with nothing under it yet +# is dropped too: an unfinished record prints only what is known. kb_body() { # [ -f "$1" ] || return 0 awk ' @@ -141,15 +155,16 @@ kb_body() { # } sub(/[ \t\r]+$/, "", line) if (line ~ /^# /) next - if (line ~ /^##+ /) { sub(/^#+ +/, "", line); print line ":"; next } + if (line ~ /^##+ /) { sub(/^#+ +/, "", line); pend = line ":"; next } if (line ~ /^[ \t]*$/) next if (line ~ /^[ \t]*[-*] *<[^>]*>[ \t]*$/) next if (line ~ /^[ \t]*<[^>]*>[ \t]*$/) next if (line ~ /^[ \t]*[-*] *[A-Za-z-]+: *<[^>]*>[ \t]*$/) next + if (pend != "") { print pend; pend = "" } print line }' "$1" } -kb_summary() { kb_body "$1/summary.md" | awk '!/^[ \t]*[-*] *Tags:/'; } # ; Tags is shown on the meta line +kb_summary() { kb_body "$1/summary.md" | awk '!/^[ \t]*[-*] *(Tags|Area):/'; } # ; Tags and Area are shown on the meta line kb_print_bounded() { # <"more" location>; text on stdin awk -v m="$1" -v ind="$2" -v where="$3" ' { n++; if (n <= m) print ind $0 } @@ -181,11 +196,49 @@ kb_features_sorted() { # printf '%s\037%s\037%s\037%s\n' "$key" "$kind" "$slug" "$dir" done | LC_ALL=C sort -t "$SEP" -k1,1r -k3,3 } -# "\t\t\t" — the fields both index passes print. +# "<state>\t<date>\t<tags>\t<areas>\t<title>" — the fields both index passes print. kb_meta() { # <dir> <slug> <style> local tags; tags=$(kb_tags "$1") [ "$3" = obsidian ] && [ -n "$tags" ] && tags=$(kb_hashtags "$tags") - printf '%s\037%s\037%s\037%s\n' "$(kb_state "$1")" "$(kb_date "$1" "$2")" "$tags" "$(kb_title "$1" | tr '\t' ' ')" + printf '%s\037%s\037%s\037%s\037%s\n' "$(kb_state "$1")" "$(kb_date "$1" "$2")" "$tags" \ + "$(kb_areas "$1" | kb_join)" "$(kb_title "$1" | tr '\t' ' ')" +} + +# --- product areas (memory/areas/*.md, templates/area.md) -------------------- +kb_area_pages() { # <store> → page paths; a page that is a symlink is not read + local f + [ -d "$1/memory/areas" ] || return 0 + for f in "$1/memory/areas"/*.md; do [ -f "$f" ] && [ ! -L "$f" ] && printf '%s\n' "$f"; done +} +kb_area_menu() { # <page> → its Menu line, else its H1, else its file name (an unfilled page) + local m; m=$(kb_get "$1" Menu); [ -n "$m" ] || m=$(kb_h1 "$1" "Area: ") + case "$m" in ''|'<'*) m=$(basename "$1" .md);; esac + printf '%s' "$m" +} +kb_area_rules() { awk '/^- P[0-9]+:/ { n++ } END { print n + 0 }' "$1"; } # live rules; a retired one reads "- ~~P…" +kb_area_last() { # <page> → the date of the newest History line + awk '/^## /{ h = ($0 ~ /^## History/); next } h && /^- [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/ { print substr($0, 3, 10); exit }' "$1" +} +# "<area><SEP><kind><SEP><slug><SEP><dir>" for every feature naming an area. +kb_area_refs() { # <store> + local kind slug dir a + kb_features "$1" 2>/dev/null | while read -r kind slug dir; do + kb_areas "$dir" | while IFS= read -r a; do printf '%s\037%s\037%s\037%s\n' "$a" "$kind" "$slug" "$dir"; done + done +} +kb_area_of() { # <menu> <file name>; kb_area_refs lines on stdin → the lines naming that page + awk -F "$SEP" -v a="$1" -v b="$2" '$1 == a || $1 == b' +} +kb_area_slugs() { awk -F "$SEP" '{ if (n++) printf ", "; printf "%s", $3 }'; } # kb_area_refs lines → "slug, slug" +kb_area_find() { # <store> <name> → the page: <name>.md, else the page whose Menu is <name> + local f + case "$2" in */*|.*) ;; *) + f="$1/memory/areas/$2.md" + [ -f "$f" ] && [ ! -L "$f" ] && { printf '%s\n' "$f"; return 0; };; + esac + kb_area_pages "$1" | while IFS= read -r f; do + [ "$(kb_area_menu "$f")" = "$2" ] && { printf '%s\n' "$f"; break; } + done } # "<slug>\t<lines>\t<dir>" for every open feature whose harvest.md still holds # candidates — the one definition `index` and `harvest` share. @@ -233,7 +286,7 @@ kb_table_cell() { # text → one table cell: pipes escaped, newlines gone, bound print substr($0, 1, i - 1) "…" }' } kb_index() { # <store> - local store="$1" style page label kind slug dir doc f key state date tags title when body n + local store="$1" style page label kind slug dir doc f key state date tags areas title when body n refs pages menu f_last page="$store/README.md"; label=$(kb_label "$store"); style=$(kb_index_style "$store") if [ -e "$page" ]; then if [ -L "$page" ]; then echo "FAIL: $page is a symlink — refusing to write through it" >&2; return 2; fi @@ -254,22 +307,43 @@ kb_index() { # <store> "$(awk '/^project: /{sub(/^project: /,""); sub(/[ \t\r]*$/,""); print; exit}' "$store/PROJECT")" \ "$(awk '/^checkout_id: /{print $2; exit}' "$store/PROJECT")" fi - printf 'Search: `tools/kb.sh search "<text>"` · one feature: `tools/kb.sh show <slug>` · knowledge not merged yet: `tools/kb.sh harvest`\n' + printf 'Search: `tools/kb.sh search "<text>"` · one feature or product area: `tools/kb.sh show <slug | area>` · knowledge not merged yet: `tools/kb.sh harvest`\n' + + # --- product areas: what a reader navigates by ----------------------------- + # One row per area page, then any area a feature names that has no page yet + # (its rules reach memory/ at the close merge — AGENTS.md rule 4). + refs=$(kb_area_refs "$store") + pages=$(kb_area_pages "$store") + if [ -n "$pages$refs" ]; then + printf '\n## Product areas\n\n| Area | Rules | Last change | Features |\n|---|---|---|---|\n' + printf '%s\n' "$pages" | while IFS= read -r f; do + [ -n "$f" ] || continue + menu=$(kb_area_menu "$f") + body=$(printf '%s\n' "$refs" | kb_area_of "$menu" "$(basename "$f" .md)" | kb_area_slugs) + f_last=$(kb_area_last "$f") + printf '| [%s](memory/areas/%s) | %s | %s | %s |\n' "$(kb_table_cell "$menu")" "$(basename "$f")" \ + "$(kb_area_rules "$f")" "${f_last:-—}" "${body:-—}" + done + printf '%s\n' "$refs" | awk -F "$SEP" 'NF && !seen[$1]++ { print $1 }' | while IFS= read -r menu; do + [ -n "$(kb_area_find "$store" "$menu")" ] && continue + printf '| %s — no page yet | — | — | %s |\n' "$(kb_table_cell "$menu")" "$(printf '%s\n' "$refs" | kb_area_of "$menu" "" | kb_area_slugs)" + done + fi # --- overview: one row per feature, open first, newest first -------------- - printf '\n## Overview\n\n| Feature | State | Date | Tags | Goal |\n|---|---|---|---|---|\n' + printf '\n## Overview\n\n| Feature | State | Date | Area | Tags | Goal |\n|---|---|---|---|---|---|\n' for kind in work archive; do kb_features_sorted "$store" "$kind" | while IFS="$SEP" read -r key kind slug dir; do [ -n "${slug:-}" ] || continue - IFS="$SEP" read -r state date tags title <<EOF + IFS="$SEP" read -r state date tags areas title <<EOF $(kb_meta "$dir" "$slug" "$style") EOF [ "$key" = "0000-00-00" ] && key="—" # the column shows what the row is sorted by: closed_at once closed, else the intent's date if [ -f "$dir/intent.md" ]; then printf '| [%s](%s/%s/intent.md) ' "$slug" "$kind" "$slug"; else printf '| %s ' "$slug"; fi - printf '| %s | %s | %s | %s |\n' "$state" "$key" "${tags:-—}" "$(kb_table_cell "$title")" + printf '| %s | %s | %s | %s | %s |\n' "$state" "$key" "$(kb_table_cell "${areas:-—}")" "${tags:-—}" "$(kb_table_cell "$title")" done done - [ -n "$(kb_features "$store" 2>/dev/null)" ] || printf '| _none_ | | | | |\n' + [ -n "$(kb_features "$store" 2>/dev/null)" ] || printf '| _none_ | | | | | |\n' # --- knowledge that exists but is not in memory/ yet ---------------------- # AGENTS.md rule 4: harvest.md is merged at close. A loop that never closes @@ -291,11 +365,11 @@ EOF case "$kind" in work) printf '\n## Open features\n';; archive) printf '\n## Closed features\n';; esac kb_features_sorted "$store" "$kind" | while IFS="$SEP" read -r key kind slug dir; do [ -n "${slug:-}" ] || continue - IFS="$SEP" read -r state date tags title <<EOF + IFS="$SEP" read -r state date tags areas title <<EOF $(kb_meta "$dir" "$slug" "$style") EOF when="${date:+ · $date}"; f=$(kb_closed "$dir" closed_at); [ -n "$f" ] && when="$when · closed $f" - printf '\n### %s — %s%s%s\n' "$slug" "$state" "$when" "${tags:+ · $tags}" + printf '\n### %s — %s%s%s%s\n' "$slug" "$state" "$when" "${areas:+ · $areas}" "${tags:+ · $tags}" [ -n "$title" ] && printf '\n%s\n' "$title" body=$(kb_summary "$dir") [ -n "$body" ] && { printf '\n'; printf '%s\n' "$body" | kb_print_bounded "$SUMMARY_MAX" "" "$kind/$slug/summary.md"; } @@ -312,6 +386,7 @@ EOF done printf '\n## Memory\n\n' for f in POLICY.md INDEX.md DOMAIN.md; do [ -f "$store/memory/$f" ] && printf -- '- [%s](memory/%s)\n' "$f" "$f"; done + [ -d "$store/memory/areas" ] && printf -- '- [areas/](memory/areas/)\n' [ -d "$store/memory/lessons" ] && printf -- '- [lessons/](memory/lessons/)\n' [ -f "$store/config.md" ] && printf -- '- [config.md](config.md)\n' printf '' @@ -348,6 +423,7 @@ kb_show() { # <store> <slug> → 0 when found v=$(kb_title "$dir"); [ -n "$v" ] && printf ' %s\n' "$v" meta="" for v in Track Type Date "Requested by" Refs; do f=$(kb_get "$dir/intent.md" "$v"); [ -n "$f" ] && meta="$meta · $v: $f"; done + f=$(kb_areas "$dir" | kb_join); [ -n "$f" ] && meta="$meta · Area: $f" f=$(kb_tags "$dir"); [ -n "$f" ] && meta="$meta · Tags: $f" [ -n "$meta" ] && printf ' %s\n' "${meta# · }" body=$(kb_summary "$dir") @@ -374,6 +450,29 @@ kb_show() { # <store> <slug> → 0 when found return $found } +# One product area: its page, bounded, then every feature that names it. An +# area features name that has no page yet is shown with those features. +kb_show_area() { # <store> <name> → 0 when found + local store="$1" page menu refs + page=$(kb_area_find "$store" "$2") + if [ -n "$page" ]; then + menu=$(kb_area_menu "$page") + printf '%s/memory/areas/%s — product area\n %s\n' "$(kb_label "$store")" "$(basename "$page")" "$menu" + kb_body "$page" | awk '!/^[ \t]*[-*] *Menu:/' | kb_print_bounded "$AREA_MAX" " " "memory/areas/$(basename "$page")" + refs=$(kb_area_refs "$store" | kb_area_of "$menu" "$(basename "$page" .md)") + else + refs=$(kb_area_refs "$store" | kb_area_of "$2" "") + [ -n "$refs" ] || return 1 + printf '%s — product area, no page yet (its rules reach memory/areas/ when a feature naming it closes shipped)\n' "$2" + fi + [ -n "$refs" ] || return 0 + printf ' Features naming this area:\n' + printf '%s\n' "$refs" | while IFS="$SEP" read -r _ kind slug dir; do + printf ' %s/%s — %s — %s\n' "$kind" "$slug" "$(kb_state "$dir")" "$(kb_title "$dir")" + done + return 0 +} + # --- harvest ----------------------------------------------------------------- # Which open features hold knowledge that is not in memory/ yet, and for how # long nobody has touched them — the trigger the close-only merge lacked. A @@ -491,7 +590,7 @@ $stores EOF ;; show) - [ $# -ge 1 ] || { echo "FAIL: show needs a feature slug" >&2; exit 2; } + [ $# -ge 1 ] || { echo "FAIL: show needs a feature slug or a product area" >&2; exit 2; } slug="$1"; rc=1 while IFS= read -r s; do [ -n "$s" ] || continue @@ -499,7 +598,15 @@ EOF done <<EOF $stores EOF - [ "$rc" -eq 0 ] || echo "no feature '$slug' in the store(s) searched" >&2;; + if [ "$rc" -ne 0 ]; then + while IFS= read -r s; do + [ -n "$s" ] || continue + if kb_show_area "$s" "$slug"; then rc=0; fi + done <<EOF +$stores +EOF + fi + [ "$rc" -eq 0 ] || echo "no feature or product area '$slug' in the store(s) searched" >&2;; harvest) [ $# -eq 0 ] || { echo "FAIL: harvest takes no argument (use --stale <days>)" >&2; exit 2; } total=0; tstale=0 @@ -514,7 +621,7 @@ EOF echo "no unmerged harvest — every candidate is in memory/ (or no feature wrote one)" >&2; rc=1 else echo "$total unmerged harvest(s), $tstale idle ≥ ${stale} day(s)." - echo "Merge (AGENTS.md rule 4): read work/<slug>/harvest.md → lesson files + INDEX.md lines + DOMAIN.md facts, then delete harvest.md." + echo "Merge (AGENTS.md rule 4): read work/<slug>/harvest.md → area pages (rules, history) + DOMAIN.md facts + lesson files and INDEX.md lines, then delete harvest.md." echo "A STALE feature may be merged without closing; it stays open. One merge at a time, in the owning checkout." fi;; search)