Installs into .claude/skills of the current project.
Are you the author of Nightgauge Issue Audit?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/nightgauge-nightgauge-issue-audit)
---
name: nightgauge-issue-audit
description: Deterministic post-creation gate that verifies every issue created by issue-create
(or any creation flow) has the labels, board membership, body sections, sub-issue
links, blockedBy edges, cross-repo references, and knowledge scaffold the pipeline
depends on. Reports findings by severity and offers --fix / --fix-interactive
auto-repair using existing Go binary primitives. Use after every issue-creation
flow or as a CI safety net.
license: Apache-2.0
metadata:
author: nightgauge
version: "1.3.0"
source: https://github.com/nightgauge/nightgauge
chainable: true
allowed-tools: Read Write Edit Glob Grep Bash Task AskUserQuestion
---
# Issue Audit
Deterministic post-creation gate for every issue-creation flow. See
[docs/ISSUE_AUDIT.md](../../docs/ISSUE_AUDIT.md) for the finding taxonomy,
severity rules, manifest schema, and repair-primitive table. This skill
encodes the orchestration; the doc encodes the spec.
## When to Use
- **Terminal pass after `/nightgauge:issue-create`** — runs automatically
unless `--no-audit` is passed
- **After manually creating issues** with `nightgauge forge issue create` followed by board
sync
- **CI safety net** with `--all-recent 1h` to catch any creation flow that
bypassed audit
- **Replaces `/nightgauge:epic-validate`** — that skill is now a thin
wrapper that delegates to `issue-audit --epic <N>`
## Outcomes
- Markdown finding report `issue-audit-<timestamp>.md`, JSON findings
`issue-audit-<timestamp>.json` and JSONL audit trail
`issue-audit-<timestamp>.audit.jsonl`, all in the clone's pipeline state
directory (`nightgauge layout path pipeline`), written through
`nightgauge layout write|append pipeline`
- Exit 0 (READY) when no CRITICAL findings remain; exit 1 (NEEDS FIXES) when
any CRITICAL persists; exit 2 on skill-level failure
- Auto-repair via `--fix` for safe categories (board, fields, links,
blockedBy edges, body Part-of insertion); `--fix-interactive` for
`closed-as-not-planned` blockers
- Hard rules: never auto-rewrite human-authored content; spike-contract
violations stay CRITICAL even with `--fix`
## Input
```
/nightgauge:issue-audit --manifest <path> # strict mode
/nightgauge:issue-audit --epic <N> [--repo <O/R>] # inferential, single epic
/nightgauge:issue-audit --issues <N1,N2,N3> [--repo <O/R>]
/nightgauge:issue-audit --all-recent <duration> # e.g. 1h, 30m
```
Run modes: dry-run (default), `--fix`, `--fix-interactive`.
Output flags: `--json`, `--no-audit-trail`, `--allow-closed`.
## Workflow
### Phase 0: Environment Preflight
<!-- include: ../_shared/PREFLIGHT.md -->
---
### Phase 1: Parse Arguments and Resolve Targets
Distinguish invocation mode from the skill arguments (the text after the
skill name):
```bash
MODE=""
MANIFEST_PATH=""
EPIC_NUMBER=""
ISSUE_LIST=""
RECENT_DURATION=""
RUN_MODE="dry-run"
JSON_OUTPUT=false
WRITE_TRAIL=true
ALLOW_CLOSED=false
REPO_OVERRIDE=""
while [ $# -gt 0 ]; do
case "$1" in
--manifest) MODE="manifest"; MANIFEST_PATH="$2"; shift 2 ;;
--epic) MODE="epic"; EPIC_NUMBER="$2"; shift 2 ;;
--issues) MODE="issues"; ISSUE_LIST="$2"; shift 2 ;;
--all-recent) MODE="all-recent"; RECENT_DURATION="$2"; shift 2 ;;
--fix) RUN_MODE="fix"; shift ;;
--fix-interactive) RUN_MODE="fix-interactive"; shift ;;
--json) JSON_OUTPUT=true; shift ;;
--no-audit-trail) WRITE_TRAIL=false; shift ;;
--allow-closed) ALLOW_CLOSED=true; shift ;;
--repo) REPO_OVERRIDE="$2"; shift 2 ;;
*) echo "ERROR: unknown flag $1" >&2; exit 2 ;;
esac
done
if [ -z "$MODE" ]; then
echo "ERROR: must specify one of --manifest, --epic, --issues, --all-recent" >&2
exit 2
fi
```
#### Resolve target issues
- **`--manifest`**: read JSON, parse via `jq`, fail-exit 2 on malformed JSON.
Each entry's `repo` and `number` becomes a target.
- **`--epic <N>`**: read the epic's sub-issues with
`nightgauge issue list --epic <N> --repo <owner/repo> --json`; the epic +
every entry of `.subIssues` (each in its own `.repo`) are targets. The
binary reads the whole list or exits non-zero, which is a skill failure
(exit 2), never a shorter target list (#1701).
- **`--issues N1,N2,N3`**: split on comma; each is a target in `--repo` (or
current repo).
- **`--all-recent <dur>`**: query
`nightgauge forge issue list --repo $REPO --search 'created:>=<since>' --json number,repository`
across the configured repos.
For inferential modes, the audit synthesizes a per-target _expected_ shape
from issue type, project config, and body content. Strict mode uses the
manifest as the source of truth.
#### Initialize report files
```bash
TS=$(date -u +%Y%m%dT%H%M%SZ)
# Read-only paths for display; every write goes through
# `nightgauge layout write|append pipeline <name>`.
REPORT_DIR="$(nightgauge layout path pipeline)"
REPORT_MD="$REPORT_DIR/issue-audit-${TS}.md"
REPORT_JSON="$REPORT_DIR/issue-audit-${TS}.json"
TRAIL_JSONL="$REPORT_DIR/issue-audit-${TS}.audit.jsonl"
FINDINGS=() # populated as JSON-line strings throughout the run
```
---
### Phase 2: Existence & Repo Placement
For each target `(owner/repo, number)`:
```bash
ISSUE_JSON=$(nightgauge forge issue view "$NUM" --repo "$OWNER_REPO" --json 2>/dev/null) || {
FINDINGS+=("$(jq -nc --arg r "$OWNER_REPO" --arg n "$NUM" '
{phase:1,type:"MISSING_ISSUE",severity:"CRITICAL",
issue:{repo:$r,number:($n|tonumber)},
detail:"Issue not found via nightgauge forge issue view"}')")
continue
}
# WRONG_REPO: the api response's repository.nameWithOwner ≠ expected
ACTUAL_REPO=$(printf '%s\n' "$ISSUE_JSON" | jq -r '.repository.nameWithOwner // empty')
if [ -n "$ACTUAL_REPO" ] && [ "$ACTUAL_REPO" != "$OWNER_REPO" ]; then
# emit WRONG_REPO finding
...
fi
# UNEXPECTED_STATE: closed but not allowed
STATE=$(printf '%s\n' "$ISSUE_JSON" | jq -r '.state')
if [ "$STATE" = "CLOSED" ] && [ "$ALLOW_CLOSED" != "true" ]; then
# emit UNEXPECTED_STATE finding (WARNING)
...
fi
```
---
### Phase 3: Label Completeness
For each target's labels:
- `MISSING_TYPE_LABEL` (CRITICAL) when no `type:*` present
- `MULTIPLE_TYPE_LABELS` (CRITICAL) when more than one
- `MISSING_COMPONENT_LABEL` (INFO) when body cites a component path but no
`component:*` label
Priority and Size are project board fields; Phase 4 checks them.
---
### Phase 4: Project Board Membership and Fields
```bash
PROJECT_QUERY=$(nightgauge forge graphql -f query='query($owner:String!,$name:String!,$num:Int!){
repository(owner:$owner,name:$name){
issue(number:$num){
projectItems(first:10){
nodes{
project{number}
fieldValues(first:30){
nodes{
... on ProjectV2ItemFieldSingleSelectValue { name field { ... on ProjectV2SingleSelectField { name } } }
}
}
}
}
}
}
}' -f owner="$OWNER" -f name="$NAME" -F num="$NUM")
```
Findings:
- `MISSING_FROM_BOARD` (CRITICAL): no `projectItems` entry
- `WRONG_BOARD` (CRITICAL): on a project that does not match the expected
`project_number` (manifest or workspace yaml mapping)
- `MISSING_STATUS_FIELD` (CRITICAL): item exists but Status is unset
- `MISSING_PRIORITY_FIELD` (WARNING): Priority unset
- `MISSING_SIZE_FIELD` (WARNING): Size unset
---
### Phase 5: Body Section Completeness
The table below is the **canonical body-section contract** for this repository.
`nightgauge-issue-create` authors bodies against it rather than restating a
shape of its own (see that skill's `_includes/environment-and-content.md`,
Phase 2), and [docs/ISSUE_AUDIT.md](../../docs/ISSUE_AUDIT.md) mirrors it for
readers. All three copies are pinned against each other by
`scripts/check-issue-body-contract.py` — edit this table and that gate stays red
until the other two follow. Before #711 the three disagreed on every row, so
Phase 5 fired on every issue the pipeline itself authored.
| Type | Required headings |
| -------- | ----------------------------------------------------------- |
| feature | Summary, Acceptance Criteria, Verification |
| bug | Summary, Steps to Reproduce, Expected, Actual, Verification |
| docs | Summary, Acceptance Criteria, Verification |
| refactor | Summary, Acceptance Criteria, Verification |
| spike | Summary, Acceptance Criteria, Recommendations, Verification |
| chore | Summary, Verification |
| epic | Summary, Sub-Issues, Acceptance Criteria, Verification |
`Verification` is required of every type (2026-08-29): the workspace backlog
groom found 167 of 224 open issues carried no falsifiable way to tell the
feature working from the feature claimed, and a pipeline dispatched against
such an issue self-grants "done". The section names, per acceptance
criterion, the test or command, the input, and the expected observable
output — see `issue-create`'s authoring rules for the bar.
**Matching is exact and case-sensitive.** The regex below is `grep -E` without
`-i`, so `## Acceptance criteria` does not satisfy a required
`Acceptance Criteria`. Reproduce every heading with the casing shown above.
Headings the table does not list are ignored, so an issue may carry any extra
sections it likes (`Business/user value`, `Technical notes`, `Related work`)
without affecting this phase.
For each required heading:
```bash
BODY=$(printf '%s\n' "$ISSUE_JSON" | jq -r '.body // ""')
if ! printf '%s\n' "$BODY" | grep -qE "^##[[:space:]]+${HEADING}\\s*$"; then
# Mode-dependent severity — see "Severity of MISSING_REQUIRED_HEADING" below.
HEADING_SEV=$([ "$MODE" = "manifest" ] && echo "CRITICAL" || echo "WARNING")
FINDINGS+=( /* MISSING_REQUIRED_HEADING ($HEADING_SEV) */ )
continue
fi
# Non-empty content check between this heading and the next ##
SECTION=$(printf '%s\n' "$BODY" | awk -v h="^##[[:space:]]+${HEADING}\\s*$" '
$0 ~ h {flag=1; next}
flag && /^##[[:space:]]/ {flag=0}
flag {print}')
if [ -z "$(echo "$SECTION" | tr -d '[:space:]')" ]; then
FINDINGS+=( /* EMPTY_REQUIRED_HEADING (WARNING) */ )
fi
```
Quality riders on the body, all WARNING and never auto-fixed — they are
heuristics, and a false positive must cost a glance, not a block. Two run on
every type; the epic riders below run only for `type: epic`:
```bash
# WEAK_VERIFICATION — the section exists but names no test, command, or file.
VSEC=$(printf '%s\n' "$BODY" | awk '
$0 ~ /^##[[:space:]]+Verification[[:space:]]*$/ {flag=1; next}
flag && /^##[[:space:]]/ {flag=0}
flag {print}')
if [ -n "$(echo "$VSEC" | tr -d '[:space:]')" ] \
&& ! printf '%s\n' "$VSEC" | grep -qE '`[^`]+`|go test|vitest|flutter test|pytest|npm (run|test)'; then
FINDINGS+=( /* WEAK_VERIFICATION (WARNING) — "add tests" is not verification */ )
fi
# SECURITY_SURFACE_UNADDRESSED — the design touches a security surface and
# neither a Security section nor a security-shaped acceptance criterion exists.
if printf '%s\n' "$BODY" | grep -qiE 'auth|token|secret|credential|webhook|endpoint|retry|retries|spawn|exec|shell|symlink|upload|permission' \
&& ! printf '%s\n' "$BODY" | grep -qiE '^##[[:space:]]+Security|^- \[ \].*(secur|sanitiz|validat|authoriz|authent|bound|rate.?limit|scope|confine|redact)'; then
FINDINGS+=( /* SECURITY_SURFACE_UNADDRESSED (WARNING) — never auto-fix */ )
fi
```
For `type: epic`, two more riders check that `## Summary` justifies the epic's
existence. Each is a presence check only — a cheap signal, not a quality
judge — and stays WARNING in every mode, `--manifest` included:
```bash
# MISSING_EPIC_FALSIFIER / MISSING_EPIC_DEMAND — the Summary lacks a non-empty
# `Falsifier:` / `Demand:` line (optionally bulleted or bolded). A line outside
# `## Summary`, or with nothing after the colon, does not count.
TYPE=$(printf '%s\n' "$ISSUE_JSON" | jq -r '[.labels[].name | select(startswith("type:"))][0] // "" | sub("^type:"; "")')
if [ "$TYPE" = "epic" ]; then
SSEC=$(printf '%s\n' "$BODY" | awk '
$0 ~ /^##[[:space:]]+Summary[[:space:]]*$/ {flag=1; next}
flag && /^##[[:space:]]/ {flag=0}
flag {print}')
for LABEL in Falsifier Demand; do
if ! printf '%s\n' "$SSEC" \
| grep -qE "^[[:space:]]*(-[[:space:]]+)?(\*\*)?${LABEL}(\*\*)?:(\*\*)?[[:space:]]*[^[:space:]*]"; then
# Falsifier: no answer to the motivating question would stop the work.
# Demand: nobody is recorded as having asked for it.
FINDINGS+=( /* MISSING_EPIC_FALSIFIER | MISSING_EPIC_DEMAND (WARNING) — never auto-fix */ )
fi
done
fi
```
For `type: spike`:
````bash
# Verify the yaml recommendations block exists and parses
if ! printf '%s\n' "$BODY" | awk '/```yaml recommendations/,/```/' | head -2 | grep -q '```yaml recommendations'; then
FINDINGS+=( /* MISSING_SPIKE_RECS_BLOCK (CRITICAL) — never auto-fix */ )
fi
````
The `MISSING_SPIKE_RECS_BLOCK` finding has **no repair primitive**. Even with
`--fix`, this remains CRITICAL — encoded in Phase 9 logic and pinned by the
`spike-contract-violation` test fixture.
#### Severity of `MISSING_REQUIRED_HEADING`
CRITICAL in strict (`--manifest`) mode, WARNING everywhere else. The split is
deliberate and was re-decided in #711 — the reasoning is recorded in
[docs/ISSUE_AUDIT.md](../../docs/ISSUE_AUDIT.md#severity-tiers).
In strict mode the manifest was written moments earlier by `issue-create`,
which authors against the table above. A missing heading there is not a
degraded issue; it is proof the authoring contract was not followed, on the one
run where following it was guaranteed. That is what CRITICAL means in this
skill's tier table, and it makes Phase 6 of `issue-create` a gate that can
actually fail.
In inferential mode (`--epic`, `--issues`, `--all-recent`) the target is
frequently a hand-filed or pre-#711 issue that no creation flow ever shaped.
Blocking those is `backlog-groom`'s job, not this skill's, so the finding stays
a WARNING and the run still exits 0.
The finding has no repair primitive in either mode: headings are human-authored
prose, and inventing a `## Actual` section is exactly the kind of fabrication
`--fix` must never do.
#### Oversized-scope check (all issue types)
Catches issues that bundle many independent refactors into a single executable
ticket — the root cause of pipeline runaways (incident #3811: $112.77 of
feature-dev churn on one issue that meant "refactor ~18 skills"). This mirrors
the `issue-create` Phase 2.85 oversized-scope gate so manually-created issues
(which never pass through issue-create) are flagged post-creation.
```bash
# Signal 1: distinct CHANGE-TARGET files via the shared deterministic
# extractor (#79) — the SAME implementation the epic wave planner uses, so
# audit counting can never drift from wave planning. Markdown-link
# destinations are citations and never count; an explicit `file_ownership`
# list in the `nightgauge:dependency-metadata` block replaces prose inference.
DISTINCT_TARGETS=$(printf '%s' "$BODY" \
| nightgauge issue extract-targets --json 2>/dev/null \
| jq -r '.count' 2>/dev/null || echo 0)
# Signal 2: predicted size == XL (objective estimate via the Go binary)
PREDICTED_SIZE=$(nightgauge size predict "$NUM" --json 2>/dev/null \
| jq -r '.SizeLabel // empty')
# Signal 3: independent acceptance-criteria groups (refactor/migrate/... bullets)
AC_GROUP_COUNT=$(printf '%s\n' "$BODY" \
| grep -ciE '^[[:space:]]*([-*]|[0-9]+\.)[[:space:]]+(refactor|migrate|convert|split|rewrite|extract|decompose|reduce|trim)[[:space:]]')
# Override marker (same marker honored by issue-create Phase 2.85)
SCOPE_OVERRIDE=false
if printf '%s' "$BODY" | grep -qi "nightgauge:oversized-scope-accepted\|oversized scope accepted"; then
SCOPE_OVERRIDE=true
fi
# An epic with native sub-issues is the correct shape for large scope — exempt.
# Labels come from ISSUE_JSON (Phase 2). totalCount counts the whole
# subIssues connection, so no page of it needs reading.
IS_DECOMPOSED_EPIC=false
if printf '%s\n' "$ISSUE_JSON" | jq -e '.labels[]? | select(.name == "type:epic")' >/dev/null 2>&1; then
SUB_ISSUE_COUNT=$(nightgauge forge graphql -f query='query($owner:String!,$name:String!,$num:Int!){
repository(owner:$owner,name:$name){issue(number:$num){subIssues{totalCount}}}
}' -f owner="$OWNER" -f name="$NAME" -F num="$NUM" \
-q '.data.repository.issue.subIssues.totalCount' 2>/dev/null || echo 0)
if [ "${SUB_ISSUE_COUNT:-0}" -gt 0 ]; then
IS_DECOMPOSED_EPIC=true
fi
fi
# Trigger: >=6 distinct targets OR size==XL OR >=6 independent AC groups,
# AND not already a decomposed epic AND no override marker.
if [ "$IS_DECOMPOSED_EPIC" = "false" ] && [ "$SCOPE_OVERRIDE" = "false" ] && {
[ "${DISTINCT_TARGETS:-0}" -ge 6 ] || \
printf '%s\n' "$PREDICTED_SIZE" | grep -qiE '^XL$' || \
[ "${AC_GROUP_COUNT:-0}" -ge 6 ]; }; then
FINDINGS+=( /* OVERSIZED_SCOPE (WARNING) — no auto-fix; requires manual decomposition */ )
fi
```
Finding:
- `OVERSIZED_SCOPE` (WARNING): the issue has ≥6 distinct change-target files
(per the shared extractor — markdown-link citations excluded, explicit
`file_ownership` declarations win, #79), OR has predicted size `XL`, OR
enumerates ≥6 independent refactor/migration acceptance-criteria groups —
and is neither a decomposed epic (`type:epic` with native sub-issues) nor
carries the `<!-- nightgauge:oversized-scope-accepted -->` override marker. This
finding has **no repair primitive**: decomposition into sub-issues under an
epic is a human/planning decision, never auto-applied by `--fix`. Even with
`--fix`, `OVERSIZED_SCOPE` remains a WARNING.
#### Step 5.x: Declared `file_ownership` substantiation (#561)
When the body carries a `nightgauge:dependency-metadata` block with a
`file_ownership` list, check it against the ACs with the deterministic verb
(no block means no-op):
```bash
OWNERSHIP_JSON=$(printf '%s' "$BODY" | nightgauge issue check-ownership --json 2>/dev/null \
|| echo '{"findings":[],"count":0}')
# One FILE_OWNERSHIP_UNSUBSTANTIATED (WARNING) per entry in .findings[]
```
- `FILE_OWNERSHIP_UNSUBSTANTIATED` (WARNING): a declared set cannot satisfy the
issue's own ACs — `test-missing`, `generated-mirror` (names the canonical
path), or `path-missing`. **No repair primitive**: correcting a declared set
is a planning edit. "No edit needed" and "provably inert" sets are not
detected; see [docs/ISSUE_AUDIT.md](../../docs/ISSUE_AUDIT.md).
---
### Phase 6: Sub-Issue & Parent Linking
Read every sub-issue through the binary, which pages the connection to its
end or exits non-zero (#1701). A fixed first page reported each sub-issue
past it as `MISSING_SUB_ISSUE_LINK`. Do not pipe the binary into `jq`: the
pipe would take `jq`'s exit status and hide a failed read.
```bash
SUB_ISSUES_JSON=$(nightgauge issue list --epic "$NUM" --repo "$OWNER/$NAME" --json) || {
echo "ERROR: could not read every sub-issue of $OWNER/$NAME#$NUM" >&2
exit 2
}
# [{number, repo}, ...]; the binary writes subIssues as null when there are none.
SUB_ISSUES=$(printf '%s\n' "$SUB_ISSUES_JSON" | jq -c '.subIssues // [] | map({number, repo})')
```
Findings:
- `MISSING_SUB_ISSUE_LINK` (CRITICAL): manifest declares sub-issue #M but no
entry in `SUB_ISSUES` matches its number and repo
- `MISSING_PART_OF_ANNOTATION` (CRITICAL): cross-repo sub-issue body lacks
`Part of <owner>/<repo>#<epic>`
- `ORPHAN_SUB_ISSUE` (WARNING): body has `Part of #X` but parent `X` has no
matching native sub-issue
---
### Phase 7: blockedBy Alignment
Delegate the structural check to the Go binary:
```bash
if [ "$MODE" = "epic" ] && [ -n "$EPIC_NUMBER" ]; then
# Exit 0: every sub-issue was checked. Non-zero WITH the result on stdout:
# some sub-issues could not be checked (.failures). Non-zero with no result:
# the epic itself could not be read, and the audit cannot run.
EPIC_VALIDATE_RC=0
EPIC_VALIDATE=$("$BINARY" epic validate "$EPIC_NUMBER" --json) || EPIC_VALIDATE_RC=$?
if ! printf '%s\n' "$EPIC_VALIDATE" | jq -e 'type == "object" and has("failedSubIssues")' >/dev/null 2>&1; then
echo "ERROR: epic validate #$EPIC_NUMBER could not run (exit $EPIC_VALIDATE_RC)" >&2
exit 2
fi
printf '%s\n' "$EPIC_VALIDATE" | jq -c '.failures // [] | .[]' | while read -r failure; do
SUB=$(printf '%s\n' "$failure" | jq -r '.subIssueNumber')
SUB_REPO=$(printf '%s\n' "$failure" | jq -r '.subIssueRepo')
REASON=$(printf '%s\n' "$failure" | jq -r '.error')
/* emit UNCHECKED_SUB_ISSUE (CRITICAL) against $SUB_REPO#$SUB with $REASON */
done
GAPS=$(printf '%s\n' "$EPIC_VALIDATE" | jq -c '.gaps // []')
printf '%s\n' "$GAPS" | jq -c '.[]' | while read -r gap; do
GAP_TYPE=$(printf '%s\n' "$gap" | jq -r '.gapType')
SUB=$(printf '%s\n' "$gap" | jq -r '.subIssueNumber')
SUB_REPO=$(printf '%s\n' "$gap" | jq -r '.subIssueRepo')
BLOCKER=$(printf '%s\n' "$gap" | jq -r '.blockerNumber')
BLOCKER_REPO=$(printf '%s\n' "$gap" | jq -r '.blockerRepo')
DETAIL=$(printf '%s\n' "$gap" | jq -r '.detail')
case "$GAP_TYPE" in
circular_blocker) /* emit CIRCULAR_BLOCKER (CRITICAL) */ ;;
stale_blocker) /* emit STALE_BLOCKED_BY (WARNING) */ ;;
esac
done
fi
```
A sub-issue `epic validate` could not read is `UNCHECKED_SUB_ISSUE`
(CRITICAL): its blockers were never checked, so the epic's alignment is
unknown, not clean (#1702). It has no auto-fix; re-run the audit once the
sub-issue can be read.
Issue numbers are per repository and an epic's sub-issues can live in several.
Each gap names the sub-issue's repository (`subIssueRepo`) and the blocker's
(`blockerRepo`); record the finding against `$SUB_REPO#$SUB` and carry both
repositories into the repair (Phase 11).
For body-declared dependencies (`Depends on:` lines and the
`<!-- nightgauge:dependency-metadata -->` YAML block), parse and verify
each declaration has a matching native `blockedBy` edge:
- `MISSING_BLOCKED_BY` (CRITICAL): declared but not wired
- `STALE_BLOCKED_BY_NOT_PLANNED` (WARNING): blocker is closed-as-not-planned
- `CROSS_REPO_BLOCKER_MISSING` (CRITICAL): cross-repo blocker not found
---
### Phase 8: Cross-Repo Consistency
When the audit set spans multiple repos (manifest entries with different
`repo`, or cross-repo body annotations):
```bash
"$BINARY" audit lifecycle --json 2>/dev/null > /tmp/lifecycle.json
```
Parse the lifecycle output for cross-repo references and emit:
- `MISSING_PARENT_BACKREF` (WARNING): cross-repo sub-issue references parent
but parent epic body has no link back
- `INCONSISTENT_PROJECT_MAPPING` (CRITICAL): two sub-issues in the same
target repo are in different projects
---
### Phase 9: Knowledge Scaffold
When `knowledge.enabled` resolves true in `.nightgauge/config.yaml` — the
default, and an absent key means the default (ADR-020) — OR the manifest entry
sets `knowledge_path`:
```bash
KNOWLEDGE_PATH=$(printf '%s\n' "$ENTRY" | jq -r '.knowledge_path // empty')
if [ -n "$KNOWLEDGE_PATH" ]; then
if [ ! -d "$KNOWLEDGE_PATH" ]; then
FINDINGS+=( /* MISSING_KNOWLEDGE_DIR (WARNING) */ )
elif [ ! -f "$KNOWLEDGE_PATH/PRD.md" ]; then
FINDINGS+=( /* MISSING_PRD_FILE (WARNING) */ )
else
PRD_STRIPPED=$(sed -e 's/<!--.*-->//g' -e '/^#/d' "$KNOWLEDGE_PATH/PRD.md" | tr -s '[:space:]' ' ' | sed 's/^ *//;s/ *$//')
if [ ${#PRD_STRIPPED} -lt 30 ]; then
FINDINGS+=( /* EMPTY_PRD_FILE (INFO) */ )
fi
fi
fi
```
---
### Phase 10: Findings Synthesis
Aggregate `${FINDINGS[@]}` into a JSON array. Compute summary counts. Verdict
is `READY` when `critical == 0`, else `NEEDS FIXES`.
Severity-tiered Markdown output (CRITICAL first, then WARNING, then INFO).
Per finding, include:
- Issue (`<owner>/<repo>#<n>`)
- Phase number
- Finding type
- Detail
- Repair command (or `(no auto-fix available)` for human-only items)
---
### Phase 11: Auto-Repair (when `--fix` / `--fix-interactive`)
Walk findings in order. For each repairable category, invoke the listed
primitive (see `docs/ISSUE_AUDIT.md` repair-primitive table). Each repair
appends one JSON line to the audit trail:
```bash
echo '{"ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","issue":"'$ISSUE'","finding":"'$TYPE'","action":"'$ACTION'","before":'$BEFORE',"after":'$AFTER',"actor":"nightgauge-issue-audit"}' \
| nightgauge layout append pipeline "issue-audit-${TS}.audit.jsonl" >/dev/null
```
#### Hard rules (encoded; pinned by negative test fixture)
1. **Never auto-rewrite human-authored content**: Summary, Acceptance
Criteria, User Story, Technical Notes, spike artifact body. Findings that
require human content stay flagged.
2. **`MISSING_SPIKE_RECS_BLOCK` is never auto-fixed**: even with `--fix`, this
stays CRITICAL.
3. **`OVERSIZED_SCOPE` is never auto-fixed**: decomposing a bundled issue into
sub-issues under an epic is a human/planning decision. Even with `--fix`,
this stays a WARNING. The same holds for `FILE_OWNERSHIP_UNSUBSTANTIATED`.
4. **`STALE_BLOCKED_BY_NOT_PLANNED` requires `--fix-interactive`**: under
`--fix` alone, the finding remains a WARNING. Under `--fix-interactive`,
prompt the operator and only repair on explicit `yes`.
5. **Repair failures become CRITICAL**: a non-zero exit from the primitive
reclassifies the finding as CRITICAL with the underlying error in
`repair_error`. The audit does not retry — operator re-runs.
#### Repair-primitive map (CRITICAL/WARNING items only)
| Finding type | Primitive command |
| ------------------------------- | ------------------------------------------------------------------------------- |
| `MISSING_FROM_BOARD` | `nightgauge project add <num> --repo <owner/repo> --project <num>` |
| `WRONG_BOARD` | `nightgauge project add <num> --repo <owner/repo> --project <num>` (idempotent) |
| `MISSING_STATUS_FIELD` | `nightgauge project sync-status <num> <expected_status>` |
| `MISSING_PRIORITY_FIELD` | `nightgauge project set-field <num> Priority <P0\|P1\|P2\|P3>` |
| `MISSING_SIZE_FIELD` | `nightgauge project set-field <num> Size <XS\|S\|M\|L\|XL>` |
| `MISSING_SUB_ISSUE_LINK` (same) | `nightgauge issue link-sub <epic> <sub>` |
| `MISSING_PART_OF_ANNOTATION` | `nightgauge issue edit <num> --append-body "Part of <owner>/<repo>#<epic>"` |
| `MISSING_BLOCKED_BY` | `nightgauge issue add-blocked-by <blocked> <blocker>` |
| `STALE_BLOCKED_BY` (completed) | `nightgauge issue remove-blocked-by <blocked> <blocker>` (auto in `--fix`) |
| `STALE_BLOCKED_BY_NOT_PLANNED` | `nightgauge issue remove-blocked-by` (only in `--fix-interactive` after `yes`) |
| `CIRCULAR_BLOCKER` | `nightgauge issue remove-blocked-by <sub> <epic>` (auto in `--fix`) |
| `MISSING_PARENT_BACKREF` | `nightgauge issue edit <epic> --append-body "<cross-repo link line>"` |
`remove-blocked-by` resolves both numbers in its one `--repo`. Repair a
`CIRCULAR_BLOCKER` or `STALE_BLOCKED_BY` gap from `epic validate` only when its
`subIssueRepo` and `blockerRepo` are the same repository, and pass that
repository as `--repo`. When they differ (a sub-issue in another repository
than its epic, or a blocker elsewhere), do not auto-fix: report the finding
with `(no auto-fix available: cross-repository relationship)` for a human to
remove. By number in one repository, the repair would act on a different
issue's relationship.
`MISSING_TYPE_LABEL`, `MULTIPLE_TYPE_LABELS`, `MISSING_REQUIRED_HEADING`,
`EMPTY_REQUIRED_HEADING`, `MISSING_SPIKE_RECS_BLOCK`, `OVERSIZED_SCOPE`,
`FILE_OWNERSHIP_UNSUBSTANTIATED`, `INCONSISTENT_PROJECT_MAPPING`, `EMPTY_PRD_FILE` — no auto-fix. Human action
required.
---
### Phase 12: Report Write and Exit
```bash
# JSON
jq -n \
--argjson summary "$SUMMARY_JSON" \
--argjson findings "$FINDINGS_JSON" \
--argjson audited "$AUDITED_JSON" \
--arg verdict "$VERDICT" \
--arg started "$STARTED_AT" \
--arg completed "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{schema_version:"1.0",verdict:$verdict,summary:$summary,
audited:$audited,findings:$findings,
started_at:$started,completed_at:$completed}' \
| nightgauge layout write pipeline "issue-audit-${TS}.json" >/dev/null
# Markdown — assembled line-by-line, then piped into
# `nightgauge layout write pipeline "issue-audit-${TS}.md"` (omitted for brevity)
# ...
# stdout: verdict line and report paths
echo "Verdict: $VERDICT ($CRITICAL_COUNT CRITICAL, $WARNING_COUNT WARNING, $INFO_COUNT INFO)"
echo "Report: $REPORT_MD"
echo "JSON: $REPORT_JSON"
[ "$WRITE_TRAIL" = "true" ] && echo "Trail: $TRAIL_JSONL"
if [ "$JSON_OUTPUT" = "true" ]; then
cat "$REPORT_JSON"
fi
if [ "$CRITICAL_COUNT" -gt 0 ]; then
exit 1
fi
exit 0
```
---
## Decision Rules
- **Strict vs inferential**: when `--manifest` is set, every assertion
declared in the manifest must hold; missing fields in inferential mode
derive expectations from issue type and project config. Strict mode also
raises `MISSING_REQUIRED_HEADING` from WARNING to CRITICAL — see
[Phase 5](#phase-5-body-section-completeness); a body authored seconds
earlier by `issue-create` has no excuse for missing a heading that skill
prescribes.
- **Closed issues**: by default, closed issues are flagged
`UNEXPECTED_STATE` (WARNING). `--allow-closed` suppresses this and runs
only state-relevant phases (existence, body sections, knowledge scaffold).
- **Single-issue runs**: `--issues <single>` is valid; the audit runs every
applicable phase but skips Phase 6 sub-issue checks.
- **Cross-repo**: when an issue is in a different repo than the current one,
every `nightgauge forge` call must pass `--repo <owner/repo>`; never rely on the current
repo default.
- **Idempotency**: re-running `--fix` on a previously-fixed audit MUST be a
no-op. Repair primitives are idempotent.
## Failure Conditions (exit 2)
The skill exits 2 — distinct from finding-driven exit 1 — when the audit
itself cannot run:
- Go binary not in `$PATH`, `$NIGHTGAUGE_BIN` or `~/go/bin`
- `nightgauge forge` not authenticated
- `--manifest <path>` does not exist or fails JSON parse
- an epic's sub-issue list cannot be read whole (`nightgauge issue list --epic`
exits non-zero)
- `epic validate` cannot read the epic itself (non-zero exit with no result
on stdout)
- `.nightgauge/config.yaml` unreadable when project number must be
derived
## Completion Checklist
- [ ] Mode and target issues resolved
- [ ] Existence + repo placement checked (Phase 2)
- [ ] Label completeness checked (Phase 3)
- [ ] Project board membership and fields checked (Phase 4)
- [ ] Body section completeness checked, including spike contract (Phase 5)
- [ ] Sub-issue and parent linking checked (Phase 6)
- [ ] `blockedBy` alignment checked via `epic validate --json` (Phase 7)
- [ ] Cross-repo consistency checked (Phase 8)
- [ ] Knowledge scaffold checked (Phase 9)
- [ ] Findings synthesized with verdict (Phase 10)
- [ ] Repairs applied (only if `--fix` / `--fix-interactive`); failures
reclassified as CRITICAL (Phase 11)
- [ ] Markdown + JSON report written; audit trail JSONL written unless
`--no-audit-trail` (Phase 12)
- [ ] Exit code reflects verdict (0 READY, 1 NEEDS FIXES, 2 skill failure)
### Self-Assessment Epilogue
<!-- include: ../_shared/SELF_ASSESSMENT_EPILOGUE.md -->