@metasession.co/devaudit-cli 0.3.9 → 0.3.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@metasession.co/devaudit-cli",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "description": "DevAudit CLI — installs, syncs, and operates the Metasession SDLC across consumer projects.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@clack/prompts": "^0.8.2",
36
- "@metasession.co/devaudit-plugin-sdk": "^0.3.9",
36
+ "@metasession.co/devaudit-plugin-sdk": "^0.3.11",
37
37
  "ajv": "^8.20.0",
38
38
  "commander": "^12.1.0",
39
39
  "consola": "^3.2.3",
@@ -76,6 +76,17 @@ Sync may warn if these are absent; it's a soft check (the CI workflow is the har
76
76
  | `config_keys.optional` | string[] | Keys consumers may define (e.g. an optional database service). |
77
77
  | `config_keys.defaults` | object | Fallback values when a key is absent. |
78
78
 
79
+ ### Runtime contract
80
+
81
+ | Field | Type | Purpose |
82
+ | ----------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------- |
83
+ | `runtime_contract.preferred_web_runtime` | string | Operator-facing guidance for the expected production web runtime shape on this host. |
84
+ | `runtime_contract.forbid_typescript_runtime` | boolean | Whether long-lived production web processes on this host should forbid `tsx`/`ts-node` style execution. |
85
+ | `runtime_contract.prefer_standalone_output` | boolean | Whether frameworks with a standalone/minimal runtime output should prefer it on this host. |
86
+ | `runtime_contract.scheduler_placement` | string | Operator-facing guidance for where scheduled/background jobs should run on this host. |
87
+
88
+ This field is not consumed by templates yet. It exists so host-level runtime-efficiency rules are versioned and schema-validated instead of living as tribal knowledge in issues and comments.
89
+
79
90
  ### Notes
80
91
 
81
92
  | Field | Type | Purpose |
