@jualopezmo/codeforge 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +368 -0
  3. package/VERSION +1 -0
  4. package/bin/codeforge.mjs +64 -0
  5. package/install.ps1 +317 -0
  6. package/install.sh +326 -0
  7. package/package.json +39 -0
  8. package/src/CLAUDE.md +88 -0
  9. package/src/CONTINUITY.template.md +16 -0
  10. package/src/PROJECT.template.md +27 -0
  11. package/src/configs/claude/settings.json +6 -0
  12. package/src/configs/codex/config.toml +17 -0
  13. package/src/configs/opencode.json +14 -0
  14. package/src/docs/CHANGELOG.md +9 -0
  15. package/src/docs/adr/.gitkeep +0 -0
  16. package/src/docs/e2e/reports/.gitkeep +0 -0
  17. package/src/docs/e2e/use-cases/.gitkeep +0 -0
  18. package/src/docs/extending.md +134 -0
  19. package/src/docs/plans/.gitkeep +0 -0
  20. package/src/docs/prds/.gitkeep +0 -0
  21. package/src/docs/research/.gitkeep +0 -0
  22. package/src/docs/solutions/.gitkeep +0 -0
  23. package/src/shared/rules/approach-comparison.md +29 -0
  24. package/src/shared/rules/continuity.md +32 -0
  25. package/src/shared/rules/docs-layout.md +23 -0
  26. package/src/shared/rules/memory.md +34 -0
  27. package/src/shared/rules/models.md +59 -0
  28. package/src/shared/rules/project-rules.md +31 -0
  29. package/src/shared/rules/research.md +30 -0
  30. package/src/shared/rules/severity.md +17 -0
  31. package/src/shared/rules/ship-gates.md +164 -0
  32. package/src/shared/rules/tdd.md +20 -0
  33. package/src/shared/rules/workflow.md +38 -0
  34. package/src/shared/scripts/check-gates.ps1 +197 -0
  35. package/src/shared/scripts/check-gates.sh +201 -0
  36. package/src/shared/scripts/claude-gate-hook.ps1 +35 -0
  37. package/src/shared/scripts/claude-gate-hook.sh +43 -0
  38. package/src/shared/state.template.md +33 -0
  39. package/src/skills/adr/SKILL.md +63 -0
  40. package/src/skills/checkpoint/SKILL.md +50 -0
  41. package/src/skills/council/SKILL.md +87 -0
  42. package/src/skills/finish-branch/SKILL.md +85 -0
  43. package/src/skills/fix-bug/SKILL.md +84 -0
  44. package/src/skills/index/SKILL.md +52 -0
  45. package/src/skills/new-feature/SKILL.md +84 -0
  46. package/src/skills/plan/SKILL.md +63 -0
  47. package/src/skills/prd/SKILL.md +62 -0
  48. package/src/skills/quick-fix/SKILL.md +60 -0
  49. package/src/skills/research/SKILL.md +61 -0
  50. package/src/skills/review/SKILL.md +66 -0
  51. package/src/skills/simplify/SKILL.md +59 -0
  52. package/src/skills/verify-e2e/SKILL.md +102 -0
  53. package/src/sync.ps1 +69 -0
  54. package/src/sync.sh +69 -0
