@windyroad/risk-scorer 0.18.5 → 0.18.6

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.
@@ -310,5 +310,5 @@
310
310
  }
311
311
  },
312
312
  "name": "wr-risk-scorer",
313
- "version": "0.18.5"
313
+ "version": "0.18.6"
314
314
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wr-risk-scorer",
3
- "version": "0.18.5",
3
+ "version": "0.18.6",
4
4
  "description": "Pipeline risk scoring, commit/push gates, and secret leak detection",
5
5
  "author": {
6
6
  "name": "Windy Road Technology",
@@ -49,7 +49,7 @@ The UNRELEASED CHANGES section emits TWO distinct changeset counts:
49
49
  - `Pending changesets (commits unpushed): N` — changesets whose introducing commit is in `origin/<base>..HEAD` (local) OR is untracked. These ARE pending consumer-facing changes at THIS commit's surface and count toward Layer 1 release risk as before.
50
50
  - `Queued changesets (commits already on origin): N` — changesets whose introducing commit is already on `origin/<base>`. The underlying code has already been pushed (and in the maintainer pipeline, reviewed); only the release-PR merge to npm is pending. These contribute **zero** release-risk at THIS commit's surface and MUST NOT count as pending consumer-facing changes in Layer 1.
51
51
 
52
- When computing Layer 1 release risk, score only the Pending count (plus any unreleased diff content). A Queued count > 0 with Pending = 0 and no other unreleased diff content is a within-appetite state — the queue is awaiting a release-PR merge, not a maintainer decision. Do NOT emit `RISK_REMEDIATIONS:` lines (such as `move-to-holding`) targeting queued-on-origin changesets; their commits have already shipped and `git mv`'ing them into `docs/changesets-holding/` would fragment the release without reducing actual risk.
52
+ When computing Layer 1 release risk, score only the Pending count (plus any unreleased diff content). A Queued count > 0 with Pending = 0 and no other unreleased diff content is a within-appetite state — the queue is awaiting a release-PR merge, not a maintainer decision.
53
53
 
54
54
  ## Catalog Consumption Protocol (ADR-059)
55
55
 
@@ -270,82 +270,6 @@ The hint is consumed by the calling orchestrator **after** the ADR-042 auto-appl
270
270
 
271
271
  Do NOT emit `RISK_REGISTER_HINT:` when all cumulative scores are within appetite AND no confidentiality disclosure AND no user-stated-precondition fired. The hint is additive to the existing Below-Appetite Output Rule — a silent pass MUST remain silent. Do not emit an empty `RISK_REGISTER_HINT:` header with no bullets either — omit the block entirely.
272
272
 
273
- ## Held-Changeset Graduation Evaluation (ADR-061)
274
-
275
- When the pipeline state indicates **within-appetite drain mode** (cumulative push and release residual both ≤ the threshold in `RISK-POLICY.md`, default 5) AND `docs/changesets-holding/` contains entries, evaluate each held changeset against ADR-061 Rule 1's symmetric graduation criterion: **reinstate when `release-risk(pipeline with held changeset hypothetically reinstated) ≤ problem-ticket Priority`**.
276
-
277
- This is the symmetric counterpart to ADR-042 Rule 2's move-to-holding contract. Material flows in when release-risk would exceed appetite; material flows out when release-risk falls at or below the originating problem-ticket Priority.
278
-
279
- ### Mechanism — invoke the deterministic graduation evaluator
280
-
281
- The Rule 1a join (changeset → problem ID → ticket Priority), the Rule 2 VP carve-out detection, and the Rule 3b cohort grouping are deterministic lookups. Invoke the `wr-risk-scorer-evaluate-graduation` shim (ADR-049 `$PATH`-resolved) to read structured candidate lines for each held changeset:
282
-
283
- ```
284
- GRADUATION_CANDIDATE: changeset=<filename> | ticket=P<NNN> | priority=<N> | class=3a | status=<resolved|vp-blocked|halt-no-resolution>
285
- GRADUATION_CANDIDATE: changeset=<filename> | ticket=P<NNN> | priority=<cohort-max-N> | class=3b | cohort=<id> | status=<resolved|vp-blocked|halt-no-resolution>
286
- GRADUATION_SUMMARY: total=<N> resolved=<N> vp_blocked=<N> halts=<N>
287
- ```
288
-
289
- Class 3b lines insert a `cohort=<id>` column between `class` and `status`. The cohort id is derived from the normalised reinstate-trigger prose (first 8 tokens, kebab-sanitised) of the `docs/changesets-holding/README.md` "Currently held" entries that share an identical normalised trigger. Cohort `priority` is `max(Priority)` across all member tickets per ADR-061 Rule 3b; cohort `status` propagates atomically — any halt → cohort halts, any VP-blocked → cohort VP-blocked, otherwise cohort resolved. Single-member "cohorts" are emitted as class=3a (no Phase 2a regression).
290
-
291
- The script does NOT compute release-risk and does NOT apply Rule 4 evidence-floor judgement — those are LLM-judgement surfaces you own per ADR-015's pure-scorer contract. The script's job is to emit candidates with their joined Priority + cohort classification; your job is to decide whether each candidate's release-risk + evidence-floor profile justifies emitting a `reinstate-from-holding` remediation line.
292
-
293
- ### Per-candidate evaluation rules
294
-
295
- For each `status=resolved` candidate:
296
-
297
- 1. **Compute release-risk with hypothetical reinstate** (Rule 1) — re-score the current pipeline as if the held changeset were `git mv`'d back to `.changeset/`. Use the same scoring path as ADR-042 Rule 2's re-score; this is your existing pipeline-scoring competence applied to the symmetric hypothesis.
298
- 2. **Compare** — `release-risk ≤ priority` from the candidate line. If false, the held entry stays held — no remediation emitted this cycle.
299
- 3. **Verify Rule 4 evidence floor** — class-specific evidence shape per ADR-061 Rule 4:
300
- - **PreToolUse:Bash gates**: ≥ 1 gate-fire log entry per intended trigger surface, with post-fire commit trail showing no false-block.
301
- - **UserPromptSubmit detectors**: ≥ 1 detector firing logged to hook stderr or `.afk-run-state/<detector>.log`.
302
- - **commit-hook-with-auto-fix**: ≥ 1 auto-fix commit log entry visible via `git log --grep=<hook-marker>` with the diff showing the correct fix shape.
303
- - **SessionStart additionalContext hooks**: ≥ 1 session-trail entry showing the injection fired without regression in the immediate-next turn.
304
-
305
- Per ADR-026 cite + persist + uncertainty: the evidence must ground in a re-readable artefact, not a bare count.
306
- 4. **Emit `reinstate-from-holding` remediation** (Rule 5) when the comparison evaluates true AND the evidence floor is met:
307
-
308
- ```
309
- RISK_REMEDIATIONS:
310
- - R<N> | reinstate-from-holding <changeset-name>: release-risk <release-score>/25 ≤ P<NNN> Priority <priority-value>; class 3a; evidence: <class-specific artefact citation> | S | -<release-score> | docs/changesets-holding/<changeset-name>, .changeset/<changeset-name>
311
- ```
312
-
313
- The `description` column (free-form prose per ADR-042 Rule 2a open vocabulary) carries the symmetric-balance verdict, the cited evidence artefact, and the class. The agent consuming this line applies it via `git mv docs/changesets-holding/<name>.md .changeset/<name>.md`.
314
-
315
- For each `status=vp-blocked` candidate (Rule 2 carve-out — originating ticket in Verification Pending):
316
-
317
- - **DO NOT emit a `reinstate-from-holding` line** for this changeset. ADR-022 establishes the user-owned verify-or-reject decision surface; auto-reinstating short-circuits that surface. The `.verifying.md` → `.closed.md` transition auto-clears the carve-out; the next Step 6.5 graduation pass evaluates the changeset normally.
318
-
319
- For each `status=halt-no-resolution` candidate (Rule 1a terminal — no ticket resolved):
320
-
321
- - **DO NOT auto-graduate**. Surface the unresolved candidate in your report body under an "Unresolvable graduation candidates" section so the caller (orchestrator) sees the join failure and can present it as a user-decision surface per ADR-013 + ADR-044 framework-resolution boundary. Per ADR-061 Rule 1a, join ambiguity is a user-decision surface, not an agent-decision surface.
322
-
323
- ### Class 3b atomic-cohort evaluation (Phase 2b — ADR-061 Rule 3b)
324
-
325
- When candidate lines emit `class=3b` with a `cohort=<id>` column, ADR-061 Rule 3b applies: **the entire cohort ships atomically or none of it does**. Per-member graduation is not authorised. Evaluate the cohort as a single unit:
326
-
327
- 1. **Group candidates by cohort id** — collect all `class=3b` candidates sharing the same `cohort=` column into a single evaluation set.
328
- 2. **Compute cohort release-risk** — re-score the current pipeline as if the **full cohort** were `git mv`'d back to `.changeset/` together (not one at a time). The marginal release-risk delta is computed against the cohort's combined diff surface, not any single member's diff.
329
- 3. **Compare against cohort priority** — the `priority=<cohort-max-N>` column on every cohort-member line already carries `max(Priority)` across all member tickets (deterministic join, Rule 3b math). Apply Rule 1: cohort graduates when `cohort-release-risk ≤ cohort-priority`.
330
- 4. **Verify Rule 4 evidence floor per cohort** — every cohort member must independently satisfy its class-specific evidence shape (PreToolUse:Bash gate / UserPromptSubmit detector / commit-hook-with-auto-fix / SessionStart additionalContext). One floor failure in any member blocks the whole cohort. Per ADR-026 cite + persist + uncertainty: cite the artefact for each member in the audit trail.
331
- 5. **Cohort-level VP carve-out** — if the deterministic evaluator already returned `status=vp-blocked` for the cohort (any member's ticket in Verification Pending), DO NOT emit a reinstate. The carve-out lifts when all member tickets transition out of `.verifying.md`.
332
- 6. **Cohort-level halt-and-prompt** — if the deterministic evaluator returned `status=halt-no-resolution` for the cohort (any member fails Rule 1a join), DO NOT auto-graduate. Surface the cohort in the "Unresolvable graduation candidates" section. Per architect C1 (2026-05-17 P162 Phase 2b review), partial-cohort resolution is NOT authorised — the cohort is atomic.
333
- 7. **Emit one `reinstate-from-holding` line per cohort member** when all six checks pass, all referencing the same cohort id so the consuming orchestrator can apply them as an atomic batch:
334
-
335
- ```
336
- RISK_REMEDIATIONS:
337
- - R<N> | reinstate-from-holding <member-1>: cohort <id> release-risk <release-score>/25 ≤ cohort-priority <priority-value>; class 3b; evidence: <member-1 artefact citation> | S | -<release-score-share> | docs/changesets-holding/<member-1>, .changeset/<member-1>
338
- - R<N+1> | reinstate-from-holding <member-2>: cohort <id> release-risk <release-score>/25 ≤ cohort-priority <priority-value>; class 3b; evidence: <member-2 artefact citation> | S | -<release-score-share> | docs/changesets-holding/<member-2>, .changeset/<member-2>
339
- ```
340
-
341
- The agent consuming these lines applies them as a single batch — either all members reinstate in one operation or none do. Partial application breaks ADR-061 Rule 3b atomicity.
342
-
343
- The cohort id-from-prose detection is the Phase 2b shape per the architect-approved 2026-05-17 design. If cohort grouping false-positives appear (e.g. two unrelated changesets coincidentally sharing trigger prose), ADR-061 Reassessment Triggers ("Manual graduations diverge from criterion verdicts") covers the upgrade to a structured cohort-declaration field.
344
-
345
- ### Audit trail (Rule 6)
346
-
347
- Every emitted `reinstate-from-holding` line MUST cite the resolved problem-ticket ID and Priority value in the description column so the audit trail extends ADR-042 Rule 6. For Class 3b cohort reinstates, every member line MUST additionally cite the cohort id and the cohort-level priority + release-risk values so the per-member audit row reconstructs the atomic cohort decision. The consuming orchestrator additionally appends to `docs/changesets-holding/README.md` "Recently reinstated" per Rule 6 § 2 with the class (3a or 3b) and, for cohort members, the cohort id.
348
-
349
273
  ## Confidential Information Disclosure
350
274
 
351
275
  Check diffs for business metrics (revenue, user counts, pricing, traffic volumes). Flag as a standalone risk if found.
@@ -375,7 +299,7 @@ For diffs touching `packages/*/skills/*/SKILL.md`, `packages/*/skills/*/REFERENC
375
299
 
376
300
  - **Control name**: `Promptfoo Tier-A/B SKILL/agent-prose eval` at `packages/<pkg>/skills/<name>/eval/promptfooconfig.yaml` (or `packages/<pkg>/agents/<name>/eval/...` for agent prose).
377
301
  - **Failure scenario exercised**: the LLM-prose surface emits the documented step ordering / vocabulary / refusal / classifier verdict. Pre-RFC-012, no test exercised this; defects landed in published SKILLs and ran in adopter sessions. Post-RFC-012, the eval's Tier-A deterministic assertions (icontains/contains/regex/not-regex) or Tier-B llm-rubric pass^k repro the prose surface behaviourally.
378
- - **How it catches before reaching the user**: the eval runs the actual SKILL.md (via `run-skill-eval.sh` exec provider wrapping `claude -p --append-system-prompt`); a behavioural regression in the prose surface fails the assertion. The held-changeset / dogfood-window catches some prose defects, but promptfoo catches edge-case defects that dogfood replays miss (per R009 Watch-out clause).
302
+ - **How it catches before reaching the user**: the eval runs the actual SKILL.md (via `run-skill-eval.sh` exec provider wrapping `claude -p --append-system-prompt`); a behavioural regression in the prose surface fails the assertion before release (per R009 Watch-out clause).
379
303
  - **Authority**: ADR-075 Amendment 2026-06-02 (scope extension to SKILL.md prose); RFC-012 (implementation); R009 standing-risk entry (`docs/risks/R009-*.active.md`) Controls table row 2 + per-action modulator + Residual risk Per-action quick path.
380
304
 
381
305
  **Crediting rule** (matches R009 modulator semantics):
@@ -273,7 +273,11 @@ except Exception:
273
273
  ;;
274
274
  failure|cancelled|timed_out|action_required|startup_failure)
275
275
  CI_GATE_CATEGORY="red"
276
- CI_GATE_REASON="Latest CI run on branch '${BRANCH}' concluded ${CONCLUSION}: ${URL}. Fix CI before pushing/releasing — there is no override (P377/RFC-029; ci-bypass removed). A live-outage restore-service release uses the separate incident-release path."
276
+ if [ "$ACTION" = "push" ]; then
277
+ CI_GATE_REASON="Latest CI run on branch '${BRANCH}' concluded ${CONCLUSION}: ${URL}. Red CI is not itself a goal blocker. Inspect that run, verify the outgoing commits directly repair that exact failure, then delegate to wr-risk-scorer:pipeline with CI-recovery context. Retry \`npm run push:watch\` only after a net risk-reducing verdict creates the existing reducing-push marker. Unrelated outgoing commits remain blocked; there is no generic override (P377/RFC-029)."
278
+ else
279
+ CI_GATE_REASON="Latest CI run on branch '${BRANCH}' concluded ${CONCLUSION}: ${URL}. Release remains blocked. Fix and push the CI repair, wait for green CI, then retry \`npm run release:watch\`. A live-outage restore-service release uses the separate incident-release path; there is no generic override (P377/RFC-029)."
280
+ fi
277
281
  return 1
278
282
  ;;
279
283
  *)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windyroad/risk-scorer",
3
- "version": "0.18.5",
3
+ "version": "0.18.6",
4
4
  "description": "Pipeline risk scoring, commit/push gates, and secret leak detection",
5
5
  "scripts": {
6
6
  "prepack": "node scripts/sync-codex-skills.mjs --pack",
@@ -1,51 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Generated by scripts/sync-shim-wrappers.sh from
3
- # packages/shared/lib/shim-wrapper-template.sh. DO NOT EDIT individual
4
- # shim files in packages/*/bin/wr-* directly; edit the template + run
5
- # `npm run sync:shim-wrappers` to regenerate.
6
- #
7
- # Resolution (ADR-080):
8
- # 1. If the wrapper's parent dir is semver-shaped, treat as installed-
9
- # cache execution and resolve to the highest-version sibling's
10
- # scripts/ entry below.
11
- # 2. Otherwise (parent dir is e.g. `architect`), treat as source-
12
- # monorepo execution and dispatch to own scripts/. The source-repo-
13
- # guard `exec` is the anchor parsed by
14
- # packages/retrospective/scripts/check-tarball-shipped-shims.sh.
15
- # 3. If the cache parent contains zero semver-shaped siblings, exit
16
- # 127 with a stderr message naming the cache parent (per SQ-080-2).
17
- #
18
- # @adr ADR-080 (highest-version-wins shim wrapper plugin scaffold)
19
- # @adr ADR-049 (plugin-bundled scripts resolve via bin/ on $PATH — amended)
20
- # @problem P343 (mid-session staleness window)
21
-
22
- set -euo pipefail
23
-
24
- SHIM_DIR="$(cd "$(dirname "$0")" && pwd)"
25
- OWN_VERSION_DIR="$(dirname "$SHIM_DIR")"
26
- OWN_VERSION_NAME="$(basename "$OWN_VERSION_DIR")"
27
- CACHE_PARENT="$(dirname "$OWN_VERSION_DIR")"
28
-
29
- SEMVER_RE='^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'
30
-
31
- # Source-repo guard: own parent dir is NOT semver → dispatch to own scripts/.
32
- if ! [[ "$OWN_VERSION_NAME" =~ $SEMVER_RE ]]; then
33
- exec "$SHIM_DIR/../scripts/evaluate-graduation.sh" "$@"
34
- fi
35
-
36
- # Cache execution: pick the highest-semver sibling under CACHE_PARENT.
37
- HIGHEST=""
38
- while IFS= read -r dir; do
39
- name="$(basename "$dir")"
40
- [[ "$name" =~ $SEMVER_RE ]] || continue
41
- if [[ -z "$HIGHEST" ]] || [[ "$(printf '%s\n%s\n' "$HIGHEST" "$name" | sort -V | tail -1)" == "$name" ]]; then
42
- HIGHEST="$name"
43
- fi
44
- done < <(find "$CACHE_PARENT" -mindepth 1 -maxdepth 1 -type d 2>/dev/null)
45
-
46
- if [[ -z "$HIGHEST" ]]; then
47
- printf 'wr-shim: no cached versions in %s\n' "$CACHE_PARENT" >&2
48
- exit 127
49
- fi
50
-
51
- exec "$CACHE_PARENT/$HIGHEST/scripts/evaluate-graduation.sh" "$@"
@@ -1,493 +0,0 @@
1
- #!/usr/bin/env bash
2
- # packages/risk-scorer/scripts/evaluate-graduation.sh
3
- #
4
- # Evaluates held-changeset graduation candidates per ADR-061
5
- # (Dogfood graduation criteria for held changesets — symmetric risk balance).
6
- #
7
- # Phase 2a — orthogonal-gate class (Class 3a per ADR-061 Rule 3): deterministic
8
- # Rule 1a join + Rule 2 VP carve-out detection per changeset, independently.
9
- #
10
- # Phase 2b — atomic-cohort class (Class 3b per ADR-061 Rule 3b): parses
11
- # docs/changesets-holding/README.md "Currently held" section, groups entries
12
- # by shared reinstate-trigger prose (parenthetical elaborations stripped
13
- # before grouping), and emits cohort-aware candidates. Cohort priority is
14
- # max(Priority) across all member tickets; any VP-blocked or halt-no-resolution
15
- # member propagates atomically to the entire cohort ("entire cohort ships or
16
- # none does" — symmetric to Rule 2's per-changeset carve-out at cohort grain).
17
- # Single-member "cohorts" fall back to class=3a (no Phase 2a regression).
18
- #
19
- # This script implements deterministic Rule 1a join + Rule 2 VP carve-out
20
- # detection + Rule 3b cohort grouping. It does NOT compute release-risk and
21
- # does NOT apply Rule 4 evidence-floor judgement — those are LLM-judgement
22
- # surfaces owned by the wr-risk-scorer:pipeline agent (per ADR-015 pure-scorer
23
- # contract).
24
- #
25
- # Cohort-id-from-prose is the Phase 2b shape per architect approval 2026-05-17.
26
- # Reassessment Triggers in ADR-061 ("Manual graduations diverge from criterion
27
- # verdicts") cover the upgrade to a structured cohort-declaration field if
28
- # prose-shape brittleness appears in dogfood.
29
- #
30
- # Usage:
31
- # evaluate-graduation.sh [<project-root>]
32
- #
33
- # Default <project-root> is $(pwd).
34
- #
35
- # Behaviour:
36
- # - Globs docs/changesets-holding/*.md (excludes README.md).
37
- # - For each held changeset, applies Rule 1a join:
38
- # 1. Filename convention (primary): <package>-p<NNN>-<slug>.md → P<NNN>
39
- # 2. Body grep fallback (secondary): grep '\bP[0-9]+\b' in changeset body
40
- # 3. Multi-ticket: max(Priority) across the referenced set
41
- # - Resolves the ticket file via dual-tolerant glob (ADR-031 + RFC-002):
42
- # docs/problems/<NNN>-*.md (flat) AND docs/problems/*/<NNN>-*.md (per-state)
43
- # - Extracts the Priority value from the ticket's `**Priority**: N (...)` line.
44
- # - Detects Rule 2 VP carve-out (ticket file ends in .verifying.md) — narrowed
45
- # per P398: a verifying ticket graduates (resolved) when its `## Fix Released`
46
- # section is populated (code already shipped, only the changelog is held);
47
- # it stays vp-blocked only while the fix is not yet shipped.
48
- # - Parses docs/changesets-holding/README.md "Currently held" section and
49
- # groups entries by normalised reinstate-trigger prose (Phase 2b).
50
- # - Multi-member groups emit class=3b + cohort=<id> with cohort-level
51
- # priority/status. Single-member groups emit class=3a unchanged.
52
- # - Emits one structured candidate line per held changeset to stdout.
53
- #
54
- # Stdout format — Class 3a (one candidate per held changeset, agent-parseable):
55
- # GRADUATION_CANDIDATE: changeset=<filename> | ticket=P<NNN> | priority=<N> | class=3a | status=<resolved|vp-blocked|halt-no-resolution>
56
- #
57
- # Stdout format — Class 3b (cohort member; cohort= column added between class and status):
58
- # GRADUATION_CANDIDATE: changeset=<filename> | ticket=P<NNN> | priority=<cohort-max-N> | class=3b | cohort=<id> | status=<resolved|vp-blocked|halt-no-resolution>
59
- #
60
- # Stdout summary line at end (member-level counts; cohorts count individually):
61
- # GRADUATION_SUMMARY: total=<N> resolved=<N> vp_blocked=<N> halts=<N>
62
- #
63
- # Exit codes:
64
- # 0 — script ran to completion (any number of halts is still exit 0;
65
- # halts surface via per-candidate status=halt-no-resolution lines so
66
- # the agent can present them as Rule 1a halt-and-prompt candidates)
67
- # 1 — no holding-area or empty holding-area (no-op caller signal)
68
- # 2 — invalid project root (missing docs/)
69
- #
70
- # @adr ADR-061 (graduation criteria — Phase 2a Rule 1a join + Rule 2 VP carve-out;
71
- # Phase 2b Rule 3b atomic-cohort grouping + cohort-level propagation)
72
- # @adr ADR-049 (resolved via bin/wr-risk-scorer-evaluate-graduation shim)
73
- # @adr ADR-052 (behavioural-fixture coverage at scripts/test/evaluate-graduation.bats)
74
- # @adr ADR-015 (pure-scorer contract — script does deterministic join + grouping;
75
- # agent owns release-risk re-computation + evidence-floor judgement)
76
- # @adr ADR-031 (dual-tolerant problem-ticket layout per RFC-002 migration window)
77
- # @problem P162 (Phase 2a + Phase 2b)
78
-
79
- set -uo pipefail
80
-
81
- PROJECT_ROOT="${1:-$(pwd)}"
82
- HOLDING_DIR="${PROJECT_ROOT}/docs/changesets-holding"
83
- PROBLEMS_DIR="${PROJECT_ROOT}/docs/problems"
84
-
85
- if [ ! -d "${PROJECT_ROOT}/docs" ]; then
86
- echo "GRADUATION_ERROR: invalid project root (missing docs/): ${PROJECT_ROOT}" >&2
87
- exit 2
88
- fi
89
-
90
- if [ ! -d "$HOLDING_DIR" ]; then
91
- echo "GRADUATION_SUMMARY: total=0 resolved=0 vp_blocked=0 halts=0"
92
- exit 1
93
- fi
94
-
95
- # Enumerate held changesets (exclude README.md). Use null-delim shape so
96
- # filenames-with-spaces never break iteration (defensive even though our
97
- # convention is kebab-case).
98
- HELD_FILES=()
99
- while IFS= read -r -d '' f; do
100
- base=$(basename "$f")
101
- if [ "$base" = "README.md" ]; then
102
- continue
103
- fi
104
- HELD_FILES+=("$f")
105
- done < <(find "$HOLDING_DIR" -maxdepth 1 -type f -name '*.md' -print0 2>/dev/null)
106
-
107
- if [ "${#HELD_FILES[@]}" -eq 0 ]; then
108
- echo "GRADUATION_SUMMARY: total=0 resolved=0 vp_blocked=0 halts=0"
109
- exit 1
110
- fi
111
-
112
- # Delegate the per-candidate join + VP-check + cohort grouping to python for
113
- # re-readable regex + dual-layout glob handling.
114
- EVAL_RESULT=$(python3 - "$HOLDING_DIR" "$PROBLEMS_DIR" "${HELD_FILES[@]}" <<'PYEOF'
115
- import os
116
- import re
117
- import sys
118
- import glob
119
-
120
- holding_dir = sys.argv[1]
121
- problems_dir = sys.argv[2]
122
- held_files = sys.argv[3:]
123
-
124
- FILENAME_TICKET_RE = re.compile(r'-p(\d+)-', re.IGNORECASE)
125
- BODY_TICKET_RE = re.compile(r'\bP(\d+)\b')
126
- PRIORITY_LINE_RE = re.compile(r'^\*\*Priority\*\*:\s*(\d+)\b')
127
- FIX_RELEASED_HEADING_RE = re.compile(r'^##\s+Fix Released\s*$', re.IGNORECASE)
128
- # Placeholder-only tokens that do NOT count as a populated `## Fix Released`
129
- # section (a fix that is scaffolded-but-not-shipped). Kept mechanical per
130
- # ADR-015 — this is deterministic detection, not prose judgement.
131
- FIX_RELEASED_PLACEHOLDERS = frozenset({'tbd', 'n/a', 'na', 'pending', '(pending)',
132
- '(deferred)', 'deferred', '(none)', 'none'})
133
-
134
- # Phase 2b — README "Currently held" bullet parser.
135
- # Matches `- \`<filename>\` ... **Reinstate trigger**: <trigger-text>`.
136
- # Captures the filename (group 1) and the trigger text (group 2; rest of line).
137
- README_BULLET_RE = re.compile(
138
- r'^-\s+`([^`]+\.md)`\s+.*?\*\*Reinstate trigger\*\*:\s*(.+?)\s*$'
139
- )
140
- # Strip parenthetical elaborations before grouping; nested parens are out of
141
- # scope for Phase 2b (no observed README entry uses them in the trigger).
142
- PAREN_RE = re.compile(r'\([^()]*\)')
143
- # Sanitise cohort-id from normalised trigger prose.
144
- NON_ID_CHAR_RE = re.compile(r'[^a-z0-9]+')
145
-
146
-
147
- def find_ticket_file(ticket_id_padded: str):
148
- """Dual-tolerant glob per ADR-031 / RFC-002 migration window.
149
-
150
- Returns (path, status_suffix) where status_suffix is one of
151
- 'open', 'known-error', 'verifying', 'closed', 'parked' or None
152
- if no file resolves.
153
- """
154
- # Per-state subdir layout
155
- for state in ('open', 'known-error', 'verifying', 'closed', 'parked'):
156
- candidates = glob.glob(os.path.join(problems_dir, state, f'{ticket_id_padded}-*.md'))
157
- if candidates:
158
- return candidates[0], state
159
- # Flat layout
160
- for state in ('open', 'known-error', 'verifying', 'closed', 'parked'):
161
- candidates = glob.glob(os.path.join(problems_dir, f'{ticket_id_padded}-*.{state}.md'))
162
- if candidates:
163
- return candidates[0], state
164
- return None, None
165
-
166
-
167
- def extract_priority(ticket_path: str):
168
- """Read the `**Priority**: N (...)` line and return integer N, or None."""
169
- try:
170
- with open(ticket_path, 'r', encoding='utf-8') as f:
171
- for line in f:
172
- m = PRIORITY_LINE_RE.match(line.strip())
173
- if m:
174
- return int(m.group(1))
175
- except (OSError, IOError):
176
- return None
177
- return None
178
-
179
-
180
- def fix_shipped(ticket_path: str) -> bool:
181
- """True when the ticket carries a populated `## Fix Released` section.
182
-
183
- A verifying ticket whose fix has shipped carries real prose under
184
- `## Fix Released` (per ADR-022 / manage-problem closing contract); an
185
- absent, empty, or placeholder-only section means the fix has not actually
186
- shipped yet. Mechanical predicate per ADR-015 (ADR-061 Rule 2 amended,
187
- P398): section present AND ≥1 non-blank line that is not a placeholder
188
- token counts as shipped.
189
- """
190
- try:
191
- with open(ticket_path, 'r', encoding='utf-8') as f:
192
- lines = f.readlines()
193
- except (OSError, IOError):
194
- return False
195
- in_section = False
196
- for line in lines:
197
- if FIX_RELEASED_HEADING_RE.match(line.strip()):
198
- in_section = True
199
- continue
200
- if not in_section:
201
- continue
202
- stripped = line.strip()
203
- if stripped.startswith('## '):
204
- break # next section reached with no real content
205
- if not stripped:
206
- continue
207
- if stripped.lower() in FIX_RELEASED_PLACEHOLDERS:
208
- continue
209
- return True # real content under the heading
210
- return False
211
-
212
-
213
- def resolve_ticket_ids(changeset_path: str):
214
- """Apply Rule 1a join: filename convention primary, body-grep fallback.
215
-
216
- Returns a list of zero-padded ticket IDs (e.g. ['085']) referenced by
217
- this changeset. Empty list means halt-no-resolution per Rule 1a terminal.
218
- """
219
- basename = os.path.basename(changeset_path)
220
- # Primary: filename convention
221
- filename_match = FILENAME_TICKET_RE.search(basename)
222
- if filename_match:
223
- return [f'{int(filename_match.group(1)):03d}']
224
-
225
- # Fallback: body grep for P\d+ references
226
- try:
227
- with open(changeset_path, 'r', encoding='utf-8') as f:
228
- body = f.read()
229
- except (OSError, IOError):
230
- return []
231
-
232
- body_matches = BODY_TICKET_RE.findall(body)
233
- if not body_matches:
234
- return []
235
-
236
- # De-duplicate while preserving order; zero-pad
237
- seen = set()
238
- ids = []
239
- for raw_id in body_matches:
240
- padded = f'{int(raw_id):03d}'
241
- if padded not in seen:
242
- seen.add(padded)
243
- ids.append(padded)
244
- return ids
245
-
246
-
247
- def normalise_trigger(trigger_text: str) -> str:
248
- """Normalise reinstate-trigger prose for cohort-key comparison.
249
-
250
- Strips parenthetical elaborations (Reassessment criterion citations,
251
- inline notes), takes the prefix up to the first em-dash separator
252
- (typical for "trigger description — review at ..." continuations),
253
- strips trailing punctuation, lowercases, and collapses whitespace
254
- LAST so paren-strip artefacts (stray spaces before punctuation) do
255
- not break equality matching.
256
- """
257
- # Strip parentheticals; loop in case there are multiple non-nested groups.
258
- prior = None
259
- cleaned = trigger_text
260
- while cleaned != prior:
261
- prior = cleaned
262
- cleaned = PAREN_RE.sub('', cleaned)
263
- # Take prefix up to first em-dash separator (continuations begin here).
264
- cleaned = cleaned.split('—', 1)[0] # em-dash U+2014
265
- # Lowercase, strip surrounding whitespace + trailing punctuation; collapse
266
- # whitespace LAST so paren-strip leaves no orphaned single spaces before
267
- # punctuation that would defeat equality comparison.
268
- cleaned = cleaned.lower().strip().rstrip('.,;:').strip()
269
- cleaned = ' '.join(cleaned.split())
270
- # Strip any trailing punctuation that was previously space-separated.
271
- cleaned = cleaned.rstrip('.,;:').strip()
272
- return cleaned
273
-
274
-
275
- def cohort_id_from_trigger(normalised: str) -> str:
276
- """Compute a filename-safe cohort id from normalised trigger prose.
277
-
278
- Takes the first 8 tokens, replaces non-alphanumeric runs with single
279
- dashes, trims surrounding dashes, caps at 60 chars.
280
- """
281
- tokens = normalised.split()[:8]
282
- joined = ' '.join(tokens)
283
- slug = NON_ID_CHAR_RE.sub('-', joined).strip('-')
284
- return slug[:60] if slug else 'cohort'
285
-
286
-
287
- def parse_currently_held_cohorts(holding_dir: str):
288
- """Parse docs/changesets-holding/README.md to build a filename→cohort-id map.
289
-
290
- Reads only entries within the "## Currently held" section (case-insensitive),
291
- extracts each bullet's filename + trigger text, normalises triggers, and
292
- groups filenames sharing an identical normalised trigger. Cohorts with ≥ 2
293
- members are returned as {filename: cohort_id}; single-member groups are
294
- omitted so they fall back to class=3a per Phase 2a semantics.
295
-
296
- Returns {} when README missing OR "Currently held" section absent OR no
297
- multi-member groups present.
298
- """
299
- readme_path = os.path.join(holding_dir, 'README.md')
300
- if not os.path.isfile(readme_path):
301
- return {}
302
- try:
303
- with open(readme_path, 'r', encoding='utf-8') as f:
304
- lines = f.readlines()
305
- except (OSError, IOError):
306
- return {}
307
-
308
- # Walk lines; track whether we're inside the "Currently held" section.
309
- in_section = False
310
- bullets = [] # list of (filename, normalised_trigger)
311
- for line in lines:
312
- stripped = line.strip()
313
- if stripped.startswith('## '):
314
- heading = stripped[3:].strip().lower()
315
- in_section = (heading == 'currently held')
316
- continue
317
- if not in_section:
318
- continue
319
- match = README_BULLET_RE.match(line.rstrip('\n'))
320
- if not match:
321
- continue
322
- filename = match.group(1)
323
- trigger = match.group(2)
324
- normalised = normalise_trigger(trigger)
325
- if not normalised:
326
- continue
327
- bullets.append((filename, normalised))
328
-
329
- # Group bullets by normalised trigger.
330
- groups = {}
331
- for filename, normalised in bullets:
332
- groups.setdefault(normalised, []).append(filename)
333
-
334
- # Keep only multi-member groups; compute cohort id.
335
- cohort_map = {}
336
- for normalised, members in groups.items():
337
- if len(members) < 2:
338
- continue
339
- cohort_id = cohort_id_from_trigger(normalised)
340
- for filename in members:
341
- cohort_map[filename] = cohort_id
342
- return cohort_map
343
-
344
-
345
- # Per-changeset resolution structure:
346
- # {basename: {ticket: 'P<NNN>'|'-', priority: <int>|None, status: <str>,
347
- # ticket_ids: [<padded>], chosen_suffix: <str>|None}}
348
- per_changeset = {}
349
-
350
- for changeset_path in held_files:
351
- basename = os.path.basename(changeset_path)
352
- ticket_ids = resolve_ticket_ids(changeset_path)
353
-
354
- if not ticket_ids:
355
- per_changeset[basename] = {
356
- 'ticket_label': '-',
357
- 'priority': None,
358
- 'status': 'halt-no-resolution',
359
- }
360
- continue
361
-
362
- resolutions = []
363
- for tid in ticket_ids:
364
- path, suffix = find_ticket_file(tid)
365
- if path is None:
366
- continue
367
- priority = extract_priority(path)
368
- if priority is None:
369
- continue
370
- resolutions.append((tid, priority, suffix, path))
371
-
372
- if not resolutions:
373
- per_changeset[basename] = {
374
- 'ticket_label': ','.join(f'P{i}' for i in ticket_ids),
375
- 'priority': None,
376
- 'status': 'halt-no-resolution',
377
- }
378
- continue
379
-
380
- resolutions.sort(key=lambda r: r[1], reverse=True)
381
- chosen_tid, chosen_priority, chosen_suffix, chosen_path = resolutions[0]
382
- if chosen_suffix == 'verifying' and not fix_shipped(chosen_path):
383
- # ADR-061 Rule 2 (narrowed per P398): the VP carve-out holds a changeset
384
- # only while its fix is NOT yet shipped. A verifying ticket with no
385
- # populated `## Fix Released` section is still mid-flight — hold it.
386
- per_changeset[basename] = {
387
- 'ticket_label': f'P{chosen_tid}',
388
- 'priority': chosen_priority,
389
- 'status': 'vp-blocked',
390
- }
391
- continue
392
- # A verifying ticket whose `## Fix Released` is populated means the code is
393
- # already live on npm; only the changelog-attribution changeset is held, so
394
- # it graduates as `resolved` rather than stranding indefinitely (P398).
395
-
396
- per_changeset[basename] = {
397
- 'ticket_label': f'P{chosen_tid}',
398
- 'priority': chosen_priority,
399
- 'status': 'resolved',
400
- }
401
-
402
- # Phase 2b — cohort detection.
403
- cohort_map = parse_currently_held_cohorts(holding_dir)
404
- # Build inverse: cohort_id → [member basenames].
405
- cohort_members = {}
406
- for filename, cohort_id in cohort_map.items():
407
- cohort_members.setdefault(cohort_id, []).append(filename)
408
-
409
- # Compute cohort-level rollups (priority + status).
410
- # Atomic propagation: any halt → cohort halts; else any vp-blocked → cohort
411
- # vp-blocked; else cohort resolved. Cohort priority = max(member priority)
412
- # across resolved/vp-blocked members; '-' when all members halted.
413
- cohort_rollup = {}
414
- for cohort_id, members in cohort_members.items():
415
- statuses = []
416
- priorities = []
417
- for filename in members:
418
- # Only consider members that are actually in the holding-area glob;
419
- # README may list entries that no longer exist on disk (stale README).
420
- info = per_changeset.get(filename)
421
- if info is None:
422
- continue
423
- statuses.append(info['status'])
424
- if info['priority'] is not None:
425
- priorities.append(info['priority'])
426
-
427
- if not statuses:
428
- # No cohort members are real held files; skip cohort treatment.
429
- continue
430
- if 'halt-no-resolution' in statuses:
431
- cohort_status = 'halt-no-resolution'
432
- elif 'vp-blocked' in statuses:
433
- cohort_status = 'vp-blocked'
434
- else:
435
- cohort_status = 'resolved'
436
- cohort_priority = max(priorities) if priorities else None
437
- cohort_rollup[cohort_id] = {
438
- 'status': cohort_status,
439
- 'priority': cohort_priority,
440
- }
441
-
442
- # Emit candidate lines in held_files order.
443
- total = 0
444
- resolved = 0
445
- vp_blocked = 0
446
- halts = 0
447
-
448
- for changeset_path in held_files:
449
- total += 1
450
- basename = os.path.basename(changeset_path)
451
- info = per_changeset[basename]
452
- cohort_id = cohort_map.get(basename)
453
- is_cohort = cohort_id is not None and cohort_id in cohort_rollup
454
-
455
- if is_cohort:
456
- rollup = cohort_rollup[cohort_id]
457
- # Use cohort-level priority + status; ticket_label remains member-local
458
- # so audit trail still cites the specific resolved ticket.
459
- priority_str = '-' if rollup['priority'] is None else str(rollup['priority'])
460
- ticket_label = info['ticket_label']
461
- status = rollup['status']
462
- print(
463
- f'GRADUATION_CANDIDATE: changeset={basename} | ticket={ticket_label} | '
464
- f'priority={priority_str} | class=3b | cohort={cohort_id} | status={status}'
465
- )
466
- else:
467
- priority_str = '-' if info['priority'] is None else str(info['priority'])
468
- print(
469
- f'GRADUATION_CANDIDATE: changeset={basename} | ticket={info["ticket_label"]} | '
470
- f'priority={priority_str} | class=3a | status={info["status"]}'
471
- )
472
-
473
- # Tally member-level counts (cohorts count per-member for backward compat).
474
- effective_status = cohort_rollup[cohort_id]['status'] if is_cohort else info['status']
475
- if effective_status == 'resolved':
476
- resolved += 1
477
- elif effective_status == 'vp-blocked':
478
- vp_blocked += 1
479
- elif effective_status == 'halt-no-resolution':
480
- halts += 1
481
-
482
- print(f'GRADUATION_SUMMARY: total={total} resolved={resolved} vp_blocked={vp_blocked} halts={halts}')
483
- PYEOF
484
- )
485
- PY_STATUS=$?
486
-
487
- echo "$EVAL_RESULT"
488
-
489
- if [ "$PY_STATUS" -ne 0 ]; then
490
- exit "$PY_STATUS"
491
- fi
492
-
493
- exit 0