diff --git a/.gitignore b/.gitignore index 4145925..d90a62a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ __pycache__/ # Aider Studio (auto-added) REPO_MAP.md .aider/ +/.xyz +/.xyz +/SHAKEDOWN diff --git a/.xyz/XYZ.json b/.xyz/XYZ.json index e3a3ecc..c227cc3 100644 --- a/.xyz/XYZ.json +++ b/.xyz/XYZ.json @@ -1,4 +1,20 @@ [ + { + "harness": "relay", + "sessionId": "p1-alpha-merge-qa", + "health": "green", + "title": "RELAY \u00b7 Phase 1 alpha-merge QA \u2014 is `marathon/reflex-hybrid-analytics-starter-p1-2026-07-21` safe to merge to `main`?", + "description": "Relay session ended: STATUS Approved (health green).", + "updatedAt": "2026-07-21T23:29:28Z" + }, + { + "harness": "relay", + "sessionId": "p1-alpha-merge-qa", + "health": "red", + "title": "RELAY \u00b7 Phase 1 alpha-merge QA \u2014 is `marathon/reflex-hybrid-analytics-starter-p1-2026-07-21` safe to merge to `main`?", + "description": "Relay session ended: STATUS Open (health red).", + "updatedAt": "2026-07-21T23:27:36Z" + }, { "harness": "relay", "sessionId": "p1-alpha-merge-qa", diff --git a/.xyz/utils/pdda/PDDA-INSTALL.md b/.xyz/utils/pdda/PDDA-INSTALL.md index 39d9a8b..eec85e0 100644 --- a/.xyz/utils/pdda/PDDA-INSTALL.md +++ b/.xyz/utils/pdda/PDDA-INSTALL.md @@ -23,8 +23,14 @@ a change to the install surface updates both. Re-run `install.sh` (no flags) against the target — `copy_runtime` overwrites the runtime + contract unconditionally, while seed/state files (`ROADMAP.md`, `CHANGELOG.md`, `.pdda-mode`, `PROJECT/**`, -the activity log) are create-only and stay untouched. Do **not** pass `--force` (it overwrites seeds) -or `--with-startup-docs` (it overwrites any repo-adapted `ROUTER.md`/`AGENTS.md`). +the activity log) are create-only and stay untouched. Do **not** pass `--force`: it overwrites seeds +*and* the startup-doc scaffolds. + +`--with-startup-docs` is safe to re-pass: the three startup docs are create-only, so an existing +`ROUTER.md`/`AGENTS.md`/`GUIDING-PRINCIPLES.md` is kept, not overwritten. (Before GH-25 it copied them +verbatim and silently destroyed repo-authored versions.) To deliberately refresh a target's startup docs +from the canonical repo — for instance to pick up a corrected `ROUTER.md` — pass +`--with-startup-docs --force`, and diff before committing. ### Migrating a repo that predates the `utils/pdda/` layout @@ -49,11 +55,45 @@ PDDA installs two things: - the canonical document contract in `PROJECT/PDDA.md` - the runnable shell checks in `utils/pdda-*.sh` -This standalone repo also carries repo-local startup docs (`ROUTER.md`, `AGENTS.md`, `README.md`) and -the `/pdda` re-orient skill (`.claude/skills/pdda/SKILL.md`) so the installer source stays -self-consistent, but those files are not part of the target-repo install surface unless the target -explicitly wants them. `install.sh --with-startup-docs` ships `ROUTER.md`, `AGENTS.md`, and the -`/pdda` skill together as the agent read-order scaffold. +This standalone repo also carries repo-local startup docs (`ROUTER.md`, `AGENTS.md`, +`GUIDING-PRINCIPLES.md`, `README.md`) and the `/pdda` re-orient skill +(`.claude/skills/pdda/SKILL.md`) so the installer source stays self-consistent, but those files are +not part of the target-repo install surface unless the target explicitly wants them. `install.sh +--with-startup-docs` ships the agent read-order scaffold, and routes each file by **who owns it after +the install** rather than copying all four the same way: + +| Semantics | Files | Behavior | +|---|---|---| +| **Templated** | `ROUTER.md` | written from `templates/ROUTER.target.md`; **this repo's own `ROUTER.md` is never copied** | +| **Scaffold** | `AGENTS.md`, `GUIDING-PRINCIPLES.md` | create-only — an existing file is kept (`--force` to overwrite) | +| **Runtime** | `.claude/skills/pdda/SKILL.md` | PDDA owns it; refreshed verbatim every install | + +The template exists because this repo's `ROUTER.md` documents things a target does not have: `install.sh`, +`utils/pdda/pdda-sync.sh`, the runtime-distribution command rails, and the vendored `.xyz/` harness. +Copying it verbatim pointed every target's agents at scripts absent from their repo (GH-23). The template +is the canonical router minus those sections; keep the two in step when either changes. + +**Post-install self-check.** For every startup doc `--with-startup-docs` actually *writes* — `ROUTER.md`, +`AGENTS.md`, `GUIDING-PRINCIPLES.md` — the installer asserts that every `*.sh` path that doc names +resolves to a file present in the target. A dead reference exits non-zero with the offending names. This +is the assertion that would have caught GH-23 at install time. Two boundaries make it safe to keep enabled: + +- It validates only a doc the installer **wrote**, decided per file. If your own `ROUTER.md` was kept + (create-only) it is yours and is never checked — the installer will not fail your install over your own + scripts — while an `AGENTS.md` scaffolded beside it in the same run still is. Each skipped doc says so. +- It runs against the **written artifact**, not the source template. During GH-23 P1 the first draft of + `templates/ROUTER.target.md` reintroduced the very bug it exists to fix; only an assertion on the + *output* caught it. Checking the input would have passed. + +Originally this covered `ROUTER.md` alone. The router was never special: GH-23 P3's widened dead-reference +scan found the identical defect — a dead `install.sh` — sitting in the `GUIDING-PRINCIPLES.md` scaffolded +into every target. Any doc the installer writes can name a script it does not ship. + +A failure here is a bug in PDDA's template, not in your repo. The install still completes — the target is +usable, its router is misleading — and the non-zero exit is what stops `pdda-sync.sh register` from +propagating it further. `.claude/skills/governance-audit/SKILL.md` +(the `pdda.sh governance` companion — see `PROJECT/PDDA.md` § "I. `pdda.sh governance`") is the same +kind of repo-local, not-installed-by-default skill; copy it manually into a target repo if wanted. Do not install deprecated PDDA companion docs from `PROJECT/4-MISC/`. @@ -76,6 +116,7 @@ utils/pdda/pdda-catchup.sh utils/pdda/pdda-gh-refresh.sh utils/pdda/pdda-edit-doc-hook.sh utils/pdda/pdda-stop-doc-health.sh +utils/pdda/PDDA-SOURCE.md ``` The shipped runtime lives in its own `utils/pdda/` subfolder so it never mixes with a target repo's @@ -84,12 +125,18 @@ deterministic check plus the aggregate `run` as subcommands (`pdda.sh run`, `pdd `pdda.sh roadmap`, ...). `utils/pdda/pdda-lib.sh` holds the shared helpers it sources; `utils/pdda/pdda-doc-ready.sh` is the opt-in LLM readiness layer and `utils/pdda/pdda-catchup.sh` is the opt-in ROUTER.md triage layer. `utils/pdda/pdda-gh-refresh.sh` (`pdda.sh gh-refresh`) refreshes the -cached GitHub issue-state file that `pdda.sh issue-doc-sync` reads when `gh` is offline. +cached GitHub issue-state file that `pdda.sh issue-doc-sync` reads when `gh` is offline. That cache is +also written automatically by any successful live lookup (every online `pdda.sh run`), so the offline +consumers — chiefly the `Stop` hook — have last-known state without anyone remembering to run +`gh-refresh`. Explicit `gh-refresh` remains useful to prime the cache before going offline. `utils/pdda/pdda-edit-doc-hook.sh` (tier 1, a `PostToolUse` single-file lint) and `utils/pdda/pdda-stop-doc-health.sh` (tier 2, a `Stop` consolidated full-scan) are the optional doc-health hooks — installs receive the scripts but **opt in by wiring them in their own `.claude/settings.json`** (see PROJECT/PDDA.md → "Doc-health hooks"); nothing is written into the -target's hook config automatically. +target's hook config automatically. `utils/pdda/PDDA-SOURCE.md` is a static resolver doc, not a +script — it ships with every install (and rides along into any nested vendored copy, e.g. +`.xyz/utils/pdda/`) so an agent standing in a copy can trace back to this canonical repo; see that +file for the lookup recipe. ## Files to create in the target repo @@ -104,6 +151,7 @@ PROJECT/4-MISC/ utils/pdda/ ROADMAP.md CHANGELOG.md +RELEASES.md PROJECT/PDDA-ACTIVITY.jsonl ``` @@ -125,10 +173,11 @@ Create a fresh empty file instead. 1. Create the target directories listed above. -> expect `PROJECT/` and `utils/` to exist. 2. Copy the canonical install-set files verbatim to the same relative paths in the target repo. -> expect `PROJECT/PDDA.md` and all shipped `utils/pdda-*.sh` files to exist. -3. Create baseline `ROADMAP.md` and `CHANGELOG.md` files if the target repo does not already have them. -> expect the roadmap contract to have a file to guard and the changelog check to warn less. +3. Create baseline `ROADMAP.md`, `CHANGELOG.md`, and `RELEASES.md` files if the target repo does not already have them. -> expect the roadmap contract to have a file to guard, the changelog check to warn less, and a release-planning ledger to exist. 4. Create an empty `PROJECT/PDDA-ACTIVITY.jsonl` if it does not exist. -> expect a zero- or low-byte log file, not this repo's historical log. -4a. Add `PROJECT/PDDA-ACTIVITY.jsonl` to the target's `.gitignore` (and `git rm --cached` it if already tracked). -> expect the churning runtime log to stop dirtying `git status` on every run. -4b. Record the install in the per-user, machine-local registry `${XDG_CONFIG_HOME:-$HOME/.config}/pdda/registry.tsv` (one tab-delimited row per target: `target · last_install_utc · mode · source_commit · startup_docs`; latest install wins). -> expect a future sync layer to read this to find copies that are behind. Machine-local, never committed; `--no-register` or `PDDA_REGISTRY` adjust it. +4a. Add `PROJECT/PDDA-ACTIVITY.jsonl` and `.pdda-gh-state.tsv` to the target's `.gitignore` (and `git rm --cached` any that are already tracked). -> expect the churning runtime state to stop dirtying `git status` on every run. +4b. Record the install in the per-user, machine-local registry `${XDG_CONFIG_HOME:-$HOME/.config}/pdda/registry.tsv` (one tab-delimited row per target: `target · last_install_utc · mode · source_commit · startup_docs`; latest install wins). -> expect `pdda-sync.sh` to read this to find copies that are behind. Machine-local, never committed; `--no-register` or `PDDA_REGISTRY` adjust it. +4c. If git-pulse (a separate, GitHub-backed activity-sync tool) is present, also write a path-normalized projection of the registry into `/pdda/registry-.tsv` (col 1 reduced to the bare repo name; **no absolute paths**), letting git-pulse's own sync roll PDDA install status up across devices. -> expect this to be best-effort and fail-open: absent git-pulse it is silently skipped and the install is unaffected. The local registry stays the source of truth. The git-pulse checkout is auto-detected: explicit `PDDA_GITPULSE_DIR` wins, else git-pulse's own `config.sh` `sync_repo_dir`, else the first existing of `${XDG_CONFIG_HOME:-$HOME/.config}/git-pulse/repo` or `~/git-pulse-sync`; set `PDDA_GITPULSE_DIR` to a nonexistent path to disable, and `--no-register` skips it too. **Hazard (GH-28):** if a machine has more than one git-pulse checkout on disk, `sync_repo_dir` always wins over the `~/git-pulse-sync` fallback — check which one is actually current with `origin` before assuming the projection reached your other devices. -> expect a follow-up warning (`warn: git-pulse checkout … is N commit(s) behind` / `… is uncommitted`) printed right after this step whenever the resolved checkout is dirty or behind its own upstream as of its last fetch (no network call is made here); the write to disk still always succeeds regardless. 5. Make the shell scripts executable. -> expect `chmod +x utils/pdda/pdda.sh utils/pdda/pdda-doc-ready.sh utils/pdda/pdda-lib.sh utils/pdda/pdda-catchup.sh utils/pdda/pdda-gh-refresh.sh utils/pdda/pdda-edit-doc-hook.sh utils/pdda/pdda-stop-doc-health.sh` to succeed. 6. Optionally create a repo-root `.pdda-mode` file with `observe` for first install. -> expect a non-destructive first run. 7. If the target repo uses a different doc layout, set environment overrides instead of editing the scripts first. -> expect the checks to honor the env vars below. @@ -145,6 +194,7 @@ PDDA_MODE PDDA_WORKING_DIR PDDA_COMPLETED_DIR PDDA_MISC_DIR +PDDA_RELEASES_FILE PDDA_ACTIVITY_LOG PDDA_GH_STATE_CACHE PDDA_ROADMAP @@ -155,6 +205,11 @@ PDDA_FORMAT PDDA_ACTIVITY_MAX_LINES PDDA_ROADMAP_MAX_LINES PDDA_ROADMAP_MAX_HEADINGS +PDDA_GOVERNANCE_DOCS +PDDA_GOVERNANCE_INDEX +PDDA_GOV_SHIPPED_DOCS +PDDA_GOV_SHIPPED_DOC_REF_EXEMPTIONS +PDDA_GOV_SHIPPED_DOC_ENVVAR_EXEMPTIONS PDDA_LLM_BIN PDDA_LLM_ARGS PDDA_LLM_MODEL @@ -165,6 +220,20 @@ flag), `PDDA_GH_STATE_CACHE` (the cached GitHub issue-state file used when `gh` `pdda-gh-refresh.sh`), and `PDDA_ISSUE_SYNC_SOURCE` = `auto` (default: live `gh`, else cache) | `gh` | `cache` (the Stop doc-health hook forces `cache` to stay fast and offline-tolerant). +`governance` adds five: `PDDA_GOVERNANCE_DOCS` (space-separated, repo-relative — the curated governance +doc set it scans; default `ROUTER.md AGENTS.md GUIDING-PRINCIPLES.md README.md CLAUDE.md PROJECT/PDDA.md +utils/pdda/PDDA-INSTALL.md`), `PDDA_GOVERNANCE_INDEX` (the doc every other governance doc must be +reachable from; default `ROUTER.md`), and three GH-15 exemption-manifest overrides scoped to the docs +that ship to every target install (`PDDA-INSTALL.md`, `PROJECT/PDDA.md`) so a fresh install's first +`pdda.sh run` doesn't self-inflict dead-reference/env-var noise from files `install.sh` deliberately +never copies: `PDDA_GOV_SHIPPED_DOCS` (which shipped docs the exemptions apply to; default +`utils/pdda/PDDA-INSTALL.md PROJECT/PDDA.md`), `PDDA_GOV_SHIPPED_DOC_REF_EXEMPTIONS` (basenames/paths +those docs may dead-reference without a warn — the target's own startup docs, canonical-only skill and +companion-doc paths, and the pre-`utils/pdda/` legacy install path), and +`PDDA_GOV_SHIPPED_DOC_ENVVAR_EXEMPTIONS` (canonical-only-tool env vars those docs may mention without a warn). +A repo-authored governance doc outside `PDDA_GOV_SHIPPED_DOCS` (e.g. this repo's own `ROUTER.md`) is +never exempted — a dead reference there stays a real drift signal. + ## Minimal target-repo expectations PDDA assumes these repo concepts exist, either literally or through overrides: @@ -211,14 +280,20 @@ Expected result: - `PROJECT/PDDA-ACTIVITY.jsonl` receives new entries - the suite exits `0` even if it reports findings -## Syncing the runtime to other repos (HQ → targets) +## Syncing the runtime to other repos (canonical → targets) -Once PDDA lives in several repos, keep them current from one canonical source ("HQ" = this clone) with -`utils/pdda/pdda-sync.sh`. HQ is the only writer; targets opt in via `register`. The synced file set is +Once PDDA lives in several repos, keep them current from one canonical source (the "canonical repo" = this clone) with +`utils/pdda/pdda-sync.sh`. The canonical repo is the only writer; targets opt in via `register`. The synced file set is the auto-regenerated manifest (`utils/pdda/pdda-sync-manifest.conf`, shared with `install.sh`), so a new runtime file under `utils/pdda/` propagates with no list edit. Per-repo adapted startup docs (`ROUTER.md`, `AGENTS.md`) are never touched. Full design + rationale: -[`PROJECT/2-WORKING/PDDA-SYNC-TO-OTHER-REPOS.md`](../../PROJECT/2-WORKING/PDDA-SYNC-TO-OTHER-REPOS.md). +[`PROJECT/3-COMPLETED/PDDA-SYNC-TO-OTHER-REPOS.md`](../../PROJECT/3-COMPLETED/PDDA-SYNC-TO-OTHER-REPOS.md). + +> **`push` cannot repair a target's `ROUTER.md`.** The startup docs are outside the sync manifest by +> design, so a target installed before GH-23 keeps its stale router indefinitely — `push` will never +> replace it. Repairing an existing target is a deliberate, per-repo act: +> `install.sh --with-startup-docs --force`, then diff before committing. That command also +> overwrites `AGENTS.md` and `GUIDING-PRINCIPLES.md`, so back up any repo-authored versions first. ```bash # Enroll a target (initial install via install.sh, then seeds sync state). Confirms first; @@ -228,7 +303,7 @@ utils/pdda/pdda-sync.sh register [--mode observe|light|full] [--with-startup-doc # Distribute the current runtime on demand — one target, or all registered if omitted. utils/pdda/pdda-sync.sh push [/path/to/repo] utils/pdda/pdda-sync.sh push --dry-run # preview copies AND deletions, write nothing -utils/pdda/pdda-sync.sh push --no-delete # copy/update only, skip HQ-side deletions +utils/pdda/pdda-sync.sh push --no-delete # copy/update only, skip canonical-side deletions utils/pdda/pdda-sync.sh list # registered targets + mode/source-commit/sync state utils/pdda/pdda-sync.sh status [/path/to/repo]# read-only: current/behind/diverged/missing/to-delete @@ -240,15 +315,15 @@ utils/pdda/pdda-sync.sh install-agent # opt-in; --no-load writes the pli utils/pdda/pdda-sync.sh uninstall-agent ``` -**Safety:** `push` only overwrites a file when HQ's copy has genuinely advanced (content hash, not +**Safety:** `push` only overwrites a file when the canonical repo's copy has genuinely advanced (content hash, not mtime), so deliberate local edits between releases are preserved; any overwrite of a *diverged* target -and any HQ-side deletion is backed up first under `temp/pdda-sync-backups/` (kept to the last -`PDDA_SYNC_BACKUPS`, default 5). A dirty HQ is refused (`--allow-dirty` to override). HQ-side deletions +and any canonical-side deletion is backed up first under `temp/pdda-sync-backups/` (kept to the last +`PDDA_SYNC_BACKUPS`, default 5). A dirty canonical repo is refused (`--allow-dirty` to override). Canonical-side deletions mirror to targets, but a **manifest-poisoning guard** aborts the delete phase before touching any target if a declared source root resolves to zero files, the manifest is empty, or it shrank past `PDDA_SYNC_MAX_SHRINK`% (default 25) — override only with `--force-delete`. -State lives under HQ's gitignored `temp/` (state stamps, manifest snapshots, backups, log, lock); the +State lives under the canonical repo's gitignored `temp/` (state stamps, manifest snapshots, backups, log, lock); the target **registry** is machine-local at `${XDG_CONFIG_HOME:-$HOME/.config}/pdda/registry.tsv` (written by `install.sh`, the single registry writer), never committed. diff --git a/.xyz/utils/pdda/pdda-catchup.sh b/.xyz/utils/pdda/pdda-catchup.sh index 8947aca..a98e0a2 100755 --- a/.xyz/utils/pdda/pdda-catchup.sh +++ b/.xyz/utils/pdda/pdda-catchup.sh @@ -1,6 +1,5 @@ #!/usr/bin/env bash set -u -# strict-mode: -e exempt — analysis tool with expected-nonzero probes (git/gh/grep); errors handled explicitly. See GUIDING-PRINCIPLES.md#strict-mode-policy. HERE="$(cd "$(dirname "$0")" && pwd)" # shellcheck source=utils/pdda/pdda-lib.sh diff --git a/.xyz/utils/pdda/pdda-doc-ready.sh b/.xyz/utils/pdda/pdda-doc-ready.sh index f6ea1a0..c3aa646 100755 --- a/.xyz/utils/pdda/pdda-doc-ready.sh +++ b/.xyz/utils/pdda/pdda-doc-ready.sh @@ -1,6 +1,5 @@ #!/usr/bin/env bash set -u -# strict-mode: -e exempt — analysis tool with expected-nonzero probes (git/gh/grep); errors handled explicitly. See GUIDING-PRINCIPLES.md#strict-mode-policy. HERE="$(cd "$(dirname "$0")" && pwd)" # shellcheck source=utils/pdda/pdda-lib.sh @@ -47,6 +46,8 @@ frontmatter/status-table/hardcoded-path issues (separate deterministic checks ow - a medium-large plan or project (NOT a typo / path repoint / <=2-3 line fix) whose frontmatter is missing the triage ratings effort, complexity, risk, phases (used by automation to select work); do NOT flag genuinely small/trivial docs for this +- a medium-large plan or project where the `related` frontmatter field is empty or missing (suggest they link past context or docs from PROJECT/3-COMPLETED/) +- a plan with frontmatter `risk: 4` or `risk: 5` that does not reference a `decisions/` record anywhere in the doc - a status table that is present but stale versus the body - the next action buried in prose instead of stated explicitly - detail duplicated from another canonical doc @@ -58,6 +59,25 @@ for advisory. Do NOT emit "error" — a non-deterministic review must not gain b If the doc is ready, output nothing at all. RUBRIC_EOF +# Quad Concepts QUALITY rubric — appended to the working-doc review ONLY when the .pdda-quad / PDDA_QUAD +# lever is on. The deterministic `pdda.sh quad-concepts` check owns STRUCTURE (present, 1-4 bullets); this +# warn-only layer judges what a regex can't: are the bullets real, specific pain->fix concepts, and are +# they still connected to the doc's actual state? It NEVER demands a missing section (that's the +# deterministic check + per-doc quad_exempt), and never blocks. +read -r -d '' QUAD_RUBRIC_APPENDIX <<'QUAD_EOF' || true + +ADDITIONALLY — "Quad Concepts" mode is enabled for this repo. If (and only if) the doc has a +"## Quad Concepts" section, review its 1-4 bullets and flag ONLY: +- a bullet that is vague or generic ("backend", "bug", "refactor", "cleanup", "misc") instead of a + specific problem this doc addresses +- a bullet that is not a real "pain -> fix" pairing — it names a topic but not BOTH the problem and how + this doc addresses it +- Quad Concepts that appear disconnected from the doc's "## Status" or "## Lessons Learned": they + describe pains the doc no longer addresses, or omit the main thing the doc is actually about (stale) +Do NOT flag a MISSING "## Quad Concepts" section — a deterministic check and the per-doc `quad_exempt` +frontmatter own presence. These are advisory "warn"/"info" only, never "error". +QUAD_EOF + # ROADMAP.md pointer-only contract (PDDA.md "ROADMAP.md contract"). Honors the deliberate carve-out # ("a short exception note is allowed when omitting would hide an operationally critical fact"), which # is exactly why this is judged by the LLM layer rather than a brittle deterministic lint. @@ -119,9 +139,14 @@ $response RESPONSE } -# 1) active working docs — generic readiness rubric. +# 1) active working docs — generic readiness rubric (+ the Quad Concepts quality rubric when enabled). +WORKING_RUBRIC="$RUBRIC" +if quad_is_enabled; then + WORKING_RUBRIC="$RUBRIC +$QUAD_RUBRIC_APPENDIX" +fi while IFS= read -r file; do - review_one "$file" "$RUBRIC" + review_one "$file" "$WORKING_RUBRIC" done < <(pdda_list_working_docs) # 2) ROADMAP.md — pointer-only contract (separate rubric; skipped if absent). diff --git a/.xyz/utils/pdda/pdda-edit-doc-hook.sh b/.xyz/utils/pdda/pdda-edit-doc-hook.sh index 43a1be0..522c0c8 100755 --- a/.xyz/utils/pdda/pdda-edit-doc-hook.sh +++ b/.xyz/utils/pdda/pdda-edit-doc-hook.sh @@ -12,7 +12,6 @@ # WARN-ONLY and FAIL-OPEN: it ALWAYS exits 0, so it can NEVER block the edit. Findings print to stderr # for visibility only. Wire it in .claude/settings.json (PostToolUse, matcher "Edit|Write|MultiEdit"). set -u -# strict-mode: -e exempt — WARN-ONLY fail-open hook; must never abort mid-scan. See GUIDING-PRINCIPLES.md#strict-mode-policy. HERE="$(cd "$(dirname "$0")" && pwd)" # shellcheck source=utils/pdda/pdda-lib.sh diff --git a/.xyz/utils/pdda/pdda-gh-refresh.sh b/.xyz/utils/pdda/pdda-gh-refresh.sh index d1a154d..8adee1a 100755 --- a/.xyz/utils/pdda/pdda-gh-refresh.sh +++ b/.xyz/utils/pdda/pdda-gh-refresh.sh @@ -11,7 +11,6 @@ # suite, so `issue-doc-sync` and the Stop scan read fresh state. See PROJECT/PDDA.md "Suggested hourly # schedule". One-off: `utils/pdda/pdda-gh-refresh.sh`. set -u -# strict-mode: -e exempt — expected-nonzero gh probes handled explicitly (leaves cache intact on failure). See GUIDING-PRINCIPLES.md#strict-mode-policy. HERE="$(cd "$(dirname "$0")" && pwd)" # shellcheck source=utils/pdda/pdda-lib.sh @@ -32,18 +31,10 @@ main() { return 4 fi - local tmp count - tmp="$PDDA_GH_STATE_CACHE.tmp.$$" - { - printf '# pdda gh-issue-state cache — regenerated by pdda-gh-refresh.sh; do not edit by hand\n' - printf '# generated: %s\n' "$(pdda_now_iso)" - printf '# format: \\t (STATE is OPEN or CLOSED)\n' - [ -n "$table" ] && printf '%s\n' "$table" - } > "$tmp" || { rm -f "$tmp"; printf 'pdda-gh-refresh: write to %s failed\n' "$tmp" >&2; return 5; } - - if ! mv "$tmp" "$PDDA_GH_STATE_CACHE"; then - rm -f "$tmp" - printf 'pdda-gh-refresh: could not move cache into place\n' >&2 + # One definition of the cache format + atomic write, shared with the live-lookup path in pdda.sh. + local count + if ! pdda_write_gh_state_cache "$table"; then + printf 'pdda-gh-refresh: could not write cache to %s\n' "$(pdda_relpath "$PDDA_GH_STATE_CACHE")" >&2 return 5 fi diff --git a/.xyz/utils/pdda/pdda-lib.sh b/.xyz/utils/pdda/pdda-lib.sh index 61e8523..64615b2 100755 --- a/.xyz/utils/pdda/pdda-lib.sh +++ b/.xyz/utils/pdda/pdda-lib.sh @@ -1,6 +1,5 @@ #!/usr/bin/env bash set -u -# strict-mode: -e exempt — analysis tool with expected-nonzero probes (git/gh/grep); errors handled explicitly. See GUIDING-PRINCIPLES.md#strict-mode-policy. PDDA_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Runtime lives in /utils/pdda/, so the repo root is two levels up from the lib dir. @@ -9,12 +8,14 @@ PDDA_INBOX_DIR="${PDDA_INBOX_DIR:-$PDDA_REPO_ROOT/PROJECT/1-INBOX}" PDDA_WORKING_DIR="${PDDA_WORKING_DIR:-$PDDA_REPO_ROOT/PROJECT/2-WORKING}" PDDA_COMPLETED_DIR="${PDDA_COMPLETED_DIR:-$PDDA_REPO_ROOT/PROJECT/3-COMPLETED}" PDDA_MISC_DIR="${PDDA_MISC_DIR:-$PDDA_REPO_ROOT/PROJECT/4-MISC}" +# Forward-looking release-planning ledger — a single root file (like ROADMAP.md/CHANGELOG.md), not +# a lifecycle bucket of per-tag docs. See PROJECT/PDDA.md "RELEASES.md — release ledger". +PDDA_RELEASES_FILE="${PDDA_RELEASES_FILE:-$PDDA_REPO_ROOT/RELEASES.md}" PDDA_ACTIVITY_LOG="${PDDA_ACTIVITY_LOG:-$PDDA_REPO_ROOT/PROJECT/PDDA-ACTIVITY.jsonl}" # Cached GitHub issue-state file (TSV: "\t", '#'-comment lines ignored). Written by # pdda-gh-refresh.sh; read by `pdda.sh issue-doc-sync` when gh is absent/offline. Gitignored runtime # state, regenerated on demand — sits beside .pdda-mode at the repo root by default. PDDA_GH_STATE_CACHE="${PDDA_GH_STATE_CACHE:-$PDDA_REPO_ROOT/.pdda-gh-state.tsv}" -PDDA_RELEASES_FILE="${PDDA_RELEASES_FILE:-$PDDA_REPO_ROOT/RELEASES.md}" PDDA_STALE_DAYS="${PDDA_STALE_DAYS:-4}" PDDA_DRY_RUN="${PDDA_DRY_RUN:-0}" # Output format for findings on stdout: "text" (human, default) or "json" (one JSON object per line, @@ -57,6 +58,21 @@ ERROR_COUNT=0 WARN_COUNT=0 INFO_COUNT=0 +# Cross-check totals for one `pdda.sh run`, accumulated by pdda_emit_summary. The per-check counters +# above are reset by every pdda_reset_counts, and a check's RETURN VALUE is gated to 0 outside full +# mode — so in observe/light a run of nothing but errors leaves cmd_run's EXIT_CODE at 0. Inferring +# "all checks passed" from that zero is BUG-001b: the mode gate is supposed to stop the run from +# BLOCKING, not to stop it from REPORTING. These totals survive the resets and ignore the gate, so the +# closing line can say what was actually found. Same family as GH-23 and GH-27: a check that could not +# run, or could not block, must never be scored as a check that passed. +PDDA_RUN_ERRORS=0 +PDDA_RUN_WARNS=0 +PDDA_RUN_ERROR_CHECKS="" +# GH-43: the warn total was accumulated below but never read, so a run whose findings were all +# warn-level printed "all checks passed". Same family, one step further out: PDDA_RUN_ERRORS answers +# "did anything go wrong", not "did anything need attention" — which is what that line asserts. +PDDA_RUN_WARN_CHECKS="" + pdda_now_iso() { date -u +"%Y-%m-%dT%H:%M:%SZ" } @@ -158,6 +174,17 @@ pdda_emit_summary() { local summary summary="errors=$ERROR_COUNT warns=$WARN_COUNT info=$INFO_COUNT" + + # Roll this check's findings into the run-level totals before the next pdda_reset_counts wipes them. + PDDA_RUN_ERRORS=$((PDDA_RUN_ERRORS + ERROR_COUNT)) + PDDA_RUN_WARNS=$((PDDA_RUN_WARNS + WARN_COUNT)) + if [ "$ERROR_COUNT" -gt 0 ]; then + PDDA_RUN_ERROR_CHECKS="$PDDA_RUN_ERROR_CHECKS $check" + fi + if [ "$WARN_COUNT" -gt 0 ]; then + PDDA_RUN_WARN_CHECKS="$PDDA_RUN_WARN_CHECKS $check" + fi + if [ "$PDDA_FORMAT" = "json" ]; then pdda_json_line "$( [ "$exit_code" -eq 0 ] && printf 'info' || printf 'error' )" \ "$check" "$PDDA_REPO_ROOT" 0 "$summary" "summary" @@ -186,10 +213,10 @@ pdda_list_working_docs() { find "$PDDA_WORKING_DIR" -type f -name '*.md' ! -name 'blank.md' | LC_ALL=C sort } -# Mirrors pdda_list_working_docs() exactly, just pointed at PDDA_COMPLETED_DIR (PROJECT/3-COMPLETED) -# instead of PDDA_WORKING_DIR. Added for GH-189: check_issue_doc_sync() needs to scan docs that were -# already swept to 3-COMPLETED, since a doc leaves PDDA_WORKING_DIR's scan set the moment the sync -# check's own recommended `git mv` remediation is followed. +# Completed plans (3-COMPLETED). issue-doc-sync needs these: a doc that reached 3-COMPLETED is the +# operator's own assertion that the work is done, so a still-OPEN issue behind it is drift. Without +# this list the check stops watching a doc at the exact moment it completes — and the `git mv` the +# check itself recommends is what blinds it (GH-27). pdda_list_completed_docs() { if [ -n "${PDDA_ONLY_FILE:-}" ]; then case "$PDDA_ONLY_FILE" in @@ -210,6 +237,59 @@ pdda_list_inbox_issue_docs() { find "$PDDA_INBOX_DIR" -type f -name 'GH-*.md' ! -name 'blank.md' | LC_ALL=C sort } +# Quad Concepts scope: active plans (2-WORKING), issue intake (1-INBOX/GH-*), and archived plans +# (3-COMPLETED — kept glanceable for cold-start recall, per the project-memory-layer reframing). +# Excludes 4-MISC. Honors PDDA_ONLY_FILE (the single-file lint path) exactly like the lists above. +pdda_list_quad_docs() { + if [ -n "${PDDA_ONLY_FILE:-}" ]; then + case "$PDDA_ONLY_FILE" in + */blank.md) : ;; # scaffold — never in scope (matches the bulk find's blank.md exclusion) + "$PDDA_WORKING_DIR"/*.md|"$PDDA_INBOX_DIR"/GH-*.md|"$PDDA_COMPLETED_DIR"/*.md) + [ -f "$PDDA_ONLY_FILE" ] && printf '%s\n' "$PDDA_ONLY_FILE" ;; + esac + return + fi + { + find "$PDDA_WORKING_DIR" -type f -name '*.md' ! -name 'blank.md' 2>/dev/null + find "$PDDA_INBOX_DIR" -type f -name 'GH-*.md' ! -name 'blank.md' 2>/dev/null + find "$PDDA_COMPLETED_DIR" -type f -name '*.md' ! -name 'blank.md' 2>/dev/null + } | LC_ALL=C sort +} + +# Quad Concepts opt-in lever — ORTHOGONAL to PDDA_MODE (observe/light/full). The lever decides whether +# the quad-concepts check joins `pdda.sh run`; the mode still decides report-only vs blocking. Resolution +# mirrors the .sentinel-mode resolver: env PDDA_QUAD -> first non-comment line of /.pdda-quad -> +# default OFF. Any of 1/on/true/enabled/yes (case-insensitive) enables; everything else stays off. +quad_is_enabled() { + local v="${PDDA_QUAD:-}" + if [ -z "$v" ] && [ -f "$PDDA_REPO_ROOT/.pdda-quad" ]; then + v="$(awk 'NF && $0 !~ /^[[:space:]]*#/ { gsub(/[[:space:]]/,""); print; exit }' "$PDDA_REPO_ROOT/.pdda-quad" 2>/dev/null)" + fi + case "$(printf '%s' "$v" | tr '[:upper:]' '[:lower:]')" in + 1|on|true|enabled|yes) return 0 ;; + *) return 1 ;; + esac +} + +# Shared Quad Concepts section parser (used by `pdda.sh quad-concepts` AND `pdda.sh glance` — one parser, +# no drift). Emits the bullet COUNT on line 1 (-1 if the section is absent), then each top-level, +# non-empty bullet's TEXT (marker stripped) on its own line. Boundaries: the FIRST "## Quad Concepts" +# header until the next h1/h2 heading or the first blank line AFTER a bullet; fenced code is skipped; +# CRLF is normalized; indented/nested and empty bullets do not count; only the first section is read. +pdda_quad_section() { # + awk ' + { sub(/\r$/, "") } + done { next } + /^```/ { in_f = !in_f; next } + in_f { next } + !seen && /^##[ \t]+Quad[ \t]+Concepts[ \t]*$/ { in_q = 1; seen = 1; started = 0; next } + in_q && /^#{1,2}[ \t]/ { in_q = 0; done = 1; next } + in_q && started && /^[ \t]*$/ { in_q = 0; done = 1; next } + in_q && /^[-*][ \t]+[^ \t]/ { b = $0; sub(/^[-*][ \t]+/, "", b); bl[++n] = b; started = 1; next } + END { if (!seen) print -1; else print n + 0; for (i = 1; i <= n; i++) print bl[i] } + ' "$1" +} + pdda_frontmatter_lines() { awk ' NR == 1 { sub(/^\357\273\277/, "") } # strip a UTF-8 BOM if present @@ -324,8 +404,31 @@ _pdda_gh_state_table() { fi } +# Write a "\t" table to the gh-state cache, atomically (temp file + mv). ONE definition, +# shared by pdda-gh-refresh.sh (the explicit refresh command) and the `auto` lookup path in +# _pdda_issue_state_table (which fetches this exact table on every `pdda.sh run` and used to throw it +# away — GH-27). Returns non-zero on write failure; callers on the read path ignore that, because a +# failed cache write must never break a check that already has its answer in hand. +pdda_write_gh_state_cache() { # + local table="$1" tmp="$PDDA_GH_STATE_CACHE.tmp.$$" + { + printf '# pdda gh-issue-state cache — regenerated by pdda-gh-refresh.sh or any live `pdda.sh` lookup; do not edit by hand\n' + printf '# generated: %s\n' "$(pdda_now_iso)" + printf '# format: \\t (STATE is OPEN or CLOSED)\n' + [ -n "$table" ] && printf '%s\n' "$table" + } > "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; } + mv "$tmp" "$PDDA_GH_STATE_CACHE" 2>/dev/null || { rm -f "$tmp"; return 1; } +} + +# --- RELEASES.md helpers ------------------------------------------------------------------------- +# RELEASES.md is a single forward-looking planning ledger (like ROADMAP.md/CHANGELOG.md), not a +# bucket of per-tag docs — see PROJECT/PDDA.md "RELEASES.md — release ledger". Each release is a +# flat "Label: value" block; a block starts at a line matching ^Release: and runs until the next +# such line or EOF (blank lines between blocks are just visual spacing, not parsed). + # List releases as rows of -# +# +# # (US = ASCII unit separator 0x1F, not tab — bash's `read` collapses empty fields around literal # tabs since tab counts as "IFS whitespace" regardless of IFS's contents, which would silently # misalign every block with a blank Description/GH_URL, i.e. the common case here). One row per @@ -333,23 +436,29 @@ _pdda_gh_state_table() { # # `Status:` is free-text (Draft/Working/Shipped/... — whatever an operator writes) and unvalidated # by design: it's a rough, non-authoritative signal for "what's in progress," not a gated lifecycle -# field. See PROJECT/PDDA.md "RELEASES.md — release ledger". +# field. `Front-door reviewed:`/`Shakedown reviewed:`/`License file:` are optional Yes/No QA-gate +# fields (`pdda.sh releases` warns on a non-Yes/No value). See PROJECT/PDDA.md "RELEASES.md — +# release ledger". pdda_releases_list() { local file="$1" [ -f "$file" ] || return 0 awk ' function flush() { if (has_release) { - printf "%s\037%s\037%s\037%s\037%s\037%s\037%d\n", release, status, target_date, codename, description, gh_url, release_line + printf "%s\037%s\037%s\037%s\037%s\037%s\037%s\037%s\037%s\037%d\n", release, status, target_date, codename, description, gh_url, front_door, shakedown, license_file, release_line } - release=""; status=""; target_date=""; codename=""; description=""; gh_url=""; release_line=0; has_release=0 + release=""; status=""; target_date=""; codename=""; description=""; gh_url="" + front_door=""; shakedown=""; license_file=""; release_line=0; has_release=0 } - /^Release:/ { flush(); v=$0; sub(/^Release:[[:space:]]*/, "", v); release=v; has_release=1; release_line=NR; next } - /^Status:/ { v=$0; sub(/^Status:[[:space:]]*/, "", v); status=v; next } - /^Target Date:/ { v=$0; sub(/^Target Date:[[:space:]]*/, "", v); target_date=v; next } - /^Codename:/ { v=$0; sub(/^Codename:[[:space:]]*/, "", v); codename=v; next } - /^Description:/ { v=$0; sub(/^Description:[[:space:]]*/, "", v); description=v; next } - /^GH_URL:/ { v=$0; sub(/^GH_URL:[[:space:]]*/, "", v); gh_url=v; next } + /^Release:/ { flush(); v=$0; sub(/^Release:[[:space:]]*/, "", v); release=v; has_release=1; release_line=NR; next } + /^Status:/ { v=$0; sub(/^Status:[[:space:]]*/, "", v); status=v; next } + /^Target Date:/ { v=$0; sub(/^Target Date:[[:space:]]*/, "", v); target_date=v; next } + /^Codename:/ { v=$0; sub(/^Codename:[[:space:]]*/, "", v); codename=v; next } + /^Description:/ { v=$0; sub(/^Description:[[:space:]]*/, "", v); description=v; next } + /^GH_URL:/ { v=$0; sub(/^GH_URL:[[:space:]]*/, "", v); gh_url=v; next } + /^Front-door reviewed:/ { v=$0; sub(/^Front-door reviewed:[[:space:]]*/, "", v); front_door=v; next } + /^Shakedown reviewed:/ { v=$0; sub(/^Shakedown reviewed:[[:space:]]*/, "", v); shakedown=v; next } + /^License file:/ { v=$0; sub(/^License file:[[:space:]]*/, "", v); license_file=v; next } END { flush() } ' "$file" } diff --git a/.xyz/utils/pdda/pdda-stop-doc-health.sh b/.xyz/utils/pdda/pdda-stop-doc-health.sh index 57b9b68..cce7866 100755 --- a/.xyz/utils/pdda/pdda-stop-doc-health.sh +++ b/.xyz/utils/pdda/pdda-stop-doc-health.sh @@ -8,7 +8,6 @@ # visibility only. Wire it in .claude/settings.json as a `Stop` hook. Pairs with tier 1, # pdda-edit-doc-hook.sh (the per-edit single-file lint). set -u -# strict-mode: -e exempt — Stop-hook that ALWAYS exits 0; must never abort mid-scan. See GUIDING-PRINCIPLES.md#strict-mode-policy. HERE="$(cd "$(dirname "$0")" && pwd)" # shellcheck source=utils/pdda/pdda-lib.sh @@ -24,6 +23,14 @@ findings="$(printf '%s\n' "$report" | grep -E '^(ERROR|WARN) ' || true)" nerr="$(printf '%s\n' "$findings" | grep -c '^ERROR ' 2>/dev/null || true)" nwarn="$(printf '%s\n' "$findings" | grep -c '^WARN ' 2>/dev/null || true)" +# Reconciliation drift is the one finding class that ends a unit of work rather than describing one: +# a doc reached 3-COMPLETED with its issue still open, or a doc declares itself ready to close. A +# script must never close an issue on its own — that is a human judgment (PDDA is recommend-never-act). +# So the hook does the one thing a script legitimately can: name the wrap that asks. Without this the +# operator sees a warn, has no prompt to act, and the drift survives to the next session. +reconcile="$(printf '%s\n' "$findings" | grep -E 'pdda-check-issue-doc-sync' || true)" +nrec="$(printf '%s\n' "$reconcile" | grep -c 'pdda-check-issue-doc-sync' 2>/dev/null || true)" + { printf '── PDDA doc-health (stop scan) ──\n' if [ "${nerr:-0}" -eq 0 ] && [ "${nwarn:-0}" -eq 0 ]; then @@ -31,6 +38,10 @@ nwarn="$(printf '%s\n' "$findings" | grep -c '^WARN ' 2>/dev/null || true)" else printf '%s error(s), %s warn(s) — incl. issue-doc-sync against the cached gh-state file:\n' "$nerr" "$nwarn" printf '%s\n' "$findings" + if [ "${nrec:-0}" -gt 0 ]; then + printf '\n%s doc/issue reconciliation finding(s): a unit of work looks finished but is not wrapped.\n' "$nrec" + printf 'Run /pdda-eod to reconcile the docs and close the issues (propose-then-confirm; nothing closes without your yes).\n' + fi fi } >&2 diff --git a/.xyz/utils/pdda/pdda.sh b/.xyz/utils/pdda/pdda.sh index e5a7acf..43b7ce6 100755 --- a/.xyz/utils/pdda/pdda.sh +++ b/.xyz/utils/pdda/pdda.sh @@ -1,6 +1,5 @@ #!/usr/bin/env bash set -u -# strict-mode: -e exempt — analysis tool with expected-nonzero probes (git/gh/grep); errors handled explicitly. See GUIDING-PRINCIPLES.md#strict-mode-policy. # PDDA unified entry point. One dispatcher for every deterministic hygiene check plus the aggregate # run. The LLM-assisted readiness review stays in its own file (utils/pdda/pdda-doc-ready.sh) — it is a @@ -16,6 +15,8 @@ set -u # pdda.sh roadmap-coverage # pdda.sh changelog # pdda.sh stale +# pdda.sh issue-doc-sync +# pdda.sh governance # repo-root governance-doc cross-reference + doc/code drift # pdda.sh doc-ready # delegates to utils/pdda/pdda-doc-ready.sh (the LLM layer) # pdda.sh help # @@ -169,6 +170,44 @@ check_status_table() { return "$(pdda_gated_exit "$rc")" } +# ------------------------------------------------------------------------------------------------ +# B2. quad-concepts (OPT-IN) — a "## Quad Concepts" section of 1..4 bullets for glance orientation. +# Structure-only by design: it checks the section EXISTS and has 1..4 bullets. Whether the bullets are +# good pain->fix concepts is a judgment left to the LLM readiness rubric (pdda-doc-ready.sh), not a +# brittle regex. Runs over the quad scope (2-WORKING + 1-INBOX/GH-* + 3-COMPLETED); a doc opts out with +# `quad_exempt: true`. Joins `run` only when the .pdda-quad / PDDA_QUAD lever is enabled (see cmd_run). +# ------------------------------------------------------------------------------------------------ +check_quad_concepts() { + pdda_reset_counts + local CHECK_NAME="pdda-check-quad-concepts" rc=0 + local file n + + while IFS= read -r file; do + # per-doc escape hatch (mirrors roadmap_exempt) + pdda_frontmatter_true "$file" "quad_exempt" && continue + + # Bullet count of the first "## Quad Concepts" section via the shared parser (pdda_quad_section: + # line 1 is the count, -1 if absent). See pdda-lib.sh for the boundary/fence/CRLF rules. + n="$(pdda_quad_section "$file" | sed -n '1p')" + # guard against an empty capture (unreadable file) so the numeric comparisons never see an empty operand. + case "$n" in ''|*[!0-9-]*) n="-1" ;; esac + + if [ "$n" = "-1" ]; then + pdda_record_finding error "$CHECK_NAME" "$file" 1 "missing '## Quad Concepts' section (add 1-4 pain->fix bullets, or set quad_exempt: true)" "add-quad-concepts" + rc=1 + elif [ "$n" -eq 0 ]; then + pdda_record_finding error "$CHECK_NAME" "$file" 1 "'## Quad Concepts' section has no bullets (need 1-4)" "fill-quad-concepts" + rc=1 + elif [ "$n" -gt 4 ]; then + pdda_record_finding error "$CHECK_NAME" "$file" 1 "'## Quad Concepts' has $n bullets (max 4 — keep it glanceable)" "trim-quad-concepts" + rc=1 + fi + done < <(pdda_list_quad_docs) + + pdda_emit_summary "$CHECK_NAME" "$rc" + return "$(pdda_gated_exit "$rc")" +} + # ------------------------------------------------------------------------------------------------ # C. hardcoded-paths # ------------------------------------------------------------------------------------------------ @@ -363,12 +402,12 @@ check_changelog() { return "$(pdda_gated_exit "$rc")" fi - cl_line="$(grep -Em1 '^##[[:space:]]+[0-9]{4}-[0-9]{2}-[0-9]{2}' "$PDDA_CHANGELOG" 2>/dev/null || true)" + cl_line="$(grep -Em1 '^##[[:space:]]+(\[[^][]*\][[:space:]]*[-–][[:space:]]*)?[0-9]{4}-[0-9]{2}-[0-9]{2}' "$PDDA_CHANGELOG" 2>/dev/null || true)" cl_date="$(printf '%s' "$cl_line" | grep -Eo '[0-9]{4}-[0-9]{2}-[0-9]{2}' | head -1)" if [ -z "$cl_date" ] || ! pdda_is_real_date "$cl_date"; then pdda_record_finding warn "$CHECK_NAME" "$PDDA_CHANGELOG" 1 \ - "no dated '## YYYY-MM-DD' entry at the top of CHANGELOG.md — add an end-of-iteration entry" "add-dated-entry" + "no dated '## YYYY-MM-DD' or '## [x.y.z] - YYYY-MM-DD' entry at the top of CHANGELOG.md — add an end-of-iteration entry" "add-dated-entry" pdda_emit_summary "$CHECK_NAME" "$rc" return "$(pdda_gated_exit "$rc")" fi @@ -467,7 +506,12 @@ _pdda_issue_state_table() { cache) _pdda_cache_state_table ;; gh) _pdda_gh_state_table ;; auto|*) - if command -v gh >/dev/null 2>&1 && out="$(_pdda_gh_state_table)"; then + if command -v gh >/dev/null 2>&1 && out="$(_pdda_gh_state_table)" && [ -n "$out" ]; then + # Persist what we just fetched (GH-27). The Stop hook reads this file with + # PDDA_ISSUE_SYNC_SOURCE=cache and makes no network call; with no writer on this path the cache + # never existed, so the hook reported "all clear" over real drift. Best-effort: a failed cache + # write must never break a lookup that already has its answer. + pdda_write_gh_state_cache "$out" || : printf '%s' "$out" else _pdda_cache_state_table @@ -504,20 +548,41 @@ _pdda_is_terminal_word() { return 1 } +# Explicit hand-off phrases anywhere in a status line. The lead-word test above is deliberately narrow +# (so "Phase 0 complete" mid-sentence never false-flags), but it is defeated by a self-contradictory +# status such as `Active — Phases 1-4 complete … Ready to close to 3-COMPLETED.` — every human reads +# that as done; the parser reads "active" and stops (GH-27 leak 2). +# +# These phrases are unambiguous operator hand-offs, not incidental progress notes. Matching is on the +# whole status, case-insensitively. Keep the list short and literal: a general "does this prose mean +# done?" parse is exactly the false-positive machine the lead-word anchor was built to avoid. +PDDA_STATUS_HANDOFF_PHRASES="ready to close|ready for 3-completed|ready to move to 3-completed|awaiting close" +_pdda_status_declares_handoff() { + printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | grep -Eq "$PDDA_STATUS_HANDOFF_PHRASES" +} + check_issue_doc_sync() { pdda_reset_counts local CHECK_NAME="pdda-check-issue-doc-sync" rc=0 local table file num state status_val leadword target rel_target + table="$(_pdda_issue_state_table)" + # --- (1) active plans: PROJECT/2-WORKING --------------------------------------------------------- while IFS= read -r file; do num="$(_pdda_doc_issue_number "$file")" - [ -n "$num" ] || continue # not an issue-tracked doc — nothing to reconcile + # A doc with no `gh_issue:` is not issue-tracked; there is nothing to reconcile it against. + # Deliberately NOT a finding: warning here would fire on every untracked plan doc in every + # installed target on the first run — the exact self-inflicted-noise failure GH-15 fixed. Making + # untracked plans declare themselves is worth doing behind an opt-in lever, not by default. + [ -n "$num" ] || continue state="$(printf '%s\n' "$table" | awk -F'\t' -v n="$num" '$1 == n { print toupper($2); exit }')" if [ -z "$state" ]; then - pdda_record_finding info "$CHECK_NAME" "$file" 1 \ - "issue #$num state unavailable (gh absent/offline and no cached state) — sync not evaluated" "skip" + # A check that could not run is NOT a check that passed. Warn, so `run` and the Stop hook say so. + pdda_record_finding warn "$CHECK_NAME" "$file" 1 \ + "issue #$num state unavailable (gh absent/offline and no cached state) — sync NOT evaluated; run: utils/pdda/pdda.sh gh-refresh" \ + "state-unavailable" continue fi @@ -531,121 +596,54 @@ check_issue_doc_sync() { continue # closed-issue drift dominates; skip the (b) test fi - # Direction (b): doc declares itself done (status lead word) while the issue is still OPEN. + # Direction (b): doc declares itself done while the issue is still OPEN. Two signals: + # - the status LEAD WORD is terminal ("Shipped — …") + # - or the status carries an explicit hand-off phrase anywhere ("Active — … Ready to close") + # The second exists because the first is defeated by a self-contradictory status (GH-27 leak 2). status_val="$(pdda_trim "$(pdda_frontmatter_value "$file" status)")" leadword="$(_pdda_status_leadword "$status_val")" if [ -n "$leadword" ] && _pdda_is_terminal_word "$leadword"; then pdda_record_finding warn "$CHECK_NAME" "$file" 1 \ "doc status reads '$leadword' (done) but issue #$num is still OPEN — close the issue or correct the status" \ "reconcile-status" + elif _pdda_status_declares_handoff "$status_val"; then + pdda_record_finding warn "$CHECK_NAME" "$file" 1 \ + "doc status declares it is ready to close but issue #$num is still OPEN — recommend: git mv to 3-COMPLETED, then gh issue close $num" \ + "reconcile-status" fi done < <(pdda_list_working_docs) - # Direction (c) — GH-189: the (a) remediation above (`git mv` into 3-COMPLETED) removes a doc from - # pdda_list_working_docs()'s scan set forever, so this is the only place left that ever looks at a - # completed doc again. Precise inverse of direction (b), scoped to 3-COMPLETED instead of 2-WORKING: - # a doc landing there implies "done", so a still-non-terminal status lead word is the drift. + # --- (2) completed plans: PROJECT/3-COMPLETED ---------------------------------------------------- + # A doc that reached 3-COMPLETED IS the operator's assertion that the work is done — recorded in a + # path, not in prose. A still-OPEN issue behind it is drift. Without this pass the check stops + # watching a doc at the exact moment it completes, so the `git mv` recommended above is what blinds + # it (GH-27 leak 1). while IFS= read -r file; do num="$(_pdda_doc_issue_number "$file")" - [ -n "$num" ] || continue # not an issue-tracked doc — nothing to reconcile + [ -n "$num" ] || continue # completed docs need not be issue-tracked; nothing to reconcile - status_val="$(pdda_trim "$(pdda_frontmatter_value "$file" status)")" - leadword="$(_pdda_status_leadword "$status_val")" - if [ -n "$leadword" ] && ! _pdda_is_terminal_word "$leadword"; then + state="$(printf '%s\n' "$table" | awk -F'\t' -v n="$num" '$1 == n { print toupper($2); exit }')" + if [ -z "$state" ]; then pdda_record_finding warn "$CHECK_NAME" "$file" 1 \ - "doc is in 3-COMPLETED (issue #$num) but frontmatter status still reads '$leadword' (non-terminal) — reconcile the status or move the doc back to 2-WORKING" \ - "reconcile-status" + "issue #$num state unavailable (gh absent/offline and no cached state) — sync NOT evaluated; run: utils/pdda/pdda.sh gh-refresh" \ + "state-unavailable" + continue fi - done < <(pdda_list_completed_docs) - - pdda_emit_summary "$CHECK_NAME" "$rc" - return "$(pdda_gated_exit "$rc")" -} - -# ------------------------------------------------------------------------------------------------ -# H2. roadmap-issue-state (warn-only, flag-only; gh-degrades like issue-doc-sync, never blocks) -# ------------------------------------------------------------------------------------------------ -# GH-189 (part 2): check_issue_doc_sync reconciles PROJECT/** capture docs against their linked -# issue's real state; nothing reconciled a ROADMAP.md ledger ENTRY's own status marker/emoji against -# that same real state. Reuses _pdda_issue_state_table (the same gh-then-cache resolver defined above) -# so this never becomes a second issue-state fetcher — one source of truth, two consumers. -# -# Signal is intentionally conservative: only lines carrying an unambiguous terminal marker (✅ or the -# word "shipped") or an unambiguous non-terminal marker (🆕, the word "captured", or "ready to fire") -# are classified; a line with both or neither markers is left alone (no reliable signal => no finding). -_pdda_roadmap_ledger_lines() { - local roadmap="$1" - awk ' - /^[[:space:]]*```/ { - if (in_fence) { in_fence=0; fexempt=0 } - else { - info=$0; sub(/^[[:space:]]*`+/,"",info); gsub(/[[:space:]]/,"",info); info=tolower(info) - in_fence=1 - fexempt=(info=="console"||info=="text"||info=="transcript")?1:0 - } - next - } - in_fence && fexempt { next } - /^[[:space:]]*>/ { next } # blockquote = allowed carve-out note, same as check_roadmap - /\[#[0-9]+\]\(https:\/\/[^)]*\/issues\/[0-9]+\)/ { print NR "\t" $0 } - ' "$roadmap" -} - -check_roadmap_issue_state() { - pdda_reset_counts - local CHECK_NAME="pdda-check-roadmap-issue-state" rc=0 - local PDDA_ROADMAP="${PDDA_ROADMAP:-$PDDA_REPO_ROOT/ROADMAP.md}" - local table line_no line num state is_terminal is_nonterminal - if [ ! -f "$PDDA_ROADMAP" ]; then - pdda_record_finding info "$CHECK_NAME" "$PDDA_ROADMAP" 0 "ROADMAP.md not found; nothing to check" "skip" - pdda_emit_summary "$CHECK_NAME" 0 - return "$(pdda_gated_exit 0)" - fi - - table="$(_pdda_issue_state_table)" - - while IFS=$'\t' read -r line_no line; do - [ -n "$line_no" ] || continue - - is_terminal=0 - is_nonterminal=0 - case "$line" in *"✅"*) is_terminal=1 ;; esac - printf '%s' "$line" | grep -qiE '\bshipped\b' && is_terminal=1 - case "$line" in *"🆕"*) is_nonterminal=1 ;; esac - printf '%s' "$line" | grep -qiE '\bcaptured\b|ready to fire' && is_nonterminal=1 - - # Ambiguous (both fired, or neither) => no reliable signal on this line; skip it entirely. - [ "$is_terminal" -eq "$is_nonterminal" ] && continue - - while IFS= read -r num; do - [ -n "$num" ] || continue - state="$(printf '%s\n' "$table" | awk -F'\t' -v n="$num" '$1 == n { print toupper($2); exit }')" - if [ -z "$state" ]; then - pdda_record_finding info "$CHECK_NAME" "$PDDA_ROADMAP" "$line_no" \ - "issue #$num state unavailable (gh absent/offline and no cached state) — sync not evaluated" "skip" - continue - fi - - if [ "$is_terminal" -eq 1 ] && [ "$state" = "OPEN" ]; then - pdda_record_finding warn "$CHECK_NAME" "$PDDA_ROADMAP" "$line_no" \ - "ledger entry reads shipped/done (✅/SHIPPED) but issue #$num is still OPEN — reconcile the ledger entry or close the issue" \ - "reconcile-roadmap-status" - fi - if [ "$is_nonterminal" -eq 1 ] && [ "$state" = "CLOSED" ]; then - pdda_record_finding warn "$CHECK_NAME" "$PDDA_ROADMAP" "$line_no" \ - "issue #$num is CLOSED but the ledger entry's status marker is still non-terminal (e.g. 🆕/captured/ready to fire) — update the ledger entry" \ - "reconcile-roadmap-status" - fi - done < <(printf '%s\n' "$line" | grep -oE '\[#[0-9]+\]\(https://[^)]*/issues/[0-9]+\)' | grep -oE '^\[#[0-9]+\]' | sed -E 's/[^0-9]//g') - done < <(_pdda_roadmap_ledger_lines "$PDDA_ROADMAP") + if [ "$state" = "OPEN" ]; then + pdda_record_finding warn "$CHECK_NAME" "$file" 1 \ + "doc is in 3-COMPLETED but issue #$num is still OPEN — recommend: gh issue close $num" \ + "close-issue" + fi + # state=CLOSED in 3-COMPLETED is the fully reconciled end state: no finding. + done < <(pdda_list_completed_docs) pdda_emit_summary "$CHECK_NAME" "$rc" return "$(pdda_gated_exit "$rc")" } # ------------------------------------------------------------------------------------------------ -# I. releases (warn-only nudge; never blocks, even in full) +# J. releases (warn-only nudge; never blocks, even in full) # ------------------------------------------------------------------------------------------------ # Validates RELEASES.md, the single forward-looking release-planning ledger (see PROJECT/PDDA.md # "RELEASES.md — release ledger"). Deliberately light: this replaced a heavier per-tag-doc lifecycle @@ -653,13 +651,13 @@ check_roadmap_issue_state() { # proved like too much data to keep current for an initial release. Grows only as real need shows up. # (1) error — a "Release:" block has an empty version # (2) warn — Target Date is set but not a valid YYYY-MM-DD date -# (3) warn — Target Date has passed and Status isn't "Shipped" (overdue/unshipped) +# (3) warn — Target Date has passed and GH_URL is still empty (looks overdue/unshipped) check_releases() { pdda_reset_counts local CHECK_NAME="pdda-check-releases" rc=0 local RELEASES_FILE_EFF="${PDDA_RELEASES_FILE:-$PDDA_REPO_ROOT/RELEASES.md}" local release status target_date codename description gh_url line_no target_epoch today_epoch - local status_lc + local status_lc front_door shakedown license_file qa_field qa_label qa_value qa_value_lc if [ ! -f "$RELEASES_FILE_EFF" ]; then pdda_record_finding info "$CHECK_NAME" "$RELEASES_FILE_EFF" 0 \ @@ -668,7 +666,8 @@ check_releases() { return "$(pdda_gated_exit 0)" fi - while IFS=$'\037' read -r release status target_date codename description gh_url line_no; do + while IFS=$'\037' read -r release status target_date codename description gh_url \ + front_door shakedown license_file line_no; do if [ -z "$(pdda_trim "$release")" ]; then pdda_record_finding error "$CHECK_NAME" "$RELEASES_FILE_EFF" "$line_no" \ "a 'Release:' block near line $line_no has no version" "fix-release-value" @@ -676,6 +675,21 @@ check_releases() { continue fi + # Front-door reviewed / Shakedown reviewed / License file: optional pre-release QA-gate + # checkboxes, warn-only Yes/No like the rest of this check (see PROJECT/PDDA.md "RELEASES.md + # — release ledger"). A blank value is fine (not yet answered); only a set-but-invalid value warns. + for qa_field in "Front-door reviewed:$front_door" "Shakedown reviewed:$shakedown" "License file:$license_file"; do + qa_label="${qa_field%%:*}" + qa_value="$(pdda_trim "${qa_field#*:}")" + [ -n "$qa_value" ] || continue + qa_value_lc="$(printf '%s' "$qa_value" | tr '[:upper:]' '[:lower:]')" + case "$qa_value_lc" in + yes | no) ;; + *) pdda_record_finding warn "$CHECK_NAME" "$RELEASES_FILE_EFF" "$line_no" \ + "release '$release' $qa_label value '$qa_value' is not exactly Yes or No" "fix-release-yesno-field" ;; + esac + done + [ -n "$target_date" ] || continue if ! pdda_is_real_date "$target_date"; then @@ -691,6 +705,8 @@ check_releases() { status_lc="$(printf '%s' "$(pdda_trim "$status")" | tr '[:upper:]' '[:lower:]')" [ "$status_lc" != "shipped" ] || continue + # _pdda_cl_epoch is the changelog check's date->epoch helper, portable BSD/GNU; reused here + # rather than duplicating the date-parsing logic for a second date-comparison check. target_epoch="$(_pdda_cl_epoch "$target_date")" today_epoch="$(_pdda_cl_epoch "$(pdda_today)")" if [ -n "$target_epoch" ] && [ -n "$today_epoch" ] && [ "$target_epoch" -lt "$today_epoch" ]; then @@ -701,21 +717,22 @@ check_releases() { done < <(pdda_releases_list "$RELEASES_FILE_EFF") pdda_emit_summary "$CHECK_NAME" "$rc" - # Warn-only in spirit — never blocks, even in full mode. The one error above is a malformed-doc - # guard, surfaced loudly, but deliberately never gates the exit code. + # Warn-only in spirit — never blocks, even in full mode (see PROJECT/PDDA.md section J). The one + # error above is a malformed-doc guard, surfaced loudly, but deliberately never gates the exit code. return "$(pdda_gated_exit 0)" } # ------------------------------------------------------------------------------------------------ -# releases-current (read-only roll-up; not part of the deterministic-check suite — no findings, no gate) +# releases-current (read-only roll-up; not part of PDDA_DETERMINISTIC_CHECKS — no findings, no gate) # ------------------------------------------------------------------------------------------------ # A rough, non-authoritative answer to "what release is in progress right now" — for a human, or for -# another repo's tooling to shell out to rather than re-implementing RELEASES.md parsing itself. -# Lists every release whose Status is empty or not "Shipped" (Status is free-text and unvalidated, -# so this is a best-effort filter, not a gate). +# another repo's tooling (e.g. the XYZ sibling harness) to shell out to rather than re-implementing +# RELEASES.md parsing itself. Lists every release whose Status is empty or not "Shipped" (Status is +# free-text and unvalidated, so this is a best-effort filter, not a gate — see PROJECT/PDDA.md). cmd_releases_current() { local RELEASES_FILE_EFF="${PDDA_RELEASES_FILE:-$PDDA_REPO_ROOT/RELEASES.md}" local release status target_date codename description gh_url line_no status_lc any=0 + local front_door shakedown license_file if [ ! -f "$RELEASES_FILE_EFF" ]; then printf '%s not found — nothing to report\n' "$(pdda_relpath "$RELEASES_FILE_EFF")" @@ -723,7 +740,8 @@ cmd_releases_current() { fi printf 'PDDA releases-current — in-progress entries in %s\n' "$(pdda_relpath "$RELEASES_FILE_EFF")" - while IFS=$'\037' read -r release status target_date codename description gh_url line_no; do + while IFS=$'\037' read -r release status target_date codename description gh_url \ + front_door shakedown license_file line_no; do [ -n "$(pdda_trim "$release")" ] || continue status_lc="$(printf '%s' "$(pdda_trim "$status")" | tr '[:upper:]' '[:lower:]')" [ "$status_lc" != "shipped" ] || continue @@ -740,6 +758,278 @@ cmd_releases_current() { [ "$any" -eq 1 ] || printf '\n(no in-progress releases — every entry is Status: Shipped)\n' return 0 } +# ------------------------------------------------------------------------------------------------ +# Targets the small, curated "read this to understand the repo's rules" doc set (ROUTER.md, AGENTS.md, +# GUIDING-PRINCIPLES.md, README.md, CLAUDE.md, PROJECT/PDDA.md, utils/pdda/PDDA-INSTALL.md) — not every +# markdown file in the tree (PROJECT/** plan docs have their own checks above). CLAUDE.md is in the +# default set because many installs carry one at the repo root beside AGENTS.md; a repo without one +# (like this one) just has it silently skipped. Override the set via PDDA_GOVERNANCE_DOCS +# (space-separated, repo-relative) and the index doc via PDDA_GOVERNANCE_INDEX (default ROUTER.md). +PDDA_GOVERNANCE_DOCS_DEFAULT="ROUTER.md AGENTS.md GUIDING-PRINCIPLES.md README.md CLAUDE.md PROJECT/PDDA.md utils/pdda/PDDA-INSTALL.md" +PDDA_GOVERNANCE_INDEX_DEFAULT="ROUTER.md" + +# GH-15: two of the docs above (utils/pdda/PDDA-INSTALL.md, PROJECT/PDDA.md) are themselves shipped to +# every target install, but legitimately reference files install.sh deliberately does NOT copy there — +# the target's own repo-authored startup docs, canonical-only skill/companion-doc paths, and the pre-utils/pdda/ +# legacy layout path. A fresh `install.sh . --mode observe` self-inflicted ~30 dead-reference/env-var +# warns from this exact mismatch on first run, drowning the target's own drift signal in PDDA-on-PDDA +# noise. This manifest was built from an actual dead-reference scan of a bare `install.sh` target +# (not retyped from the issue's illustrative list), so it matches real warns, not guesses. Scoped ONLY +# to the shipped docs named below — a repo-authored governance doc (this canonical repo's own ROUTER.md, +# AGENTS.md, ...) referencing one of these is still a real dead-reference bug and stays flagged. +# +# GH-23 P3 widened the scan from .md to .sh, which grew this manifest along three axes. Each entry was +# read off an actual scan of a bare `install.sh --with-startup-docs` target, same method as +# above — 46 warns before, 0 after: +# - canonical-only TOOLS a target never receives: install.sh (targets are installed, not installers), +# the sync engine (excluded by pdda-sync-manifest.conf: targets are leaf nodes), templates/, test/. +# - LEGACY flat-layout paths (utils/pdda.sh, ...) that PDDA-INSTALL.md names precisely BECAUSE they +# must not exist — it is documenting the layout install.sh migrates away from. Their .md sibling +# (utils/PDDA-INSTALL.md) was already exempt for this reason; the .sh ones only surfaced once the +# suffix widened. +# - config.sh, which belongs to git-pulse, a separate program. It is not ours and never will be here. +PDDA_GOV_SHIPPED_DOCS_DEFAULT="utils/pdda/PDDA-INSTALL.md PROJECT/PDDA.md" +PDDA_GOV_SHIPPED_DOC_REF_EXEMPTIONS_DEFAULT="ROUTER.md AGENTS.md GUIDING-PRINCIPLES.md README.md CLAUDE.md .claude/skills/pdda/SKILL.md .claude/skills/governance-audit/SKILL.md .claude/skills/release/SKILL.md PROJECT/3-COMPLETED/PDDA-SYNC-TO-OTHER-REPOS.md utils/PDDA-INSTALL.md install.sh templates/ROUTER.target.md test/pdda-doc-health-hooks.sh pdda-sync.sh utils/pdda/pdda-sync.sh utils/pdda/pdda-manifest.sh utils/pdda.sh utils/pdda-lib.sh utils/pdda-doc-ready.sh utils/pdda-catchup.sh config.sh" +PDDA_GOV_SHIPPED_DOC_ENVVAR_EXEMPTIONS_DEFAULT="PDDA_REGISTRY PDDA_GITPULSE_DIR PDDA_SYNC_MAX_SHRINK" + +# Print "\t" for lines outside an exempt fence/blockquote — same carve-out convention as +# check_hardcoded_paths (fenced console/text/transcript blocks and blockquotes are not scanned). +_pdda_gov_scannable_lines() { + awk ' + /^[[:space:]]*```/ { + if (in_fence) { in_fence=0; fexempt=0 } + else { + info=$0; sub(/^[[:space:]]*`+/,"",info); gsub(/[[:space:]]/,"",info); info=tolower(info) + in_fence=1 + fexempt=(info=="console"||info=="text"||info=="transcript")?1:0 + } + next + } + in_fence && fexempt { next } + /^[[:space:]]*>/ { next } + { print NR "\t" $0 } + ' "$1" +} + +# Extract candidate file references from one line. Three patterns, unioned then deduplicated: +# +# (a) markdown-link targets `](target.md)` `](target.sh)` optionally carrying a `#anchor` +# (b) whole-span code refs `` `target.md` `` `` `target.sh` `` +# (c) command-position paths a *.sh token opening a code span or a scanned fence line +# +# Why (c) exists (GH-23). A router's most load-bearing references are the commands it tells an agent to +# run, and those never look like (a) or (b): they carry arguments. `` `.xyz/utils/marathon-plan.sh +# --help` `` and a bare `utils/pdda/pdda-sync.sh push` inside a ```bash fence both name a real file, yet +# neither closes its span right after the suffix. A router can therefore point every agent at a script +# that does not exist and no check would ever see it — which is exactly how the LTVera-Pandas install +# shipped a router full of scripts its target never received. +# +# Scope of (c) is deliberately narrow: the token must open the span or the line, which is where a shell +# command's *program* sits. A `.sh` word later in a sentence is prose, not a path claim. That keeps +# `` `pdda.sh run` `` from being read as two refs while still resolving `pdda.sh` itself. +# +# A leading `./` is stripped from (c): in command position `./install.sh` means "relative to the repo +# root I am standing in", not "relative to the doc that mentions it". Left intact, it would resolve +# against the referencing doc's directory and report a phantom dead ref for a script that plainly exists. +# +# Pattern (c) ends at whitespace, a backtick, end-of-line, or one of `,;:)"'` — the punctuation a command +# is written against in prose ("run x.sh, then y") or in a fence ("x.sh; y.sh"). A trailing `.` is +# deliberately NOT a terminator: it cannot be told apart from a suffix, and `deploy.sh.bak` would be +# extracted as `deploy.sh`. A sentence ending in a bare command name is the rarer case; a false flag on a +# real backup file is the worse one. +# +# Anchor-only links never match (no suffix) — this check validates file existence, not heading anchors. +# A bare `GH--*.md` name is filtered out: those are illustrative instances of the issue-doc naming +# convention (PDDA.md's own examples), not fixed cross-references to a real file. +# +# KNOWN GAP: an interpreter-wrapped invocation (`bash utils/x.sh`, `sudo ./x.sh`) still names a real path +# but the .sh sits in argument position, so (c) skips it. Closing it needs an interpreter allowlist plus +# negative controls (`bash -c "..."` must not flag); tracked separately rather than guessed at here. +_pdda_gov_extract_refs() { + local text="$1" + { printf '%s\n' "$text" \ + | grep -oE '\]\([^)[:space:]]+\.(md|sh)(#[A-Za-z0-9_-]*)?\)' \ + | sed -E 's/^\]\(//; s/\)$//' + printf '%s\n' "$text" \ + | grep -oE '`[A-Za-z0-9_./-]+\.(md|sh)(#[A-Za-z0-9_-]*)?`' \ + | sed -E 's/^`//; s/`$//' + printf '%s\n' "$text" \ + | grep -oE '(^|`)[[:space:]]*(\.{1,2}/)?[A-Za-z0-9_.][A-Za-z0-9_./-]*\.sh([[:space:]`,;:)"'"'"']|$)' \ + | sed -E 's/^`//; s/^[[:space:]]+//; s/[[:space:]`,;:)"'"'"']+$//; s|^\./||' + # (d) interpreter-wrapped invocation: `bash x.sh`, `sudo ./x.sh`, `sh setup.sh`, `env FOO=1 ./x.sh`. + # When a script is handed to an interpreter it leaves PROGRAM position (which (c) keys on) for + # ARGUMENT position and (c) goes blind. An explicit allowlist of transparent wrappers — sudo, env + # (with VAR=val assignments), and the interpreters bash/sh/zsh/source/. — is stripped to recover the + # path. The leading char class rejects `-` (so `bash -c` is not a path) and `"`/`$` (so a quoted or + # variable-expanded argument is never mistaken for a path claim). GH-33. + printf '%s\n' "$text" \ + | grep -oE '(^|`)[[:space:]]*((sudo|source|bash|zsh|env|sh|\.)([[:space:]]+[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*)*[[:space:]]+)+(\.{1,2}/)?[A-Za-z0-9_.][A-Za-z0-9_./-]*\.sh([[:space:]`,;:)"'"'"']|$)' \ + | sed -E 's/^`//; s/^[[:space:]]+//; s/^((sudo|source|bash|zsh|env|sh|\.)([[:space:]]+[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*)*[[:space:]]+)+//; s/[[:space:]`,;:)"'"'"']+$//; s|^\./||' + } | grep -Ev '(^|/)GH-[0-9]+-[^/]*\.md(#.*)?$' | LC_ALL=C sort -u +} + +# Resolve a raw ref (its #anchor stripped) against repo root or the referencing file's directory. +# Prints nothing (and returns non-zero) for an external URL — the caller then skips it. A bare +# filename (no directory component) that doesn't resolve at its expected spot falls back to a +# repo-wide basename search — bare mentions (e.g. "blank.md", used generically across four lifecycle +# folders) aren't precise path claims, so only a filename absent everywhere counts as truly dead. A +# ref WITH a directory component stays a precise claim: if that exact path is wrong, that IS the bug +# (e.g. a doc pointing at PROJECT/2-WORKING/X.md after X.md was completed and moved to 3-COMPLETED/). +_pdda_gov_resolve_ref() { + local ref="$1" from_dir="$2" path candidate found p + path="${ref%%#*}" + case "$path" in + http://*|https://*|//*) return 1 ;; + /*) candidate="$PDDA_REPO_ROOT$path" ;; + ./*|../*) candidate="$from_dir/$path" ;; + */*) candidate="$PDDA_REPO_ROOT/$path" ;; + *) + candidate="$PDDA_REPO_ROOT/$path" + if [ ! -f "$candidate" ]; then + # basename match must be LITERAL, not a glob: `find -name "$path"` would read a markdown-link + # ref of `build[1].sh` (whose extraction class admits [ ] * ?) as a pattern and resolve it + # against a DIFFERENT file `build1.sh`, scoring a dead ref live. Compare basenames as strings. + # First traversal match wins, preserving the previous `| head -1` semantics. GH-34. + found="" + while IFS= read -r -d '' p; do + if [ "${p##*/}" = "$path" ]; then found="$p"; break; fi + done < <(find "$PDDA_REPO_ROOT" -not -path '*/.git/*' -print0 2>/dev/null) + [ -n "$found" ] && candidate="$found" + fi + ;; + esac + printf '%s\n' "$candidate" +} + +check_governance() { + pdda_reset_counts + local CHECK_NAME="pdda-check-governance" rc=0 + local docs="${PDDA_GOVERNANCE_DOCS:-$PDDA_GOVERNANCE_DOCS_DEFAULT}" + local index_doc="${PDDA_GOVERNANCE_INDEX:-$PDDA_GOVERNANCE_INDEX_DEFAULT}" + local shipped_docs="${PDDA_GOV_SHIPPED_DOCS:-$PDDA_GOV_SHIPPED_DOCS_DEFAULT}" + local ref_exempt="${PDDA_GOV_SHIPPED_DOC_REF_EXEMPTIONS:-$PDDA_GOV_SHIPPED_DOC_REF_EXEMPTIONS_DEFAULT}" + local envvar_exempt="${PDDA_GOV_SHIPPED_DOC_ENVVAR_EXEMPTIONS:-$PDDA_GOV_SHIPPED_DOC_ENVVAR_EXEMPTIONS_DEFAULT}" + local doc file abs_file from_dir line_no text ref resolved base var line + local present_docs="" index_abs is_shipped_doc ref_path + + for doc in $docs; do + [ -f "$PDDA_REPO_ROOT/$doc" ] && present_docs="$present_docs $doc" + done + + if [ -z "$(pdda_trim "$present_docs")" ]; then + pdda_record_finding info "$CHECK_NAME" "$PDDA_REPO_ROOT" 0 \ + "no governance docs found in the configured set ($docs)" "skip" + pdda_emit_summary "$CHECK_NAME" 0 + return "$(pdda_gated_exit 0)" + fi + + # --- (1) dead references: every .md ref in a governance doc must resolve to a real file --------- + # warn-only: markdown-reference extraction from free-form prose is inherently more heuristic than + # the mechanical checks above (frontmatter, status-table), so a false flag costs one ignorable line + # rather than blocking a build even in full mode — same calibration as check_stale/check_changelog. + for doc in $present_docs; do + abs_file="$PDDA_REPO_ROOT/$doc" + from_dir="$(dirname "$abs_file")" + is_shipped_doc=0 + case " $shipped_docs " in *" $doc "*) is_shipped_doc=1 ;; esac + while IFS=$'\t' read -r line_no text; do + [ -n "$line_no" ] || continue + while IFS= read -r ref; do + [ -n "$ref" ] || continue + if [ "$is_shipped_doc" -eq 1 ]; then + # normalize away leading ./ or ../ so a relative mention (e.g. "../../PROJECT/3-COMPLETED/ + # PDDA-SYNC-TO-OTHER-REPOS.md") matches the same manifest entry as its repo-relative form + ref_path="${ref%%#*}" + while :; do + case "$ref_path" in + ../*) ref_path="${ref_path#../}" ;; + ./*) ref_path="${ref_path#./}" ;; + *) break ;; + esac + done + case " $ref_exempt " in *" $ref_path "*) continue ;; esac + fi + resolved="$(_pdda_gov_resolve_ref "$ref" "$from_dir")" || continue + [ -f "$resolved" ] && continue + pdda_record_finding warn "$CHECK_NAME" "$abs_file" "$line_no" \ + "dead reference '$ref' — no file at $(pdda_relpath "$resolved")" "fix-dead-reference" + done <<< "$(_pdda_gov_extract_refs "$text")" + done < <(_pdda_gov_scannable_lines "$abs_file") + done + + # --- (2) orphan governance docs: a present doc the index doc never points at -------------------- + index_abs="$PDDA_REPO_ROOT/$index_doc" + if [ -f "$index_abs" ]; then + for doc in $present_docs; do + [ "$doc" = "$index_doc" ] && continue + base="$(basename "$doc")" + if ! grep -Fq "$base" "$index_abs"; then + pdda_record_finding warn "$CHECK_NAME" "$PDDA_REPO_ROOT/$doc" 1 \ + "governance doc is not referenced anywhere in $index_doc — a cold agent following its read order won't discover it" \ + "add-index-pointer" + fi + done + else + pdda_record_finding info "$CHECK_NAME" "$index_abs" 0 \ + "governance index doc '$index_doc' not found; skipping orphan-doc check" "skip" + fi + + # --- (3) subcommand drift: every pdda.sh dispatcher subcommand must be named in the index doc --- + if [ -f "$index_abs" ]; then + local subcommands sub + subcommands="$(awk ' + /case "\$cmd" in/ { in_case = 1; next } + in_case && /^esac/ { in_case = 0 } + in_case && /^[[:space:]]*[A-Za-z*][A-Za-z0-9*_-]*(\|[A-Za-z0-9*_-]+)*\)/ { + line = $0 + sub(/^[[:space:]]*/, "", line) + sub(/\).*/, "", line) + n = split(line, parts, "|") + for (i = 1; i <= n; i++) print parts[i] + } + ' "$HERE/pdda.sh" | grep -Ev '^(run|help|-h|--help|\*)$' | LC_ALL=C sort -u)" + + for sub in $subcommands; do + if ! grep -Eq "(^|[^A-Za-z0-9_-])${sub}([^A-Za-z0-9_-]|\$)" "$index_abs"; then + pdda_record_finding error "$CHECK_NAME" "$index_abs" 1 \ + "pdda.sh subcommand '$sub' is not documented anywhere in $index_doc — keep the installer surface in lockstep (AGENTS.md #5)" \ + "document-subcommand" + rc=1 + fi + done + fi + + # --- (4) env-var drift: a PDDA_* var named in a governance doc should exist in a shipped script --- + # warn-only: PDDA_GOVERNANCE_INDEX_DEFAULT names ONLY the files a target install actually receives + # (utils/pdda/*.sh + install.sh). PDDA-INSTALL.md itself ships to every target but also documents + # utils/pdda/pdda-sync.sh (a canonical-only tool never copied to targets, per its own "Canonical install + # set" list) — so a var like PDDA_SYNC_BACKUPS legitimately mentioned there will never resolve in a + # target install. That's expected, not drift, so a false flag must cost one ignorable line, not a + # blocked build (same calibration as the dead-reference check above). + local shipped_vars doc_vars install_sh + install_sh="$PDDA_REPO_ROOT/install.sh" + shipped_vars="$(grep -ohE 'PDDA_[A-Z0-9_]+' "$HERE"/*.sh "$install_sh" 2>/dev/null | LC_ALL=C sort -u)" + for doc in $present_docs; do + abs_file="$PDDA_REPO_ROOT/$doc" + is_shipped_doc=0 + case " $shipped_docs " in *" $doc "*) is_shipped_doc=1 ;; esac + doc_vars="$(grep -ohE 'PDDA_[A-Z0-9_]+' "$abs_file" | LC_ALL=C sort -u)" + for var in $doc_vars; do + if [ "$is_shipped_doc" -eq 1 ]; then + case " $envvar_exempt " in *" $var "*) continue ;; esac + fi + if ! printf '%s\n' "$shipped_vars" | grep -Fxq "$var"; then + line="$(grep -nF "$var" "$abs_file" | head -1 | cut -d: -f1)" + pdda_record_finding warn "$CHECK_NAME" "$abs_file" "${line:-1}" \ + "governance doc references env var '$var' which no shipped script in this install reads or sets" \ + "remove-or-implement-envvar" + fi + done + done + + pdda_emit_summary "$CHECK_NAME" "$rc" + return "$(pdda_gated_exit "$rc")" +} # ------------------------------------------------------------------------------------------------ # run — the aggregate deterministic suite, then the LLM readiness review (in order) @@ -758,8 +1048,8 @@ pdda-check-roadmap-coverage:check_roadmap_coverage pdda-check-changelog:check_changelog pdda-stale-working-docs:check_stale pdda-check-issue-doc-sync:check_issue_doc_sync -pdda-check-roadmap-issue-state:check_roadmap_issue_state pdda-check-releases:check_releases +pdda-check-governance:check_governance " cmd_run() { @@ -774,7 +1064,15 @@ cmd_run() { runner_say "PDDA run starting — mode: $MODE_NOTE" pdda_log_activity info "pdda-run" "$PDDA_REPO_ROOT" 0 "starting deterministic PDDA run (mode=$PDDA_MODE)" "start" - for entry in $PDDA_DETERMINISTIC_CHECKS; do + # Quad Concepts is opt-in and orthogonal to the mode: include its check in the suite only when the + # .pdda-quad / PDDA_QUAD lever is enabled, so a default run's output is unchanged when it's off. + local CHECKS="$PDDA_DETERMINISTIC_CHECKS" + if quad_is_enabled; then + CHECKS="$CHECKS +pdda-check-quad-concepts:check_quad_concepts" + fi + + for entry in $CHECKS; do label="${entry%%:*}" fn="${entry##*:}" runner_say "" @@ -792,9 +1090,12 @@ cmd_run() { # pdda-doc-ready.sh script also self-skips when PDDA_LLM_BIN is unset. runner_say "" runner_say "== pdda-doc-ready ==" - if [ "$EXIT_CODE" -ne 0 ]; then - runner_say "skipped pdda-doc-ready — fix the deterministic failures above first ($FAILED)" - pdda_log_activity info "pdda-doc-ready" "$PDDA_REPO_ROOT" 0 "readiness review skipped — deterministic checks failed:$FAILED" "skip" + if [ "$EXIT_CODE" -ne 0 ] || [ "$PDDA_RUN_ERRORS" -gt 0 ]; then + # Gate on the FINDINGS, not just the exit code (BUG-001b). In observe/light every check returns 0, + # so gating on EXIT_CODE alone spent an LLM call reviewing docs that never passed structural hygiene + # — the opposite of PDDA.md's "spend time only on docs that passed" rule. + runner_say "skipped pdda-doc-ready — fix the deterministic findings above first (${FAILED:-$PDDA_RUN_ERROR_CHECKS})" + pdda_log_activity info "pdda-doc-ready" "$PDDA_REPO_ROOT" 0 "readiness review skipped — deterministic checks reported errors:${FAILED:-$PDDA_RUN_ERROR_CHECKS}" "skip" elif "$HERE/pdda-doc-ready.sh"; then : else @@ -802,14 +1103,28 @@ cmd_run() { FAILED="$FAILED pdda-doc-ready" fi - if [ "$EXIT_CODE" -eq 0 ]; then - runner_say "" - runner_say "PDDA run complete: all checks passed" - pdda_log_activity info "pdda-run" "$PDDA_REPO_ROOT" 0 "PDDA run completed successfully" "finish" - else - runner_say "" + # Four outcomes, not three (GH-43, extending BUG-001b). "EXIT_CODE is 0" answers "did anything + # block?", which outside full mode is always no. PDDA_RUN_ERRORS answers "did anything go wrong?". + # Neither answers "did anything need attention?" — and that is what "all checks passed" asserts. A + # run of nothing but warns took the else branch and printed success over its own findings, which is + # how a doc could sit stale-flagged for 12 days while every run reported clean. Warns stay + # non-blocking; only the closing line changes. + runner_say "" + if [ "$EXIT_CODE" -ne 0 ]; then runner_say "PDDA run complete: failures:$FAILED" pdda_log_activity error "pdda-run" "$PDDA_REPO_ROOT" 0 "PDDA run completed with failures:$FAILED" "finish" + elif [ "$PDDA_RUN_ERRORS" -gt 0 ]; then + runner_say "PDDA run complete: $PDDA_RUN_ERRORS error(s) found, not blocking in $PDDA_MODE mode —$PDDA_RUN_ERROR_CHECKS" + runner_say "Run with PDDA_MODE=full (or .pdda-mode) once these are fixed, so they block." + pdda_log_activity error "pdda-run" "$PDDA_REPO_ROOT" 0 \ + "PDDA run reported $PDDA_RUN_ERRORS error(s) in mode=$PDDA_MODE (non-blocking):$PDDA_RUN_ERROR_CHECKS" "finish" + elif [ "$PDDA_RUN_WARNS" -gt 0 ]; then + runner_say "PDDA run complete: no errors, $PDDA_RUN_WARNS warning(s) to review —$PDDA_RUN_WARN_CHECKS" + pdda_log_activity warn "pdda-run" "$PDDA_REPO_ROOT" 0 \ + "PDDA run reported $PDDA_RUN_WARNS warning(s) and no errors:$PDDA_RUN_WARN_CHECKS" "finish" + else + runner_say "PDDA run complete: all checks passed" + pdda_log_activity info "pdda-run" "$PDDA_REPO_ROOT" 0 "PDDA run completed successfully" "finish" fi pdda_rotate_activity # keep PROJECT/PDDA-ACTIVITY.jsonl bounded @@ -818,6 +1133,38 @@ cmd_run() { return "$(pdda_gated_exit "$EXIT_CODE")" } +# ------------------------------------------------------------------------------------------------ +# glance — a read-only portfolio roll-up: title + Quad Concepts for each active plan doc, so the whole +# 2-WORKING surface's pain coverage is visible on one screen. Not gated by the lever (a manual read). +# ------------------------------------------------------------------------------------------------ +cmd_glance() { + local file rel title sec n any=0 + printf '%s\n' "PDDA glance — Quad Concepts across PROJECT/2-WORKING" + while IFS= read -r file; do + any=1 + rel="$(pdda_relpath "$file")" + title="$(pdda_trim "$(pdda_frontmatter_value "$file" "title")")" + # strip one layer of surrounding YAML quotes for a clean line (title: "X" / 'X'). A block-scalar + # title (title: > / |) would show only its indicator — titles are single-line by convention. + case "$title" in + \"*\") title="${title#\"}"; title="${title%\"}" ;; + \'*\') title="${title#\'}"; title="${title%\'}" ;; + esac + sec="$(pdda_quad_section "$file")" + n="${sec%%$'\n'*}" + printf '\n• %s — %s\n' "$rel" "${title:-(untitled)}" + if [ "$n" = "-1" ]; then + printf ' (no ## Quad Concepts)\n' + elif [ "$n" = "0" ]; then + printf ' (## Quad Concepts present but empty)\n' + else + printf '%s\n' "$sec" | sed -n '2,$p' | while IFS= read -r b; do printf ' - %s\n' "$b"; done + fi + done < <(pdda_list_working_docs) + [ "$any" -eq 1 ] || printf '\n(no active docs in PROJECT/2-WORKING)\n' + return 0 +} + # ------------------------------------------------------------------------------------------------ # dispatcher # ------------------------------------------------------------------------------------------------ @@ -831,15 +1178,17 @@ Commands: run aggregate: all deterministic checks, then the LLM readiness review (default) frontmatter active-doc frontmatter contract status-table exact two-column "## Status" table + quad-concepts opt-in: a "## Quad Concepts" section of 1-4 bullets (lever: .pdda-quad / PDDA_QUAD) + glance read-only roll-up: title + Quad Concepts for each PROJECT/2-WORKING doc hardcoded-paths no machine-specific absolute paths in working docs roadmap no execution detail leaks INTO ROADMAP.md roadmap-coverage nothing active goes MISSING from ROADMAP.md changelog end-of-iteration changelog nudge (warn-only) stale flag stale working docs (flag-only; never moves) - issue-doc-sync flag 2-WORKING/GH-*.md and 3-COMPLETED/GH-*.md docs drifted from their GitHub issue state (warn-only) - roadmap-issue-state flag ROADMAP.md ledger entries whose status marker drifted from the linked issue's real state (warn-only) - releases validate RELEASES.md, the release-planning ledger (warn-only nudge) - releases-current read-only roll-up: RELEASES.md entries whose Status isn't "Shipped" + issue-doc-sync flag 2-WORKING/GH-*.md docs drifted from their GitHub issue state (warn-only) + releases validate RELEASES.md — the release-planning ledger (warn-only nudge) + releases-current read-only roll-up: RELEASES.md entries whose Status isn't "Shipped" (rough, unvalidated) + governance repo-root governance-doc (ROUTER/AGENTS/CLAUDE/...) cross-reference + doc/code drift gh-refresh refresh the cached GitHub issue-state file issue-doc-sync reads offline (needs gh) doc-ready LLM readiness review (delegates to pdda-doc-ready.sh; opt-in via PDDA_LLM_BIN) catchup LLM repo triage and ROUTER.md recommendations (delegates to pdda-catchup.sh) @@ -856,15 +1205,17 @@ case "$cmd" in run) cmd_run; exit "$?" ;; frontmatter) check_frontmatter; exit "$?" ;; status-table) check_status_table; exit "$?" ;; + quad-concepts) check_quad_concepts; exit "$?" ;; + glance) cmd_glance; exit "$?" ;; hardcoded-paths) check_hardcoded_paths; exit "$?" ;; roadmap) check_roadmap; exit "$?" ;; roadmap-coverage) check_roadmap_coverage; exit "$?" ;; changelog) check_changelog; exit "$?" ;; stale) check_stale; exit "$?" ;; issue-doc-sync) check_issue_doc_sync; exit "$?" ;; - roadmap-issue-state) check_roadmap_issue_state; exit "$?" ;; releases) check_releases; exit "$?" ;; releases-current) cmd_releases_current; exit "$?" ;; + governance) check_governance; exit "$?" ;; gh-refresh) exec "$HERE/pdda-gh-refresh.sh" "$@" ;; doc-ready) exec "$HERE/pdda-doc-ready.sh" "$@" ;; catchup) exec "$HERE/pdda-catchup.sh" "$@" ;; diff --git a/PROJECT/PDDA.md b/PROJECT/PDDA.md index f4857a1..52a38d6 100644 --- a/PROJECT/PDDA.md +++ b/PROJECT/PDDA.md @@ -603,6 +603,9 @@ Minimum behavior: - **overdue check**: `warn` if `Target Date` has passed and `Status` doesn't read exactly `Shipped` (case-insensitive) — `Status: Shipped` is the sole "already shipped" signal; a populated `GH_URL` alone does not silence this (it means a Release object exists, not that it shipped) +- **QA-gate field check** (optional fields): `warn` if `Front-door reviewed`, `Shakedown reviewed`, + or `License file` is set but its value isn't exactly `Yes` or `No` (case-insensitive); a blank + value is fine (not yet answered) - **never blocks, even in `full` mode** — this check does not gate its exit code at all, regardless of findings. The one `error` above (empty `Release:` value) is a malformed-doc guard, surfaced loudly so it isn't missed, but deliberately cannot fail a build @@ -628,6 +631,9 @@ Target Date: 2026-07-31 Codename: n/a Description: GH_URL: +Front-door reviewed: +Shakedown reviewed: +License file: ``` Fields: @@ -644,12 +650,19 @@ Fields: `/release` skill). **This means "a Release object exists," not "shipped"** — a draft's `GH_URL` is real but the release isn't out. Flip `Status: Shipped` yourself (or let `/release` do it on an actual, non-draft publish) when it's really out; `GH_URL` alone no longer implies that. +- `Front-door reviewed:` / `Shakedown reviewed:` / `License file:` (optional) — pre-release QA-gate + checkboxes: has the `/front-door` onboarding audit run, has the `/shakedown` script-path audit + run, is a `LICENSE` file present. `Yes` or `No`; `pdda.sh releases` warns on any other non-blank + value. A blank value just means not yet answered, not a failure. Add new fields only when a real need shows up. This format intentionally started smaller than the earlier per-tag-doc convention (status lifecycle, linked marathons, linked issues, a GitHub release-tag cache) — that was more data than was practical to keep current for an initial release. `Status:` is the first field added back in, deliberately kept unvalidated (baby steps, not a new -gated lifecycle) rather than reintroducing the old rigid `Draft → RC → Published` enum. +gated lifecycle) rather than reintroducing the old rigid `Draft → RC → Published` enum. The three +QA-gate fields are the second: a real pre-release checklist need (open-sourcing a release means a +front-door pass, a shakedown pass, and a `LICENSE` file all need to be true before shipping) that, +unlike `Status`, has an unambiguous right answer — so they're validated `Yes`/`No` rather than free-text. Two skills operate on this file: `/release-plan` **authors** entries (interviews the operator, proposes a canonical version by cross-referencing `CHANGELOG.md`, previews, appends on confirmation) diff --git a/utils/pdda/PDDA-INSTALL.md b/utils/pdda/PDDA-INSTALL.md index 7f4aa35..eec85e0 100644 --- a/utils/pdda/PDDA-INSTALL.md +++ b/utils/pdda/PDDA-INSTALL.md @@ -116,6 +116,7 @@ utils/pdda/pdda-catchup.sh utils/pdda/pdda-gh-refresh.sh utils/pdda/pdda-edit-doc-hook.sh utils/pdda/pdda-stop-doc-health.sh +utils/pdda/PDDA-SOURCE.md ``` The shipped runtime lives in its own `utils/pdda/` subfolder so it never mixes with a target repo's @@ -132,7 +133,10 @@ consumers — chiefly the `Stop` hook — have last-known state without anyone r `utils/pdda/pdda-stop-doc-health.sh` (tier 2, a `Stop` consolidated full-scan) are the optional doc-health hooks — installs receive the scripts but **opt in by wiring them in their own `.claude/settings.json`** (see PROJECT/PDDA.md → "Doc-health hooks"); nothing is written into the -target's hook config automatically. +target's hook config automatically. `utils/pdda/PDDA-SOURCE.md` is a static resolver doc, not a +script — it ships with every install (and rides along into any nested vendored copy, e.g. +`.xyz/utils/pdda/`) so an agent standing in a copy can trace back to this canonical repo; see that +file for the lookup recipe. ## Files to create in the target repo diff --git a/utils/pdda/PDDA-SOURCE.md b/utils/pdda/PDDA-SOURCE.md new file mode 100644 index 0000000..d31cd3d --- /dev/null +++ b/utils/pdda/PDDA-SOURCE.md @@ -0,0 +1,56 @@ +# PDDA Source + +Every copy of this runtime ships this file — a real install, or a copy vendored inside another +vendored tool (for example `.xyz/utils/pdda/`, which arrives when a repo vendors xyz-3-agents-swarm +and that repo's own `utils/pdda/` was itself installed from here). It exists so an agent standing in +any such copy can find the canonical repo without guessing. + +## Canonical repo + +- name: `pdda` +- remote: `https://github.com/Hypercart-Dev-Tools/pdda` +- canonical paths inside it: `utils/pdda/` (this runtime), `PROJECT/PDDA.md` (the document contract) + +## Is *this* copy the canonical repo? + +Check for `pdda-sync.sh` in the same directory as this file (its canonical checkout path is +`utils/pdda/pdda-sync.sh`). It is canonical-only tooling, deliberately excluded from every install and +vendored copy (see `pdda-sync-manifest.conf`), so its presence is what distinguishes the canonical repo +from any copy of it. If `pdda-sync.sh` is sitting right here, this already is the canonical repo — stop. + +## Locating the canonical repo on this machine + +Both recipes below print a match and nothing else on failure — don't gate on the exit status: `find` +can exit non-zero from an unrelated permission-denied subtree (e.g. a Photos library) while still +printing a correct match, so check whether the output is non-empty instead of chaining `&&`. + +`-maxdepth` isn't strict POSIX but is near-universal (GNU/BSD/macOS find all ship it) and is kept +deliberately: an unbounded `find "$HOME" ...` measured 2+ minutes / hung outright on a real machine with +a large `~/Library/Mobile Documents` (iCloud Drive) tree under `$HOME` — `-maxdepth 6` cut the same +search to ~15s. A depth-bounded, slightly-non-POSIX command that actually returns beats a portable one +that hangs. + +1. Exact-name search, filtered to real canonical checkouts (skips renamed installs and copies): + + ``` + find "$HOME" -maxdepth 6 -type d -name pdda -exec sh -c 'test -f "$1/utils/pdda/pdda-sync.sh"' sh {} \; -print 2>/dev/null + ``` + +2. Fuzzy fallback if the clone folder was renamed (a bracket-expression pattern stands in for + case-insensitive `-iname`, which isn't POSIX): + + ``` + find "$HOME" -maxdepth 6 -type d -name '*[pP][dD][dD][aA]*' -exec sh -c 'test -f "$1/utils/pdda/pdda-sync.sh"' sh {} \; -print 2>/dev/null + ``` + +3. If neither finds it (different machine, CI, fresh checkout), clone it fresh: + + ``` + git clone https://github.com/Hypercart-Dev-Tools/pdda.git + ``` + +Machine-local registries such as `${XDG_CONFIG_HOME:-$HOME/.config}/xyz/registry.tsv` or +`~/git-pulse-sync/pdda/registry-*.tsv` may also have a lead, but neither is guaranteed to exist or to +name this repo specifically — treat them as a hint, not a source of truth. + +This file is metadata only; nothing in PDDA reads it at runtime. diff --git a/utils/pdda/pdda-lib.sh b/utils/pdda/pdda-lib.sh index 634e725..64615b2 100755 --- a/utils/pdda/pdda-lib.sh +++ b/utils/pdda/pdda-lib.sh @@ -427,7 +427,8 @@ pdda_write_gh_state_cache() { #
# such line or EOF (blank lines between blocks are just visual spacing, not parsed). # List releases as rows of -# +# +# # (US = ASCII unit separator 0x1F, not tab — bash's `read` collapses empty fields around literal # tabs since tab counts as "IFS whitespace" regardless of IFS's contents, which would silently # misalign every block with a blank Description/GH_URL, i.e. the common case here). One row per @@ -435,23 +436,29 @@ pdda_write_gh_state_cache() { #
# # `Status:` is free-text (Draft/Working/Shipped/... — whatever an operator writes) and unvalidated # by design: it's a rough, non-authoritative signal for "what's in progress," not a gated lifecycle -# field. See PROJECT/PDDA.md "RELEASES.md — release ledger". +# field. `Front-door reviewed:`/`Shakedown reviewed:`/`License file:` are optional Yes/No QA-gate +# fields (`pdda.sh releases` warns on a non-Yes/No value). See PROJECT/PDDA.md "RELEASES.md — +# release ledger". pdda_releases_list() { local file="$1" [ -f "$file" ] || return 0 awk ' function flush() { if (has_release) { - printf "%s\037%s\037%s\037%s\037%s\037%s\037%d\n", release, status, target_date, codename, description, gh_url, release_line + printf "%s\037%s\037%s\037%s\037%s\037%s\037%s\037%s\037%s\037%d\n", release, status, target_date, codename, description, gh_url, front_door, shakedown, license_file, release_line } - release=""; status=""; target_date=""; codename=""; description=""; gh_url=""; release_line=0; has_release=0 + release=""; status=""; target_date=""; codename=""; description=""; gh_url="" + front_door=""; shakedown=""; license_file=""; release_line=0; has_release=0 } - /^Release:/ { flush(); v=$0; sub(/^Release:[[:space:]]*/, "", v); release=v; has_release=1; release_line=NR; next } - /^Status:/ { v=$0; sub(/^Status:[[:space:]]*/, "", v); status=v; next } - /^Target Date:/ { v=$0; sub(/^Target Date:[[:space:]]*/, "", v); target_date=v; next } - /^Codename:/ { v=$0; sub(/^Codename:[[:space:]]*/, "", v); codename=v; next } - /^Description:/ { v=$0; sub(/^Description:[[:space:]]*/, "", v); description=v; next } - /^GH_URL:/ { v=$0; sub(/^GH_URL:[[:space:]]*/, "", v); gh_url=v; next } + /^Release:/ { flush(); v=$0; sub(/^Release:[[:space:]]*/, "", v); release=v; has_release=1; release_line=NR; next } + /^Status:/ { v=$0; sub(/^Status:[[:space:]]*/, "", v); status=v; next } + /^Target Date:/ { v=$0; sub(/^Target Date:[[:space:]]*/, "", v); target_date=v; next } + /^Codename:/ { v=$0; sub(/^Codename:[[:space:]]*/, "", v); codename=v; next } + /^Description:/ { v=$0; sub(/^Description:[[:space:]]*/, "", v); description=v; next } + /^GH_URL:/ { v=$0; sub(/^GH_URL:[[:space:]]*/, "", v); gh_url=v; next } + /^Front-door reviewed:/ { v=$0; sub(/^Front-door reviewed:[[:space:]]*/, "", v); front_door=v; next } + /^Shakedown reviewed:/ { v=$0; sub(/^Shakedown reviewed:[[:space:]]*/, "", v); shakedown=v; next } + /^License file:/ { v=$0; sub(/^License file:[[:space:]]*/, "", v); license_file=v; next } END { flush() } ' "$file" } diff --git a/utils/pdda/pdda.sh b/utils/pdda/pdda.sh index 9831fb6..43b7ce6 100755 --- a/utils/pdda/pdda.sh +++ b/utils/pdda/pdda.sh @@ -657,7 +657,7 @@ check_releases() { local CHECK_NAME="pdda-check-releases" rc=0 local RELEASES_FILE_EFF="${PDDA_RELEASES_FILE:-$PDDA_REPO_ROOT/RELEASES.md}" local release status target_date codename description gh_url line_no target_epoch today_epoch - local status_lc + local status_lc front_door shakedown license_file qa_field qa_label qa_value qa_value_lc if [ ! -f "$RELEASES_FILE_EFF" ]; then pdda_record_finding info "$CHECK_NAME" "$RELEASES_FILE_EFF" 0 \ @@ -666,7 +666,8 @@ check_releases() { return "$(pdda_gated_exit 0)" fi - while IFS=$'\037' read -r release status target_date codename description gh_url line_no; do + while IFS=$'\037' read -r release status target_date codename description gh_url \ + front_door shakedown license_file line_no; do if [ -z "$(pdda_trim "$release")" ]; then pdda_record_finding error "$CHECK_NAME" "$RELEASES_FILE_EFF" "$line_no" \ "a 'Release:' block near line $line_no has no version" "fix-release-value" @@ -674,6 +675,21 @@ check_releases() { continue fi + # Front-door reviewed / Shakedown reviewed / License file: optional pre-release QA-gate + # checkboxes, warn-only Yes/No like the rest of this check (see PROJECT/PDDA.md "RELEASES.md + # — release ledger"). A blank value is fine (not yet answered); only a set-but-invalid value warns. + for qa_field in "Front-door reviewed:$front_door" "Shakedown reviewed:$shakedown" "License file:$license_file"; do + qa_label="${qa_field%%:*}" + qa_value="$(pdda_trim "${qa_field#*:}")" + [ -n "$qa_value" ] || continue + qa_value_lc="$(printf '%s' "$qa_value" | tr '[:upper:]' '[:lower:]')" + case "$qa_value_lc" in + yes | no) ;; + *) pdda_record_finding warn "$CHECK_NAME" "$RELEASES_FILE_EFF" "$line_no" \ + "release '$release' $qa_label value '$qa_value' is not exactly Yes or No" "fix-release-yesno-field" ;; + esac + done + [ -n "$target_date" ] || continue if ! pdda_is_real_date "$target_date"; then @@ -716,6 +732,7 @@ check_releases() { cmd_releases_current() { local RELEASES_FILE_EFF="${PDDA_RELEASES_FILE:-$PDDA_REPO_ROOT/RELEASES.md}" local release status target_date codename description gh_url line_no status_lc any=0 + local front_door shakedown license_file if [ ! -f "$RELEASES_FILE_EFF" ]; then printf '%s not found — nothing to report\n' "$(pdda_relpath "$RELEASES_FILE_EFF")" @@ -723,7 +740,8 @@ cmd_releases_current() { fi printf 'PDDA releases-current — in-progress entries in %s\n' "$(pdda_relpath "$RELEASES_FILE_EFF")" - while IFS=$'\037' read -r release status target_date codename description gh_url line_no; do + while IFS=$'\037' read -r release status target_date codename description gh_url \ + front_door shakedown license_file line_no; do [ -n "$(pdda_trim "$release")" ] || continue status_lc="$(printf '%s' "$(pdda_trim "$status")" | tr '[:upper:]' '[:lower:]')" [ "$status_lc" != "shipped" ] || continue