@@ -103,6 +114,12 @@ See the step-by-step walkthrough: **[docs/adding-a-host.md](../docs/adding-a-hos
103
114
  "wait_for_deploy": "for i in $(seq 1 30); do flyctl status --app \"${APP_NAME}\" --json | jq -e '.Deployment.Status == \"successful\"' && break; sleep 10; done",
104
115
  "required_secrets": ["FLY_API_TOKEN", "DEVAUDIT_API_KEY"],
105
116
  "required_env": ["APP_NAME"],
117
+ "runtime_contract": {
118
+ "preferred_web_runtime": "compiled JavaScript or framework standalone output",
119
+ "forbid_typescript_runtime": true,
120
+ "prefer_standalone_output": true,
121
+ "scheduler_placement": "scheduled jobs should run in a separate worker or platform cron, not in the web process"
122
+ },
106
123
  "notes": [
107
124
  "Production URL is resolved at deploy time by flyctl — no GitHub Secret to maintain.",
108
125
  "Deploy step runs `flyctl deploy --remote-only` in the post-deploy workflow; FLY_API_TOKEN is the auth."
package/sdlc/SKILLS.md CHANGED
@@ -112,6 +112,14 @@ node scripts/validate-adapter.cjs sdlc/files/_common/skills/<name>/SKILL.md
112
112
 
113
113
  These delegations are hard contracts — the orchestrator's SKILL.md fails review if it inlines a specialist skill's procedure. The invocation pattern is documented in [docs/adding-a-skill.md §Orchestrator skills](../docs/adding-a-skill.md#orchestrator-skills-calling-other-skills).
114
114
 
115
+ Phase 4 is now more than a one-shot handoff note. When a release PR is blocked or waiting, `sdlc-implementer` can hand execution to the bundled watcher:
116
+
117
+ ```bash
118
+ node SDLC/bin/devaudit-sdlc.js --watch-pr=<number> --repo <owner/name> --once
119
+ ```
120
+
121
+ or, without `--once`, a bounded poll loop that persists retry state in `.sdlc-pr-watch.json`, re-runs likely flaky workflows, and re-runs the Release Approval Gate when the portal is already approved but GitHub has not yet converged. That makes "blocked PR handling" an executable part of the orchestration contract rather than a prose-only reminder.
122
+
115
123
  `sdlc-implementer` is **not** used for trivial / housekeeping changes (docs, formatting, dependency bumps, CI tweaks) — those skip the requirement and the ceremony. See [the change-type matrix](../docs/change-workflows.md) and the [trivial-change walkthrough](./files/_common/implementing-an-sdlc-issue.md#trivial-change-walkthrough).
116
124
 
117
125
  ### The SoT-alignment skill family
@@ -152,9 +152,10 @@ Two distinct credentials exist; conflating them is what causes the silent-CI-tok
152
152
  |---|---|---|---|---|
153
153
  | **Personal PAT** | `mctok_…` | `~/.config/devaudit/auth.json` (per developer) | You (the user) | Your local CLI commands |
154
154
  | **Project API key** | `dak_…` | Repo secret `DEVAUDIT_API_KEY` | The project | CI's `devaudit push` calls |
155
- | **Operator's PAT** | `mctok_…` | Repo secret `DEVAUDIT_USER_TOKEN` | The operator (singular) | CI's mutation calls (release register, approval submit) |
155
+ | **Operator's PAT** | `mctok_…` | Repo secret `DEVAUDIT_USER_TOKEN` | The operator (singular) | CI's DevAudit portal calls that need human attribution (release submit / approval flows) |
156
+ | **GitHub workflow token** | `${{ github.token }}` | Issued automatically per workflow run | The GitHub Actions workflow | CI's GitHub repo mutations (checkout, branch push, PR, issue, comment, label, check-run updates) |
156
157
 
157
- **Never paste your personal PAT into a repo secret.** Repo `DEVAUDIT_USER_TOKEN` is operator-owned. CI portal mutations are attributed to whoever's PAT is there — if it's yours, your name shows up against every release the team ships, and the moment your PAT expires CI breaks. Rotation belongs to the operator: `devaudit install --force-team-config` on their machine.
158
+ **Never paste your personal PAT into a repo secret.** Repo `DEVAUDIT_USER_TOKEN` is operator-owned and is not the default GitHub auth path for workflow repo mutations. CI portal mutations are attributed to whoever's PAT is there — if it's yours, your name shows up against every release the team ships, and the moment your PAT expires CI portal actions break. Rotation belongs to the operator: `devaudit install --force-team-config` on their machine.
158
159
 
159
160
  If `devaudit auth status` shows the wrong user, run `devaudit auth logout && devaudit auth login` and paste the right PAT.
160
161
 
@@ -181,10 +182,10 @@ The synced CI gates expect a specific environment. Here's what you need locally
181
182
 
182
183
  | Surface | CI | Local (your machine) |
183
184
  |---|---|---|
184
- | Personal identity | `secrets.DEVAUDIT_USER_TOKEN` (operator's) | `~/.config/devaudit/auth.json` (yours) — `devaudit auth login` |
185
+ | Personal identity | `secrets.DEVAUDIT_USER_TOKEN` (operator's, portal-only) | `~/.config/devaudit/auth.json` (yours) — `devaudit auth login` |
185
186
  | Project API key | `secrets.DEVAUDIT_API_KEY` | Usually unset locally — only needed if you're testing `devaudit push` against the live portal; ask the operator if you need to debug it |
186
187
  | Portal URL | `vars.DEVAUDIT_BASE_URL` | `~/.config/devaudit/auth.json` (set by `auth login`) or `$DEVAUDIT_BASE_URL` env |
187
- | GitHub auth | `${{ github.token }}` (auto) | `gh auth login` |
188
+ | GitHub auth | `${{ github.token }}` (auto, repo mutations) | `gh auth login` |
188
189
  | Node | matrix-pinned to project's `node_version` | nvm / volta / whatever — `devaudit doctor` checks ≥ 22 |
189
190
  | `jq` / `curl` | always present on GH runners | install via package manager — `devaudit doctor` flags absence |
190
191
 
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env bash
2
+ # close-out-contract.test.sh — Tests for the close-out marker helper.
3
+ #
4
+ # Usage:
5
+ # ./scripts/close-out-contract.test.sh
6
+
7
+ set -euo pipefail
8
+
9
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
10
+ # shellcheck source=/dev/null
11
+ source "$SCRIPT_DIR/close-out-contract.sh"
12
+
13
+ PASS=0
14
+ FAIL=0
15
+
16
+ assert_eq() {
17
+ local desc="$1" want="$2" got="$3"
18
+ if [ "$got" = "$want" ]; then
19
+ echo " PASS: $desc"
20
+ PASS=$((PASS + 1))
21
+ else
22
+ echo " FAIL: $desc"
23
+ echo " want: $want"
24
+ echo " got: $got"
25
+ FAIL=$((FAIL + 1))
26
+ fi
27
+ }
28
+
29
+ assert_true() {
30
+ local desc="$1"
31
+ shift
32
+ if "$@"; then
33
+ echo " PASS: $desc"
34
+ PASS=$((PASS + 1))
35
+ else
36
+ echo " FAIL: $desc"
37
+ FAIL=$((FAIL + 1))
38
+ fi
39
+ }
40
+
41
+ assert_false() {
42
+ local desc="$1"
43
+ shift
44
+ if "$@"; then
45
+ echo " FAIL: $desc"
46
+ FAIL=$((FAIL + 1))
47
+ else
48
+ echo " PASS: $desc"
49
+ PASS=$((PASS + 1))
50
+ fi
51
+ }
52
+
53
+ echo "=== close-out-contract.sh tests ==="
54
+
55
+ assert_eq "branch prefix constant" "chore/close-out-" "$CLOSEOUT_BRANCH_PREFIX"
56
+ assert_eq "marker prefix constant" "Release-Closeout:" "$CLOSEOUT_MARKER_PREFIX"
57
+ assert_eq "closeout_marker emits structured line" "Release-Closeout: REQ-090" "$(closeout_marker REQ-090)"
58
+
59
+ assert_true "is_closeout_marker accepts canonical marker" is_closeout_marker "Release-Closeout: REQ-090"
60
+ assert_true "is_closeout_marker accepts extra whitespace" is_closeout_marker "Release-Closeout: REQ-123"
61
+ assert_false "is_closeout_marker rejects wrong prefix" is_closeout_marker "Closeout: REQ-090"
62
+ assert_false "is_closeout_marker rejects short req ids" is_closeout_marker "Release-Closeout: REQ-9"
63
+ assert_false "is_closeout_marker rejects bare prose mention" is_closeout_marker "This closes REQ-090 after release."
64
+
65
+ assert_eq "closeout_marker_req extracts req id" "REQ-090" "$(closeout_marker_req "Release-Closeout: REQ-090")"
66
+ assert_eq "closeout_marker_req extracts first req id from mixed text" "REQ-111" "$(closeout_marker_req $'x\nRelease-Closeout: REQ-111\nmore')"
67
+ assert_eq "closeout_marker_req returns empty when marker absent" "" "$(closeout_marker_req "no marker here" || true)"
68
+
69
+ echo
70
+ echo "Result: $PASS passed, $FAIL failed"
71
+ [ "$FAIL" = "0" ]
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env bash
2
+ # prepare-release-pr.sh — ensure there is one truthful release PR.
3
+ #
4
+ # Usage:
5
+ # ./scripts/prepare-release-pr.sh [--apply] [--mode=update|recreate]
6
+ #
7
+ # Default mode is dry-run + update. Dry-run prints the action it would take.
8
+ # With --apply:
9
+ # - no existing PR: creates one
10
+ # - current PR: updates title/body only when needed
11
+ # - stale PR + mode=update: rewrites title/body and comments
12
+ # - stale PR + mode=recreate: closes old PR and opens a fresh one
13
+
14
+ set -euo pipefail
15
+
16
+ APPLY=false
17
+ MODE="update"
18
+ for arg in "$@"; do
19
+ case "$arg" in
20
+ --apply) APPLY=true ;;
21
+ --mode=update) MODE="update" ;;
22
+ --mode=recreate) MODE="recreate" ;;
23
+ *) echo "Unknown argument: $arg" >&2; exit 2 ;;
24
+ esac
25
+ done
26
+
27
+ INTEGRATION_BRANCH=$(jq -r '.integration_branch // "develop"' sdlc-config.json 2>/dev/null || echo "develop")
28
+ RELEASE_BRANCH=$(jq -r '.release_branch // "main"' sdlc-config.json 2>/dev/null || echo "main")
29
+
30
+ if [ ! -x scripts/derive-release-version.sh ]; then
31
+ chmod +x scripts/derive-release-version.sh 2>/dev/null || true
32
+ fi
33
+ CURRENT_RELEASE=$(./scripts/derive-release-version.sh)
34
+ if [ -z "$CURRENT_RELEASE" ]; then
35
+ echo "No active release version derived; close-out reconciliation appears in progress." >&2
36
+ exit 1
37
+ fi
38
+
39
+ TITLE="Release: ${CURRENT_RELEASE}"
40
+ BODY=$(cat <<EOF
41
+ ## Release
42
+
43
+ - Release: ${CURRENT_RELEASE}
44
+ - Base: ${RELEASE_BRANCH}
45
+ - Head: ${INTEGRATION_BRANCH}
46
+
47
+ ## DevAudit
48
+
49
+ - Release context is derived from the current ${INTEGRATION_BRANCH} head.
50
+ - If this PR replaced an older release PR, the older PR was superseded because it no longer matched the governing release context.
51
+ EOF
52
+ )
53
+
54
+ OPEN_PRS=$(gh pr list --base "$RELEASE_BRANCH" --head "$INTEGRATION_BRANCH" --state open --json number,title,body,url --limit 10)
55
+ COUNT=$(echo "$OPEN_PRS" | jq 'length')
56
+
57
+ if [ "$COUNT" -eq 0 ]; then
58
+ echo "No open ${INTEGRATION_BRANCH}->${RELEASE_BRANCH} PR found."
59
+ if [ "$APPLY" = "true" ]; then
60
+ gh pr create --base "$RELEASE_BRANCH" --head "$INTEGRATION_BRANCH" --title "$TITLE" --body "$BODY"
61
+ else
62
+ echo "Dry run: would create release PR titled '$TITLE'."
63
+ fi
64
+ exit 0
65
+ fi
66
+
67
+ if [ "$COUNT" -gt 1 ]; then
68
+ echo "ERROR: ${COUNT} open ${INTEGRATION_BRANCH}->${RELEASE_BRANCH} PRs found. Resolve duplicates manually." >&2
69
+ echo "$OPEN_PRS" | jq -r '.[] | " #\(.number) \(.title) \(.url)"' >&2
70
+ exit 1
71
+ fi
72
+
73
+ PR_NUMBER=$(echo "$OPEN_PRS" | jq -r '.[0].number')
74
+ PR_TITLE=$(echo "$OPEN_PRS" | jq -r '.[0].title // ""')
75
+ PR_BODY=$(echo "$OPEN_PRS" | jq -r '.[0].body // ""')
76
+ PR_URL=$(echo "$OPEN_PRS" | jq -r '.[0].url')
77
+
78
+ if printf '%s\n%s\n' "$PR_TITLE" "$PR_BODY" | grep -q "$CURRENT_RELEASE"; then
79
+ echo "Open release PR #${PR_NUMBER} already matches ${CURRENT_RELEASE}: ${PR_URL}"
80
+ exit 0
81
+ fi
82
+
83
+ echo "Open release PR #${PR_NUMBER} is stale for current release ${CURRENT_RELEASE}: ${PR_URL}"
84
+ echo "Current title: ${PR_TITLE}"
85
+
86
+ if [ "$APPLY" != "true" ]; then
87
+ echo "Dry run: would ${MODE} stale PR #${PR_NUMBER} for ${CURRENT_RELEASE}."
88
+ exit 0
89
+ fi
90
+
91
+ if [ "$MODE" = "recreate" ]; then
92
+ gh pr comment "$PR_NUMBER" --body "Superseded: current ${INTEGRATION_BRANCH} head now derives ${CURRENT_RELEASE}; this PR no longer matches the governing release context."
93
+ gh pr close "$PR_NUMBER" --comment "Closed as superseded by ${CURRENT_RELEASE} release context."
94
+ gh pr create --base "$RELEASE_BRANCH" --head "$INTEGRATION_BRANCH" --title "$TITLE" --body "$BODY"
95
+ else
96
+ gh pr edit "$PR_NUMBER" --title "$TITLE" --body "$BODY"
97
+ gh pr comment "$PR_NUMBER" --body "Release PR context refreshed: current ${INTEGRATION_BRANCH} head derives ${CURRENT_RELEASE}."
98
+ echo "Updated PR #${PR_NUMBER} to ${CURRENT_RELEASE}: ${PR_URL}"
99
+ fi
@@ -37,6 +37,23 @@ fi
37
37
  TOTAL=0
38
38
  FAILED=0
39
39
 
40
+ ACTIVE_RELEASE_REQS=""
41
+ if [ -d compliance/pending-releases ]; then
42
+ ACTIVE_RELEASE_REQS=$(find compliance/pending-releases -maxdepth 1 -type f \
43
+ -name 'RELEASE-TICKET-REQ-*.md' -print 2>/dev/null \
44
+ | sed -E 's|.*/RELEASE-TICKET-(REQ-[0-9]+)\.md$|\1|' | sort -u || true)
45
+ fi
46
+ if [ -z "$ACTIVE_RELEASE_REQS" ] && [ -f compliance/RTM.md ]; then
47
+ ACTIVE_RELEASE_REQS=$(sed 's/\\|/ /g' compliance/RTM.md 2>/dev/null \
48
+ | grep -E '\|[[:space:]]+IN PROGRESS|\|[[:space:]]+TESTED - PENDING SIGN-OFF' \
49
+ | grep -oE '^\|[[:space:]]*REQ-[0-9]+' \
50
+ | grep -oE 'REQ-[0-9]+' | sort -u || true)
51
+ fi
52
+ ACTIVE_RELEASE_REQ_COUNT=0
53
+ if [ -n "$ACTIVE_RELEASE_REQS" ]; then
54
+ ACTIVE_RELEASE_REQ_COUNT=$(printf '%s\n' "$ACTIVE_RELEASE_REQS" | grep -c . || true)
55
+ fi
56
+
40
57
  while IFS= read -r sha; do
41
58
  TOTAL=$((TOTAL + 1))
42
59
  SUBJECT=$(git log -1 --format='%s' "$sha")
@@ -66,13 +83,21 @@ while IFS= read -r sha; do
66
83
  feat|fix|refactor|perf)
67
84
  if ! echo "$SUBJECT" | grep -qP '\[REQ-\d{3,}\]' \
68
85
  && ! echo "$BODY" | grep -qiP 'Ref:\s*REQ-\d{3,}'; then
69
- echo "ERROR [$SHORT]: '$TYPE' is an implementation commit but cites no requirement."
70
- echo " Add [REQ-XXX] to the subject or a 'Ref: REQ-XXX' trailer. Start work"
71
- echo " from a requirement via the sdlc-implementer skill (it assigns the REQ"
72
- echo " from the originating issue in Phase 1)."
73
- FAILED=$((FAILED + 1))
74
- EXIT_CODE=1
75
- continue
86
+ if [ "$ACTIVE_RELEASE_REQ_COUNT" -eq 1 ]; then
87
+ ACTIVE_REQ=$(printf '%s\n' "$ACTIVE_RELEASE_REQS" | head -1)
88
+ echo "WARNING [$SHORT]: '$TYPE' cites no requirement, but the branch has one active tracked release (${ACTIVE_REQ})."
89
+ echo " Treating the release ticket / RTM context as authoritative for this already-merged history."
90
+ echo " Add a follow-up compliance note if this was not an intentional recovery path."
91
+ WARN_COUNT=$((WARN_COUNT + 1))
92
+ else
93
+ echo "ERROR [$SHORT]: '$TYPE' is an implementation commit but cites no requirement."
94
+ echo " Add [REQ-XXX] to the subject or a 'Ref: REQ-XXX' trailer. Start work"
95
+ echo " from a requirement via the sdlc-implementer skill (it assigns the REQ"
96
+ echo " from the originating issue in Phase 1)."
97
+ FAILED=$((FAILED + 1))
98
+ EXIT_CODE=1
99
+ continue
100
+ fi
76
101
  fi
77
102
 
78
103
  # RTM provenance check (devaudit-installer#226): verify the REQ-XXX
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env bash
2
+ # validate-commits.test.sh — Fixture-based tests for validate-commits.sh.
3
+ #
4
+ # Covers the relaxed "exactly one active tracked release" behavior:
5
+ # implementation commits without REQ tags downgrade to warnings only when
6
+ # release context is unambiguous; otherwise they remain hard errors.
7
+ #
8
+ # Usage:
9
+ # ./scripts/validate-commits.test.sh
10
+
11
+ set -euo pipefail
12
+
13
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
14
+ VALIDATOR="$SCRIPT_DIR/validate-commits.sh"
15
+ [ -x "$VALIDATOR" ] || chmod +x "$VALIDATOR"
16
+
17
+ PASS=0
18
+ FAIL=0
19
+
20
+ make_fixture() {
21
+ local dir="$1" subject="$2" body="${3:-}"
22
+ rm -rf "$dir"
23
+ mkdir -p "$dir"
24
+ cd "$dir"
25
+ git init -q --initial-branch=main
26
+ git config user.email "test@example.com"
27
+ git config user.name "test"
28
+ echo "base" > base.txt
29
+ git add base.txt
30
+ git commit -q -m "chore: base"
31
+
32
+ git checkout -q -b feature
33
+ echo "feature" > app.ts
34
+ git add app.ts
35
+ if [ -n "$body" ]; then
36
+ git commit -q -m "$subject" -m "$body"
37
+ else
38
+ git commit -q -m "$subject"
39
+ fi
40
+ }
41
+
42
+ run_validator() {
43
+ set +e
44
+ OUT_FILE=$(mktemp)
45
+ bash "$VALIDATOR" main > "$OUT_FILE" 2>&1
46
+ LAST_EXIT=$?
47
+ set -e
48
+ }
49
+
50
+ assert_exit() {
51
+ local desc="$1" want="$2"
52
+ if [ "$LAST_EXIT" = "$want" ]; then
53
+ echo " PASS: $desc (exit=$LAST_EXIT)"
54
+ PASS=$((PASS + 1))
55
+ else
56
+ echo " FAIL: $desc (want=$want, got=$LAST_EXIT)"
57
+ sed 's/^/ /' "$OUT_FILE"
58
+ FAIL=$((FAIL + 1))
59
+ fi
60
+ }
61
+
62
+ assert_grep() {
63
+ local desc="$1" pattern="$2" want_match="$3"
64
+ local found=0
65
+ if grep -qE "$pattern" "$OUT_FILE"; then
66
+ found=1
67
+ fi
68
+ if [ "$found" = "$want_match" ]; then
69
+ echo " PASS: $desc"
70
+ PASS=$((PASS + 1))
71
+ else
72
+ echo " FAIL: $desc (want match=$want_match, got=$found, pattern=$pattern)"
73
+ sed 's/^/ /' "$OUT_FILE"
74
+ FAIL=$((FAIL + 1))
75
+ fi
76
+ }
77
+
78
+ WORKDIR=$(mktemp -d -t validate-commits-test-XXXX)
79
+ trap 'rm -rf "$WORKDIR"' EXIT
80
+
81
+ echo "=== validate-commits.sh tests ==="
82
+
83
+ # Case 1: one active pending ticket makes a missing-REQ implementation
84
+ # commit a warning, not a hard failure.
85
+ echo "Case 1: single active pending release downgrades missing REQ to warning"
86
+ make_fixture "$WORKDIR/case1" "feat: implement kitchen workflow" "Co-Authored-By: Test <test@example.com>"
87
+ mkdir -p compliance/pending-releases
88
+ cat > compliance/pending-releases/RELEASE-TICKET-REQ-123.md <<'EOF'
89
+ # Release Ticket: REQ-123
90
+ EOF
91
+ git add compliance/pending-releases
92
+ git commit -q --amend --no-edit
93
+ run_validator
94
+ assert_exit "single active pending release exits 0" 0
95
+ assert_grep "warning emitted for missing REQ with one active release" 'WARNING .*one active tracked release \(REQ-123\)' 1
96
+ assert_grep "no hard error for missing requirement" "ERROR .*implementation commit but cites no requirement" 0
97
+
98
+ # Case 2: ambiguous pending release context keeps the missing-REQ path as
99
+ # a hard error.
100
+ echo "Case 2: two active pending releases keep missing REQ as hard error"
101
+ make_fixture "$WORKDIR/case2" "feat: implement kitchen workflow" "Co-Authored-By: Test <test@example.com>"
102
+ mkdir -p compliance/pending-releases
103
+ cat > compliance/pending-releases/RELEASE-TICKET-REQ-123.md <<'EOF'
104
+ # Release Ticket: REQ-123
105
+ EOF
106
+ cat > compliance/pending-releases/RELEASE-TICKET-REQ-124.md <<'EOF'
107
+ # Release Ticket: REQ-124
108
+ EOF
109
+ git add compliance/pending-releases
110
+ git commit -q --amend --no-edit
111
+ run_validator
112
+ assert_exit "ambiguous pending releases exits 1" 1
113
+ assert_grep "hard error emitted when active context is ambiguous" "ERROR .*implementation commit but cites no requirement" 1
114
+
115
+ # Case 3: no pending ticket, but exactly one active RTM row in the
116
+ # accepted statuses also downgrades to a warning.
117
+ echo "Case 3: RTM fallback with one active row downgrades missing REQ to warning"
118
+ make_fixture "$WORKDIR/case3" "fix: recover already-merged history" "Co-Authored-By: Test <test@example.com>"
119
+ mkdir -p compliance
120
+ cat > compliance/RTM.md <<'EOF'
121
+ # RTM
122
+ | REQ-ID | Title | Status |
123
+ | ------- | ----- | ------------------------- |
124
+ | REQ-200 | Done | APPROVED - DEPLOYED |
125
+ | REQ-201 | Live | TESTED - PENDING SIGN-OFF |
126
+ EOF
127
+ git add compliance/RTM.md
128
+ git commit -q --amend --no-edit
129
+ run_validator
130
+ assert_exit "single active RTM row exits 0" 0
131
+ assert_grep "warning emitted for RTM fallback context" 'WARNING .*one active tracked release \(REQ-201\)' 1
132
+ assert_grep "no hard error under single RTM fallback context" "ERROR .*implementation commit but cites no requirement" 0
133
+
134
+ # Case 4: zero active context remains a hard error.
135
+ echo "Case 4: no active release context keeps missing REQ as hard error"
136
+ make_fixture "$WORKDIR/case4" "perf: tune kitchen workflow" "Co-Authored-By: Test <test@example.com>"
137
+ run_validator
138
+ assert_exit "no active context exits 1" 1
139
+ assert_grep "hard error emitted without any active release context" "ERROR .*implementation commit but cites no requirement" 1
140
+
141
+ echo
142
+ echo "Result: $PASS passed, $FAIL failed"
143
+ [ "$FAIL" = "0" ]
@@ -553,7 +553,17 @@ Reached only on the **tracked** route from Phase 0 (the issue is already fetched
553
553
 
554
554
  ### Phase 4 — Submit for UAT review (SDLC stage 4)
555
555
 
556
- 1. **Open the release PR** — the PR that carries the UAT four-eyes approval gate (`check-release-approval.yml`), always into `$RELEASE_BRANCH`: `gh pr create --base "$RELEASE_BRANCH" --head "$INTEGRATION_BRANCH"` (e.g. `develop → main`). The implementation already landed on `$INTEGRATION_BRANCH` in Phase 2; this promotes it. (Note: if other work is also waiting on `$INTEGRATION_BRANCH`, this is a bundled release — every in-scope REQ keeps its own release record and Production approval.)
556
+ 1. **Prepare the truthful release PR** — the PR that carries the UAT four-eyes approval gate (`check-release-approval.yml`), always into `$RELEASE_BRANCH` from `$INTEGRATION_BRANCH` (e.g. `develop → main`). The implementation already landed on `$INTEGRATION_BRANCH` in Phase 2; this promotes it. (Note: if other work is also waiting on `$INTEGRATION_BRANCH`, this is a bundled release — every in-scope REQ keeps its own release record and Production approval.)
557
+
558
+ Before creating a PR manually, run the synced helper:
559
+
560
+ ```bash
561
+ ./scripts/prepare-release-pr.sh --apply --mode=update
562
+ ```
563
+
564
+ This is mandatory for devaudit-installer#312. It detects an existing open `$INTEGRATION_BRANCH → $RELEASE_BRANCH` PR, derives the current governing release from the current head, and ensures there is exactly one truthful release PR. If the existing PR is stale, the default `update` mode rewrites the title/body and comments with the refresh. Use `--mode=recreate` only when the operator explicitly wants the older PR closed and a new PR opened.
565
+
566
+ If the helper reports multiple open release PRs, halt with: "Multiple open release PRs for `$INTEGRATION_BRANCH → $RELEASE_BRANCH`; operator action — close duplicates or choose the authoritative PR, then resume REQ-XXX." Do not open another PR.
557
567
 
558
568
  PR body per the SDLC PR template (see [`.github/pull_request_template.md`](../../../../../.github/pull_request_template.md)):
559
569
  - Closes #N
@@ -571,8 +581,30 @@ Reached only on the **tracked** route from Phase 0 (the issue is already fetched
571
581
 
572
582
  3. **Apply labels** — `awaiting-uat-review`, `risk:<class>`.
573
583
  4. **Comment on the issue**: "Implementation complete. PR #M opened. Evidence on portal: <link>. UAT review requested. Resume with `resume REQ-XXX` once UAT approval is granted on the portal."
574
- 5. **Hard stop.** Phase 4 ends here. Do not proceed to merge; the human's next action is reviewing on the portal.
575
- 6. **Update SDLC status sticky** before halting: `bash scripts/update-sdlc-status.sh "$ISSUE_NUM" "Phase 4 release PR #<N> opened against $RELEASE_BRANCH; CI running" "Operator action — review PR #<N> + approve UAT release on the portal; sdlc-implementer halts until you ping resume REQ-XXX"`. This is a critical handoff — the sticky must reflect that the agent has stopped + the operator is on the hook.
584
+ 5. **Inspect PR blockers before handoff (devaudit-installer#304).** Run `gh pr view <N> --json mergeStateStatus,reviewDecision,statusCheckRollup,url` and categorize the state:
585
+ - `reviewDecision` not approved human approval blocker; hand off to the reviewer.
586
+ - Release Approval Gate failing while portal state is not `uat_approved` → portal approval blocker; hand off to portal UAT reviewer.
587
+ - Release Approval Gate failing while portal state is `uat_approved` → retry path below.
588
+ - Compliance validation / evidence completeness failing → real workflow or evidence defect; fix or create a follow-up PR before handoff.
589
+ - Checks pending → wait/poll until terminal before declaring the PR ready.
590
+ - Stale PR context detected by `prepare-release-pr.sh` → rerun the helper in `--apply` mode and repeat this blocker inspection.
591
+
592
+ **Executable loop (devaudit-installer#304).** Use the bundled watcher rather than a one-shot `gh` read when the PR is blocked or waiting:
593
+
594
+ ```bash
595
+ node SDLC/bin/devaudit-sdlc.js \
596
+ --watch-pr=<N> \
597
+ --repo <owner/name> \
598
+ --req XXX \
599
+ --release REQ-XXX \
600
+ --project-slug <project-slug> \
601
+ --base-url <devaudit-base-url>
602
+ ```
603
+
604
+ The watcher persists retry state in `.sdlc-pr-watch.json`, polls `gh pr view` + `gh pr checks`, re-runs likely flaky workflows automatically, and re-runs the Release Approval Gate when the portal is already approved but GitHub has not converged yet. Use `--once` when you only need a single classification pass.
605
+
606
+ 6. **Hard stop only after blocker classification.** Phase 4 ends here. Do not proceed to merge; the human's next action is reviewing on the portal or clearing the categorized blocker above.
607
+ 7. **Update SDLC status sticky** before halting: `bash scripts/update-sdlc-status.sh "$ISSUE_NUM" "Phase 4 — truthful release PR #<N> prepared against $RELEASE_BRANCH; blockers classified" "Operator action — review PR #<N> + approve UAT release on the portal; sdlc-implementer halts until you ping resume REQ-XXX"`. This is a critical handoff — the sticky must reflect that the agent has stopped + the operator is on the hook.
576
608
 
577
609
  **When an external gate hangs or fails for unrelated reasons.** A required gate may fail for reasons outside the change's scope — flaky infra, an unrelated regression test that hangs at hour-plus runtime with no log activity, a known-failing suite. When this happens:
578
610
 
@@ -685,6 +717,10 @@ The native agent must NOT continue to the next phase (Phase 3 → Phase 4, Phase
685
717
 
686
718
  **PR merged to main ≠ done.** After the release PR is merged to `main`, the native agent must re-invoke the skill for Phase 5 close-out (see Phase 5 step 0 below). The merge is not the end of the workflow — post-merge compliance steps are mandatory.
687
719
 
720
+ ### Commit-history hygiene recovery (devaudit-installer#315)
721
+
722
+ Commit-level `[REQ-XXX]` hygiene is preventive, not the strongest source of release truth after history has already merged to a protected integration branch. If CI reports a missing commit-level REQ after the branch has exactly one active tracked release ticket or one matching RTM row, treat the release ticket / RTM context as authoritative and add a follow-up compliance note instead of rewriting protected history. The generated `validate-commits.sh` implements this as a warning in the exactly-one-active-release case. If there are zero or multiple active tracked releases, the validation remains a hard error because the release context is ambiguous.
723
+
688
724
  ### Commit-scoping rule for SRS updates (devaudit-installer#226)
689
725
 
690
726
  When a commit is authored as part of REQ-XXX's SDLC flow, the commit subject must cite `[REQ-XXX]` — the **active REQ** driving the current SDLC flow — regardless of which other REQs the content touches. Other REQs may appear in the body for traceability, but never in the subject.
@@ -32,6 +32,10 @@ concurrency:
32
32
  group: ${{ github.workflow }}-${{ github.ref }}
33
33
  cancel-in-progress: true
34
34
 
35
+ permissions:
36
+ contents: read
37
+ statuses: write
38
+
35
39
  jobs:
36
40
  quality-gates:
37
41
  name: Quality Gates
@@ -783,13 +783,13 @@ jobs:
783
783
  # "zero traceable evidence" even though well-written, REQ-tagged
784
784
  # specs exist on disk. If specs are found but weren't run in
785
785
  # this tier, emit a non-blocking warning instead of an error.
786
- SPECS_ON_DISK=$(grep -rl --include='*.spec.ts' -e "@requirement ${REQ}" -e "tagTest.*${REQ}" -e "'${REQ}'" e2e/ 2>/dev/null | wc -l)
786
+ SPECS_ON_DISK=$( { grep -rl --include='*.spec.ts' -e "@requirement ${REQ}" -e "tagTest.*${REQ}" -e "'${REQ}'" e2e/ 2>/dev/null || true; } | wc -l | tr -d ' ')
787
787
 
788
788
  # devaudit-installer#237 — also scan unit test files for
789
789
  # @requirement REQ-XXX annotations. A REQ with thorough unit
790
790
  # test coverage but no E2E specs (legitimate for API-only or
791
791
  # backend-only changes) should not be hard-blocked.
792
- UNIT_TESTS_ON_DISK=$(grep -rl --include='*.test.ts' --include='*.test.tsx' -e "@requirement ${REQ}" -e "'${REQ}'" e2e/ __tests__/ tests/ src/ 2>/dev/null | wc -l)
792
+ UNIT_TESTS_ON_DISK=$( { grep -rl --include='*.test.ts' --include='*.test.tsx' -e "@requirement ${REQ}" -e "'${REQ}'" e2e/ __tests__/ tests/ src/ 2>/dev/null || true; } | wc -l | tr -d ' ')
793
793
 
794
794
  # devaudit-installer#237 — check for test-execution-summary.md
795
795
  # as a secondary signal. This is a human-authored document and
@@ -638,6 +638,7 @@ jobs:
638
638
  permissions:
639
639
  contents: read
640
640
  actions: read
641
+ issues: write
641
642
  env:
642
643
  DEVAUDIT_BASE_URL_VAR: ${{ vars.DEVAUDIT_BASE_URL }}
643
644
  DEVAUDIT_API_KEY: ${{ secrets.DEVAUDIT_API_KEY }}
@@ -682,7 +683,7 @@ jobs:
682
683
  name: e2e-regression-report
683
684
  path: e2e-artifacts/
684
685
  run-id: ${{ github.event.workflow_run.id }}
685
- github-token: ${{ secrets.GITHUB_TOKEN }}
686
+ github-token: ${{ github.token }}
686
687
  continue-on-error: true
687
688
 
688
689
  - name: Derive release version
@@ -726,8 +727,9 @@ jobs:
726
727
  FLAGS="${FLAGS} --test-cycle ${{ github.event.workflow_run.id }}"
727
728
 
728
729
  # In-scope REQs from pending release tickets at the triggering
729
- # SHA. Fall back to `_compliance-docs` if no tickets present so
730
- # the artefact at least lands somewhere visible.
730
+ # SHA. If the ticket was created after the triggering E2E run,
731
+ # recover REQs from the Playwright JSON artifact before falling
732
+ # back to `_compliance-docs` (DevAudit-Installer#311).
731
733
  REQS=()
732
734
  if [ -d compliance/pending-releases ]; then
733
735
  for TICKET in compliance/pending-releases/RELEASE-TICKET-REQ-*.md; do
@@ -742,6 +744,47 @@ jobs:
742
744
  REQS+=("$REQ_ID")
743
745
  done
744
746
  fi
747
+
748
+ if [ "${#REQS[@]}" -eq 0 ] && [ -f e2e-artifacts/e2e-regression-results.json ]; then
749
+ while IFS= read -r REQ_ID; do
750
+ [ -n "$REQ_ID" ] || continue
751
+ REQS+=("$REQ_ID")
752
+ done < <(python3 - <<'PY'
753
+ import json
754
+ import re
755
+ import sys
756
+
757
+ try:
758
+ with open('e2e-artifacts/e2e-regression-results.json', 'r', encoding='utf-8') as handle:
759
+ data = json.load(handle)
760
+ except Exception:
761
+ sys.exit(0)
762
+
763
+ seen = set()
764
+
765
+ def walk(value):
766
+ if isinstance(value, str):
767
+ for req in re.findall(r'REQ-[0-9]+', value):
768
+ seen.add(req)
769
+ elif isinstance(value, dict):
770
+ for item in value.values():
771
+ walk(item)
772
+ elif isinstance(value, list):
773
+ for item in value:
774
+ walk(item)
775
+
776
+ walk(data)
777
+ for req in sorted(seen):
778
+ print(req)
779
+ PY
780
+ )
781
+ fi
782
+
783
+ if [ "${#REQS[@]}" -eq 1 ] && echo "$DERIVED_RELEASE" | grep -qE '^v[0-9]{4}\.[0-9]{2}\.[0-9]{2}([.][0-9]+)?$'; then
784
+ echo "E2E upload recovered tracked release ${REQS[0]} from artifact metadata; overriding bare-date release ${DERIVED_RELEASE}."
785
+ DERIVED_RELEASE="${REQS[0]}"
786
+ fi
787
+
745
788
  if [ "${#REQS[@]}" -eq 0 ]; then
746
789
  REQS=(_compliance-docs)
747
790
  fi
@@ -812,7 +855,9 @@ jobs:
812
855
  - name: Triage and file incidents on E2E Regression failure
813
856
  if: steps.resolve.outputs.skip != 'true' && github.event.workflow_run.conclusion == 'failure'
814
857
  env:
815
- GH_TOKEN: ${{ secrets.DEVAUDIT_USER_TOKEN || github.token }}
858
+ # GitHub issue/label mutations use the workflow token. DevAudit
859
+ # user tokens are portal PATs, not guaranteed GitHub PATs (#305).
860
+ GH_TOKEN: ${{ github.token }}
816
861
  CI_RUN_ID: ${{ github.event.workflow_run.id }}
817
862
  WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}
818
863
  GIT_SHA: ${{ github.event.workflow_run.head_sha }}