name: CJ_document-release
description: "Workbench wrapper around upstream /document-release. Reads the MERGED two-tier doc-spec registry (the general spec/doc-spec.md, resolved spec/-then-root, + the optional spec/doc-spec-custom.md overlay; self-bootstraps a missing general file from the portable seed INTO spec/doc-spec.md; stub-scaffolds any missing declared doc — spec/test-spec.md special-cased via test-spec.sh --seed), adds a --docs subset flag for per-invocation doc filtering (best-effort, documentation-only), a halt-on-red contract that emits [doc-sync-red] on upstream failure, and an auto-commit step gated by a doc-only whitelist DERIVED from the merged registry (non-whitelist writes HALT with [doc-sync-non-doc-write]). A missing/invalid registry HALTs with [doc-sync-no-config] BEFORE any audit. Invoked inline by the cj_goal orchestrators at Step 5.5 — between the QA pass + post-QA audit checkpoint and /ship — so doc updates fold into the same code PR rather than chasing them post-merge."
version: 0.1.0
allowed-tools:
- Bash
- Read
- Glob
- Grep
- Skill
Preamble
Check for collection updates (silent if none, banner if newer):
_UC="${CJ_SHARED_SCRIPTS:-$HOME/.claude/_cj-shared/scripts}/skills-update-check"
[ -x "$_UC" ] && "$_UC" 2>/dev/null || true
Verify this is a git repository:
git rev-parse --show-toplevel 2>/dev/null || echo "NOT_A_GIT_REPO"
If NOT_A_GIT_REPO: print Error: /CJ_document-release requires a git repository. and stop.
Overview
Canonical convention home: the doc contract this skill enforces — what docs the repo carries, what each is for, and the human-doc rules — lives in the root
doc-spec.md(both human prose and a machineyamlregistry). The mechanism reference (how it is parsed, enforced, and self-healed) lives indocs/architecture.md## The doc-spec.md contract + /CJ_document-release.
/CJ_document-release is a thin workbench wrapper around upstream gstack
/document-release. It adds four workbench-specific concerns the orchestrator
family needs:
Reads + self-heals the
doc-spec.mdcontract. Thespec/doc-spec.md(resolved spec/-then-root) declares the repo's docs (a fencedyamlregistry parsed byscripts/doc-spec.sh). Ifdoc-spec.mdis missing, the wrapper self-bootstraps it from the portable Common seed and commits it. For each declared doc that is missing, it stub-scaffolds a skeleton (title + a section skeleton + a<!-- TODO: fill in -->marker) and commits it. Both are idempotent — a re-run never writes a second copy.Tier logic (the general/custom contract). The general docs (declared in the seed
spec/doc-spec.md) are the portable contract and are REQUIRED — every adopting repo carries them: the portable seed declares all of them on self-bootstrap, and the stub-scaffold step creates any missing one. Overlay docs (declared inspec/doc-spec-custom.md) are per-repo additions — declared and carried by the repo that wants them, never required anywhere else. The Step 6.7 audit surfaces a repo registry that omits a general-contract doc as an advisorystale:verdict (see 6.7.3b) — advisory, never a halt.--docs <comma-list>per-invocation doc subset. Operator can scope an invocation to a subset of declared docs (e.g.--docs READMEor--docs README,CHANGELOG). The subset is a documentation-only signal to/document-releasevia the project-context block; it is best-effort, not enforced. Case-insensitive parsing; whitespace trimmed; empty subset = full audit; the literalallis an explicit no-filter token.Halt-on-red contract. If
/document-releasereturns non-green (audit error, mid-write failure, hard-abort), the wrapper emits[doc-sync-red]to the caller (an orchestrator) and exits non-green. The orchestrator HALTs withhalt class = halted_at_doc_sync. This is a hard halt, not a warning.Doc-only auto-commit (derived whitelist gate). After a green
/document-release, the wrapper auto-commits doc-only changes so/shipsees a clean tree. The whitelist is derived from thedoc-spec.mdregistry (scripts/doc-spec.sh --expand-whitelist= every declaredpath+doc-spec.md+ everydocs/**/*.md). If any non-whitelist file is dirty after/document-releaseruns, the wrapper refuses to auto-commit and HALTs with[doc-sync-non-doc-write]. Ifdoc-spec.mdis missing/invalid, the wrapper HALTs with[doc-sync-no-config]BEFORE any audit runs.
The orchestrator invocation shape:
(orchestrator session)
|
v
... QA passes green (Step 5) ...
|
v
Skill(CJ_document-release) <- THIS SKILL (no --docs in v1 orchestrator wiring)
|
|-- read doc-spec.md (self-bootstrap from seed if missing)
|-- stub-scaffold any missing declared doc
|-- arg parse: --docs <list>
|-- branch + clean-tree gate
|-- project-context block (doc-only signal)
|-- Skill(/document-release) -> upstream gstack (NOT MODIFIED)
|-- halt-on-red [doc-sync-red] (RESULT=red)
|-- auto-commit doc-only (derived whitelist gate) [doc-sync-non-doc-write]
|-- registered-doc audit (advisory; + no-ref check for human-docs)
`-- success summary (RESULT=green or RESULT=green-noop)
|
v
/ship (Step 6) <- clean-tree precondition NOW satisfied
|
v
PR includes BOTH code + doc commits
Usage
/CJ_document-release # full audit
/CJ_document-release --docs README # README-only filter
/CJ_document-release --docs README,CHANGELOG # multi-doc filter
/CJ_document-release --docs all # explicit full-audit token
--docs parsing is case-insensitive (--docs readme, --docs README, and
--docs Readme are equivalent). Whitespace inside the comma list is trimmed
(--docs "README, CHANGELOG" is accepted). Unknown values warn-and-skip
(--docs README,UNKNOWN_DOC audits README only and prints a one-line warn for
UNKNOWN_DOC). Empty subset (no flag, or --docs "") is a full audit.
Step 0.5: Resolve the doc-spec helper + read/self-heal the contract
Before any audit runs, resolve the doc-spec.sh helper, then read the
doc-spec.md registry. The helper reads doc-spec.md via git rev-parse --show-toplevel, so a _cj-shared-resolved helper still parses THIS repo's
registry — never the workbench's.
# Resolve the doc-spec helper: (1) repo-local first (workbench self-dev or a repo
# that vendors scripts/), then (2) the deployed _cj-shared home (a consumer repo
# where the helper lives ONLY in the installed workbench). 2-tier resolution:
# repo-local -> _cj-shared (no .source / manifest reach-back).
_DS_REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo "")
_DS_SHARED="${CJ_SHARED_SCRIPTS:-$HOME/.claude/_cj-shared/scripts}"
_DS_HELPER=""
if [ -n "$_DS_REPO_ROOT" ] && [ -x "$_DS_REPO_ROOT/scripts/doc-spec.sh" ]; then
_DS_HELPER="$_DS_REPO_ROOT/scripts/doc-spec.sh"
elif [ -x "$_DS_SHARED/doc-spec.sh" ]; then
_DS_HELPER="$_DS_SHARED/doc-spec.sh"
fi
if [ -z "$_DS_HELPER" ]; then
echo "[doc-sync-no-config] doc-spec.sh unreachable (repo-local scripts/ + deployed _cj-shared both absent)"
echo "RESULT: red; HALT_MARKER=[doc-sync-no-config]"
echo "next_action=restore scripts/doc-spec.sh in this repo, or re-run 'skills-deploy install' to refresh the deployed _cj-shared home; re-run /CJ_document-release"
echo "resume_cmd=/CJ_document-release${DOCS_SUBSET:+ --docs $DOCS_SUBSET}"
echo "pr_url=N/A"
exit 1
fi
Self-bootstrap a missing doc-spec.md
If doc-spec.md does not exist at EITHER spec/doc-spec.md (the canonical
home) OR the repo root, scaffold it from the portable general seed
(doc-spec.sh --seed) and commit it. This is the duty that replaces a separate
repo-init step — a fresh repo adopts the contract with no manual step. The
READ/guard probes spec/-then-root so a repo that already carries
spec/doc-spec.md is NOT treated as missing (no spurious duplicate root file);
the WRITE target for a genuinely-missing-everywhere file is spec/doc-spec.md
(delivery standardizes on spec/ — the seed self-declares that path, and a
doc-release-seeded repo and a /CJ_doc_audit-seeded repo produce the identical
file at the identical path; the spec/-then-root READ fallback stays for
pre-existing root-style consumers):
Write the seed to a temp file, verify it is non-empty AND passes --validate,
THEN move it into place. This guards against a --seed failure ever redirecting
a halt string (or an empty stream) into doc-spec.md and corrupting it:
# Guard: present if EITHER spec/doc-spec.md OR root doc-spec.md exists.
if [ ! -f "$_DS_REPO_ROOT/spec/doc-spec.md" ] && [ ! -f "$_DS_REPO_ROOT/doc-spec.md" ]; then
_DS_TMPD=$(mktemp -d)
if bash "$_DS_HELPER" --seed > "$_DS_TMPD/doc-spec.md" 2>/dev/null \
&& [ -s "$_DS_TMPD/doc-spec.md" ] \
&& REPO_ROOT="$_DS_TMPD" bash "$_DS_HELPER" --validate >/dev/null 2>&1; then
mkdir -p "$_DS_REPO_ROOT/spec"
mv "$_DS_TMPD/doc-spec.md" "$_DS_REPO_ROOT/spec/doc-spec.md"
rm -rf "$_DS_TMPD"
git -C "$_DS_REPO_ROOT" add spec/doc-spec.md
git -C "$_DS_REPO_ROOT" commit -m "docs: self-bootstrap spec/doc-spec.md from the portable general seed" >/dev/null 2>&1 || true
echo "CJ_document-release: scaffolded spec/doc-spec.md from the portable general seed."
else
rm -rf "$_DS_TMPD"
echo "[doc-sync-no-config] self-bootstrap failed: doc-spec.sh --seed did not emit a valid doc-spec.md"
echo "RESULT: red; HALT_MARKER=[doc-sync-no-config]"
echo "next_action=check scripts/doc-spec.sh --seed + templates/doc-spec-common.md; re-run /CJ_document-release"
echo "resume_cmd=/CJ_document-release${DOCS_SUBSET:+ --docs $DOCS_SUBSET}"
echo "pr_url=N/A"
exit 1
fi
fi
Validate the registry (strict)
Validate the registry via the helper. The helper exits 1 + emits
[doc-sync-no-config] <reason> when the registry is missing / has no registry
table / has a malformed table row (a literal | inside a cell or the wrong
column count) / a path is duplicated across the two files. The wrapper HALTs
immediately on non-zero exit — no fallback:
CONFIG_OUT=$(bash "$_DS_HELPER" --validate 2>&1)
CONFIG_RC=$?
if [ "$CONFIG_RC" -ne 0 ]; then
echo "$CONFIG_OUT"
echo "RESULT: red; HALT_MARKER=[doc-sync-no-config]"
echo "next_action=repair spec/doc-spec.md's yaml registry; re-run /CJ_document-release"
echo "resume_cmd=/CJ_document-release${DOCS_SUBSET:+ --docs $DOCS_SUBSET}"
echo "pr_url=N/A"
exit 1
fi
Stub-scaffold missing declared docs
For each declared doc that is missing from disk, write a stub (title + a section
skeleton its path-derived audit_class implies + a <!-- TODO: fill in -->
marker) and commit it. Idempotent: only missing docs are written, so a re-run is
a NO-OP and never produces a second stub. Record each stubbed doc in the audit as stub — needs content:
_STUBBED=""
while IFS= read -r _decl; do
[ -n "$_decl" ] || continue
if [ ! -f "$_DS_REPO_ROOT/$_decl" ]; then
mkdir -p "$_DS_REPO_ROOT/$(dirname "$_decl")"
{
echo "# $(basename "$_decl" .md)"
echo ""
echo "<!-- TODO: fill in -->"
echo ""
echo "This doc is declared in doc-spec.md but has not been written yet."
} > "$_DS_REPO_ROOT/$_decl"
git -C "$_DS_REPO_ROOT" add "$_decl"
_STUBBED="$_STUBBED $_decl"
fi
done < <(bash "$_DS_HELPER" --list-declared)
if [ -n "$_STUBBED" ]; then
git -C "$_DS_REPO_ROOT" commit -m "docs: stub-scaffold missing declared docs ($_STUBBED)" >/dev/null 2>&1 || true
echo "CJ_document-release: stub-scaffolded missing declared docs:$_STUBBED (audit: stub — needs content)"
fi
Stub shape for spec/test-spec.md (special-cased — never the generic stub).
The seed declares spec/test-spec.md (the general test contract), and that file
is itself a machine registry: a generic title-plus-section stub would be a
PRESENT-but-INVALID registry — test-spec.sh --validate would halt
[test-spec-no-config] and /CJ_test_audit would hard-find in any consumer
repo. So when spec/test-spec.md is declared-but-missing, deliver it via
test-spec.sh --seed (resolve the engine repo-local scripts/ then
_cj-shared; temp-file + --validate + mv, mirroring the doc-spec
self-bootstrap corruption guard). Fall back to the plain stub ONLY if the
engine is unreachable (and record the audit verdict stub — needs content).
TODOS.md dual-creation (convergent, not conflicting). TODOS-reading skills
lazy-create TODOS.md on first use; this stub-scaffold also creates it when it
is declared-but-missing. The two paths are convergent: whichever runs first
creates a minimal parseable skeleton, and the other no-ops because the file
exists.
The helper supports these subcommands the rest of this skill consumes (every
registry-reading subcommand operates on the MERGE of the general
spec/doc-spec.md + the optional spec/doc-spec-custom.md overlay; a path
duplicated across the two files is a --validate error):
--validate— exit 0 + printOK schema_version=<n>if the merged registry is valid; exit 1 + halt-emit otherwise (incl. a present-but-invalid overlay).--list-declared— emit every declaredDocpath (merged; sorted, unique).--list-human-docs— emit only the path-derived human-doc paths (a path underdocs/or the rootREADME.md; used by the no-work-item-ref audit check).--expand-whitelist— emit the doc-only auto-commit whitelist (every merged declaredpath+ the contract files + everydocs/**/*.md). Step 2 + Step 6 use this.--seed— emit the portable general file (used by the self-bootstrap).
Step 1: Parse arguments
Parse the optional --docs <comma-list> flag (case-insensitive; whitespace
trimmed; resolved against the registry's declared paths at Step 4):
DOCS_RAW=""
ARGS=()
i=1
while [ $i -le $# ]; do
arg="${!i}"
case "$arg" in
--docs)
i=$((i+1))
DOCS_RAW="${!i}"
;;
--docs=*)
DOCS_RAW="${arg#--docs=}"
;;
*)
ARGS+=("$arg")
;;
esac
i=$((i+1))
done
# Normalize: lowercase + strip whitespace + dedupe
DOCS_SUBSET=""
if [ -n "$DOCS_RAW" ]; then
DOCS_SUBSET=$(printf '%s' "$DOCS_RAW" \
| tr '[:upper:]' '[:lower:]' \
| tr -d ' \t' \
| tr ',' '\n' \
| grep -v '^$' \
| sort -u \
| paste -sd ',' -)
fi
# 'all' is the explicit no-filter token
if [ "$DOCS_SUBSET" = "all" ]; then
DOCS_SUBSET=""
fi
The set of known --docs tokens is the basename (or path) of any doc the
doc-spec.md registry declares. Step 4 resolves each requested token against the
declared set; a token matching no declared doc is warn-and-skipped (the full
audit still runs). The registry seeds with this repo's docs (README, CHANGELOG,
CLAUDE.md, docs/philosophy.md, docs/workflow.md, docs/architecture.md, …); other
repos adopting /CJ_document-release declare their own. Upstream
/document-release still decides what to actually audit — the filter is
best-effort communication of operator intent via the project-context block.
Step 2: Branch + clean-tree gate
Upstream /document-release refuses on the base branch (it hard-aborts on main
with "You're on the base branch. Run from a feature branch."). Mirror that
refusal here as a pre-flight check so the wrapper fails fast rather than spending
a Skill call:
_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null)
case "$_BRANCH" in
main|master|trunk|develop|base)
echo "CJ_document-release: refuses on the base branch '$_BRANCH' — /document-release hard-aborts on main. Run from a feature branch."
echo "RESULT: red; HALT_MARKER=[doc-sync-red]; reason=refuses on the base branch"
exit 1
;;
esac
Clean-tree gate: /document-release itself writes doc files. The wrapper refuses
if the working tree already has uncommitted NON-DOC changes (those must commit
first; doc-only dirtiness is OK because the wrapper will auto-commit it later).
The doc-only set is the helper-derived whitelist, not a hardcoded regex:
# Re-resolve the doc-spec helper (shell vars do NOT persist across bash blocks):
# repo-local first, else the deployed _cj-shared home.
_DS_REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo "")
_DS_SHARED="${CJ_SHARED_SCRIPTS:-$HOME/.claude/_cj-shared/scripts}"
_DS_HELPER="$_DS_REPO_ROOT/scripts/doc-spec.sh"
[ -x "$_DS_HELPER" ] || _DS_HELPER="$_DS_SHARED/doc-spec.sh"
# Build doc-only file set from the registry-derived whitelist.
DOC_WHITELIST_SET=$(bash "$_DS_HELPER" --expand-whitelist)
# Inspect uncommitted files; refuse if any are NOT in the whitelist set.
DIRTY_FILES=$(git status --porcelain 2>/dev/null | awk '{print $2}')
NON_DOC_DIRTY=""
while IFS= read -r f; do
[ -n "$f" ] || continue
if ! printf '%s\n' "$DOC_WHITELIST_SET" | grep -qFx "$f"; then
NON_DOC_DIRTY="$NON_DOC_DIRTY$f"$'\n'
fi
done <<< "$DIRTY_FILES"
NON_DOC_DIRTY=$(printf '%s' "$NON_DOC_DIRTY" | head -5)
if [ -n "$NON_DOC_DIRTY" ]; then
echo "CJ_document-release: Working tree has uncommitted non-doc changes — refusing to run /document-release on top of them."
echo "Non-doc dirty files (first 5):"
echo "$NON_DOC_DIRTY"
echo "Recovery: commit or stash the non-doc changes first; then re-run /CJ_document-release."
echo "RESULT: red; HALT_MARKER=[doc-sync-red]; reason=non-doc dirty tree pre-run"
exit 1
fi
Step 3: Build the project-context block
The block is a documentation-only signal to /document-release that this run is
filtered (or unfiltered). Upstream may honor the filter or audit everything —
both outcomes are fine; the wrapper auto-commits whatever upstream produces
(gated by the whitelist).
When --docs <token> is set, resolve each token against the registry's declared
docs (doc-spec.sh --list-declared): a token that matches a declared doc's
basename (or full path) is kept; a token matching nothing is warn-and-skipped:
AUDIT_FILES=""
if [ -n "$DOCS_SUBSET" ]; then
_DS_REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo "")
_DS_SHARED="${CJ_SHARED_SCRIPTS:-$HOME/.claude/_cj-shared/scripts}"
_DS_HELPER="$_DS_REPO_ROOT/scripts/doc-spec.sh"
[ -x "$_DS_HELPER" ] || _DS_HELPER="$_DS_SHARED/doc-spec.sh"
_DECLARED=$(bash "$_DS_HELPER" --list-declared)
while IFS= read -r token; do
[ -n "$token" ] || continue
# Match a declared doc by full path OR basename (case-insensitive token).
_hit=$(printf '%s\n' "$_DECLARED" | awk -v t="$token" 'BEGIN{IGNORECASE=1} {b=$0; sub(/.*\//,"",b); sub(/\.md$/,"",b); fp=$0; sub(/\.md$/,"",fp); if (tolower(b)==tolower(t) || tolower(fp)==tolower(t) || tolower($0)==tolower(t)) print $0}')
if [ -z "$_hit" ]; then
echo "CJ_document-release: warn — --docs token '$token' matches no declared doc; skipping it."
continue
fi
AUDIT_FILES="$AUDIT_FILES$_hit"$'\n'
done < <(printf '%s' "$DOCS_SUBSET" | tr ',' '\n')
AUDIT_FILES=$(printf '%s' "$AUDIT_FILES" | sort -u | grep -v '^$' || true)
CONTEXT_BLOCK="CJ_document-release: running with --docs filter = '$DOCS_SUBSET'.
This invocation should audit only the following files (resolved via doc-spec.md):
$AUDIT_FILES
The filter is best-effort communication of operator intent — upstream behavior
is authoritative."
else
CONTEXT_BLOCK="CJ_document-release: running with no --docs filter (full audit)."
fi
Step 4: Invoke upstream /document-release via the Skill tool
Invoke Skill(skill="document-release") with the project-context block as
guidance. The wrapper does NOT pass any flags to upstream (--docs is
documentation-only and not an upstream-supported flag):
Skill: document-release
(project-context block from Step 3)
Capture the upstream verdict. The Skill tool returns once /document-release
finishes; the wrapper reads the result as green or red.
Step 4→5 boundary — two failure modes, one marker. A failure can surface
HERE in two distinct ways, and BOTH route to the Step 5 [doc-sync-red] halt:
- Resolution failure —
Skill(document-release)cannot be resolved at all (the upstream gstack/document-releaseskill is not installed on this machine). This is a Step-4 resolution failure, not a Step-5 audit verdict. - Non-green return — the skill resolved and ran but returned non-green (audit error, mid-write failure, hard-abort, crashed, exceeded budget).
The wrapper does NOT add a programmatic skill-presence probe (a probe risks a new
false-halt class). Instead, in EITHER case it falls through to Step 5, whose
[doc-sync-red] message names "gstack /document-release not installed" as a
possible cause alongside the doc-error cause — so the operator gets the actionable
hint for the resolution-failure mode too, not only the non-green mode.
Step 5: Halt-on-red ([doc-sync-red])
If the Step 4 invocation failed to RESOLVE (gstack /document-release not
installed) OR upstream returned non-green (audit error, mid-write failure,
hard-abort, crashed, exceeded budget): emit a halt marker and exit RESULT=red.
The message names both possible causes so it covers the Step-4 resolution-failure
mode and the Step-5 non-green mode:
echo "CJ_document-release: upstream /document-release did not return green (it either could not be resolved or returned non-green); halting."
echo "Possible causes: gstack /document-release not installed; or a doc audit error in /document-release."
echo "RESULT: red; HALT_MARKER=[doc-sync-red]"
echo "next_action=confirm gstack /document-release is installed, OR inspect its output and fix doc errors; then re-run /CJ_document-release"
echo "resume_cmd=/CJ_document-release${DOCS_SUBSET:+ --docs $DOCS_SUBSET}"
echo "pr_url=N/A"
exit 1
Step 6: Auto-commit doc-only (derived whitelist gate; [doc-sync-non-doc-write])
After a green /document-release, inspect the working tree. If any dirty file is
OUTSIDE the doc-only whitelist, refuse to auto-commit and HALT — this is the
upstream-misbehaved case (or an unexpected stealth-write surface). The whitelist
set comes from bash "$_DS_HELPER" --expand-whitelist (reuses Step 2's
expansion):
# Re-resolve the doc-spec helper (shell vars do NOT persist across bash blocks):
# repo-local first, else the deployed _cj-shared home.
_DS_REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo "")
_DS_SHARED="${CJ_SHARED_SCRIPTS:-$HOME/.claude/_cj-shared/scripts}"
_DS_HELPER="$_DS_REPO_ROOT/scripts/doc-spec.sh"
[ -x "$_DS_HELPER" ] || _DS_HELPER="$_DS_SHARED/doc-spec.sh"
DOC_WHITELIST_SET=$(bash "$_DS_HELPER" --expand-whitelist)
DIRTY=$(git status --porcelain 2>/dev/null | awk '{print $2}' | grep -v '^$')
DOC_DIRTY=""
NON_DOC_DIRTY=""
while IFS= read -r f; do
[ -n "$f" ] || continue
if printf '%s\n' "$DOC_WHITELIST_SET" | grep -qFx "$f"; then
DOC_DIRTY="$DOC_DIRTY$f"$'\n'
else
NON_DOC_DIRTY="$NON_DOC_DIRTY$f"$'\n'
fi
done <<< "$DIRTY"
DOC_DIRTY=$(printf '%s' "$DOC_DIRTY" | grep -v '^$' || true)
NON_DOC_DIRTY=$(printf '%s' "$NON_DOC_DIRTY" | grep -v '^$' || true)
if [ -n "$NON_DOC_DIRTY" ]; then
echo "CJ_document-release: upstream wrote files outside the doc-only whitelist — refusing to auto-commit."
echo "Non-doc dirty files:"
echo "$NON_DOC_DIRTY"
echo "RESULT: red; HALT_MARKER=[doc-sync-non-doc-write]"
echo "next_action=inspect uncommitted non-doc files; revert if unexpected; re-run /CJ_document-release"
echo "resume_cmd=/CJ_document-release${DOCS_SUBSET:+ --docs $DOCS_SUBSET}"
echo "pr_url=N/A"
echo "non_doc_files=$(printf '%s' "$NON_DOC_DIRTY" | tr '\n' ',' | sed 's/,$//')"
exit 1
fi
# Green path: doc-only changes (or none)
if [ -n "$DOC_DIRTY" ]; then
printf '%s\n' "$DOC_DIRTY" | xargs git add
git commit -m "docs: post-build sync via CJ_document-release${DOCS_SUBSET:+ (--docs $DOCS_SUBSET)}"
COMMIT_SHA=$(git rev-parse --short HEAD)
echo "CJ_document-release: committed doc-only changes at $COMMIT_SHA"
echo "RESULT: green; doc_commit=$COMMIT_SHA; filtered=${DOCS_SUBSET:-full}"
else
echo "CJ_document-release: no doc changes needed (green-noop)"
echo "RESULT: green-noop; doc_commit=none; filtered=${DOCS_SUBSET:-full}"
fi
Step 6.7: Registered-doc requirements audit (ADVISORY — never halts)
This step runs on the GREEN / green-noop TAIL of Step 6 — only after the
auto-commit RESULT line has printed (so it is on the non-exiting path; Step 6's
[doc-sync-non-doc-write] / [doc-sync-red] halts have already exited before
control reaches here). It is strictly advisory: it emits one verdict per
registered doc and NEVER halts, exits non-green, or blocks /ship. A
missing-requirement verdict is a soft finding, not a halt.
This is the producer for the workbench's PR-body audit subheadings — see
docs/architecture.md ## The doc-spec.md contract + /CJ_document-release. The
agent running the wrapper performs the judgment; the deliverable is a grep-able
block written to BOTH the wrapper RESULT and a gitignored scratch file the
orchestrator surfaces post-/ship.
What "registered docs" means. Two sets, both enumerated dynamically (no hardcoded counts):
- The registry docs — every entry in the
doc-spec.mdregistry, each carrying arequirement:value. - The routable skill MDs — every skill enumerated by the
!= "deprecated"selector; each skill's requirement is its optionaldoc_requirementinskills-catalog.json, else the shared default skill-MD requirement.
6.7.1 — Parse the registry requirements
Parse the doc-spec.md registry (the same block the helper reads). For each
declared doc, capture BOTH its path: value AND its requirement: child value.
The requirement: value MAY wrap across a continuation line, so read the FULL
value:
_DS_REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo "")
# Resolve spec/-then-root (the registry moved into spec/; root is the fallback).
_DS_REG="$_DS_REPO_ROOT/spec/doc-spec.md"
[ -f "$_DS_REG" ] || _DS_REG="$_DS_REPO_ROOT/doc-spec.md"
# Extract the yaml registry block and walk path + requirement pairs.
awk '
/^```yaml/ { if (!seen) { f=1; seen=1; next } }
/^```/ { if (f) { f=0 } }
f { print }
' "$_DS_REG"
For each captured (path, requirement) pair: the registered doc is path, and
its declared requirement is the full requirement: string. A human-doc entry
ALSO gets the no-work-item-ref check below.
6.7.2 — Enumerate the skill MDs + their requirements
Enumerate routable skills with the != "deprecated" selector, and read each
skill's optional doc_requirement (absent => the shared default).
Non-workbench guard. This half of the audit reads skills-catalog.json,
which exists only in the workbench (and any repo that ships its own skill
catalog). In a consumer repo with no catalog, skip the skill-MD enumeration
cleanly — one note, no jq: Could not open file stderr — and set
CATALOG_PRESENT=false so 6.7.4 skips the cj_goal scratch-file write too. The
registry-doc audit (6.7.1) and the human-doc no-work-item-ID lint (6.7.3) are
catalog-independent and STILL run. The $(…)-capture idiom is preserved (and the
jq reads are || true-guarded), so no set -e abort is introduced:
_CATALOG="$_DS_REPO_ROOT/skills-catalog.json"
if [ ! -f "$_CATALOG" ]; then
CATALOG_PRESENT=false
echo "CJ_document-release: no skills-catalog.json — non-workbench mode; skipping the skill-MD audit half (registry-doc audit still runs)."
else
CATALOG_PRESENT=true
SKILL_NAMES=$(jq -r '.[] | select(.status != "deprecated") | select((.files | length) > 0) | .name' "$_CATALOG" 2>/dev/null || true)
SHARED_DEFAULT="The SKILL.md frontmatter \`description\` and the documented behavior/steps match the skill's current implementation; the skill's USAGE.md is current."
for _name in $SKILL_NAMES; do
_req=$(jq -r --arg n "$_name" '.[] | select(.name==$n) | .doc_requirement // empty' "$_CATALOG" 2>/dev/null || true)
[ -z "$_req" ] && _req="$SHARED_DEFAULT"
# registered doc = skills/$_name/SKILL.md ; requirement = $_req
done
fi
6.7.3 — Judge each registered doc (+ no-work-item-ref check for human-docs)
Determine the diff base (the merge-base of the branch against the default
branch). For EACH registered doc, the agent reads the doc + its requirement + the
run's git diff <base>...HEAD and assigns ONE verdict:
up-to-date— satisfies its requirement given what this run changed.stale: <one-line why>— no longer satisfies its requirement.missing-requirement— the registered doc has NO declared requirement. Soft; never a halt.n/a— registered but out of scope for this run's judgment.
For every path-derived human-doc (a declared path under docs/ or the root
README.md) registered doc, ALSO run the
no-work-item-ref check: grep the doc for [FSTD][0-9]{6}; any hit forces the
verdict stale: contains work-item refs (the advisory mirror of the hard
validate.sh Check 19):
_DS_HELPER="$_DS_REPO_ROOT/scripts/doc-spec.sh"
[ -x "$_DS_HELPER" ] || _DS_HELPER="${CJ_SHARED_SCRIPTS:-$HOME/.claude/_cj-shared/scripts}/doc-spec.sh"
for _hd in $(bash "$_DS_HELPER" --list-human-docs); do
if grep -qE '[FSTD][0-9]{6}' "$_DS_REPO_ROOT/$_hd" 2>/dev/null; then
echo " $_hd: stale — contains work-item refs"
fi
done
6.7.3b — General-contract coverage check (advisory missing-general-doc rule)
The general contract (the portable seed) declares the general docs
every adopting repo is REQUIRED to carry. When the REPO's registry omits one of
them, surface the gap as part of the contract file's own verdict line (the
registry entry whose basename is doc-spec.md):
stale: registry missing general-contract doc(s): <paths>
Because this is a stale verdict on a registered doc, it naturally suppresses
the Registered-doc requirements: all current positive line — intended and
honest. It is ADVISORY, never a halt: no exit, no halt marker, no
RESULT=red.
Enumerating the general set. Do NOT hand-parse the seed table. Write the
seed to a temp file (with NO sibling overlay) and reuse the parser —
--list-declared on the isolated seed IS the general set, because the seed
carries only general rows:
_GC_TMP=$(mktemp -d)
bash "$_DS_HELPER" --seed > "$_GC_TMP/doc-spec.md" 2>/dev/null || true
_GENERAL_SET=$(DOC_SPEC_PATH="$_GC_TMP/doc-spec.md" bash "$_DS_HELPER" --list-declared 2>/dev/null)
rm -rf "$_GC_TMP"
_DECLARED=$(bash "$_DS_HELPER" --list-declared)
_MISSING_GC=""
for _gc in $_GENERAL_SET; do
printf '%s\n' "$_DECLARED" | grep -qFx "$_gc" && continue
# Path equivalence: the seed declares the contract files spec/-style
# (spec/doc-spec.md, spec/test-spec.md); ANY declared path with the SAME
# BASENAME satisfies that entry (e.g. a root-style consumer's doc-spec.md)
# — mirroring the helper's own spec/-then-root resolution. The rule is
# scoped to spec/-prefixed seed paths only, so docs/* entries never get
# basename-equivalence. Without this rule a root-style consumer would
# false-positive on every run.
case "$_gc" in
spec/*)
_gc_base=$(basename "$_gc")
if printf '%s\n' "$_DECLARED" | awk -F/ '{print $NF}' | grep -qFx "$_gc_base"; then
continue
fi
;;
esac
_MISSING_GC="$_MISSING_GC $_gc"
done
# Non-empty _MISSING_GC => the contract file's verdict line becomes:
# stale: registry missing general-contract doc(s):$_MISSING_GC
6.7.4 — Emit the block (RESULT + scratch file)
Compose the grep-able block and always write it to the wrapper RESULT (stdout).
In WORKBENCH mode (CATALOG_PRESENT=true), ALSO write it to the gitignored
scratch file "$_DS_REPO_ROOT/.cj-goal-feature/registered-doc-verdicts.md" that
the cj_goal orchestrator surfaces post-/ship. Emit the positive line
Registered-doc requirements: all current ONLY when EVERY verdict is
up-to-date.
Non-workbench scratch-write skip. The .cj-goal-feature/ scratch file ONLY
feeds the cj_goal orchestrator's PR-body surfacing, which does not exist when the
skill runs standalone — and in a consumer repo .cj-goal-feature/ is NOT
gitignored, so writing it would leave a stray untracked artifact. So when
CATALOG_PRESENT=false (set in 6.7.2), emit the block to stdout only and skip the
scratch write:
{
echo "### Registered-doc requirements"
printf '%s\n' "$VERDICT_BODY"
if [ "$ALL_UP_TO_DATE" = "true" ]; then
echo "Registered-doc requirements: all current"
fi
} > /tmp/cj-docrel-verdicts.$$ 2>/dev/null || true
cat /tmp/cj-docrel-verdicts.$$ 2>/dev/null || true
if [ "${CATALOG_PRESENT:-true}" = "true" ]; then
_VERDICT_DIR="$_DS_REPO_ROOT/.cj-goal-feature"
mkdir -p "$_VERDICT_DIR"
_VERDICT_FILE="$_VERDICT_DIR/registered-doc-verdicts.md"
cp /tmp/cj-docrel-verdicts.$$ "$_VERDICT_FILE" 2>/dev/null || true
fi
rm -f /tmp/cj-docrel-verdicts.$$ 2>/dev/null || true
The block is ADVISORY: control falls straight through to Step 7. No exit, no halt marker, no RESULT=red is emitted by this step under any verdict.
Step 7: Success summary
Print a single-line success summary the orchestrator can grep:
CJ_document-release: <green|green-noop> / /document-release: green / commit: <sha-or-none> / filtered: <subset-or-full>
The orchestrator's Step 5.5 reads:
RESULT: green→ continue to/ship. Doc commit was made;/shipopens one PR containing both code + doc updates.RESULT: green-noop→ continue to/ship. No doc commit needed.RESULT: red→ HALT with the corresponding marker.
Halt-marker shape (machine-readable)
RESULT: red; HALT_MARKER=[doc-sync-red]
next_action=<one-line>
resume_cmd=/CJ_document-release [--docs <same-subset>]
pr_url=N/A
raw_output_path=<path-from-document-release-or-N/A>
RESULT: red; HALT_MARKER=[doc-sync-non-doc-write]
next_action=inspect uncommitted non-doc files; revert if unexpected; re-run
resume_cmd=/CJ_document-release [--docs <same-subset>]
pr_url=N/A
non_doc_files=<comma-separated list from git status>
Cron / --quiet interaction
Halt-on-red is a hard halt regardless of caller mode. /CJ_goal_todo_fix --quiet
(cron) suppresses Phase 3 summary AUQs + start-of-run banners; it does NOT
suppress the [doc-sync-red] or [doc-sync-non-doc-write] halt contracts.
Error Handling
| Error | Marker | Recovery |
|---|---|---|
| Not a git repo | (no marker — usage halt) | Run inside a repo |
doc-spec.md missing / no registry table / malformed table row (literal ` |
` in a cell, wrong column count) / duplicate path across the two files | [doc-sync-no-config] |
doc-spec.sh helper unreachable |
[doc-sync-no-config] |
Restore scripts/doc-spec.sh, or re-run skills-deploy install to refresh the deployed _cj-shared home; re-run |
| On main / base branch (refuses on the base branch) | [doc-sync-red] |
Run from a feature branch |
| Working tree has uncommitted non-doc changes (pre-run) | [doc-sync-red] |
Commit or stash non-doc changes; re-run |
Upstream /document-release did not return green — either it could not be resolved (gstack /document-release not installed) or it returned non-green (audit error) |
[doc-sync-red] |
Confirm gstack /document-release is installed; OR inspect its output and fix doc errors; re-run |
| Upstream wrote files outside the doc-only whitelist | [doc-sync-non-doc-write] |
Inspect uncommitted non-doc files; revert if unexpected; re-run |
--docs UNKNOWN_VALUE (token matches no declared doc) |
(no halt — warn-and-skip) | Use a token that matches a doc declared in doc-spec.md |
Notes
- Wrapper around an upstream gstack skill.
/CJ_document-releasecalls/document-releasevia the Skill tool, adding workbench-specific concerns (doc-spec.md self-heal, per-doc filtering, halt taxonomy, auto-commit doc-only) without touching upstream. - Project-context block is documentation-only, not programmatic. Best-effort filter, not enforced filter — the wrapper auto-commits whatever upstream produces, gated by the derived whitelist.
- The doc-only whitelist is DERIVED from the registry, never hand-maintained. Deleting a doc from the registry removes it from the whitelist automatically; there is no second list to keep in sync.
- No upstream
/document-releasemodification. All workbench-specific logic lives in this wrapper.