@bookedsolid/rea 0.29.0 → 0.30.1

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.
@@ -0,0 +1,314 @@
1
+ #!/bin/sh
2
+ # rea:prepare-commit-msg v1
3
+ # rea:augment-body-v1
4
+ #
5
+ # Husky prepare-commit-msg hook installed by `rea init` / `rea upgrade`.
6
+ # Do NOT edit by hand — the file is refreshed on every rea upgrade.
7
+ #
8
+ # Governance contract: when policy.attribution.co_author.enabled is
9
+ # `true`, append a `Co-Authored-By: <name> <email>` trailer to the
10
+ # commit message file. Idempotent on email match (case-insensitive,
11
+ # line-anchored). Skips merge commits when policy.attribution.co_author
12
+ # .skip_merge is true.
13
+ #
14
+ # Triggers under all five commit sources git delivers:
15
+ # - $2 unset / empty (`git commit` with no body provided)
16
+ # - $2 = 'message' (`git commit -m "..."`)
17
+ # - $2 = 'template' (commit.template configured)
18
+ # - $2 = 'merge' (merge commit; honored by skip_merge: true)
19
+ # - $2 = 'squash' (squash merge / rebase)
20
+ # - $2 = 'commit' (`git commit --amend`)
21
+ #
22
+ # Skip conditions:
23
+ # - REA_SKIP_ATTRIBUTION=1 in env (per-invocation override)
24
+ # - .rea/HALT present (kill switch active)
25
+ # - $1 (message file path) missing or not a file
26
+ # - policy.attribution.co_author.enabled !== true
27
+ #
28
+ # Coexistence: this hook does NOT block on anything. The companion
29
+ # `commit-msg` hook (which runs AFTER prepare-commit-msg in git's
30
+ # lifecycle) still enforces `block_ai_attribution`. A human trailer
31
+ # `Co-Authored-By: Real Name <real@email.tld>` is NOT AI attribution
32
+ # (no AI noreply domain, no AI name keyword) and is not blocked.
33
+
34
+ set -u
35
+
36
+ COMMIT_MSG_FILE="${1:-}"
37
+ COMMIT_SOURCE="${2:-}"
38
+
39
+ # Skip conditions: any missing precondition exits 0 silently. The hook
40
+ # is purely additive; refusing here would break commits with no upside.
41
+
42
+ # Missing message file → nothing to augment.
43
+ if [ -z "$COMMIT_MSG_FILE" ] || [ ! -f "$COMMIT_MSG_FILE" ]; then
44
+ exit 0
45
+ fi
46
+
47
+ # Per-invocation override.
48
+ if [ -n "${REA_SKIP_ATTRIBUTION:-}" ]; then
49
+ exit 0
50
+ fi
51
+
52
+ REA_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
53
+
54
+ # HALT kill switch — refuse to mutate anything while frozen.
55
+ if [ -f "${REA_ROOT}/.rea/HALT" ]; then
56
+ exit 0
57
+ fi
58
+
59
+ POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
60
+ if [ ! -f "$POLICY_FILE" ]; then
61
+ exit 0
62
+ fi
63
+
64
+ # Delegate policy reads to the canonical rea CLI when available so we
65
+ # get the zod-validated document regardless of whether the operator
66
+ # wrote block-form (`attribution:\n co_author:\n enabled: true`)
67
+ # or inline-form (`attribution: { co_author: { enabled: true } }`)
68
+ # YAML. Codex round 1 P2: the prior Python inline parser only handled
69
+ # block form. When the CLI is unreachable (fresh consumer install
70
+ # pre-`pnpm i`, foreign dev environment, …) we fall back to the
71
+ # embedded Python state machine — it correctly handles block-form
72
+ # YAML, which is what `rea init` writes.
73
+ #
74
+ # Locator priority mirrors `.husky/pre-push`: project node_modules →
75
+ # dogfood dist → PATH.
76
+ rea_invoke() {
77
+ if [ -x "${REA_ROOT}/node_modules/.bin/rea" ]; then
78
+ "${REA_ROOT}/node_modules/.bin/rea" "$@"
79
+ elif [ -f "${REA_ROOT}/dist/cli/index.js" ] && [ -f "${REA_ROOT}/package.json" ] && grep -q '"name": *"@bookedsolid/rea"' "${REA_ROOT}/package.json" 2>/dev/null; then
80
+ node "${REA_ROOT}/dist/cli/index.js" "$@"
81
+ elif command -v rea >/dev/null 2>&1; then
82
+ rea "$@"
83
+ else
84
+ return 127
85
+ fi
86
+ }
87
+
88
+ ENABLED=$(rea_invoke hook policy-get attribution.co_author.enabled 2>/dev/null)
89
+ REA_RC=$?
90
+
91
+ # REA_RC interpretation:
92
+ # 0 — rea CLI ran and returned a value (or empty for an
93
+ # unset key). Use the CLI reads.
94
+ # non-zero — rea CLI unreachable (127 sentinel), too old to know
95
+ # `hook policy-get`, OR the policy YAML is unparseable.
96
+ # In every one of those cases the policy file ITSELF
97
+ # may still be valid block-form YAML, so fall back to
98
+ # the embedded python3 parser. The realistic invalid-
99
+ # config case — `enabled: true` with an empty name or
100
+ # email — is caught downstream by the `[ -z "$CO_NAME" ]`
101
+ # defense-in-depth guard, which exits 0 without
102
+ # augmenting regardless of which reader produced the
103
+ # values. (An earlier 0.30.1 revision fail-closed on
104
+ # non-127 exit codes; codex round 1 showed that
105
+ # regressed the supported stale-CLI / pre-`pnpm i` flow,
106
+ # because an old `rea` exits non-zero exactly like an
107
+ # unparseable policy — the two are indistinguishable by
108
+ # exit code.)
109
+ if [ "$REA_RC" = "0" ]; then
110
+ CO_NAME=$(rea_invoke hook policy-get attribution.co_author.name 2>/dev/null || printf '')
111
+ CO_EMAIL=$(rea_invoke hook policy-get attribution.co_author.email 2>/dev/null || printf '')
112
+ SKIP_MERGE=$(rea_invoke hook policy-get attribution.co_author.skip_merge 2>/dev/null || printf 'false')
113
+ elif command -v python3 >/dev/null 2>&1; then
114
+ # rea CLI unreachable / stale / policy unparseable — fall back to the
115
+ # Python block-form parser.
116
+ CO_AUTHOR_PARSE=$(python3 - "$POLICY_FILE" <<'PY' 2>/dev/null
117
+ import re
118
+ import sys
119
+
120
+ path = sys.argv[1]
121
+ try:
122
+ with open(path, 'r', encoding='utf-8') as fh:
123
+ lines = fh.readlines()
124
+ except OSError:
125
+ print('false'); print(''); print(''); print('false'); sys.exit(0)
126
+
127
+ in_attr = False
128
+ in_co = False
129
+ enabled = 'false'
130
+ name = ''
131
+ email = ''
132
+ skip_merge = 'false'
133
+
134
+ def strip_value(raw):
135
+ raw = raw.rstrip('\n').rstrip()
136
+ if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in ("'", '"'):
137
+ return raw[1:-1]
138
+ if '#' in raw:
139
+ raw = raw.split('#', 1)[0].rstrip()
140
+ return raw
141
+
142
+ for line in lines:
143
+ stripped_line = line.rstrip('\n')
144
+ if re.match(r'^\s*#', stripped_line):
145
+ continue
146
+ if re.match(r'^attribution:\s*(#.*)?$', stripped_line):
147
+ in_attr = True; in_co = False; continue
148
+ if in_attr and re.match(r'^\S', stripped_line):
149
+ in_attr = False; in_co = False
150
+ if in_attr and re.match(r'^\s+co_author:\s*(#.*)?$', stripped_line):
151
+ in_co = True; continue
152
+ if in_co:
153
+ m = re.match(r'^(\s*)\S', stripped_line)
154
+ if m and len(m.group(1)) <= 2:
155
+ in_co = False; continue
156
+ if re.search(r'enabled:\s*true(\s|$)', stripped_line):
157
+ enabled = 'true'
158
+ elif re.search(r'enabled:\s*false(\s|$)', stripped_line):
159
+ enabled = 'false'
160
+ if re.search(r'skip_merge:\s*true(\s|$)', stripped_line):
161
+ skip_merge = 'true'
162
+ elif re.search(r'skip_merge:\s*false(\s|$)', stripped_line):
163
+ skip_merge = 'false'
164
+ m = re.search(r'name:\s*(.*)$', stripped_line)
165
+ if m:
166
+ name = strip_value(m.group(1))
167
+ m = re.search(r'email:\s*(.*)$', stripped_line)
168
+ if m:
169
+ email = strip_value(m.group(1))
170
+
171
+ print(enabled); print(name); print(email); print(skip_merge)
172
+ PY
173
+ )
174
+ if [ -z "$CO_AUTHOR_PARSE" ]; then
175
+ exit 0
176
+ fi
177
+ ENABLED=$(printf '%s\n' "$CO_AUTHOR_PARSE" | sed -n '1p')
178
+ CO_NAME=$(printf '%s\n' "$CO_AUTHOR_PARSE" | sed -n '2p')
179
+ CO_EMAIL=$(printf '%s\n' "$CO_AUTHOR_PARSE" | sed -n '3p')
180
+ SKIP_MERGE=$(printf '%s\n' "$CO_AUTHOR_PARSE" | sed -n '4p')
181
+ else
182
+ # Neither rea CLI nor python3 reachable — silent no-op.
183
+ exit 0
184
+ fi
185
+
186
+ if [ "$ENABLED" != "true" ]; then
187
+ exit 0
188
+ fi
189
+
190
+ # Defense-in-depth: if we got here with enabled=true but no identity,
191
+ # the policy loader's cross-field refinement was bypassed (or someone
192
+ # edited the YAML around the load path). Bail without augmenting and
193
+ # emit a stderr advisory so the operator sees the misconfig at commit
194
+ # time. We deliberately do NOT exit non-zero — refusing the commit
195
+ # would be more disruptive than the silent no-op (the loader + doctor
196
+ # already surface the misconfig at policy load and at `rea doctor`).
197
+ #
198
+ # When `rea audit record <topic>` lands in a future release this
199
+ # branch should emit a `rea.attribution_augmented_invalid_config`
200
+ # record instead of stderr. Tracked as a 0.31.0+ item.
201
+ if [ -z "$CO_NAME" ] || [ -z "$CO_EMAIL" ]; then
202
+ printf 'rea: attribution.co_author.enabled=true but %s%s%s is empty — augmenter no-op.\n' \
203
+ "$([ -z "$CO_NAME" ] && printf name)" \
204
+ "$([ -z "$CO_NAME" ] && [ -z "$CO_EMAIL" ] && printf '+')" \
205
+ "$([ -z "$CO_EMAIL" ] && printf email)" >&2
206
+ printf 'rea: edit .rea/policy.yaml — set name + email, OR set enabled: false.\n' >&2
207
+ exit 0
208
+ fi
209
+
210
+ # skip_merge: true → skip when commit source is 'merge'.
211
+ if [ "$SKIP_MERGE" = "true" ] && [ "$COMMIT_SOURCE" = "merge" ]; then
212
+ exit 0
213
+ fi
214
+
215
+ # Idempotency: scan the current message file for a Co-Authored-By line
216
+ # that names the same email (case-insensitive). Line-anchored — body
217
+ # prose mentioning the email in passing does NOT count.
218
+ LOWER_EMAIL=$(printf '%s' "$CO_EMAIL" | tr '[:upper:]' '[:lower:]')
219
+ # grep -E with case-insensitive flag; portable across BSD + GNU grep.
220
+ # The pattern: ^co-authored-by: <anything> <EMAIL>[ws]*$
221
+ # Email is regex-escaped via the conservative approach: assume the
222
+ # email passed policy validation (only safe chars per loader regex
223
+ # /^[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+$/), so the only metachars present
224
+ # are `.` and possibly `+` / `-`. We escape `.` and rely on the
225
+ # permissive char set.
226
+ ESCAPED_EMAIL=$(printf '%s' "$LOWER_EMAIL" | sed 's/[.[\*^$(){}+?|]/\\&/g')
227
+ if grep -iE "^co-authored-by:[[:space:]]*[^<]*<${ESCAPED_EMAIL}>[[:space:]]*$" \
228
+ "$COMMIT_MSG_FILE" >/dev/null 2>&1; then
229
+ exit 0
230
+ fi
231
+
232
+ # Build the trailer line. Idempotency above already lower-cased the
233
+ # email for comparison; we ship the trailer with the policy-supplied
234
+ # casing so the user's preferred display name + email render verbatim.
235
+ TRAILER="Co-Authored-By: ${CO_NAME} <${CO_EMAIL}>"
236
+
237
+ # Find the insert point: at the bottom of the message, after stripping
238
+ # trailing blank/comment lines (git's scissors line `# -- >8 --` and
239
+ # everything below is appended verbatim to preserve git's own view).
240
+ TMP_BODY=$(mktemp "${TMPDIR:-/tmp}/rea-pcm.XXXXXX") || exit 0
241
+ TMP_TAIL=$(mktemp "${TMPDIR:-/tmp}/rea-pcm.XXXXXX") || { rm -f "$TMP_BODY"; exit 0; }
242
+ trap 'rm -f "$TMP_BODY" "$TMP_TAIL"' EXIT INT TERM
243
+
244
+ # Split the file: body (above the scissors marker) vs. tail (scissors
245
+ # and everything below). Codex round 2 P1: previously used python3
246
+ # unconditionally — on environments where rea CLI is reachable but
247
+ # python3 is missing, the split silently failed and the user's commit
248
+ # body got dropped. awk is universally available on POSIX systems and
249
+ # does the same work.
250
+ SCISSORS='# ------------------------ >8 ------------------------'
251
+ awk -v scissors="$SCISSORS" -v body_dst="$TMP_BODY" -v tail_dst="$TMP_TAIL" '
252
+ BEGIN { found = 0 }
253
+ {
254
+ if (!found && $0 == scissors) found = 1
255
+ if (found) print > tail_dst
256
+ else print > body_dst
257
+ }
258
+ ' "$COMMIT_MSG_FILE"
259
+
260
+ # Determine whether the body's last non-blank/non-comment line is a
261
+ # real git trailer (`Key: value` where Key matches `[A-Za-z][-A-Za-z0-9]*`)
262
+ # AND part of a multi-line trailer block (not the subject of a single-line
263
+ # conventional commit). Codex round 3 P1: the round-2 fix correctly
264
+ # rejected commit-prose `: ` patterns but still matched the conventional
265
+ # commit subject form `feat: add x` because that line is ALSO
266
+ # `[A-Za-z][-A-Za-z0-9]*: <value>`. The right distinguisher: a real
267
+ # trailer block has at least one preceding non-blank body line; a bare
268
+ # `feat: x` commit is just a subject and always needs a separator.
269
+ LAST_BODY_LINE=$(awk '
270
+ /^[[:space:]]*#/ { next }
271
+ /^[[:space:]]*$/ { next }
272
+ { lastline = $0 }
273
+ END { if (lastline != "") print lastline }
274
+ ' "$TMP_BODY")
275
+ BODY_LINE_COUNT=$(awk '
276
+ /^[[:space:]]*#/ { next }
277
+ /^[[:space:]]*$/ { next }
278
+ { count++ }
279
+ END { print count + 0 }
280
+ ' "$TMP_BODY")
281
+
282
+ SEPARATOR_NEEDED=1
283
+ if [ -z "$LAST_BODY_LINE" ]; then
284
+ SEPARATOR_NEEDED=0
285
+ elif [ "$BODY_LINE_COUNT" -gt 1 ] && printf '%s' "$LAST_BODY_LINE" | grep -qE '^[A-Za-z][-A-Za-z0-9]*: '; then
286
+ SEPARATOR_NEEDED=0
287
+ fi
288
+
289
+ # Trim trailing blank lines from the body so the trailer lands cleanly
290
+ # (without leaving a triple-newline before it).
291
+ TMP_BODY_TRIMMED=$(mktemp "${TMPDIR:-/tmp}/rea-pcm.XXXXXX") || exit 0
292
+ awk '
293
+ { lines[NR] = $0; total = NR }
294
+ END {
295
+ end = total
296
+ while (end > 0 && lines[end] ~ /^[[:space:]]*$/) { end-- }
297
+ for (i = 1; i <= end; i++) print lines[i]
298
+ }
299
+ ' "$TMP_BODY" > "$TMP_BODY_TRIMMED"
300
+
301
+ # Compose the new file: trimmed body + (optional blank) + trailer + tail.
302
+ {
303
+ cat "$TMP_BODY_TRIMMED"
304
+ if [ "$SEPARATOR_NEEDED" -eq 1 ]; then
305
+ printf '\n'
306
+ fi
307
+ printf '%s\n' "$TRAILER"
308
+ if [ -s "$TMP_TAIL" ]; then
309
+ cat "$TMP_TAIL"
310
+ fi
311
+ } > "${COMMIT_MSG_FILE}.rea-tmp" && mv "${COMMIT_MSG_FILE}.rea-tmp" "$COMMIT_MSG_FILE"
312
+
313
+ rm -f "$TMP_BODY_TRIMMED"
314
+ exit 0
package/MIGRATING.md CHANGED
@@ -59,6 +59,10 @@ are on the vanilla-git path — install husky first.
59
59
  - `.husky/pre-push` — package-managed; **do not edit**. Refreshed on every
60
60
  `rea upgrade`.
61
61
  - `.husky/commit-msg` — package-managed; **do not edit**. Same.
62
+ - `.husky/prepare-commit-msg` — package-managed (added in 0.30.0).
63
+ Drives the optional `attribution.co_author` augmenter; **do not edit**.
64
+ No-op when `policy.attribution.co_author.enabled !== true`, so it is
65
+ safe to ship under every profile (default disabled).
62
66
  - `.git/hooks/pre-push` (fallback when `core.hooksPath` is unset).
63
67
  - `.claude/hooks/*.sh` — protection + audit + advisory hooks.
64
68
  - `.claude/agents/*.md`, `.claude/commands/*.md`.
@@ -125,6 +129,77 @@ Re-run `rea upgrade`. The package-managed `.husky/commit-msg` body now
125
129
  runs first (HALT check, AI-attribution block when policy enables it),
126
130
  then runs your fragment.
127
131
 
132
+ ## Conflict pattern: existing prepare-commit-msg (rea 0.30.0+)
133
+
134
+ You probably have a hook that templates the message, adds a Jira
135
+ ticket prefix, or inserts a branch name:
136
+
137
+ ```sh
138
+ #!/bin/sh
139
+ # .husky/prepare-commit-msg — user-authored
140
+ COMMIT_MSG_FILE=$1
141
+ BRANCH=$(git rev-parse --abbrev-ref HEAD)
142
+ echo "[$BRANCH] $(cat "$COMMIT_MSG_FILE")" > "$COMMIT_MSG_FILE"
143
+ ```
144
+
145
+ **rea 0.30.0+ refuses to overwrite a foreign `.husky/prepare-commit-msg`.**
146
+ On `rea init` you'll see a `[fail]` from `rea doctor`:
147
+
148
+ ```
149
+ [fail] prepare-commit-msg hook (attribution augmenter)
150
+ (attribution.co_author.enabled: true but the prepare-commit-msg
151
+ hook is foreign (no rea marker) — remove the existing hook
152
+ and re-run `rea init`, or set enabled: false.)
153
+ ```
154
+
155
+ ### Migration
156
+
157
+ Two paths, depending on whether you intend to use the rea augmenter.
158
+
159
+ **Path A — you want the augmenter (Co-Authored-By trailer)**
160
+
161
+ Move your branch-prefix logic into rea's chained body. As of 0.30.0
162
+ rea's prepare-commit-msg body does NOT support `.husky/prepare-commit-msg.d/*`
163
+ fragments yet (it's on the 0.31.0 roadmap). For now, port the logic
164
+ into a wrapper invoked by `commit-msg.d` instead:
165
+
166
+ ```bash
167
+ mkdir -p .husky/commit-msg.d
168
+ cat > .husky/commit-msg.d/00-branch-prefix <<'EOF'
169
+ #!/bin/sh
170
+ # Branch-prefix logic moved from prepare-commit-msg to commit-msg.d
171
+ # (runs AFTER rea's augmenter, before the commit is finalized).
172
+ BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)
173
+ case $(head -1 "$1") in
174
+ "[$BRANCH]"*) ;; # already prefixed
175
+ *) printf '[%s] %s' "$BRANCH" "$(cat "$1")" > "$1" ;;
176
+ esac
177
+ EOF
178
+ chmod +x .husky/commit-msg.d/00-branch-prefix
179
+ ```
180
+
181
+ Then remove the old `.husky/prepare-commit-msg`:
182
+
183
+ ```bash
184
+ rm .husky/prepare-commit-msg .git/hooks/prepare-commit-msg
185
+ ```
186
+
187
+ Re-run `rea init`. rea's prepare-commit-msg now installs cleanly.
188
+
189
+ **Path B — you do NOT want the augmenter**
190
+
191
+ Leave your existing hook in place. Set the augmenter off explicitly:
192
+
193
+ ```yaml
194
+ # .rea/policy.yaml
195
+ attribution:
196
+ co_author:
197
+ enabled: false
198
+ ```
199
+
200
+ `rea doctor` reports `[warn]` (not fail) for the foreign hook —
201
+ your commits keep going through your existing logic.
202
+
128
203
  ## Conflict pattern: lint-staged on pre-push
129
204
 
130
205
  You probably have:
@@ -19,6 +19,44 @@ export interface CheckResult {
19
19
  * Exported so tests can drive this without spinning up the full `runDoctor`.
20
20
  */
21
21
  export declare function checkFingerprintStore(baseDir: string): Promise<CheckResult>;
22
+ /**
23
+ * 0.30.0 (Class M settings.json schema) — `EXPECTED_HOOKS` is exported
24
+ * so the schema validator at `src/config/settings-schema.ts` can
25
+ * cross-check rea-shipped hook filenames against entries it sees in
26
+ * a consumer's `.claude/settings.json`. The validator's `--strict`
27
+ * mode FAILS when a known rea-managed hook is missing from the
28
+ * consumer's registration; default mode logs a warn.
29
+ */
30
+ export declare const EXPECTED_AGENTS: string[];
31
+ export declare const EXPECTED_HOOKS: string[];
32
+ /**
33
+ * 0.30.0 Class M — validate `.claude/settings.json` against the zod
34
+ * schema in `src/config/settings-schema.ts`.
35
+ *
36
+ * Status posture:
37
+ *
38
+ * - `strict: false` (default `rea doctor`) — emit a warn when:
39
+ * - zod parse fails (unknown top-level key, missing matcher,
40
+ * malformed hook entry, etc.),
41
+ * - any `command` contains a `..` traversal after stripping
42
+ * `$CLAUDE_PROJECT_DIR`,
43
+ * - any rea-shipped hook from `EXPECTED_HOOKS` is missing from
44
+ * the consumer's registrations.
45
+ * The harness keeps working — the schema only refuses to call
46
+ * malformed hook entries; we surface the issue without breaking
47
+ * the install.
48
+ *
49
+ * - `strict: true` (`rea doctor --strict`) — fail (hard) on the
50
+ * same conditions. Used by CI gates that want a hard floor on
51
+ * consumer settings.
52
+ *
53
+ * Returns `pass` when everything cleared. Returns one `CheckResult`
54
+ * per concern; called once and emits one result. Combined with the
55
+ * existing `checkSettingsJson` (which checks for the historical Bash
56
+ * + Write|Edit|MultiEdit|NotebookEdit matchers), gives consumers a
57
+ * complete picture.
58
+ */
59
+ export declare function checkSettingsSchema(baseDir: string, strict: boolean): CheckResult;
22
60
  /**
23
61
  * Detect whether `baseDir` is a git repository. Returns true for the three
24
62
  * shapes git itself accepts:
@@ -46,6 +84,7 @@ export declare function checkFingerprintStore(baseDir: string): Promise<CheckRes
46
84
  * NOT a trust boundary. Do not key security decisions on the return value.
47
85
  */
48
86
  export declare function isGitRepo(baseDir: string): boolean;
87
+ export declare function checkPrepareCommitMsgHook(baseDir: string): CheckResult;
49
88
  /**
50
89
  * Hard-fail when `policy.review.codex_required: true` but the `codex`
51
90
  * binary is not on PATH. Pre-0.12.0 this prereq surfaced only at first
@@ -137,7 +176,9 @@ export declare function checkDelegationRoundTrip(baseDir: string): Promise<Check
137
176
  *
138
177
  * `activeForeign` always yields `fail` — a foreign hook bypassing the gate is a hard governance gap.
139
178
  */
140
- export declare function collectChecks(baseDir: string, codexProbeState?: CodexProbeState, prePushState?: PrePushDoctorState): CheckResult[];
179
+ export declare function collectChecks(baseDir: string, codexProbeState?: CodexProbeState, prePushState?: PrePushDoctorState, options?: {
180
+ strict?: boolean;
181
+ }): CheckResult[];
141
182
  export interface RunDoctorOptions {
142
183
  /** When true, print a 7-day telemetry summary after the checks (G11.5). */
143
184
  metrics?: boolean;
@@ -156,6 +197,13 @@ export interface RunDoctorOptions {
156
197
  * audit log with probe records.
157
198
  */
158
199
  smoke?: boolean;
200
+ /**
201
+ * 0.30.0 — when true, every advisory check (settings.json schema
202
+ * cross-check, prepare-commit-msg foreign-hook warn, etc.) is
203
+ * promoted to hard fail. Used by CI gates that want a strict floor
204
+ * on consumer installs. Default `false`.
205
+ */
206
+ strict?: boolean;
159
207
  }
160
208
  export interface DriftRow {
161
209
  path: string;