@@ -0,0 +1,164 @@
1
+ # Ship Gates
2
+
3
+ Which boxes are required before an outward action (`git commit`, `git push`,
4
+ `gh pr create`) depends on the **gate profile** of the active workflow. The skill records
5
+ its profile in `.workflow/state.md`; `finish-branch` validates the active profile before
6
+ shipping.
7
+
8
+ ## Gate profiles
9
+
10
+ ### `standard` — used by `new-feature`, `fix-bug`
11
+
12
+ - [ ] On a feature branch (not `main`)
13
+ - [ ] Plan written and design-reviewed (cross-engine — see single-engine fallback below).
14
+ **`N/A: <reason>` is allowed for a simple `fix-bug`** (1–2 files, not a high-impact
15
+ surface) — the failing test + code review still apply. `new-feature` and high-impact
16
+ fixes always require it.
17
+ - [ ] Tests written (TDD) and passing
18
+ - [ ] Code review clean — no open P0/P1/P2 (`severity.md`), cross-engine
19
+ - [ ] E2E verified via verify-e2e (report: docs/e2e/reports/<...>.md) — `N/A: <reason>` allowed for purely internal changes (migration, refactor, tooling) and UI-only changes (no v1 adapter)
20
+ - [ ] `.workflow/state.md` updated
21
+
22
+ > The `E2E verified` box is an **Attested** signal: it asserts that a verify-e2e run
23
+ > produced a `VERDICT: PASS` report committed under `docs/e2e/reports/`. `check-gates`
24
+ > binds the box to that artifact, but the report is still the agent's own output — it is
25
+ > not the Verified tier (which requires an out-of-turn recompute in CI). See the
26
+ > Verified / Attested / Advisory ladder below.
27
+
28
+ #### E2E evidence binding
29
+
30
+ When the `E2E verified` box is checked as a **real run** (not an `— N/A:` escape), it must
31
+ carry the concrete report path it produced:
32
+
33
+ `- [x] E2E verified via verify-e2e (report: docs/e2e/reports/<file>.md)`
34
+
35
+ `check-gates` validates the report **named in the box** — not just "any report in the
36
+ directory". For the checked box, the line itself must name **exactly one** report (more
37
+ than one `(report: …)` group on the line is rejected as ambiguous — see below), and that
38
+ named path must:
39
+
40
+ 1. **match a strict whitelist** — `^docs/e2e/reports/[A-Za-z0-9._-]+\.md$` (a bare filename
41
+ directly under `docs/e2e/reports/`, no `..`, no subdirectories, no absolute path). This
42
+ also rejects the `<...>` placeholder, since `<`/`>` fall outside the allowed charset;
43
+ 2. **be a regular file, not a symlink** — resolved against the git toplevel, not the
44
+ current directory; a symlink at the named path (e.g. pointing outside the repo at a
45
+ fabricated report) is rejected before existence is even checked;
46
+ 3. **exist** as that regular file;
47
+ 4. **carry a top-level `VERDICT: PASS`** — the *first* `VERDICT:` line must be exactly
48
+ `VERDICT: PASS` (a per-UC `PASS` under a top-level `FAIL`, or `VERDICT: PASS extra`, fails);
49
+ 5. **be fresh on this branch** — the path must be new work (committed since the branch
50
+ point, staged, an unstaged tracked edit, or untracked).
51
+
52
+ The branch **base is auto-detected** by the *closest* merge-base among
53
+ `dev`, `main`, `master`, and their `origin/…` counterparts (plus `origin/HEAD`), so it
54
+ works whether the repo integrates on `dev` or `main`. Freshness is **best-effort**: if no
55
+ base ref resolves (single-branch or detached HEAD), freshness is skipped **with a note to
56
+ stderr** — but the whitelist, symlink-rejection, existence, and top-level `PASS` checks are
57
+ *always* enforced regardless of freshness.
58
+
59
+ **Scope of the guarantee — be precise, not sweeping.** This closes a specific, named set of
60
+ bypasses on the *record*: a checked box can't point at a placeholder, a path outside
61
+ `docs/e2e/reports/` (traversal or absolute), a subdirectory, a **leaf symlink** at the named
62
+ path, or an ambiguous multi-report line — and it can't point at a stale/inherited report once
63
+ a base resolves. That is the full extent of what `check-gates` proves. It does **not** prove
64
+ the report's *content* is true: the file at that path still says whatever an agent or human
65
+ wrote into it. It also does not defend against filesystem indirection such as a symlinked
66
+ ancestor directory. A committed report with a hand-typed `VERDICT: PASS` and no verify-e2e run
67
+ behind it satisfies every check above. This is the **Attested** tier (see the Verified /
68
+ Attested / Advisory ladder below) — the record's *shape* is validated, not the underlying
69
+ claim. Only the **Verified** tier — an out-of-turn CI job that re-runs `verify-e2e` itself
70
+ against the PR commit, independent of the agent's say-so — is bypass-proof against a
71
+ bad-faith or mistaken attestation.
72
+
73
+ The `— N/A:` escape is an **exact em-dash form** (em-dash, space, `N/A:`, non-empty reason).
74
+ A bare `N/A:`, a `- N/A:`, or a backticked `` `N/A:` `` inside explanatory text does **not**
75
+ count as the escape — those fall through to the report-path checks above.
76
+
77
+ The active workflow records its profile in `.workflow/state.md` (the **Profile** field —
78
+ see `shared/state.template.md`); `finish-branch` validates that profile's boxes before shipping.
79
+
80
+ ### `light` — used by `quick-fix`
81
+
82
+ - [ ] On a feature branch (not `main`)
83
+ - [ ] Change verified (ran it, or the relevant test passes)
84
+ - [ ] Still trivial (<3 files, no behavior risk) — otherwise **escalate** to `new-feature`/`fix-bug` and switch to the `standard` profile
85
+
86
+ ### No gate — `prd`, `research`, `plan`, `index`, `checkpoint`, `review`, `council`
87
+
88
+ These produce an artifact or advice and don't ship on their own; they feed a workflow that
89
+ carries one of the profiles above.
90
+
91
+ ## Single-engine fallback (the cross-engine review items)
92
+
93
+ The `standard` profile's review items assume **≥2 engines are available**. Most users run
94
+ one CLI, so this must degrade honestly instead of becoming unsatisfiable:
95
+
96
+ If no second engine is available, satisfy the review items by either:
97
+ - a **delayed self-review** — step away, then re-read the plan/diff fresh against
98
+ `severity.md` as if it were someone else's; or
99
+ - a **human reviewer**.
100
+
101
+ Then log a waiver in `.workflow/state.md`, e.g.:
102
+ `Review: single-engine self-review (no second engine available) — <date>`
103
+
104
+ Cross-engine review is the default and preferred (real model diversity). The waiver just
105
+ makes the degradation **explicit and auditable**, never silent.
106
+
107
+ ## What a gate can honestly claim — Verified / Attested / Advisory
108
+
109
+ Be precise about the strength of each gate signal. A claim is only as strong as what
110
+ produced it:
111
+
112
+ - **Verified** — an out-of-turn check *recomputed* the fact, independent of the agent's
113
+ say-so. Example: CI ran the test suite at the exact PR commit and it passed. This is the
114
+ strongest signal, and the only one that survives a bad-faith or mistaken agent.
115
+ - **Attested** — an agent or human *claimed* it and something validated the claim's *shape*,
116
+ not its truth. A `- [x]` box in `.workflow/state.md`, or `check-gates.sh` reporting the
117
+ checklist is complete, is attested: it confirms the record says "done", not that the work
118
+ was done. A file that says `reviewer_engine: codex` is not proof Codex reviewed anything.
119
+ - **Advisory** — present only as an instruction the agent is asked to follow. The workflow
120
+ skills and this rules file are advisory.
121
+
122
+ **Never present an attested checkbox as if it were verified.** The honest upgrade path is to
123
+ move a claim up the ladder — e.g. bind "tests passing" to CI (verified) rather than a box
124
+ someone ticked (attested).
125
+
126
+ ## How enforcement works here (advisory + Tier-B check + native prompt)
127
+
128
+ **Be honest about what this is: discipline, not a hard gate.** Nothing conditionally
129
+ blocks a commit when a box is unchecked. Three things stand in — none of them a hard block:
130
+
131
+ 1. **Advisory (all engines):** you are instructed — here and in the workflow skill — not
132
+ to ship until the profile's boxes pass. Honor it.
133
+ 2. **Deterministic Tier-B check (all engines):** `finish-branch` runs
134
+ `shared/scripts/check-gates.sh` (`.ps1` on Windows), which reads `.workflow/state.md` and
135
+ exits non-zero listing any unchecked box. This turns "eyeball the file" into "run a
136
+ command that fails loudly" — a much harder thing to rationalize past, and the *same*
137
+ command a human or CI can run. It is still **attested** (it validates the record, not the
138
+ work) and still **skippable** (Tier B runs only when invoked — it is not a hook). For a
139
+ real *verified* gate, run it in CI with branch protection so the check binds to the PR
140
+ commit outside the agent's turn.
141
+ 3. **Best-effort native prompt (per engine):** each engine can prompt for human approval
142
+ on outward commands — but it reads **no** gate state and matches commands by pattern,
143
+ so it is bypassable (e.g. `git -C . push`, a PR via API, another tool):
144
+ - **Claude Code** — `git push` / `gh pr create` are `ask`-tier in `.claude/settings.json`.
145
+ - **Codex** — `approval_policy` in `.codex/config.toml` asks when a command crosses the
146
+ sandbox boundary (not before every command).
147
+ - **OpenCode** — `permission.bash` in `opencode.json` sets `git push*` / `gh pr create*`
148
+ to `ask` (force-push `deny`).
149
+
150
+ The prompt shows the human a generic "allow this command?", **not** the checklist — so it
151
+ is a commit-confirmation, not proof the gates are green. The approver must
152
+ **independently check `.workflow/state.md` before approving** (or run `check-gates.sh`).
153
+
154
+ ### Opt-in hard block (Tier C, Claude Code only)
155
+
156
+ For the one engine that supports it, `install.sh --with-hooks` (`-WithHooks` on PowerShell)
157
+ installs a Claude Code `PreToolUse` hook into `.claude/settings.local.json` that runs
158
+ `shared/scripts/claude-gate-hook.sh` — the same `check-gates.sh` behind a hook — and **exits 2
159
+ to actually block** `git commit` / `git push` / `gh pr create` when the gates are incomplete.
160
+ This is the only place codeforge can hard-block. It is deliberately **not the default**: it's
161
+ per-developer (the local settings file is gitignored), Claude-Code-specific (breaks the
162
+ cross-engine promise, so it stays opt-in), and fails **open** if it can't verify. The gate is
163
+ still *attested* — it confirms the recorded boxes, not the underlying work. Codex/OpenCode have
164
+ the mechanism (see `docs/extending.md` Tier C) but no adapter ships yet.
@@ -0,0 +1,20 @@
1
+ # TDD
2
+
3
+ Write the test before the implementation. Non-negotiable for any behavior change.
4
+
5
+ ## Red → Green → Refactor
6
+
7
+ 1. **Red** — write a test that fails for the right reason (the behavior doesn't exist yet,
8
+ or the bug is present). Run it; confirm it fails with the expected message.
9
+ 2. **Green** — write the minimum code to make it pass. No extra scope.
10
+ 3. **Refactor** — clean up with the test still green. Improve names/structure, remove
11
+ duplication.
12
+
13
+ ## Rules
14
+
15
+ - One behavior per test; name it `test_<action>_<scenario>_<expected>`.
16
+ - Test both the success path and the meaningful error/edge cases.
17
+ - Mock only external systems (network, third-party APIs, time) — never your own code.
18
+ - A test you never saw fail proves nothing. If it passed the first time, break the code
19
+ on purpose and confirm the test catches it.
20
+ - Never commit with a failing or skipped test unless the skip is justified in writing.
@@ -0,0 +1,38 @@
1
+ # Workflow
2
+
3
+ Use a workflow skill for any non-trivial change. Follow its steps in order.
4
+
5
+ ## Which skill
6
+
7
+ | Scenario | Skill |
8
+ | --- | --- |
9
+ | Define what/why before building | `prd` |
10
+ | Check current docs / prior art | `research` |
11
+ | Design: compare approaches, write a plan | `plan` |
12
+ | New feature / behavior change | `new-feature` |
13
+ | Bug fix | `fix-bug` |
14
+ | Trivial change (<3 files) | `quick-fix` |
15
+ | Cross-engine second opinion (plan or diff) | `review` |
16
+ | Verify a user-facing change end to end | `verify-e2e` |
17
+ | Hard, expensive decision fork | `council` |
18
+ | Wrap up + open PR | `finish-branch` |
19
+ | Session handoff before closing | `checkpoint` |
20
+ | Project map for orientation | `index` |
21
+
22
+ ## Phases (shared shape)
23
+
24
+ 1. **Brainstorm** — clarify intent, constraints, success criteria before designing.
25
+ 2. **Plan** — write the approach; identify files, edge cases, tests.
26
+ 3. **Design review** — get a second opinion from a *different* engine (any of Claude
27
+ Code / Codex / OpenCode; models in `models.md`) before implementing. Cross-engine
28
+ diversity is the point.
29
+ 4. **TDD** — red → green → refactor. Write the failing test first.
30
+ 5. **Code review** — dual review (the other engine + self) against the diff; fix all
31
+ P0/P1/P2 before shipping (see `severity.md`).
32
+ 6. **Verify** — actually exercise the change, don't just trust tests.
33
+ 7. **Ship** — only when `.workflow/state.md` gates pass (see `ship-gates.md`).
34
+
35
+ ## Tracking
36
+
37
+ Keep `.workflow/state.md` current: check boxes as phases complete, record the active
38
+ branch and the review iterations. It is the source of truth the ship-gate checklist reads.
@@ -0,0 +1,197 @@
1
+ # check-gates.ps1 — deterministic ship-gate checklist validator (Tier B).
2
+ #
3
+ # PowerShell parity of shared/scripts/check-gates.sh. Reads .workflow/state.md and
4
+ # confirms every ship-gate box for the active profile is checked (or N/A).
5
+ #
6
+ # pwsh shared/scripts/check-gates.ps1 # reads .workflow/state.md
7
+ # pwsh shared/scripts/check-gates.ps1 path\to\state.md
8
+ #
9
+ # HONESTY: verifies the RECORD, not the underlying work — a checked box is an
10
+ # attestation, not proof. See shared/rules/ship-gates.md (Verified / Attested /
11
+ # Advisory). Advisory (Tier B): it does not block a commit on its own.
12
+ #
13
+ # Exit codes: 0 = complete · 1 = unmet boxes · 3 = cannot read state/checklist.
14
+ [CmdletBinding()]
15
+ param([string]$StatePath = ".workflow/state.md")
16
+
17
+ $ErrorActionPreference = "Stop"
18
+ # Parity with sh's masked git failures (`|| echo ""` / `2>$null`): explicitly set
19
+ # $PSNativeCommandUseErrorActionPreference = $false (defensive: guards against upstream
20
+ # configs that enable it) so native git non-zero exits are handled via $LASTEXITCODE, not thrown.
21
+ $PSNativeCommandUseErrorActionPreference = $false
22
+
23
+ if (-not (Test-Path -LiteralPath $StatePath -PathType Leaf)) {
24
+ [Console]::Error.WriteLine("check-gates: no state file at '$StatePath' — cannot verify gates.")
25
+ [Console]::Error.WriteLine(" Start a workflow (copy shared/state.template.md) before shipping.")
26
+ exit 3
27
+ }
28
+
29
+ $lines = Get-Content -LiteralPath $StatePath
30
+
31
+ $profile = "(unknown)"
32
+ foreach ($line in $lines) {
33
+ $m = [regex]::Match($line, 'Profile:[*]*\s*([A-Za-z][A-Za-z-]*)')
34
+ if ($m.Success) { $profile = $m.Groups[1].Value; break }
35
+ }
36
+
37
+ $inList = $false
38
+ $total = 0
39
+ $unmetLines = @()
40
+ foreach ($line in $lines) {
41
+ if ($line -match '^##\s+Ship-gate checklist') { $inList = $true; continue }
42
+ elseif ($line -match '^##\s') { $inList = $false }
43
+ if (-not $inList) { continue }
44
+ if ($line -match '^- \[[ xX]\]') { $total++ }
45
+ if ($line -match '^- \[ \]') { $unmetLines += (" " + [char]0x2717 + $line.Substring(5)) }
46
+ }
47
+
48
+ if ($total -eq 0) {
49
+ [Console]::Error.WriteLine("check-gates: no '## Ship-gate checklist' boxes found in '$StatePath'.")
50
+ [Console]::Error.WriteLine(" Is this a real workflow state file?")
51
+ exit 3
52
+ }
53
+
54
+ # Validate the checklist carries the REQUIRED gates for its profile (standard = 6, light = 3,
55
+ # per shared/rules/ship-gates.md) — otherwise a state file that deletes gates reads green.
56
+ $required = switch ($profile) {
57
+ 'standard' { 6; break }
58
+ 'light' { 3; break }
59
+ default {
60
+ [Console]::Error.WriteLine("check-gates: unknown gate profile '$profile' — can't determine required gates.")
61
+ [Console]::Error.WriteLine(" Set Profile to 'standard' or 'light' in the Active workflow section.")
62
+ exit 3
63
+ }
64
+ }
65
+ if ($total -lt $required) {
66
+ [Console]::Error.WriteLine("check-gates: profile '$profile' requires $required gates but the checklist has only $total —")
67
+ [Console]::Error.WriteLine(" required ship-gate boxes are missing. Restore them from shared/state.template.md.")
68
+ exit 1
69
+ }
70
+
71
+ $unmet = $unmetLines.Count
72
+ if ($unmet -gt 0) {
73
+ [Console]::Error.WriteLine("check-gates: profile '$profile' — $($total - $unmet)/$total boxes checked — UNMET.")
74
+ [Console]::Error.WriteLine("Unchecked ship-gate boxes (do NOT ship):")
75
+ foreach ($l in $unmetLines) { [Console]::Error.WriteLine($l) }
76
+ [Console]::Error.WriteLine("Confirms the recorded checklist, not the work itself (ship-gates.md:")
77
+ [Console]::Error.WriteLine("Verified / Attested / Advisory). Finish the boxes, then re-run.")
78
+ exit 1
79
+ }
80
+
81
+ # --- E2E evidence check (Attested) --------------------------------------------
82
+ $e2eLine = $null
83
+ $inE = $false
84
+ foreach ($line in $lines) {
85
+ if ($line -match '^##\s+Ship-gate checklist') { $inE = $true; continue }
86
+ elseif ($line -match '^##\s') { $inE = $false }
87
+ if ($inE -and $line -cmatch '^- \[[xX]\]\s+E2E verified') { $e2eLine = $line; break }
88
+ }
89
+ if ($e2eLine) {
90
+ if ($e2eLine -cmatch '— N/A:') {
91
+ $reason = ([regex]::Match($e2eLine, 'N/A:\s*(.*)$')).Groups[1].Value.Trim()
92
+ if ([string]::IsNullOrEmpty($reason)) {
93
+ [Console]::Error.WriteLine("check-gates: 'E2E verified' uses 'N/A:' with no reason — treated as unmet.")
94
+ exit 1
95
+ }
96
+ } else {
97
+ # 0. Reject an ambiguous line carrying more than one "(report:" group — one report
98
+ # per box. Without this, sh's greedy extraction (rightmost group) and ps1's
99
+ # leftmost-first regex Match could disagree on WHICH path is checked; refusing
100
+ # the ambiguous line outright removes the divergence entirely.
101
+ $reportGroups = ([regex]::Matches($e2eLine, '\(report:')).Count
102
+ if ($reportGroups -gt 1) {
103
+ [Console]::Error.WriteLine("check-gates: 'E2E verified' line names more than one (report: ...) group — ambiguous.")
104
+ [Console]::Error.WriteLine(" A checked box must name exactly one report.")
105
+ exit 1
106
+ }
107
+ # 1. Parse the report path named in the box: (report: <PATH>).
108
+ $reportPath = ''
109
+ $mrp = [regex]::Match($e2eLine, '\(report:\s*([^)]*)\)')
110
+ if ($mrp.Success) { $reportPath = $mrp.Groups[1].Value.TrimEnd() }
111
+ if ([string]::IsNullOrEmpty($reportPath)) {
112
+ [Console]::Error.WriteLine("check-gates: 'E2E verified' is checked but names no report path.")
113
+ [Console]::Error.WriteLine(" Put the real report path in the box: (report: docs/e2e/reports/<file>.md).")
114
+ exit 1
115
+ }
116
+ # 1b. Whitelist the path shape: it must be a bare filename directly under
117
+ # docs/e2e/reports/ — no '..', no subdirectories, no absolute paths. Since '<'
118
+ # and '>' fall outside the allowed charset, this also subsumes the previous
119
+ # placeholder-only rejection with one strict allowlist.
120
+ if ($reportPath -cnotmatch '^docs/e2e/reports/[A-Za-z0-9._-]+\.md$') {
121
+ [Console]::Error.WriteLine("check-gates: 'E2E verified' names report path '$reportPath', which is not a")
122
+ [Console]::Error.WriteLine(" real report under docs/e2e/reports/. The box must name a real file directly")
123
+ [Console]::Error.WriteLine(" under docs/e2e/reports/ (e.g. docs/e2e/reports/<feature>.md) — no '..', no")
124
+ [Console]::Error.WriteLine(" subdirectories, no absolute paths, no placeholders.")
125
+ exit 1
126
+ }
127
+ # 2. Resolve against the git toplevel (not cwd), and require a REGULAR FILE — a
128
+ # symlink at the named path (e.g. pointing outside the repo at a fabricated
129
+ # report) must never satisfy the gate. Checked BEFORE the existence check so a
130
+ # symlink can never count as "exists".
131
+ $toplevel = (git rev-parse --show-toplevel 2>$null)
132
+ if ($toplevel) { $absReport = Join-Path $toplevel $reportPath } else { $absReport = $reportPath }
133
+ $absItem = Get-Item -LiteralPath $absReport -Force -ErrorAction SilentlyContinue
134
+ if ($absItem -and $absItem.LinkType) {
135
+ [Console]::Error.WriteLine("check-gates: 'E2E verified' names report '$reportPath' but that path is a")
136
+ [Console]::Error.WriteLine(" symlink, not a regular file. Reports must be real files under docs/e2e/reports/.")
137
+ exit 1
138
+ }
139
+ if (-not (Test-Path -LiteralPath $absReport -PathType Leaf)) {
140
+ [Console]::Error.WriteLine("check-gates: 'E2E verified' names report '$reportPath' but that file does not exist.")
141
+ [Console]::Error.WriteLine(" Run the verify-e2e skill to produce it, or use '— N/A: <reason>'.")
142
+ exit 1
143
+ }
144
+ # 3. Top-level verdict must be exactly PASS (the FIRST "VERDICT:" line only, so a
145
+ # per-UC "VERDICT: PASS" below a top-level FAIL can never satisfy the gate).
146
+ $firstVerdict = (Get-Content -LiteralPath $absReport | Where-Object { $_ -cmatch '^VERDICT:' } | Select-Object -First 1)
147
+ if (-not ($firstVerdict -cmatch '^VERDICT:\s+PASS\s*$')) {
148
+ [Console]::Error.WriteLine("check-gates: report '$reportPath' top-level verdict is not 'VERDICT: PASS'.")
149
+ [Console]::Error.WriteLine(" The first VERDICT: line must be exactly 'VERDICT: PASS'.")
150
+ exit 1
151
+ }
152
+ # 4. Freshness (best-effort, never silently passes): the named path must be new
153
+ # work on this branch. Base = closest merge-base among dev/main/master/origin.
154
+ $inRepo = $false
155
+ try { git rev-parse --is-inside-work-tree *> $null; if ($LASTEXITCODE -eq 0) { $inRepo = $true } } catch {}
156
+ if ($inRepo) {
157
+ $current = (git rev-parse --abbrev-ref HEAD 2>$null)
158
+ $originHead = (git rev-parse --abbrev-ref origin/HEAD 2>$null)
159
+ $base = ''
160
+ $bestCount = -1
161
+ $cands = @('dev','main','master','origin/dev','origin/main','origin/master')
162
+ if ($originHead) { $cands += $originHead }
163
+ foreach ($ref in $cands) {
164
+ if (-not $ref) { continue }
165
+ if ($ref -eq $current) { continue }
166
+ git rev-parse --verify --quiet $ref *> $null
167
+ if ($LASTEXITCODE -ne 0) { continue }
168
+ $mb = (git merge-base HEAD $ref 2>$null)
169
+ if (-not $mb) { continue }
170
+ $count = (git rev-list --count "$mb..HEAD" 2>$null)
171
+ if (-not $count) { continue }
172
+ $c = [int]$count
173
+ if ($bestCount -lt 0 -or $c -lt $bestCount) { $bestCount = $c; $base = $mb }
174
+ }
175
+ if ($base) {
176
+ $committed = (git diff --name-only "$base..HEAD" -- $reportPath 2>$null)
177
+ $staged = (git diff --cached --name-only -- $reportPath 2>$null)
178
+ $unstaged = (git diff --name-only -- $reportPath 2>$null)
179
+ $untracked = (git ls-files --others --exclude-standard -- $reportPath 2>$null)
180
+ if (-not $committed -and -not $staged -and -not $unstaged -and -not $untracked) {
181
+ [Console]::Error.WriteLine("check-gates: report '$reportPath' is not fresh on this branch (base $base).")
182
+ [Console]::Error.WriteLine(" It is unchanged from the base — a stale or inherited report cannot satisfy the gate.")
183
+ [Console]::Error.WriteLine(" Run the verify-e2e skill to produce a report for THIS change.")
184
+ exit 1
185
+ }
186
+ } else {
187
+ [Console]::Error.WriteLine("check-gates: note — no base branch (dev/main/master/origin) resolved; report")
188
+ [Console]::Error.WriteLine(" freshness could not be checked. Existence + VERDICT: PASS were enforced for '$reportPath'.")
189
+ }
190
+ }
191
+ }
192
+ }
193
+ # --- end E2E evidence check ---------------------------------------------------
194
+
195
+ Write-Output "check-gates: profile '$profile' — all $total recorded boxes checked."
196
+ Write-Output "Attested-complete: a checked box is an attestation, not independent proof."
197
+ exit 0
@@ -0,0 +1,201 @@
1
+ #!/bin/sh
2
+ # check-gates.sh — deterministic ship-gate checklist validator (Tier B).
3
+ #
4
+ # Reads .workflow/state.md and confirms every ship-gate box for the active
5
+ # profile is checked (or explicitly N/A). Exit 0 = the recorded checklist is
6
+ # complete; non-zero + a list = unmet boxes.
7
+ #
8
+ # sh shared/scripts/check-gates.sh # reads .workflow/state.md
9
+ # sh shared/scripts/check-gates.sh path/to/state.md
10
+ #
11
+ # HONESTY: this verifies the RECORD, not the underlying work. A checked box is an
12
+ # *attestation* by whoever checked it — the script confirms the checklist is
13
+ # complete, it cannot confirm the tests really passed or the change was really
14
+ # exercised. See shared/rules/ship-gates.md (Verified / Attested / Advisory).
15
+ # It runs only when the agent (or a human, or CI) chooses to call it — Tier B is
16
+ # deterministic but still advisory; it does not block a commit on its own.
17
+ #
18
+ # Exit codes: 0 = complete · 1 = unmet boxes · 3 = cannot read state/checklist.
19
+ set -eu
20
+
21
+ STATE="${1:-.workflow/state.md}"
22
+
23
+ if [ ! -f "$STATE" ]; then
24
+ echo "check-gates: no state file at '$STATE' — cannot verify gates." >&2
25
+ echo " Start a workflow (copy shared/state.template.md) before shipping." >&2
26
+ exit 3
27
+ fi
28
+
29
+ profile=$(sed -n 's/.*[Pp]rofile:[*]*[[:space:]]*\([A-Za-z][A-Za-z-]*\).*/\1/p' "$STATE" | head -n1)
30
+ [ -n "$profile" ] || profile="(unknown)"
31
+
32
+ # Walk only the "## Ship-gate checklist" section; tally checked vs unmet boxes.
33
+ # A box is unmet only when it is literally "- [ ]" (unchecked). "- [x]" counts as
34
+ # satisfied, including an "- [x] ... N/A: <reason>" line. Counts come out on one
35
+ # line (kept free of embedded newlines); the unmet box text is a separate pass.
36
+ counts=$(awk '
37
+ /^##[[:space:]]+Ship-gate checklist/ { inlist = 1; next }
38
+ /^##[[:space:]]/ { inlist = 0 }
39
+ inlist && /^- \[[ xX]\]/ { total++ }
40
+ inlist && /^- \[ \]/ { unmet++ }
41
+ END { printf "%d %d", total + 0, unmet + 0 }
42
+ ' "$STATE")
43
+ total=${counts% *}
44
+ unmet=${counts#* }
45
+
46
+ if [ "$total" -eq 0 ]; then
47
+ echo "check-gates: no '## Ship-gate checklist' boxes found in '$STATE'." >&2
48
+ echo " Is this a real workflow state file?" >&2
49
+ exit 3
50
+ fi
51
+
52
+ # Validate the checklist actually carries the REQUIRED gates for its profile — otherwise a
53
+ # state file that deletes required gates (or claims a profile it doesn't satisfy) reads green.
54
+ # Required counts mirror shared/rules/ship-gates.md (standard = 6, light = 3).
55
+ case "$profile" in
56
+ standard) required=6 ;;
57
+ light) required=3 ;;
58
+ *) echo "check-gates: unknown gate profile '$profile' — can't determine required gates." >&2
59
+ echo " Set Profile to 'standard' or 'light' in the Active workflow section." >&2
60
+ exit 3 ;;
61
+ esac
62
+ if [ "$total" -lt "$required" ]; then
63
+ echo "check-gates: profile '$profile' requires $required gates but the checklist has only $total —" >&2
64
+ echo " required ship-gate boxes are missing. Restore them from shared/state.template.md." >&2
65
+ exit 1
66
+ fi
67
+
68
+ if [ "$unmet" -gt 0 ]; then
69
+ echo "check-gates: profile '$profile' — $((total - unmet))/$total boxes checked — UNMET." >&2
70
+ echo "Unchecked ship-gate boxes (do NOT ship):" >&2
71
+ awk '
72
+ /^##[[:space:]]+Ship-gate checklist/ { inlist = 1; next }
73
+ /^##[[:space:]]/ { inlist = 0 }
74
+ inlist && /^- \[ \]/ { print " \342\234\227" substr($0, 6) }
75
+ ' "$STATE" >&2
76
+ echo "Confirms the recorded checklist, not the work itself (ship-gates.md:" >&2
77
+ echo "Verified / Attested / Advisory). Finish the boxes, then re-run." >&2
78
+ exit 1
79
+ fi
80
+
81
+ # --- E2E evidence check (Attested) --------------------------------------------
82
+ # When the "E2E verified" box is checked as a real run (not "— N/A: <reason>"),
83
+ # bind it to the report PATH NAMED in the box. That named file must EXIST, carry a
84
+ # top-level VERDICT: PASS, and (best-effort) be fresh on this branch. A checked box
85
+ # NEVER passes the gate without its named PASS report — no silent fail-open. Base is
86
+ # auto-detected by closest merge-base among dev/main/master/origin (this repo
87
+ # integrates on dev, not main). Freshness uses git, not mtime (clone/checkout resets
88
+ # mtimes); it degrades to a stderr note only when no base ref resolves.
89
+ e2e_line=$(awk '
90
+ /^##[[:space:]]+Ship-gate checklist/ { inlist = 1; next }
91
+ /^##[[:space:]]/ { inlist = 0 }
92
+ inlist && /^- \[[xX]\][[:space:]]+E2E verified/ { print; exit }
93
+ ' "$STATE")
94
+
95
+ if [ -n "$e2e_line" ]; then
96
+ case "$e2e_line" in
97
+ *"— N/A:"*)
98
+ # N/A escape must carry a non-empty reason.
99
+ reason=$(printf '%s' "$e2e_line" | sed -n 's/.*N\/A:[[:space:]]*\(.*\)$/\1/p')
100
+ if [ -z "$reason" ]; then
101
+ echo "check-gates: 'E2E verified' uses 'N/A:' with no reason — treated as unmet." >&2
102
+ exit 1
103
+ fi
104
+ ;;
105
+ *)
106
+ # 0. Reject an ambiguous line carrying more than one "(report:" group — one report
107
+ # per box. Without this, sh's greedy extraction (rightmost group) and ps1's
108
+ # leftmost-first regex Match could disagree on WHICH path is checked; refusing
109
+ # the ambiguous line outright removes the divergence entirely.
110
+ report_groups=$(printf '%s' "$e2e_line" | grep -o '(report:' | wc -l | tr -d '[:space:]')
111
+ if [ "$report_groups" -gt 1 ]; then
112
+ echo "check-gates: 'E2E verified' line names more than one (report: ...) group — ambiguous." >&2
113
+ echo " A checked box must name exactly one report." >&2
114
+ exit 1
115
+ fi
116
+ # 1. Parse the report path named in the box: (report: <PATH>).
117
+ report_path=$(printf '%s' "$e2e_line" | sed -n 's/.*(report:[[:space:]]*\([^)]*\)).*/\1/p' | head -n1)
118
+ report_path=$(printf '%s' "$report_path" | sed 's/[[:space:]]*$//')
119
+ if [ -z "$report_path" ]; then
120
+ echo "check-gates: 'E2E verified' is checked but names no report path." >&2
121
+ echo " Put the real report path in the box: (report: docs/e2e/reports/<file>.md)." >&2
122
+ exit 1
123
+ fi
124
+ # 1b. Whitelist the path shape: it must be a bare filename directly under
125
+ # docs/e2e/reports/ — no '..', no subdirectories, no absolute paths. Since '<'
126
+ # and '>' fall outside the allowed charset, this also subsumes the previous
127
+ # placeholder-only rejection with one strict allowlist.
128
+ if ! printf '%s' "$report_path" | grep -Eq '^docs/e2e/reports/[A-Za-z0-9._-]+\.md$'; then
129
+ echo "check-gates: 'E2E verified' names report path '$report_path', which is not a" >&2
130
+ echo " real report under docs/e2e/reports/. The box must name a real file directly" >&2
131
+ echo " under docs/e2e/reports/ (e.g. docs/e2e/reports/<feature>.md) — no '..', no" >&2
132
+ echo " subdirectories, no absolute paths, no placeholders." >&2
133
+ exit 1
134
+ fi
135
+ # 2. Resolve against the git toplevel (not cwd), and require a REGULAR FILE — a
136
+ # symlink at the named path (e.g. pointing outside the repo at a fabricated
137
+ # report) must never satisfy the gate. Checked BEFORE the existence check so a
138
+ # symlink can never count as "exists".
139
+ toplevel=$(git rev-parse --show-toplevel 2>/dev/null || echo "")
140
+ if [ -n "$toplevel" ]; then abs_report="$toplevel/$report_path"; else abs_report="$report_path"; fi
141
+ if [ -L "$abs_report" ]; then
142
+ echo "check-gates: 'E2E verified' names report '$report_path' but that path is a" >&2
143
+ echo " symlink, not a regular file. Reports must be real files under docs/e2e/reports/." >&2
144
+ exit 1
145
+ fi
146
+ if [ ! -f "$abs_report" ]; then
147
+ echo "check-gates: 'E2E verified' names report '$report_path' but that file does not exist." >&2
148
+ echo " Run the verify-e2e skill to produce it, or use '— N/A: <reason>'." >&2
149
+ exit 1
150
+ fi
151
+ # 3. Top-level verdict must be exactly PASS (the FIRST "VERDICT:" line only, so a
152
+ # per-UC "VERDICT: PASS" below a top-level FAIL can never satisfy the gate).
153
+ first_verdict=$(awk '/^VERDICT:/{print; exit}' "$abs_report")
154
+ if ! printf '%s' "$first_verdict" | grep -Eq '^VERDICT:[[:space:]]+PASS[[:space:]]*$'; then
155
+ echo "check-gates: report '$report_path' top-level verdict is not 'VERDICT: PASS'." >&2
156
+ echo " The first VERDICT: line must be exactly 'VERDICT: PASS'." >&2
157
+ exit 1
158
+ fi
159
+ # 4. Freshness (best-effort, never silently passes): the named path must be new
160
+ # work on this branch. Base = closest merge-base among dev/main/master/origin.
161
+ if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
162
+ current=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
163
+ origin_head=$(git rev-parse --abbrev-ref origin/HEAD 2>/dev/null || echo "")
164
+ base=""
165
+ best_count=""
166
+ for ref in dev main master origin/dev origin/main origin/master "$origin_head"; do
167
+ [ -n "$ref" ] || continue
168
+ [ "$ref" != "$current" ] || continue
169
+ git rev-parse --verify --quiet "$ref" >/dev/null 2>&1 || continue
170
+ mb=$(git merge-base HEAD "$ref" 2>/dev/null || echo "")
171
+ [ -n "$mb" ] || continue
172
+ count=$(git rev-list --count "$mb"..HEAD 2>/dev/null || echo "")
173
+ [ -n "$count" ] || continue
174
+ if [ -z "$best_count" ] || [ "$count" -lt "$best_count" ]; then
175
+ best_count="$count"; base="$mb"
176
+ fi
177
+ done
178
+ if [ -n "$base" ]; then
179
+ committed=$(git diff --name-only "$base"..HEAD -- "$report_path" 2>/dev/null || echo "")
180
+ staged=$(git diff --cached --name-only -- "$report_path" 2>/dev/null || echo "")
181
+ unstaged=$(git diff --name-only -- "$report_path" 2>/dev/null || echo "")
182
+ untracked=$(git ls-files --others --exclude-standard -- "$report_path" 2>/dev/null || echo "")
183
+ if [ -z "$committed" ] && [ -z "$staged" ] && [ -z "$unstaged" ] && [ -z "$untracked" ]; then
184
+ echo "check-gates: report '$report_path' is not fresh on this branch (base $base)." >&2
185
+ echo " It is unchanged from the base — a stale or inherited report cannot satisfy the gate." >&2
186
+ echo " Run the verify-e2e skill to produce a report for THIS change." >&2
187
+ exit 1
188
+ fi
189
+ else
190
+ echo "check-gates: note — no base branch (dev/main/master/origin) resolved; report" >&2
191
+ echo " freshness could not be checked. Existence + VERDICT: PASS were enforced for '$report_path'." >&2
192
+ fi
193
+ fi
194
+ ;;
195
+ esac
196
+ fi
197
+ # --- end E2E evidence check ---------------------------------------------------
198
+
199
+ echo "check-gates: profile '$profile' — all $total recorded boxes checked."
200
+ echo "Attested-complete: a checked box is an attestation, not independent proof."
201
+ exit 0
@@ -0,0 +1,35 @@
1
+ # claude-gate-hook.ps1 — Claude Code PreToolUse adapter (opt-in Tier-C hardening).
2
+ #
3
+ # PowerShell parity of claude-gate-hook.sh. Installed only by `install.ps1 -WithHooks`
4
+ # into .claude/settings.local.json. On a Bash ship action it runs check-gates.ps1 and
5
+ # exits 2 (BLOCK) if the ship-gate boxes are incomplete. Claude Code only, per-developer
6
+ # opt-in, not portable. Fails OPEN if it can't verify. See shared/rules/ship-gates.md.
7
+ $ErrorActionPreference = "Stop"
8
+
9
+ # NOTE: read into a non-reserved variable — assigning to the automatic $input in -File mode
10
+ # yields an empty string, which silently turned this hook into a no-op.
11
+ $payload = [Console]::In.ReadToEnd()
12
+
13
+ if ($payload -notmatch 'git commit|git push|gh pr create') { exit 0 }
14
+
15
+ $hookDir = Split-Path -Parent $MyInvocation.MyCommand.Path
16
+ $gates = Join-Path $hookDir 'check-gates.ps1'
17
+ if (-not (Test-Path -LiteralPath $gates -PathType Leaf)) {
18
+ [Console]::Error.WriteLine("codeforge gate-hook: check-gates.ps1 not found next to the hook; allowing (fail-open).")
19
+ exit 0
20
+ }
21
+
22
+ & pwsh -NoProfile -File $gates *> $null
23
+ $rc = $LASTEXITCODE
24
+ if ($rc -eq 0) { exit 0 } # gates complete → allow
25
+ if ($rc -ne 1) { # can't verify (e.g. no workflow state, exit 3) → fail OPEN, never block
26
+ [Console]::Error.WriteLine("codeforge gate-hook: could not verify gates (check-gates exit $rc); allowing (fail-open).")
27
+ exit 0
28
+ }
29
+
30
+ [Console]::Error.WriteLine("codeforge gate: ship BLOCKED — ship-gate boxes are not complete.")
31
+ $detail = & pwsh -NoProfile -File $gates 2>&1
32
+ foreach ($line in $detail) { [Console]::Error.WriteLine(" $line") }
33
+ [Console]::Error.WriteLine(" (opt-in -WithHooks gate; Claude Code only. Finish the boxes in")
34
+ [Console]::Error.WriteLine(" .workflow/state.md, then retry. This checks the record, not the work.)")
35
+ exit 2