Project-Template/docs/architecture/scripts/scaffold.sh

621 lines
25 KiB
Bash
Raw Normal View History

#!/usr/bin/env bash
#
# Create the documentation tree a project is expected to have, copied from the
# template's own copy of it, without ever overwriting a document that is
# already there.
#
# bash scripts/scaffold.sh # into the current directory
# bash scripts/scaffold.sh --dry-run # list everything, write nothing
# bash scripts/scaffold.sh --into ../new-app
# bash scripts/scaffold.sh --force # overwrite; needs a clean git tree
# SCAFFOLD_TEMPLATE_ROOT=/path/to/Template bash scripts/scaffold.sh
#
# ===========================================================================
# TEMPLATE COPY — configure this before the first run
# ===========================================================================
#
# Copy to `scripts/scaffold.sh` and set TEMPLATE_ROOT in the CONFIGURATION
# block below, or pass it as SCAFFOLD_TEMPLATE_ROOT. It is empty and it stays
# empty: the template sits at a different absolute path on every machine, and
# a plausible-looking default would quietly resolve to whatever checkout
2026-08-17 22:57:20 -05:00
# happens to be there. That is not a "file not found" — it is a whole
# documentation tree arriving with another project's Owner lines, another
# project's review dates and another project's branding, each document reading
# as this project's own the moment it lands.
#
# Assumes: bash and coreutils. git is used only by --force, and only to read.
#
# ## Why this exists
#
# The convention this tree encodes is cheap to state and, on the evidence,
# almost never adopted whole. Eight repositories were surveyed before the
# template was last rewritten: exactly one kept the ledger the template asked
# for, three had invented their own dialects, and one had pushed nothing but a
# README. Nobody refused the convention. They started projects, and a
# convention that has to be re-typed from memory at the start of each one is a
# convention that decays into four different ones.
#
# So the first act of a new project is a command, not a reading exercise. What
# is left afterwards — the placeholders, the tracker, the branding — is work
# that genuinely needs a person, and it is visible precisely because the
# skeleton around it is already correct.
#
# ## Never overwriting is the whole safety property
#
# Every other rule here follows from one: a scaffold that clobbers a written
# document destroys work that nothing would notice was gone. There is no test
# for "the architecture notes used to say more than this", no build failure, no
# diff to read if the file was never committed. It is silent at the moment it
# happens and silent forever afterwards, and the natural time to re-run a
# scaffold — partway into a project, to pick up a file that was missed — is
# exactly the time the tree is full of real writing.
#
# So an existing file is reported as "kept" and left untouched, always. --force
# exists for the one honest case (a tree scaffolded from a stale template, not
# yet written into) and it refuses twice over: once for the tree, which must be
# clean, and again for each file, which git must actually track. A clean tree
# alone is not enough — it says nothing about ignored files — so the worst
# --force can do is something `git checkout` undoes.
#
# ## What it deliberately does not do
#
# It does not touch git. No init, no add, no commit — creating files is enough,
# and committing them is a judgement about what belongs in the history of this
# project, made by whoever will answer for that history.
#
# It does not fill anything in. The angle-bracket placeholders are left
# standing because a document whose header says it was reviewed on a date
# nobody reviewed it is worse than one that visibly has not been filled in: the
# first is trusted at the exact moment it should not be. An unfilled header
# keeps asking; an invented one stops.
#
# It does not copy branding. See the docs/data/img note near the bottom.
#
# It does not copy the scripts that live in this folder. They are in the
# template's docs tree for the template's own reasons, and each one needs
# configuring before its first run — an unconfigured release.sh landing in
# every new repository is a loaded gun, not a head start. Copy those one at a
# time, having read them.
#
# It does not create the tracker labels, the first milestone or the project's
# README, and it does not check conformance. It reports what it did. Whether
# the project passes is a different question, asked by a different thing.
set -uo pipefail
# ---------------------------------------------------------------------------
# CONFIGURATION — set this one, then delete this banner.
#
# Empty on purpose. See the note at the top: a default here does not fail, it
2026-08-17 22:57:20 -05:00
# copies the wrong project's documents and says "created" for every one.
# ---------------------------------------------------------------------------
# Absolute path to the template checkout — the directory holding `docs/`.
TEMPLATE_ROOT="${SCAFFOLD_TEMPLATE_ROOT:-}"
# ---------------------------------------------------------------------------
# The tree, listed explicitly.
#
# A recursive copy would be shorter and would drag in whatever else is sitting
# in the template's docs tree today, including this script. An explicit list is
# also the only thing that can tell the difference between "the template does
# not have that file" and "the copy quietly produced twelve of thirteen" — the
# second looks identical to success from the outside.
# ---------------------------------------------------------------------------
DOCS=(
docs/DOC_TRUST_MAP.md
docs/WORK_CYCLE.md
2026-08-17 22:57:20 -05:00
docs/OPERATIONS.md
docs/TOOLS.md
docs/planning/PROJECT_PLAN.md
docs/qa/ClaudeQAPlan.md
docs/qa/ClaudeQACoverage.md
docs/qa/ClaudeReport.md
docs/history/HISTORY.md
docs/history/DEVELOPMENT_LOG.md
docs/history/BATCH_LEDGER.md
docs/security/SECURITY.md
docs/security/SECURITY_CHECKLIST.md
docs/architecture/README.md
fix(scaffold): new projects were missing two documents they reference docs/data/README.md links to `img/README.md` for the dimensions, weights and magic-byte rule of the three marks. scaffold.sh never copied it: DOCS did not list it and DIRS created docs/data/img empty. Every scaffolded project therefore started with a broken link in a required document, a doc-claims failure on its first run, and no copy of the spec the link promises. The omission looks like a misreading of the script's own rule. Its header says it does not copy branding and that the template's docs/data/img is not a source -- both true of the three .webp marks, which must not be invented because a placeholder that looks deliberate outlives the issue that would have replaced it. img/README.md is documentation about those files, not one of them. Verifying that fix surfaced a second, plainer one: docs/architecture/GUARDS.md was never scaffolded either, while being referenced by TOOLS.md, DOC_TRUST_MAP.md and architecture/README.md twice. It is a document, not a script, and nothing argued for leaving it out. A scaffold now writes 19 files, and a freshly scaffolded project no longer names a document that is not there. This is the fourth instance here of one shape -- a document naming a path that is not present. The others were docs/planning/FUTURE.md in the batch ledger, an Exempt: line inside a code fence, and docs/data/logo.webp as an example of where NOT to put an asset. The first three were wrong in the template; this one was correct in the template and wrong in every copy of it, which is why running doc-claims here never caught it and scaffolding into a scratch directory did. closes #15 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:34:12 -05:00
docs/architecture/GUARDS.md
docs/design/README.md
docs/data/README.md
fix(scaffold): new projects were missing two documents they reference docs/data/README.md links to `img/README.md` for the dimensions, weights and magic-byte rule of the three marks. scaffold.sh never copied it: DOCS did not list it and DIRS created docs/data/img empty. Every scaffolded project therefore started with a broken link in a required document, a doc-claims failure on its first run, and no copy of the spec the link promises. The omission looks like a misreading of the script's own rule. Its header says it does not copy branding and that the template's docs/data/img is not a source -- both true of the three .webp marks, which must not be invented because a placeholder that looks deliberate outlives the issue that would have replaced it. img/README.md is documentation about those files, not one of them. Verifying that fix surfaced a second, plainer one: docs/architecture/GUARDS.md was never scaffolded either, while being referenced by TOOLS.md, DOC_TRUST_MAP.md and architecture/README.md twice. It is a document, not a script, and nothing argued for leaving it out. A scaffold now writes 19 files, and a freshly scaffolded project no longer names a document that is not there. This is the fourth instance here of one shape -- a document naming a path that is not present. The others were docs/planning/FUTURE.md in the batch ledger, an Exempt: line inside a code fence, and docs/data/logo.webp as an example of where NOT to put an asset. The first three were wrong in the template; this one was correct in the template and wrong in every copy of it, which is why running doc-claims here never caught it and scaffolding into a scratch directory did. closes #15 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 23:34:12 -05:00
# The spec for the three marks, not one of the marks. `docs/data/README.md`
# links to it, so leaving it behind gave every scaffolded project a broken
# link and a doc-claims failure on its first run -- the rule above about not
# copying branding is about the .webp files, which genuinely must not be
# invented, and this is documentation about them.
docs/data/img/README.md
)
feat(plan): six sections every plan must name, and the files an agent reads first WORK_CYCLE.md covered only the end of the cycle. A cycle has two ends, and the same six questions kept having to be asked out loud on every piece of work. They are now mandatory in every plan, each justified by something this household has actually paid for: unified code eight copies of secrets.sh once existed here and five of seven could not detect the most common secret shape -- INCLUDING THE TEMPLATE, so every project scaffolded from it inherited a blind scanner error handling the recurring fault is the silent pass, not the crash logging an append-only log is unbounded by construction blind spots named ones get fixed landmine fixes the trap found while passing is cheapest to fix while passing hardcode little derive it, or justify the constant GUARDS.md was five sections behind the project that has been learning; 11, 12 and 13 are backported, genericised to match the template's style. 13 is the narrow form of the sixth rule, and WORK_CYCLE now cites it -- so backporting it is what makes that citation true rather than a broken reference. Also adds the three files an agent reads BEFORE it reads docs/: CLAUDE.md short, and it POINTS at DOC_TRUST_MAP rather than repeating it -- a second copy of the map is the failure that map exists to prevent. Carries the exit-code table, the commit gates, and two standing instructions: flag what looks wrong even when it is not what you were asked about, and never print a credential -- not from a file, not from a command's output, not from a config subtree "with the secrets filtered out", because that filter has failed before by matching key NAMES while the secret sat inside an object whose name was innocent .claudeignore excludes artifacts and NEVER docs/. The Command Center reads this repository's documents at a commit; a generic ignore file that sweeps "documentation" or "data" starves both the agent and the reconcile, and everything still runs, just blind .claude/settings.json deny rules in the double-slash absolute form. A tilde-style rule looks right in review and silently matches nothing. It closes the Read TOOL only -- a shell reads a file a hundred ways -- so it catches the accidental read, not the determined one scaffold.sh gains a ROOT array for the three, kept apart from DOCS so the H1-plus-status-block check stays meaningful rather than being loosened into a warning that is always wrong (GUARDS.md 5). Verified: scaffold --dry-run into a scratch repo creates 22 files including all three, with no HEADERLESS warning; doc-claims passes with 124 claimed paths, all present. Does not touch docs/architecture/scripts/secrets.sh, which carries someone else's uncommitted improvement.
2026-09-01 21:44:00 -05:00
# Root-level files an agent reads BEFORE it reads anything in docs/.
#
# Kept apart from DOCS for one reason: the header check below wants an H1
# followed by a fenced status block, which is right for a document and wrong for
# a JSON settings file and an ignore list. Listing them here rather than
# loosening that check keeps the check meaningful -- a guard that is often wrong
# is worse than none (`GUARDS.md` §5).
#
# They carry angle-bracket placeholders like every other template document, for
# the reason stated at the top of this file: an unfilled header keeps asking,
# and an invented one stops.
ROOT=(
CLAUDE.md
.claudeignore
.claude/settings.json
)
# Directories made empty, holding nothing this script is willing to invent.
DIRS=(docs/data/img)
# The three names the conformance check looks for, by name, in docs/data/img.
BRANDING=(icon.webp logo.webp banner.webp)
# The placeholders the template leaves for a person to replace. Used only to
# count what is still outstanding at the end — if the template's placeholder
# style changes this finds nothing, which is why finding nothing prints nothing
# rather than an all-clear.
PLACEHOLDER_RE='<(Project|YYYY-MM-DD|who maintains this)>'
say() { printf '\033[1mscaffold:\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1mscaffold:\033[0m %s\n' "$*" >&2; exit 1; }
usage() {
cat >&2 <<'EOF'
scaffold: create the documentation tree a project is expected to have.
bash scripts/scaffold.sh into the current directory
bash scripts/scaffold.sh --into <path> into another project root
bash scripts/scaffold.sh --dry-run list what would happen, write nothing
bash scripts/scaffold.sh --force overwrite; refuses on a dirty git tree
SCAFFOLD_TEMPLATE_ROOT path to the template checkout (required, no default)
SCAFFOLD_INTO same as --into
An existing file is never overwritten without --force. Exits non-zero if any
path was skipped, including one the template itself is missing.
EOF
}
DRY_RUN=""
FORCE=""
TARGET="${SCAFFOLD_INTO:-}"
# Set only when --force resolves a repository. Declared here so the copy loop
# can test it under `set -u` whether or not --force was passed.
force_top=""
# Whether the operator named the target, as opposed to inheriting the current
# directory. The guard below is only for the inherited case.
TARGET_NAMED=""
[ -n "$TARGET" ] && TARGET_NAMED="yes"
while [ "$#" -gt 0 ]; do
case "$1" in
--dry-run) DRY_RUN="yes" ;;
--force) FORCE="yes" ;;
--into)
# A bare --into would otherwise swallow the next flag as a path and
# scaffold into a directory called "--dry-run".
[ "$#" -ge 2 ] || die "--into needs a path."
case "$2" in -*) die "--into needs a path, got '$2'." ;; esac
# An empty string is not "here". Without this it becomes "." while still
# counting as named, which silently disables the project-root guard below
# — the one case where the guard is most likely to be right.
[ -n "$2" ] || die "--into was given an empty path. Use --into . to mean the
current directory."
TARGET="$2"; TARGET_NAMED="yes"; shift ;;
--into=*)
TARGET="${1#--into=}"; TARGET_NAMED="yes"
[ -n "$TARGET" ] || die "--into needs a path." ;;
-h|--help) usage; exit 0 ;;
*) die "unknown argument '$1'. Run --help for usage." ;;
esac
shift
done
# ---------------------------------------------------------------------------
# Resolve both ends before touching either. Everything checkable is checked
# before the first file is written.
# ---------------------------------------------------------------------------
[ -n "$TEMPLATE_ROOT" ] || die "set TEMPLATE_ROOT (or SCAFFOLD_TEMPLATE_ROOT) — the path to the
template checkout holding docs/. See the CONFIGURATION block."
# A leading ~ inside a variable is a literal character, not $HOME. The failure
# is otherwise "no such directory" naming a path the operator can see exists,
# which sends them looking in the wrong place.
case "$TEMPLATE_ROOT" in
'~'*) die "TEMPLATE_ROOT is '$TEMPLATE_ROOT' — a leading ~ is not expanded inside a
variable. Write the path out, or use \"\$HOME/...\"." ;;
esac
[ -d "$TEMPLATE_ROOT" ] || die "TEMPLATE_ROOT '$TEMPLATE_ROOT' is not a directory."
TEMPLATE_ABS=$(cd "$TEMPLATE_ROOT" 2>/dev/null && pwd -P) \
|| die "cannot enter TEMPLATE_ROOT '$TEMPLATE_ROOT' — check permissions."
[ -d "$TEMPLATE_ABS/docs" ] || die "'$TEMPLATE_ABS' has no docs/ directory, so it is not the template
root. TEMPLATE_ROOT is the checkout, not the docs folder inside it."
# Same trap as TEMPLATE_ROOT above, and worse here: the bare "does not exist"
# message names a path the operator can see does exist.
case "$TARGET" in
'~'*) die "target is '$TARGET' — a leading ~ is not expanded inside a variable or
inside quotes. Write the path out, or use \"\$HOME/...\"." ;;
esac
[ -n "$TARGET" ] || TARGET="."
# Refused rather than created. `mkdir -p` on a typo succeeds, and the result is
# a complete, correct documentation tree in a directory that should not exist —
# which reads as success and is found weeks later, if at all.
[ -d "$TARGET" ] || die "target '$TARGET' does not exist. Create it first — scaffolding into a
mistyped path produces a tree that looks entirely correct."
TARGET_ABS=$(cd "$TARGET" 2>/dev/null && pwd -P) \
|| die "cannot enter target '$TARGET' — check permissions."
[ "$TARGET_ABS" != "$TEMPLATE_ABS" ] \
|| die "the target is the template root itself. There is nothing to scaffold."
# ---------------------------------------------------------------------------
# An inherited current directory is the one input nobody typed.
#
# Run from src/ or from docs/ itself — both of which are where you are when you
# think to run this — the default would build a second, wrong-place tree that
# every convention here then fails to find. Only guessed targets are checked;
# --into is an instruction and is obeyed.
# ---------------------------------------------------------------------------
if [ -z "$TARGET_NAMED" ]; then
if command -v git >/dev/null 2>&1; then
guess_top=$(git -C "$TARGET_ABS" rev-parse --show-toplevel 2>/dev/null)
if [ -n "$guess_top" ]; then
guess_top=$(cd "$guess_top" 2>/dev/null && pwd -P)
if [ -n "$guess_top" ] && [ "$guess_top" != "$TARGET_ABS" ]; then
die "you are in a subdirectory of '$guess_top'. docs/ belongs at the project
root. Re-run with --into '$guess_top', or --into . to mean here."
fi
fi
else
# Said out loud rather than passed over. Without git this check cannot run
# at all, and a silent skip reads exactly like a check that passed.
say "note: git is not installed, so the \"is this the project root?\" check did"
say " not run. '$TARGET_ABS' is being taken as the root on trust."
fi
fi
# ---------------------------------------------------------------------------
# --force may only run where the damage is undoable.
#
# Overwriting is recoverable exactly when git already holds the current
# contents. A dirty tree means it does not, and an untracked file is the worst
# case of all — scaffolded an hour ago, written into since, never committed,
# and nothing anywhere remembers what it said. So untracked counts as dirty.
#
# This is the tree-wide half of the check, and on its own it is not enough:
# `git status --porcelain` is silent about ignored files, so the per-file
# `ls-files` test in the copy loop below is what actually closes the gap.
#
# Every way of failing to answer the question is refused, not passed over.
# ---------------------------------------------------------------------------
if [ -n "$FORCE" ]; then
command -v git >/dev/null 2>&1 \
|| die "--force needs git, so an overwrite can be undone with 'git checkout'."
force_top=$(git -C "$TARGET_ABS" rev-parse --show-toplevel 2>/dev/null) \
|| force_top=""
[ -n "$force_top" ] || die "--force refused: '$TARGET_ABS' is not inside a git repository, so an
overwrite would be permanent. Commit the tree somewhere first, or drop
--force and let existing files be kept."
if ! dirty=$(git -C "$force_top" status --porcelain 2>/dev/null); then
die "--force refused: could not read git status in '$force_top'. Not knowing
whether the tree is clean is a reason to stop, not to continue."
fi
if [ -n "$dirty" ]; then
say "--force refused: '$force_top' has uncommitted or untracked changes:"
printf '%s\n' "$dirty" | head -n 10 >&2
die "commit or stash them first. --force is only safe when git already holds
what it is about to replace."
fi
fi
say "template $TEMPLATE_ABS"
say "target $TARGET_ABS"
[ -n "$DRY_RUN" ] && say "--dry-run: nothing will be written."
[ -n "$FORCE" ] && say "--force: existing files WILL be replaced (git tree is clean)."
say ""
# ---------------------------------------------------------------------------
# The copy.
# ---------------------------------------------------------------------------
CREATED=0
KEPT=0
OVERWRITTEN=0
MISSING_TEMPLATE=0
UNREADABLE=0
CONFLICTED=0
FAILED=0
HEADERLESS=0
MISSING_LIST=()
PLACEHOLDER_LIST=()
report() { printf ' %-13s %s%s\n' "$1" "$2" "${3:+ ($3)}" >&2; }
# Every document opens with an H1 and then a fenced status header. Checked on
# the template's copy rather than the result, because a header missing here
# means the template drifted and every project scaffolded from it starts
# non-conformant — a fact about the template, reported as one.
has_status_header() {
awk 'NR==1 { if ($0 !~ /^# /) { bad=1; exit } }
NR>1 && NR<=4 { if ($0 ~ /^```/) { ok=1; exit } }
NR>4 { exit }
END { exit (ok && !bad) ? 0 : 1 }' "$1" 2>/dev/null
}
for rel in "${DIRS[@]}"; do
dst="$TARGET_ABS/$rel"
if [ -d "$dst" ]; then
report "kept" "$rel/" "directory exists"
KEPT=$((KEPT + 1))
continue
fi
if [ -e "$dst" ] || [ -L "$dst" ]; then
report "SKIPPED" "$rel/" "exists and is not a directory"
CONFLICTED=$((CONFLICTED + 1))
continue
fi
if [ -n "$DRY_RUN" ]; then
report "would create" "$rel/" "empty directory"
CREATED=$((CREATED + 1))
continue
fi
if mkdir -p "$dst" 2>/dev/null; then
report "created" "$rel/" "empty directory"
CREATED=$((CREATED + 1))
else
report "FAILED" "$rel/" "could not create"
FAILED=$((FAILED + 1))
fi
done
feat(plan): six sections every plan must name, and the files an agent reads first WORK_CYCLE.md covered only the end of the cycle. A cycle has two ends, and the same six questions kept having to be asked out loud on every piece of work. They are now mandatory in every plan, each justified by something this household has actually paid for: unified code eight copies of secrets.sh once existed here and five of seven could not detect the most common secret shape -- INCLUDING THE TEMPLATE, so every project scaffolded from it inherited a blind scanner error handling the recurring fault is the silent pass, not the crash logging an append-only log is unbounded by construction blind spots named ones get fixed landmine fixes the trap found while passing is cheapest to fix while passing hardcode little derive it, or justify the constant GUARDS.md was five sections behind the project that has been learning; 11, 12 and 13 are backported, genericised to match the template's style. 13 is the narrow form of the sixth rule, and WORK_CYCLE now cites it -- so backporting it is what makes that citation true rather than a broken reference. Also adds the three files an agent reads BEFORE it reads docs/: CLAUDE.md short, and it POINTS at DOC_TRUST_MAP rather than repeating it -- a second copy of the map is the failure that map exists to prevent. Carries the exit-code table, the commit gates, and two standing instructions: flag what looks wrong even when it is not what you were asked about, and never print a credential -- not from a file, not from a command's output, not from a config subtree "with the secrets filtered out", because that filter has failed before by matching key NAMES while the secret sat inside an object whose name was innocent .claudeignore excludes artifacts and NEVER docs/. The Command Center reads this repository's documents at a commit; a generic ignore file that sweeps "documentation" or "data" starves both the agent and the reconcile, and everything still runs, just blind .claude/settings.json deny rules in the double-slash absolute form. A tilde-style rule looks right in review and silently matches nothing. It closes the Read TOOL only -- a shell reads a file a hundred ways -- so it catches the accidental read, not the determined one scaffold.sh gains a ROOT array for the three, kept apart from DOCS so the H1-plus-status-block check stays meaningful rather than being loosened into a warning that is always wrong (GUARDS.md 5). Verified: scaffold --dry-run into a scratch repo creates 22 files including all three, with no HEADERLESS warning; doc-claims passes with 124 claimed paths, all present. Does not touch docs/architecture/scripts/secrets.sh, which carries someone else's uncommitted improvement.
2026-09-01 21:44:00 -05:00
for rel in "${DOCS[@]}" "${ROOT[@]}"; do
src="$TEMPLATE_ABS/$rel"
dst="$TARGET_ABS/$rel"
# Reported, counted and returned in the exit status. A template missing a
# file it is supposed to carry produces a tree that is wrong in a way only
# this run can see — by the next run, "not in the template" and "already
# here" look the same from the target's side.
if [ ! -e "$src" ]; then
report "SKIPPED" "$rel" "missing from the template"
MISSING_TEMPLATE=$((MISSING_TEMPLATE + 1))
MISSING_LIST+=("$rel")
continue
fi
# Present but unreadable is a different fact with a different fix — a mode
# bit here, a missing document there. Reporting both as "missing from the
# template" sends someone to write a file that is already sitting in front
# of them.
if [ ! -f "$src" ] || [ ! -r "$src" ]; then
report "SKIPPED" "$rel" "in the template but not a readable regular file"
UNREADABLE=$((UNREADABLE + 1))
continue
fi
feat(plan): six sections every plan must name, and the files an agent reads first WORK_CYCLE.md covered only the end of the cycle. A cycle has two ends, and the same six questions kept having to be asked out loud on every piece of work. They are now mandatory in every plan, each justified by something this household has actually paid for: unified code eight copies of secrets.sh once existed here and five of seven could not detect the most common secret shape -- INCLUDING THE TEMPLATE, so every project scaffolded from it inherited a blind scanner error handling the recurring fault is the silent pass, not the crash logging an append-only log is unbounded by construction blind spots named ones get fixed landmine fixes the trap found while passing is cheapest to fix while passing hardcode little derive it, or justify the constant GUARDS.md was five sections behind the project that has been learning; 11, 12 and 13 are backported, genericised to match the template's style. 13 is the narrow form of the sixth rule, and WORK_CYCLE now cites it -- so backporting it is what makes that citation true rather than a broken reference. Also adds the three files an agent reads BEFORE it reads docs/: CLAUDE.md short, and it POINTS at DOC_TRUST_MAP rather than repeating it -- a second copy of the map is the failure that map exists to prevent. Carries the exit-code table, the commit gates, and two standing instructions: flag what looks wrong even when it is not what you were asked about, and never print a credential -- not from a file, not from a command's output, not from a config subtree "with the secrets filtered out", because that filter has failed before by matching key NAMES while the secret sat inside an object whose name was innocent .claudeignore excludes artifacts and NEVER docs/. The Command Center reads this repository's documents at a commit; a generic ignore file that sweeps "documentation" or "data" starves both the agent and the reconcile, and everything still runs, just blind .claude/settings.json deny rules in the double-slash absolute form. A tilde-style rule looks right in review and silently matches nothing. It closes the Read TOOL only -- a shell reads a file a hundred ways -- so it catches the accidental read, not the determined one scaffold.sh gains a ROOT array for the three, kept apart from DOCS so the H1-plus-status-block check stays meaningful rather than being loosened into a warning that is always wrong (GUARDS.md 5). Verified: scaffold --dry-run into a scratch repo creates 22 files including all three, with no HEADERLESS warning; doc-claims passes with 124 claimed paths, all present. Does not touch docs/architecture/scripts/secrets.sh, which carries someone else's uncommitted improvement.
2026-09-01 21:44:00 -05:00
# Only documents are held to the header convention; the ROOT files above are
# deliberately not documents.
case "$rel" in
docs/*) has_status_header "$src" || HEADERLESS=$((HEADERLESS + 1)) ;;
esac
# A symlink is followed by cp, so "writing into docs/" could land anywhere on
# the disk, including on top of the file it points at. Never written through,
# in either direction, --force included.
if [ -L "$dst" ]; then
report "SKIPPED" "$rel" "is a symlink — never written through"
CONFLICTED=$((CONFLICTED + 1))
continue
fi
if [ -e "$dst" ] && [ ! -f "$dst" ]; then
report "SKIPPED" "$rel" "exists and is not a regular file"
CONFLICTED=$((CONFLICTED + 1))
continue
fi
if [ -f "$dst" ]; then
if [ -z "$FORCE" ]; then
report "kept" "$rel" "already here, not touched"
KEPT=$((KEPT + 1))
continue
fi
# An identical file is not an overwrite worth announcing, and calling it
# one inflates the number that is supposed to mean "work was replaced".
if cmp -s "$src" "$dst"; then
report "kept" "$rel" "identical to the template"
KEPT=$((KEPT + 1))
continue
fi
# The clean-tree check above is necessary and not sufficient. `git status
# --porcelain` says nothing about ignored files, so a repository with
# docs/qa/ClaudeReport.md in .gitignore reports clean while git holds no
# copy of it at all. Overwriting that is exactly the permanent, silent loss
# the whole script is built to refuse, announced as "(git tree is clean)".
#
# So the question is asked per file, of git, in the only form that answers
# it: does git track this path. Anything else — ignored, untracked, or an
# unreadable index — is refused and counted, never overwritten.
if ! git -C "$force_top" ls-files --error-unmatch -- "$dst" >/dev/null 2>&1; then
report "SKIPPED" "$rel" "--force: git does not track it (ignored?) — overwrite would be permanent"
CONFLICTED=$((CONFLICTED + 1))
continue
fi
if [ -n "$DRY_RUN" ]; then
report "would REPLACE" "$rel" "--force, differs from the template"
OVERWRITTEN=$((OVERWRITTEN + 1))
continue
fi
elif [ -n "$DRY_RUN" ]; then
report "would create" "$rel"
CREATED=$((CREATED + 1))
continue
fi
if ! mkdir -p "$(dirname "$dst")" 2>/dev/null; then
report "FAILED" "$rel" "could not create its directory"
FAILED=$((FAILED + 1))
continue
fi
existed=""
[ -f "$dst" ] && existed="yes"
if ! cp -- "$src" "$dst" 2>/dev/null; then
report "FAILED" "$rel" "copy failed"
FAILED=$((FAILED + 1))
continue
fi
# Verified rather than assumed. A short write on a full disk leaves a file
# that exists, reports as created, and is a truncated document — which is the
# one outcome here that is worse than not copying at all.
if ! cmp -s "$src" "$dst"; then
report "FAILED" "$rel" "copied file does not match the template — check disk space"
FAILED=$((FAILED + 1))
continue
fi
if [ -n "$existed" ]; then
report "REPLACED" "$rel" "--force"
OVERWRITTEN=$((OVERWRITTEN + 1))
else
report "created" "$rel"
CREATED=$((CREATED + 1))
fi
grep -Eq "$PLACEHOLDER_RE" "$dst" 2>/dev/null && PLACEHOLDER_LIST+=("$rel")
done
SKIPPED=$((MISSING_TEMPLATE + UNREADABLE + CONFLICTED + FAILED))
say ""
if [ -n "$DRY_RUN" ]; then
say "--dry-run: nothing was written. It would have made"
say " ${CREATED} created, ${KEPT} kept, ${OVERWRITTEN} replaced, ${SKIPPED} skipped."
else
say "${CREATED} created, ${KEPT} kept, ${OVERWRITTEN} replaced, ${SKIPPED} skipped."
fi
say " skipped breaks down as ${MISSING_TEMPLATE} missing from the template,"
say " ${UNREADABLE} present in the template but unreadable, ${CONFLICTED} conflicting"
say " with something already at that path, ${FAILED} failed."
if [ "$MISSING_TEMPLATE" -gt 0 ]; then
say ""
say "WARNING: the template at $TEMPLATE_ABS does not have:"
printf ' %s\n' ${MISSING_LIST[@]+"${MISSING_LIST[@]}"} >&2
say " This tree is incomplete and nothing downstream will say so."
say " Fix the template, or write those files by hand."
fi
if [ "$HEADERLESS" -gt 0 ]; then
say ""
say "WARNING: ${HEADERLESS} template document(s) do not open with an H1 followed by"
say " a fenced block within the first four lines — the shape a status"
say " header is written in. Only that shape was checked: the field"
say " names (Status / Owner / Last reviewed / Governs / Review"
say " trigger) were not read, so a passing document is not a"
say " conformant one. Every project scaffolded from this template"
say " inherits whatever is there. Fix it in the template."
fi
# Counted only among files this run created, and silent when there are none —
# an "all placeholders filled in" line would be a claim about documents this
# run never looked at, and about a placeholder style that may simply have
# changed underneath the pattern above.
if [ "${#PLACEHOLDER_LIST[@]}" -gt 0 ]; then
say ""
say "${#PLACEHOLDER_LIST[@]} file(s) written by this run still carry placeholders. Every status"
say "header needs a real Owner, a real Last reviewed date and a real Review"
say "trigger — that last line is what stops a document going quietly stale."
say "Find them with:"
say " grep -rnE '$PLACEHOLDER_RE' docs/"
fi
# ---------------------------------------------------------------------------
# Branding: named, measured, and never invented.
#
# The template's own docs/data/img is not a source. When this was written it
# held one zero-byte logo.webp, and copying that in would satisfy a check that
# looks for the name while producing a project card with nothing on it. The
# deeper reason is the one docs/data/README.md gives: a placeholder that looks
# deliberate outlives the issue that would have replaced it, because nobody
# files a ticket against an image that appears finished.
#
# So the directory is created empty and the three names are reported by what is
# actually there — including the difference between absent and present-but-
# empty, which the check itself distinguishes and a file listing does not.
# ---------------------------------------------------------------------------
img_dir="$TARGET_ABS/docs/data/img"
say ""
say "docs/data/img/ needs this project's own branding. The conformance check"
say "looks for exactly these three names, and reads webp by magic bytes rather"
say "than by extension — a renamed PNG does not pass:"
if [ -d "$img_dir" ]; then
for name in "${BRANDING[@]}"; do
if [ -s "$img_dir/$name" ]; then
report "present" "docs/data/img/$name"
elif [ -e "$img_dir/$name" ]; then
report "EMPTY" "docs/data/img/$name" "0 bytes — fails the magic-byte check"
else
report "absent" "docs/data/img/$name"
fi
done
else
# Not "none present". The directory does not exist yet, so nothing about
# these three files has been measured and no count would be honest.
say " not checked — $img_dir does not exist yet."
printf ' %s\n' ${BRANDING[@]+"${BRANDING[@]}"} >&2
fi
say ""
say "Nothing here can draw them. When one is absent, file an issue — title it"
say "for the asset, label it P2, and end the body with its Verify: line — rather"
say "than committing a placeholder."
say ""
say "note: no git command was run. Nothing is staged and nothing is committed;"
say " what belongs in this project's history is your call."
# Non-zero when the tree that came out is not the tree that was asked for. The
# caller — often another script, or an agent — needs that distinction to survive
# past the last line of output.
if [ "$SKIPPED" -gt 0 ]; then
say ""
die "${SKIPPED} path(s) were skipped, so this project is not fully scaffolded.
Resolve each one listed above and run this again — re-running is safe,
because everything already here is kept."
fi
exit 0