@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.
- package/LICENSE +21 -0
- package/README.md +368 -0
- package/VERSION +1 -0
- package/bin/codeforge.mjs +64 -0
- package/install.ps1 +317 -0
- package/install.sh +326 -0
- package/package.json +39 -0
- package/src/CLAUDE.md +88 -0
- package/src/CONTINUITY.template.md +16 -0
- package/src/PROJECT.template.md +27 -0
- package/src/configs/claude/settings.json +6 -0
- package/src/configs/codex/config.toml +17 -0
- package/src/configs/opencode.json +14 -0
- package/src/docs/CHANGELOG.md +9 -0
- package/src/docs/adr/.gitkeep +0 -0
- package/src/docs/e2e/reports/.gitkeep +0 -0
- package/src/docs/e2e/use-cases/.gitkeep +0 -0
- package/src/docs/extending.md +134 -0
- package/src/docs/plans/.gitkeep +0 -0
- package/src/docs/prds/.gitkeep +0 -0
- package/src/docs/research/.gitkeep +0 -0
- package/src/docs/solutions/.gitkeep +0 -0
- package/src/shared/rules/approach-comparison.md +29 -0
- package/src/shared/rules/continuity.md +32 -0
- package/src/shared/rules/docs-layout.md +23 -0
- package/src/shared/rules/memory.md +34 -0
- package/src/shared/rules/models.md +59 -0
- package/src/shared/rules/project-rules.md +31 -0
- package/src/shared/rules/research.md +30 -0
- package/src/shared/rules/severity.md +17 -0
- package/src/shared/rules/ship-gates.md +164 -0
- package/src/shared/rules/tdd.md +20 -0
- package/src/shared/rules/workflow.md +38 -0
- package/src/shared/scripts/check-gates.ps1 +197 -0
- package/src/shared/scripts/check-gates.sh +201 -0
- package/src/shared/scripts/claude-gate-hook.ps1 +35 -0
- package/src/shared/scripts/claude-gate-hook.sh +43 -0
- package/src/shared/state.template.md +33 -0
- package/src/skills/adr/SKILL.md +63 -0
- package/src/skills/checkpoint/SKILL.md +50 -0
- package/src/skills/council/SKILL.md +87 -0
- package/src/skills/finish-branch/SKILL.md +85 -0
- package/src/skills/fix-bug/SKILL.md +84 -0
- package/src/skills/index/SKILL.md +52 -0
- package/src/skills/new-feature/SKILL.md +84 -0
- package/src/skills/plan/SKILL.md +63 -0
- package/src/skills/prd/SKILL.md +62 -0
- package/src/skills/quick-fix/SKILL.md +60 -0
- package/src/skills/research/SKILL.md +61 -0
- package/src/skills/review/SKILL.md +66 -0
- package/src/skills/simplify/SKILL.md +59 -0
- package/src/skills/verify-e2e/SKILL.md +102 -0
- package/src/sync.ps1 +69 -0
- 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
|