Installs into .claude/skills of the current project.
Are you the author of Issue Audit?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/nightgauge-issue-audit)
---
name: 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](https://github.com/nightgauge/nightgauge/blob/main/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
### Phase 0: Environment Preflight
**PURPOSE**: Verify the nightgauge binary is available and the environment
is healthy before executing any pipeline stage. Skills halt immediately with
actionable instructions when this check fails.
Three-layer check:
1. **Binary discovery** — shell-level, runs before invoking the binary
2. **Doctor check** — binary-level, full environment health via `doctor --json`
3. **Project environment** — the checks the project declares under
`preflight.environment` (#101); opt-in, silent when none are declared
The binary discovery cascade below is **provider-neutral** (issue 4029): it works
identically under every adapter (Claude, Codex, Gemini, …) and references no
VSCode-extension-specific path. The host that spawns the skill (the VSCode
extension's `skillRunner`, or the Go `nightgauge` binary in auto/CLI mode)
resolves the binary authoritatively and exports `NIGHTGAUGE_BIN`, which
this cascade honors first. The remaining steps (PATH, the development-only repo
`bin/` and worktree canonical-repo `bin/`, `~/go/bin`) are fallbacks for direct
or terminal invocation.
This intentionally **diverges** from
`claude-plugins/nightgauge/hooks/lib/guard.sh` (issue 3262 → issue 4029): guard.sh is
Claude-Code-only and retains a trailing `~/.vscode/extensions/...` glob to serve
the standalone-terminal-Claude case (where no host exports `NIGHTGAUGE_BIN`).
Skills must stay portable Markdown and cannot reference that VSCode-only path —
so the shared resolution order (`NIGHTGAUGE_BIN` → PATH → gated repo → gated
canonical → `~/go/bin`) stays in sync, and only guard.sh carries the extra Claude-only
fallback. The `nightgauge preflight skill-portability` gate enforces that
no skill reintroduces a `.vscode/extensions` path, and that every cascade keeps
the `NIGHTGAUGE_DEV_REPO_BIN` gate (#2758): a foreign repository's
`bin/nightgauge` must never run.
After the cascade resolves `$BINARY`, the preflight prepends
`dirname($BINARY)` to `PATH` so subsequent bare `nightgauge ...`
invocations later in the same skill body resolve through PATH rather than
failing with "command not found" (issue 3262).
```bash
# Layer 1: provider-neutral binary discovery (NIGHTGAUGE_BIN → PATH → gated repo
# bin → gated canonical-repo bin → ~/go/bin). No VSCode-extension path — see issue 4029.
# Repo bins need NIGHTGAUGE_DEV_REPO_BIN=1 and a verified core checkout (#2758).
BINARY="${NIGHTGAUGE_BIN:-}"
[ -n "$BINARY" ] && [ ! -x "$BINARY" ] && BINARY=""
[ -z "$BINARY" ] && BINARY=$(command -v nightgauge 2>/dev/null || echo "")
if [ -z "$BINARY" ] && [ "${NIGHTGAUGE_DEV_REPO_BIN:-}" = "1" ]; then
for NG_ROOT in "$(git rev-parse --show-toplevel 2>/dev/null)" \
"$(cd "$(git rev-parse --git-common-dir 2>/dev/null)/.." 2>/dev/null && pwd)"; do
[ -x "$NG_ROOT/bin/nightgauge" ] || continue
grep -qx 'module github.com/nightgauge/nightgauge' "$NG_ROOT/go.mod" 2>/dev/null || continue
NG_ORIGIN="$(git -C "$NG_ROOT" config --get remote.origin.url 2>/dev/null)"
case "${NG_ORIGIN%.git}" in
git@github.com:nightgauge/nightgauge | https://github.com/nightgauge/nightgauge | \
ssh://git@github.com/nightgauge/nightgauge) BINARY="$NG_ROOT/bin/nightgauge"; break ;;
esac
done
fi
[ -z "$BINARY" ] && [ -x "$HOME/go/bin/nightgauge" ] && BINARY="$HOME/go/bin/nightgauge"
if [ -z "$BINARY" ]; then
echo "ERROR: nightgauge binary not found via NIGHTGAUGE_BIN, PATH or ~/go/bin" >&2
echo "" >&2
echo "Install via: go install github.com/nightgauge/nightgauge/cmd/nightgauge@latest" >&2
echo "Or download from: https://github.com/nightgauge/nightgauge/releases" >&2
echo "Run \`nightgauge doctor\` after installing to verify your environment." >&2
exit 1
fi
# Make the resolved binary callable via bare `nightgauge ...` later in this skill body (issue 3262).
export PATH="$(dirname "$BINARY"):$PATH"
# Per-repo GitHub token: export GH_TOKEN so every `gh` call in this skill
# authenticates as the *current repo's* configured user instead of the machine-
# global `gh auth` active account. `forge auth token` resolves per-repo (config
# github_user/token → GITHUB_TOKEN → `gh auth token --user <github_user>`), so
# concurrent sessions/workspaces owned by different GitHub users each use their
# own token. GH_TOKEN outranks GITHUB_TOKEN and the gh keyring. Only resolve when
# unset (an upstream env value — e.g. the VSCode extension's terminal env —
# wins); never fail preflight over it. Mirrors hooks/lib/guard.sh (sync, issue 3262).
if [ -z "${GH_TOKEN:-}" ]; then
IB_REPO_TOKEN="$("$BINARY" forge auth token 2>/dev/null || true)"
if [ -n "$IB_REPO_TOKEN" ]; then
export GH_TOKEN="$IB_REPO_TOKEN"
export GITHUB_TOKEN="${GITHUB_TOKEN:-$IB_REPO_TOKEN}"
fi
unset IB_REPO_TOKEN
fi
# Layer 2: full environment health check via doctor --json
DOCTOR_RESULT=$("$BINARY" doctor --json 2>/dev/null)
DOCTOR_EXIT=$?
if [ "$DOCTOR_EXIT" -eq 2 ]; then
echo "ERROR: Environment check failed — nightgauge doctor reports broken environment." >&2
FAILED_CHECKS=$(printf '%s\n' "$DOCTOR_RESULT" | jq -r '.failed_checks[]? // empty' 2>/dev/null)
if [ -n "$FAILED_CHECKS" ]; then
echo "Failing check(s): $(echo "$FAILED_CHECKS" | tr '\n' ',' | sed 's/,$//')" >&2
fi
printf '%s\n' "$DOCTOR_RESULT" | jq -r '.errors[]' >&2 2>/dev/null || true
INSTALL_MSG=$(printf '%s\n' "$DOCTOR_RESULT" | jq -r '.install_instructions // empty' 2>/dev/null)
[ -n "$INSTALL_MSG" ] && echo "$INSTALL_MSG" >&2
exit 1
fi
if [ "$DOCTOR_EXIT" -eq 1 ]; then
echo "WARNING: Environment has non-critical issues:" >&2
printf '%s\n' "$DOCTOR_RESULT" | jq -r '.warnings[]' >&2 2>/dev/null || true
# Continue — warnings do not block skill execution
fi
# Layer 3: project-declared environment checks (preflight.environment, #101).
# Opt-in: with none declared this prints nothing and continues. The verb
# writes one `[environment-halt]` line per failed check, with its remedy.
if ! "$BINARY" preflight environment >/dev/null; then
echo "[environment-halt] Project environment check failed — fix the environment, not the code." >&2
exit 1
fi
```
**Exit codes from `nightgauge doctor`**:
| Code | Meaning | Skill behavior |
| ---- | ------------------------------- | --------------------------------------- |
| 0 | Healthy | Continue |
| 1 | Degraded (warnings only) | Continue with warning printed to stderr |
| 2 | Broken (required checks failed) | Halt immediately with error details |
**Layer 3** halts the stage when any check declared under
`preflight.environment` in the workspace's `.nightgauge/config.yaml` fails
(exit 1) or the declaration is invalid (exit 2). The failure is in the
operator's environment, not the code: do not edit code to work around it, do
not run the remedy yourself, and end the stage. The pipeline records it as
`environment_halt` and holds the issue for an operator.
Do not theorize about the cause beyond what is printed here — the failing
check name (`failed_checks`) and its `.errors[]` message are the complete
diagnosis (#277).
---
### 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](https://github.com/nightgauge/nightgauge/blob/main/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](https://github.com/nightgauge/nightgauge/blob/main/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](https://github.com/nightgauge/nightgauge/blob/main/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
### Self-Assessment Epilogue
**PURPOSE**: Evaluate whether this skill's instructions matched reality during
this execution. This phase is **non-blocking** — skip entirely if any main phase
failed. A perfectly working skill produces **no output** from this phase.
> See [docs/SKILL_SELF_ASSESSMENT.md](https://github.com/nightgauge/nightgauge/blob/main/docs/SKILL_SELF_ASSESSMENT.md) for
> the full strategy, synthesis algorithm, and integration architecture.
#### When to Skip
- Any prior phase exited with an error before completing
- The skill was cancelled or timed out
- Running in `--dry-run` mode
#### Step 1: Evaluate Execution Friction
Review the execution that just completed. Answer these questions honestly:
1. **Command failures**: Did any command, script, or binary call in the skill
instructions fail? (e.g., script not found, wrong flags, missing binary)
2. **Workarounds**: Did you have to deviate from the skill instructions to
accomplish the goal? (e.g., used `gh` directly because a referenced script
didn't exist, skipped a step that referenced a nonexistent file)
3. **Stale references**: Did any file path, function name, API endpoint, or tool
referenced in the instructions not exist in the current codebase?
4. **Unclear instructions**: Were any instructions ambiguous enough that you had
to guess at the intended behavior?
5. **Missing instructions**: Was there a significant step you had to figure out
on your own that should have been documented in this skill?
**If ALL answers are "no" — write nothing and complete the skill normally.** The
goal is silence when everything works.
#### Step 2: Write Assessment Record
Only if friction was detected in Step 1. Write a single JSON file,
`assessments/{STAGE_NAME}-${ISSUE_NUMBER}.json` in the clone's pipeline state
directory, through the binary (JSON on stdin; never a direct file write):
```bash
ISSUE_NUMBER="${NIGHTGAUGE_ISSUE_NUMBER:-$(git branch --show-current | sed -n 's#^[^/]*/\([0-9]*\)-.*#\1#p')}"
: "${ISSUE_NUMBER:?set NIGHTGAUGE_ISSUE_NUMBER or check out the issue branch}"
nightgauge layout write pipeline "assessments/{STAGE_NAME}-${ISSUE_NUMBER}.json" <<'JSON'
{ ... the record below ... }
JSON
```
The assessment record MUST follow this schema:
```json
{
"schema_version": "1",
"skill": "{STAGE_NAME}",
"skill_file": "skills/nightgauge-{STAGE_NAME}/SKILL.md",
"issue_number": 42,
"timestamp": "2026-03-10T14:30:00Z",
"friction": [
{
"type": "command_failure",
"severity": "high",
"description": "hooks/lib/add-to-project.sh not found — script was deleted",
"skill_line_hint": "claude-plugins/nightgauge/hooks/lib/add-to-project.sh <issue-number>",
"actual_resolution": "Used gh api graphql to add issue to project board directly",
"suggested_fix": "Replace with: nightgauge project add <issue-number>"
}
]
}
```
**Friction types**: `command_failure`, `workaround`, `stale_reference`,
`unclear_instruction`, `missing_instruction`
**Severity levels**:
- `high` — instruction is **broken**. Required manual workaround to complete.
- `medium` — instruction is **misleading**. Agent adapted without user help.
- `low` — instruction is **suboptimal**. No functional impact.
**Rules**:
- **One record per execution** — multiple friction items go in the `friction`
array, not separate files.
- **Be specific** — quote the exact instruction text that was wrong. Not "some
commands didn't work" but "Step 5.2 calls `hooks/lib/add-to-project.sh` but
this script was deleted in commit 65915701."
- **Suggest the fix** — every finding MUST include `suggested_fix` with the
concrete SKILL.md change needed. Not "update the docs" but "replace
`hooks/lib/add-to-project.sh <N>` with `nightgauge project add <N>`."
- **Don't invent friction** — only report issues you actually encountered during
this execution. Do not speculate about potential problems.
#### Step 3: Validate and Complete
```bash
# Validate JSON if written
ISSUE_NUMBER="${NIGHTGAUGE_ISSUE_NUMBER:-$(git branch --show-current | sed -n 's#^[^/]*/\([0-9]*\)-.*#\1#p')}"
: "${ISSUE_NUMBER:?set NIGHTGAUGE_ISSUE_NUMBER or check out the issue branch}"
ASSESSMENT_FILE="$(nightgauge layout path pipeline "assessments/{STAGE_NAME}-${ISSUE_NUMBER}.json")"
if [ -f "$ASSESSMENT_FILE" ]; then
python3 -m json.tool "$ASSESSMENT_FILE" > /dev/null 2>&1 || \
echo "WARNING: Assessment record is not valid JSON" >&2
fi
```