From c1c10c3b4ff1de338cc22595b01d30bbc19fd2cc Mon Sep 17 00:00:00 2001 From: Dan Moisan Date: Tue, 11 Aug 2026 09:04:27 -0400 Subject: [PATCH] (chore): push down claude parallel orchestrator --- .claude/agents/parallel-orchestrator.md | 35 +- .claude/agents/parallel-planner.md | 54 ++- .claude/lib/bash/compute-cohorts.sh | 143 ++++++++ .../lib/bash/compute-concurrency-batches.sh | 122 +++++++ .claude/lib/bash/parallel-cohorts.sh | 330 +++++++++++++++++ .claude/lib/bash/parallel-common.sh | 238 ++++++++++++ .claude/lib/bash/parallel-items-validate.sh | 244 +++++++++++++ .../lib/bash/parallel-manifest-validate.sh | 187 ++++++++++ .claude/lib/bash/parallel-yaml-emit.sh | 340 ++++++++++++++++++ .claude/lib/bash/parallel-yaml-scan.sh | 335 +++++++++++++++++ .../lib/bash/validate-parallel-manifest.sh | 134 +++++++ .claude/rules/parallel-orchestration.md | 184 ++++++++++ .claude/rules/shell.md | 93 +++++ .claude/settings.json | 3 + .claude/skills/parallel-add/SKILL.md | 14 +- .claude/skills/parallel-orchestrate/SKILL.md | 37 +- .claude/skills/parallel-plan/SKILL.md | 83 +++-- config/blast-radius.json | 16 + config/orchestration-routing.json | 60 +++- 19 files changed, 2576 insertions(+), 76 deletions(-) create mode 100644 .claude/lib/bash/compute-cohorts.sh create mode 100644 .claude/lib/bash/compute-concurrency-batches.sh create mode 100644 .claude/lib/bash/parallel-cohorts.sh create mode 100644 .claude/lib/bash/parallel-common.sh create mode 100644 .claude/lib/bash/parallel-items-validate.sh create mode 100644 .claude/lib/bash/parallel-manifest-validate.sh create mode 100644 .claude/lib/bash/parallel-yaml-emit.sh create mode 100644 .claude/lib/bash/parallel-yaml-scan.sh create mode 100644 .claude/lib/bash/validate-parallel-manifest.sh create mode 100644 .claude/rules/parallel-orchestration.md create mode 100644 .claude/rules/shell.md create mode 100644 config/blast-radius.json diff --git a/.claude/agents/parallel-orchestrator.md b/.claude/agents/parallel-orchestrator.md index afc74795..80e30deb 100644 --- a/.claude/agents/parallel-orchestrator.md +++ b/.claude/agents/parallel-orchestrator.md @@ -15,6 +15,9 @@ tools: - "Bash(gh *)" - "Bash(poetry run python -c *)" - "Bash(poetry run python -m *)" + - "Bash(bash .claude/lib/bash/compute-cohorts.sh*)" + - "Bash(bash .claude/lib/bash/compute-concurrency-batches.sh*)" + - "Bash(bash .claude/lib/bash/validate-parallel-manifest.sh*)" - "mcp__drm-copilot__collect_pr_context" - "mcp__drm-copilot__validate_orchestration_artifacts" skills: @@ -66,16 +69,32 @@ frames the *who* and *when*; the skill documents the *how* in full. The manifest checkpoint schema, and the parallel enums are defined once in `.claude/rules/parallel-orchestration.md` and are consumed here, never redefined. -Two of that procedure's steps are reached through a Python interpreter rather than through a -dedicated command, so the `tools` allowlist grants exactly two invocation prefixes for them. -`scripts/dev_tools/parallel_manifest_contract.py` is an import-only library with no CLI entry point, -so the manifest gate's `validate_parallel_manifest_text` check is invoked as -`poetry run python -c`; the checkpoint-validator CLI fallback the skill names in its -`## Parallel-Level Checkpoint` section is invoked as `poetry run python -m`. Both grants are scoped +The manifest gate is reached through the destination-runtime bash entry point, which needs no +Python interpreter and is published by push-down alongside `.claude`, so the `tools` allowlist +grants one entry per command-line entry point — +`"Bash(bash .claude/lib/bash/compute-cohorts.sh*)"`, +`"Bash(bash .claude/lib/bash/compute-concurrency-batches.sh*)"`, and +`"Bash(bash .claude/lib/bash/validate-parallel-manifest.sh*)"` — the last of which covers it: + +```bash +bash .claude/lib/bash/validate-parallel-manifest.sh docs/features/parallel//parallel.md +``` + +Exit 0 accepts the manifest, exit 1 rejects it with one error per line on stdout, and exit 2 means +the file is unreadable or uses a YAML construct outside the supported subset. The same entry point's +`--print-mode` and `--print-max-concurrency` subcommands supply `mode` and `max_concurrency` with +their documented defaults. `validate_parallel_manifest_text` in +`scripts/dev_tools/parallel_manifest_contract.py` remains the repository authority and the parity +reference; it is not invoked on the destination-runtime path. Cohort recoloring and concurrency +batching use `compute-cohorts.sh` and `compute-concurrency-batches.sh` under the same allowlist +entry. + +The two `poetry run` grants remain for the repository-local paths that still need an interpreter: +the checkpoint-validator CLI fallback the skill names in its `## Parallel-Level Checkpoint` section +is invoked as `poetry run python -m`, and the drift-detection CLI likewise. Both grants stay scoped to those two invocation forms only — not to `poetry run` as a whole — so `pytest`, `black`, `ruff`, and every other `poetry run` subcommand remain outside the allowlist. The sibling persona -`.claude/agents/parallel-planner.md` records the same rationale for the same class of import-only -upstream library. +`.claude/agents/parallel-planner.md` records the same destination-runtime posture. ## Startup Protocol diff --git a/.claude/agents/parallel-planner.md b/.claude/agents/parallel-planner.md index 1cb8968e..b98c3b6e 100644 --- a/.claude/agents/parallel-planner.md +++ b/.claude/agents/parallel-planner.md @@ -14,6 +14,9 @@ tools: - "Bash(git *)" - "Bash(gh *)" - "Bash(poetry run *)" + - "Bash(bash .claude/lib/bash/compute-cohorts.sh*)" + - "Bash(bash .claude/lib/bash/compute-concurrency-batches.sh*)" + - "Bash(bash .claude/lib/bash/validate-parallel-manifest.sh*)" - "mcp__drm-copilot__validate_orchestration_artifacts" skills: - policy-compliance-order @@ -133,17 +136,48 @@ Do not report completion until: ## Upstream Library Invocation -The `"Bash(poetry run *)"` allowlist entry is retained deliberately, and its justification is -recorded here rather than left implicit. The blast-radius library -(`scripts/dev_tools/compute_blast_radius.py`) and the cohort-computation library -(`scripts/dev_tools/parallel_cohort_computation.py`) landed as import-only Python libraries with -no CLI entry point, matching the repository's `scripts/dev_tools/epic_wave_computation.py` -precedent. Radius derivation, V1-V3 validation, the contention relation, and cohort seeding are -therefore reached through a `poetry run` Python invocation, for example: +Every upstream library this planner needs is reachable from the published customization payload +alone, with no Python interpreter and no repository checkout. That is the point of the +destination-portability work in issue #462: a workspace that received `.claude` and `config` can +plan a parallel run. + +**Blast radius — PowerShell port.** Radius derivation, V1-V3 validation, and the contention +relation come from `.claude/lib/blast-radius/BlastRadius.psm1`: + +```powershell +Import-Module .claude/lib/blast-radius/BlastRadius.psm1 -Force +``` + +The facade exports `Get-PlanPaths`, `Get-BlastRadius`, `Get-BlastRadiusFromObservedPaths`, +`Test-BlastRadius`, and `Test-BlastRadiusConflict`. Its truth table is +`config/blast-radius.json`, which push-down publishes alongside `.claude`. + +**Cohort seeding and concurrency batching — bash entry points.** The bash library is granted as +three entry-point-specific allowlist entries — `"Bash(bash .claude/lib/bash/compute-cohorts.sh*)"`, +`"Bash(bash .claude/lib/bash/compute-concurrency-batches.sh*)"`, and +`"Bash(bash .claude/lib/bash/validate-parallel-manifest.sh*)"` — one per command-line entry point. +The six sourceable libraries carry no grant because they are never invoked directly. The two +commands below require the first two of those entries: ```bash -poetry run python -c "from scripts.dev_tools.compute_blast_radius import derive_blast_radius" +bash .claude/lib/bash/compute-cohorts.sh --keys " ..." --edges ": ..." +bash .claude/lib/bash/compute-concurrency-batches.sh --keys " ..." --max-concurrency ``` -That invocation form requires exactly this allowlist entry. Without it the planner cannot obtain a -declared radius or a cohort partition, and planning cannot reach a ready state. +**Manifest validation — bash entry point.** The +`"Bash(bash .claude/lib/bash/validate-parallel-manifest.sh*)"` allowlist entry covers: + +```bash +bash .claude/lib/bash/validate-parallel-manifest.sh +bash .claude/lib/bash/validate-parallel-manifest.sh --print-mode +bash .claude/lib/bash/validate-parallel-manifest.sh --print-max-concurrency +``` + +**Python modules are the repository authority, not the runtime path.** +`scripts/dev_tools/compute_blast_radius.py`, `scripts/dev_tools/parallel_cohort_computation.py`, +and `scripts/dev_tools/parallel_manifest_contract.py` remain the reference implementations that the +ported libraries are asserted against by shared fixture corpora. Do not invoke them on the +destination-runtime path; cite them for their contract. + +The `"Bash(poetry run *)"` allowlist entry is retained for the repository-local paths that still +need it — it is not required by any step above. diff --git a/.claude/lib/bash/compute-cohorts.sh b/.claude/lib/bash/compute-cohorts.sh new file mode 100644 index 00000000..54a84d0c --- /dev/null +++ b/.claude/lib/bash/compute-cohorts.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# compute-cohorts.sh: destination-portable command-line entry point for the +# parallel surface's cohort computation. It exists so a workspace that received +# the Claude customization payload can compute cohorts with nothing but bash -- +# no Python, no Poetry, no repository checkout. +# +# Usage: +# bash .claude/lib/bash/compute-cohorts.sh --keys " ..." \ +# [--edges ": : ..."] +# +# `--edges` is optional; omitting it, or passing an empty string, means the +# conflict graph has no edges. Item keys and edge endpoints are decimal +# integers matching `-?(0|[1-9][0-9]*)`; a token with a leading zero is +# rejected fail-closed with a lexical error, because the Python authority would +# read such a token differently and a silent disagreement is worse than a +# refusal. +# +# Output contract: +# stdout compact JSON array of arrays, identical to Python +# json.dumps(..., separators=(",", ":")) +# stderr on invalid input, the exact message the Python reference +# implementation raises +# exit 0 success +# exit 1 invalid input (duplicate key, self-loop, unknown endpoint) +# exit 2 usage error or a token outside the accepted integer lexis +set -euo pipefail + +# Resolve this script's own directory so the library sources regardless of cwd. +CC_SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=.claude/lib/bash/parallel-cohorts.sh +# shellcheck disable=SC1091 +source "$CC_SCRIPT_DIR/parallel-cohorts.sh" + +pc_enforce_c_locale + +cc_usage() { + # Print the entry point's usage text. + cat <<'EOF' +Usage: compute-cohorts.sh --keys " ..." [--edges ": ..."] + +Computes parallel execution cohorts from an undirected conflict graph by +deterministic greedy graph coloring in Welsh-Powell order. + +Options: + --keys Space-separated item keys (required; may be an empty string). + --edges Space-separated conflict edges as : (optional). + +Prints a compact JSON array of arrays on stdout. On invalid input, prints the +reference implementation's exact message on stderr and exits 1. +EOF +} + +cc_require_integer() { + # Validate one token against the accepted decimal-integer lexis. + # + # Args: $1 = the token, $2 = a label naming where the token came from. + # Exits 2 with a lexical error when the token is outside the lexis. + local token="$1" label="$2" + if [[ ! $token =~ ^-?(0|[1-9][0-9]*)$ ]]; then + printf 'compute-cohorts.sh: %s must be a decimal integer matching -?(0|[1-9][0-9]*); found: %s\n' \ + "$label" "$token" >&2 + exit 2 + fi +} + +cc_validate_tokens() { + # Validate every key token and every edge endpoint. + # + # Args: $1 = space-separated keys, $2 = space-separated `a:b` edges. + local keys="$1" edges="$2" token + pcoh_split_words "$keys" + local -a key_tokens=("${PCOH_WORDS[@]}") + for token in "${key_tokens[@]}"; do + cc_require_integer "$token" "item key" + done + + pcoh_split_words "$edges" + local -a edge_tokens=("${PCOH_WORDS[@]}") + # Each edge must be exactly two integer endpoints joined by a single colon; + # anything else is a malformed edge token rather than a graph error. + for token in "${edge_tokens[@]}"; do + if [[ $token != *:* || $token == *:*:* ]]; then + printf 'compute-cohorts.sh: edge must be :; found: %s\n' "$token" >&2 + exit 2 + fi + cc_require_integer "${token%%:*}" "edge endpoint" + cc_require_integer "${token#*:}" "edge endpoint" + done +} + +cc_main() { + # Parse arguments, compute the cohorts, and print the result. + local keys="" edges="" keys_seen=0 + while (($# > 0)); do + case "$1" in + --keys) + (($# >= 2)) || { + cc_usage >&2 + return 2 + } + keys="$2" + keys_seen=1 + shift 2 + ;; + --edges) + (($# >= 2)) || { + cc_usage >&2 + return 2 + } + edges="$2" + shift 2 + ;; + --help | -h) + cc_usage + return 0 + ;; + *) + cc_usage >&2 + return 2 + ;; + esac + done + ((keys_seen == 1)) || { + cc_usage >&2 + return 2 + } + + cc_validate_tokens "$keys" "$edges" + if ! pcoh_compute_cohorts "$keys" "$edges"; then + printf '%s\n' "$PCOH_ERROR" >&2 + return 1 + fi + printf '%s\n' "$PCOH_RESULT" + return 0 +} + +# Guard so the file can be sourced without executing main. main's return code +# is captured and re-exited explicitly as the final statement. +if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then + cc_rc=0 + cc_main "$@" || cc_rc=$? + exit "$cc_rc" +fi diff --git a/.claude/lib/bash/compute-concurrency-batches.sh b/.claude/lib/bash/compute-concurrency-batches.sh new file mode 100644 index 00000000..aa456265 --- /dev/null +++ b/.claude/lib/bash/compute-concurrency-batches.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# compute-concurrency-batches.sh: destination-portable command-line entry point +# for the parallel surface's concurrency batching. It exists so a workspace that +# received the Claude customization payload can cap fan-out with nothing but +# bash -- no Python, no Poetry, no repository checkout. +# +# Usage: +# bash .claude/lib/bash/compute-concurrency-batches.sh \ +# --keys " ..." --max-concurrency +# +# Item keys are decimal integers matching `-?(0|[1-9][0-9]*)`; a token with a +# leading zero is rejected fail-closed with a lexical error, because the Python +# authority would read such a token differently and a silent disagreement is +# worse than a refusal. +# +# Output contract: +# stdout compact JSON array of arrays, identical to Python +# json.dumps(..., separators=(",", ":")) +# stderr on invalid input, the exact message the Python reference +# implementation raises +# exit 0 success +# exit 1 invalid input (max_concurrency below 1) +# exit 2 usage error or a token outside the accepted integer lexis +set -euo pipefail + +# Resolve this script's own directory so the library sources regardless of cwd. +CB_SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=.claude/lib/bash/parallel-cohorts.sh +# shellcheck disable=SC1091 +source "$CB_SCRIPT_DIR/parallel-cohorts.sh" + +pc_enforce_c_locale + +cb_usage() { + # Print the entry point's usage text. + cat <<'EOF' +Usage: compute-concurrency-batches.sh --keys " ..." --max-concurrency + +Chunks one cohort's item keys into concurrency-capped batches in ascending key +order. Every batch holds exactly keys except a possibly smaller final batch. + +Options: + --keys Space-separated cohort item keys (required; may be empty). + --max-concurrency The fan-out cap (required). + +Prints a compact JSON array of arrays on stdout. When the cap is below 1, prints +the reference implementation's exact message on stderr and exits 1. +EOF +} + +cb_require_integer() { + # Validate one token against the accepted decimal-integer lexis. + # + # Args: $1 = the token, $2 = a label naming where the token came from. + # Exits 2 with a lexical error when the token is outside the lexis. + local token="$1" label="$2" + if [[ ! $token =~ ^-?(0|[1-9][0-9]*)$ ]]; then + printf 'compute-concurrency-batches.sh: %s must be a decimal integer matching -?(0|[1-9][0-9]*); found: %s\n' \ + "$label" "$token" >&2 + exit 2 + fi +} + +cb_main() { + # Parse arguments, compute the batches, and print the result. + local keys="" cap="" keys_seen=0 token + while (($# > 0)); do + case "$1" in + --keys) + (($# >= 2)) || { + cb_usage >&2 + return 2 + } + keys="$2" + keys_seen=1 + shift 2 + ;; + --max-concurrency) + (($# >= 2)) || { + cb_usage >&2 + return 2 + } + cap="$2" + shift 2 + ;; + --help | -h) + cb_usage + return 0 + ;; + *) + cb_usage >&2 + return 2 + ;; + esac + done + if ((keys_seen == 0)) || [[ -z $cap ]]; then + cb_usage >&2 + return 2 + fi + + cb_require_integer "$cap" "max_concurrency" + pcoh_split_words "$keys" + local -a key_tokens=("${PCOH_WORDS[@]}") + for token in "${key_tokens[@]}"; do + cb_require_integer "$token" "item key" + done + + if ! pcoh_compute_concurrency_batches "$keys" "$cap"; then + printf '%s\n' "$PCOH_ERROR" >&2 + return 1 + fi + printf '%s\n' "$PCOH_RESULT" + return 0 +} + +# Guard so the file can be sourced without executing main. main's return code +# is captured and re-exited explicitly as the final statement. +if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then + cb_rc=0 + cb_main "$@" || cb_rc=$? + exit "$cb_rc" +fi diff --git a/.claude/lib/bash/parallel-cohorts.sh b/.claude/lib/bash/parallel-cohorts.sh new file mode 100644 index 00000000..3ede0a55 --- /dev/null +++ b/.claude/lib/bash/parallel-cohorts.sh @@ -0,0 +1,330 @@ +#!/usr/bin/env bash +# parallel-cohorts.sh: sourceable bash port of +# scripts/dev_tools/parallel_cohort_computation.py. Partitions an item-key set +# into execution cohorts by deterministic greedy graph coloring in Welsh-Powell +# order, and chunks one cohort into concurrency-capped batches. +# +# Determinism is the objective, not optimality. Every ordering decision is made +# by an explicit numeric sort under LC_ALL=C: the Welsh-Powell visit order comes +# from `sort -k1,1nr -k2,2n` over ` ` lines, which is the +# composite key (-degree, item_key) ascending; cohort membership and batch +# contents come from `sort -n`. Associative arrays are used for adjacency +# membership, degree counting, and index assignment only, and their iteration +# order never reaches output. +# +# Error messages reproduce the Python module's four literal failure messages +# byte for byte, including the tuple repr `(a, b)` with its comma and space. +# The caller reads PCOH_ERROR after a non-zero return. +# +# The Python module remains the repository authority. +# +# shellcheck disable=SC2034 +# SC2034 is disabled file-wide because PCOH_ERROR and PCOH_RESULT are +# written here and read by the entry points compute-cohorts.sh and +# compute-concurrency-batches.sh, which shellcheck analyses separately. + +# Resolve this file's own directory so its dependencies source regardless of +# the caller's working directory. +PCOH_LIB_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=.claude/lib/bash/parallel-common.sh +# shellcheck disable=SC1091 +source "$PCOH_LIB_DIR/parallel-common.sh" + +# Adjacency membership flags keyed by `a,b`; never iterated into output. +declare -gA PCOH_ADJACENT=() + +# Distinct-neighbor degree per item key; never iterated into output. +declare -gA PCOH_DEGREE=() + +# Space-separated neighbor list per item key; never iterated into output. +declare -gA PCOH_NEIGHBORS=() + +# Assigned cohort index per item key; never iterated into output. +declare -gA PCOH_INDEX_OF=() + +# The failure message set by the most recent non-zero return. +PCOH_ERROR="" + +# The compact JSON array-of-arrays produced by the most recent success. +PCOH_RESULT="" + +# Scratch array populated by pcoh_split_words. +PCOH_WORDS=() + +pcoh_split_words() { + # Populate PCOH_WORDS by splitting a space-separated string. + # + # Args: $1 = the string to split. An empty or whitespace-only string yields + # an empty array, which is the empty-graph and empty-cohort case. `read -ra` + # is used rather than an unquoted expansion so a token is never subjected to + # pathname expansion. + PCOH_WORDS=() + read -ra PCOH_WORDS <<<"${1-}" +} + +pcoh_fail() { + # Record a failure message and return 1. + # + # Args: $1 = the complete, literal failure message. + PCOH_ERROR="$1" + return 1 +} + +pcoh_validate_item_keys() { + # Reject duplicate item keys, walking the supplied order. + # + # Duplicates break the uniqueness assumption behind the total-order sort + # key (-degree, item_key), so they are rejected rather than deduplicated. + # + # Args: $1 = space-separated item keys. + # Returns 0 when every key is distinct, 1 with PCOH_ERROR otherwise. + local key seen="" + pcoh_split_words "$1" + local -a key_items=("${PCOH_WORDS[@]}") + # Walk in the supplied order so the first repeat encountered is the key + # reported, which keeps the message stable for a given input. + for key in "${key_items[@]}"; do + if pc_contains_word "$seen" "$key"; then + pcoh_fail "Duplicate item key $key in item_keys; item keys must be unique because cohort ordering relies on key uniqueness." + return 1 + fi + seen="$seen $key" + done + return 0 +} + +pcoh_record_edge() { + # Record one validated edge on both endpoints, ignoring repeats. + # + # Storing a membership flag per ordered pair normalizes the edge list by + # construction: an edge supplied as (a, b), as (b, a), or supplied + # repeatedly collapses to the same single neighbor entry on both sides. + # + # Args: $1 = first endpoint, $2 = second endpoint. + local first="$1" second="$2" + [[ -z ${PCOH_ADJACENT["$first,$second"]-} ]] || return 0 + PCOH_ADJACENT["$first,$second"]=1 + PCOH_ADJACENT["$second,$first"]=1 + PCOH_NEIGHBORS["$first"]="${PCOH_NEIGHBORS["$first"]} $second" + PCOH_NEIGHBORS["$second"]="${PCOH_NEIGHBORS["$second"]} $first" + PCOH_DEGREE["$first"]=$((PCOH_DEGREE["$first"] + 1)) + PCOH_DEGREE["$second"]=$((PCOH_DEGREE["$second"] + 1)) +} + +pcoh_build_adjacency() { + # Build symmetric adjacency, normalizing edge direction and duplicates. + # + # Args: $1 = space-separated item keys, $2 = space-separated `a:b` edges. + # Populates PCOH_DEGREE and PCOH_NEIGHBORS. Returns 0 on success, 1 with + # PCOH_ERROR when an edge is a self-loop or names an undeclared endpoint. + local keys="$1" key edge first second + PCOH_DEGREE=() + PCOH_NEIGHBORS=() + PCOH_ADJACENT=() + pcoh_split_words "$keys" + local -a key_list=("${PCOH_WORDS[@]}") + # Seed every declared key so an isolated vertex survives into the coloring + # step; inferring vertices from the edge list alone would drop it. + for key in "${key_list[@]}"; do + PCOH_DEGREE["$key"]=0 + PCOH_NEIGHBORS["$key"]="" + done + + pcoh_split_words "${2-}" + local -a edge_list=("${PCOH_WORDS[@]}") + # Validate then record each conflict. Validation order is part of the + # contract: the self-loop check precedes the endpoint-membership check, and + # the first endpoint is checked before the second. + for edge in "${edge_list[@]}"; do + first="${edge%%:*}" + second="${edge#*:}" + if [[ $first == "$second" ]]; then + pcoh_fail "Self-loop edge on item key $first; the conflict relation is defined over distinct items, so an item cannot conflict with itself." + return 1 + fi + if ! pc_contains_word "$keys" "$first"; then + pcoh_fail "Conflict edge ($first, $second) names item key $first, which is not a member of item_keys; every edge endpoint must be a declared item key." + return 1 + fi + if ! pc_contains_word "$keys" "$second"; then + pcoh_fail "Conflict edge ($first, $second) names item key $second, which is not a member of item_keys; every edge endpoint must be a declared item key." + return 1 + fi + pcoh_record_edge "$first" "$second" + done + return 0 +} + +pcoh_welsh_powell_order() { + # Echo the item keys in Welsh-Powell visit order, one per line. + # + # The composite key (-degree, item_key) is a total order because item keys + # are unique. That is the single load-bearing determinism guard: the visit + # order depends on the graph alone, never on the caller's input order. The + # ordering is delegated to sort so no bash-side comparison logic can drift + # from the Python sort key. + # + # Args: $1 = space-separated item keys. + local key + pcoh_split_words "$1" + local -a key_items=("${PCOH_WORDS[@]}") + for key in "${key_items[@]}"; do + printf '%s %s\n' "${PCOH_DEGREE["$key"]}" "$key" + done | LC_ALL=C sort -k1,1nr -k2,2n | cut -d' ' -f2 +} + +pcoh_lowest_free_index() { + # Echo the smallest cohort index not held by any assigned neighbor. + # + # Args: $1 = the item key being placed. + local key="$1" neighbor taken="" candidate=0 + pcoh_split_words "${PCOH_NEIGHBORS["$key"]}" + local -a neighbors=("${PCOH_WORDS[@]}") + # Collecting neighbor indices into a membership string is order + # insensitive, so reading the neighbor list here cannot affect the outcome. + for neighbor in "${neighbors[@]}"; do + if [[ -n ${PCOH_INDEX_OF["$neighbor"]-} ]]; then + taken="$taken ${PCOH_INDEX_OF["$neighbor"]}" + fi + done + # Scan upward from zero for the first free index. An isolated vertex and + # the first-visited vertex both land in cohort 0. + while pc_contains_word "$taken" "$candidate"; do + candidate=$((candidate + 1)) + done + printf '%s' "$candidate" +} + +pcoh_assign_cohort_indices() { + # Assign each vertex the lowest cohort index free among its neighbors. + # + # Args: $1 = space-separated item keys. Populates PCOH_INDEX_OF. A vertex + # never takes an index already held by a neighbor, so each index class is + # an independent set of the conflict graph. + local ordered key + PCOH_INDEX_OF=() + ordered=$(pcoh_welsh_powell_order "$1") + [[ -n $ordered ]] || return 0 + # Visit vertices in Welsh-Powell order; each takes the smallest index its + # already-assigned neighbors have not taken. + while IFS= read -r key; do + [[ -n $key ]] || continue + PCOH_INDEX_OF["$key"]=$(pcoh_lowest_free_index "$key") + done <<<"$ordered" + return 0 +} + +pcoh_render_cohorts() { + # Echo the compact JSON array-of-arrays for the current assignment. + # + # Args: $1 = space-separated item keys. List position is the cohort index + # and each inner list holds that cohort's keys sorted ascending. + local key index highest=-1 ordered + pcoh_split_words "$1" + local -a key_items=("${PCOH_WORDS[@]}") + for key in "${key_items[@]}"; do + index="${PCOH_INDEX_OF["$key"]}" + ((index > highest)) && highest=$index + done + if ((highest < 0)); then + printf '[]' + return 0 + fi + + local -a buckets=() + for ((index = 0; index <= highest; index++)); do + buckets+=("") + done + ordered=$(printf '%s\n' "${key_items[@]}" | LC_ALL=C sort -n) + # Walk the ascending key order rather than any hash order, so each cohort's + # keys come out ascending regardless of how the caller ordered its input. + while IFS= read -r key; do + [[ -n $key ]] || continue + index="${PCOH_INDEX_OF["$key"]}" + if [[ -z ${buckets[index]} ]]; then + buckets[index]="$key" + else + buckets[index]="${buckets[index]},$key" + fi + done <<<"$ordered" + + local rendered="" + for ((index = 0; index <= highest; index++)); do + [[ -z $rendered ]] || rendered="$rendered," + rendered="${rendered}[${buckets[index]}]" + done + printf '[%s]' "$rendered" +} + +pcoh_compute_cohorts() { + # Partition item keys into cohorts by deterministic greedy graph coloring. + # + # Args: $1 = space-separated item keys, $2 = space-separated `a:b` edges. + # On success returns 0 with PCOH_RESULT holding the compact JSON output. + # On malformed input returns 1 with PCOH_ERROR holding the exact Python + # message. All validation runs before any coloring work. + local keys="$1" edges="${2-}" + PCOH_ERROR="" + PCOH_RESULT="" + pcoh_validate_item_keys "$keys" || return 1 + pcoh_build_adjacency "$keys" "$edges" || return 1 + pcoh_assign_cohort_indices "$keys" + PCOH_RESULT=$(pcoh_render_cohorts "$keys") + return 0 +} + +pcoh_compute_concurrency_batches() { + # Chunk one cohort into concurrency-capped batches in ascending key order. + # + # The cohort's keys are sorted inside this function rather than trusting + # the caller's ordering, so determinism does not depend on caller + # discipline. Every batch is exactly max_concurrency long except a possibly + # smaller final batch. + # + # Args: $1 = space-separated cohort item keys, $2 = the fan-out cap. + # On success returns 0 with PCOH_RESULT holding the compact JSON output; + # on a cap below 1 returns 1 with PCOH_ERROR holding the exact message. + local cap="$2" key sorted + PCOH_ERROR="" + PCOH_RESULT="" + # A cap below 1 would admit no items into any batch, so the cohort could + # never drain; reject it rather than returning an unusable schedule. + if ((cap < 1)); then + pcoh_fail "max_concurrency must be >= 1; received $cap." + return 1 + fi + + pcoh_split_words "$1" + local -a supplied=("${PCOH_WORDS[@]}") + local -a ordered=() + if ((${#supplied[@]} > 0)); then + sorted=$(printf '%s\n' "${supplied[@]}" | LC_ALL=C sort -n) + while IFS= read -r key; do + [[ -n $key ]] || continue + ordered+=("$key") + done <<<"$sorted" + fi + + local total=${#ordered[@]} + if ((total == 0)); then + PCOH_RESULT="[]" + return 0 + fi + + local start end position rendered="" batch + # Walk the sorted keys in fixed-size strides so slot filling follows + # ascending key order and the batch boundaries are reproducible. + for ((start = 0; start < total; start += cap)); do + end=$((start + cap)) + ((end > total)) && end=$total + batch="" + for ((position = start; position < end; position++)); do + [[ -z $batch ]] || batch="$batch," + batch="$batch${ordered[position]}" + done + [[ -z $rendered ]] || rendered="$rendered," + rendered="${rendered}[$batch]" + done + PCOH_RESULT="[$rendered]" + return 0 +} diff --git a/.claude/lib/bash/parallel-common.sh b/.claude/lib/bash/parallel-common.sh new file mode 100644 index 00000000..019c14eb --- /dev/null +++ b/.claude/lib/bash/parallel-common.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env bash +# parallel-common.sh: sourceable bash port of the shared predicates, enum +# vocabularies, and error-string builders that back the parallel-surface +# validators. This is the bash counterpart of +# scripts/dev_tools/_parallel_state_common.py; the Python module remains the +# repository authority and this file must reproduce its output byte for byte. +# +# Scope. Pure string and arithmetic helpers only: nothing here reads a file, +# starts a process, reads the clock, or mutates global state other than the +# PC_ERRORS accumulator its own reset/add functions own. The enum tuples are +# consumed from .claude/rules/parallel-orchestration.md and are never extended +# here; member order is load-bearing because pc_enum_error renders it. +# +# Locale. Every entry point that sources this file calls pc_enforce_c_locale +# before doing any work, so sorting and character classification are byte +# ordered and independent of the destination workspace's environment. +# +# shellcheck disable=SC2034 +# SC2034 is disabled file-wide because every constant below is consumed by a +# sibling file of this library -- parallel-items-validate.sh, +# parallel-manifest-validate.sh, and parallel-cohorts.sh -- which shellcheck +# analyses separately and therefore cannot see the use. + +# Item lifecycle states, in canonical order (schema S4). +PC_VALID_ITEM_STATES="proposed, admitted, prepared, scheduled, in_flight, merged, withdrawn, blocked" + +# Per-item merge lifecycle, in canonical order (schema S4). +PC_VALID_MERGE_STATUS="not_started, worktree_created, pr_open, ci_green, merged, worktree_removed, blocked_drift, blocked_ci_loop_limit" + +# Blast-radius confidence sources, in canonical order (schema S4). +PC_VALID_SOURCES="derived, declared, observed" + +# Work-item kinds carried by the manifest (schema S4). +PC_VALID_KINDS="feature, bug" + +# Run modes; closed is the documented default (schema S4). +PC_VALID_MODES="closed, open" + +# Merge-status values meaning the item reached a terminal merged outcome. +PC_MERGED_MERGE_STATUSES="merged worktree_removed" + +# Merge-status values meaning the item is blocked. +PC_BLOCKED_MERGE_STATUSES="blocked_drift blocked_ci_loop_limit" + +# The four blast_radius collection fields, in serialization order. +PC_BLAST_RADIUS_LIST_FIELDS="paths modules shared_surfaces contracts" + +# Path label for the document root in prohibited-key error strings. +PC_ROOT_PATH="" + +# Accumulated validation errors, in emission order. +PC_ERRORS=() + +pc_enforce_c_locale() { + # Pin the byte-ordered C locale for the remainder of the process. + # + # Determinism countermeasure R9: every numeric sort, glob expansion, and + # character class in this library must behave identically regardless of the + # destination workspace's locale settings. + export LC_ALL=C +} + +pc_errors_reset() { + # Discard any accumulated errors so a fresh validation starts empty. + PC_ERRORS=() +} + +pc_error_add() { + # Append one error string to the accumulator. + # + # Args: $1 = the complete error string, already fully rendered. + PC_ERRORS+=("$1") +} + +pc_errors_count() { + # Echo the number of accumulated errors. + printf '%s' "${#PC_ERRORS[@]}" +} + +pc_errors_print() { + # Print every accumulated error, one per line, in emission order. + # + # Prints nothing for an empty accumulator, which is the valid-artifact case. + ((${#PC_ERRORS[@]} > 0)) || return 0 + local entry + # Emit in accumulation order: message sequence is part of the parity + # contract, so the accumulator is never sorted or de-duplicated. + for entry in "${PC_ERRORS[@]}"; do + printf '%s\n' "$entry" + done +} + +pc_repr_string() { + # Echo the Python repr of a string, including Python's quote selection. + # + # Python prefers single quotes and switches to double quotes only when the + # string contains a single quote and no double quote. Backslashes are + # escaped first so a later escape's own backslash is not doubled, then the + # three printable control escapes, then the active quote character. + # + # Args: $1 = the raw string value. + local value="$1" + local quote="'" + # Quote selection: only a string carrying an apostrophe but no double quote + # switches Python to double quotes. + if [[ $value == *"'"* && $value != *'"'* ]]; then + quote='"' + fi + local body=${value//\\/\\\\} + body=${body//$'\n'/\\n} + body=${body//$'\r'/\\r} + body=${body//$'\t'/\\t} + # Escape whichever quote is active. Under double quoting the escape is a + # no-op by the selection rule above; under single quoting it fires only for + # a string that carries both quote characters. + if [[ $quote == "'" ]]; then + body=${body//\'/\\\'} + else + body=${body//\"/\\\"} + fi + printf '%s%s%s' "$quote" "$body" "$quote" +} + +pc_repr() { + # Echo the Python repr of a lexically typed value. + # + # Args: $1 = type tag (absent, null, bool, int, float, str), $2 = raw value. + # An absent key reads as None, matching Python's mapping.get default. + local type="$1" + local value="${2-}" + # Routing table: the five scalar types the manifest subset can carry, plus + # the absent-key case that Python renders as None. + case "$type" in + absent | null) + printf 'None' + ;; + bool) + if [[ $value == true ]]; then + printf 'True' + else + printf 'False' + fi + ;; + int | float) + printf '%s' "$value" + ;; + str) + pc_repr_string "$value" + ;; + *) + # A container reaching repr would be a caller defect; render the tag so + # the failure is visible rather than silently producing a bare value. + printf '<%s>' "$type" + ;; + esac +} + +pc_is_non_empty_string() { + # Return 0 when a typed value is a string carrying a non-space character. + # + # Args: $1 = type tag, $2 = raw value. Mirrors is_non_empty_string. + [[ $1 == str ]] || return 1 + local stripped="${2-}" + # A left strip is sufficient: a value that is entirely whitespace becomes + # empty, which is exactly what Python's two-sided strip would report. + stripped=${stripped#"${stripped%%[![:space:]]*}"} + [[ -n $stripped ]] +} + +pc_is_integer() { + # Return 0 when a typed value is a genuine integer rather than a boolean. + # + # Args: $1 = type tag. The parser classifies booleans as `bool`, never as + # `int`, so the boolean exclusion that Python needs is structural here. + [[ $1 == int ]] +} + +pc_is_positive_integer() { + # Return 0 when a typed value is an integer greater than zero. + # + # Args: $1 = type tag, $2 = raw value. + [[ $1 == int ]] || return 1 + local number="$2" + ((number > 0)) +} + +pc_is_non_negative_integer() { + # Return 0 when a typed value is an integer of zero or more. + # + # Args: $1 = type tag, $2 = raw value. + [[ $1 == int ]] || return 1 + local number="$2" + ((number >= 0)) +} + +pc_in_bounded_range() { + # Return 0 when a typed value is an integer inside an inclusive range. + # + # Args: $1 = type tag, $2 = raw value, $3 = minimum, $4 = maximum. + [[ $1 == int ]] || return 1 + local number="$2" minimum="$3" maximum="$4" + ((number >= minimum && number <= maximum)) +} + +pc_enum_error() { + # Echo the standard out-of-enum error string for a field. + # + # Args: $1 = context prefix, $2 = dotted field name, $3 = comma-and-space + # joined member list in canonical order, $4 = the already-rendered repr of + # the offending value. Mirrors enum_error so the wording cannot drift. + printf '%s %s must be one of %s; found: %s.' "$1" "$2" "$3" "$4" +} + +pc_item_context() { + # Echo the context prefix for one items[] entry. + # + # Args: $1 = surface prefix, $2 = zero-based entry index. The positional + # index is used rather than issue_num because it exists for every entry, + # including one whose issue_num is missing. + printf '%s items[%s]' "$1" "$2" +} + +pc_contains_word() { + # Return 0 when a space-separated list contains an exact word. + # + # Args: $1 = space-separated haystack, $2 = needle. Used for the enum + # membership tests whose member lists are stored space separated. + local haystack=" $1 " + [[ $haystack == *" $2 "* ]] +} + +pc_enum_members_contains() { + # Return 0 when a comma-and-space joined enum list contains an exact member. + # + # Args: $1 = the rendered member list, $2 = candidate member. + local haystack="${1//, / }" + pc_contains_word "$haystack" "$2" +} diff --git a/.claude/lib/bash/parallel-items-validate.sh b/.claude/lib/bash/parallel-items-validate.sh new file mode 100644 index 00000000..4be70889 --- /dev/null +++ b/.claude/lib/bash/parallel-items-validate.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env bash +# parallel-items-validate.sh: sourceable bash port of the shared work-item +# validators in scripts/dev_tools/_parallel_state_common.py -- validate_items, +# validate_item_record, _validate_merge_status, and validate_blast_radius_block. +# It reads the node table populated by parallel-yaml-emit.sh and appends errors +# to the PC_ERRORS accumulator owned by parallel-common.sh. +# +# Message order is part of the contract, not an implementation detail. Within +# one item the order is issue_num, feature_folder, state, kind (when required), +# merge_status, then the blast-radius block in field order. Across items the +# order is positional, and every duplicate issue_num is reported after the +# whole per-entry pass, in ascending key order, so the sequence is reproducible +# regardless of the order the entries appeared in. +# +# The Python module remains the repository authority; this file must reproduce +# its output byte for byte for every fixture in tests/fixtures/parallel_manifest_bash. + +# Resolve this file's own directory so its dependencies source regardless of +# the caller's working directory. +PI_LIB_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=.claude/lib/bash/parallel-common.sh +# shellcheck disable=SC1091 +source "$PI_LIB_DIR/parallel-common.sh" +# shellcheck source=.claude/lib/bash/parallel-yaml-emit.sh +# shellcheck disable=SC1091 +source "$PI_LIB_DIR/parallel-yaml-emit.sh" + +pi_repr_at() { + # Echo the Python repr of the value stored at a node path. + # + # Args: $1 = node path. An unknown path renders as None, matching the + # Python code's mapping.get default. + local path="$1" type value + type=$(yp_type_of "$path") + value=$(yp_value_of "$path") + pc_repr "$type" "$value" +} + +pi_is_string_list() { + # Return 0 when a node is a sequence whose every entry is a non-empty string. + # + # An empty sequence satisfies the predicate: every blast-radius collection + # other than paths is legitimately empty. Mirrors is_string_list. + # + # Args: $1 = node path. + local path="$1" + [[ $(yp_type_of "$path") == seq ]] || return 1 + local count index entry_type entry_value + count=$(yp_count_of "$path") + # A single blank entry fails the whole list, because it would silently + # widen a radius and under-report contention. + for ((index = 0; index < count; index++)); do + entry_type=$(yp_type_of "${path}[${index}]") + entry_value=$(yp_value_of "${path}[${index}]") + pc_is_non_empty_string "$entry_type" "$entry_value" || return 1 + done + return 0 +} + +pi_validate_blast_radius_block() { + # Validate one blast_radius block against spec invariant 9. + # + # Args: $1 = node path of the block, $2 = item-scoped context prefix. + local path="$1" context="$2" + if [[ $(yp_type_of "$path") != map ]]; then + pc_error_add "$context blast_radius must be an object." + return 0 + fi + local field + local -a radius_fields=() + read -ra radius_fields <<<"$PC_BLAST_RADIUS_LIST_FIELDS" + # Report every malformed collection field rather than stopping at the + # first, so one validation pass tells the author everything to fix. + for field in "${radius_fields[@]}"; do + if ! pi_is_string_list "${path}.${field}"; then + pc_error_add "$context blast_radius.$field must be a list of non-empty strings." + fi + done + + local source_type source_value + source_type=$(yp_type_of "${path}.source") + source_value=$(yp_value_of "${path}.source") + if [[ $source_type != str ]] || ! pc_enum_members_contains "$PC_VALID_SOURCES" "$source_value"; then + pc_error_add "$(pc_enum_error "$context" "blast_radius.source" "$PC_VALID_SOURCES" "$(pi_repr_at "${path}.source")")" + fi + + local computed_type computed_value + computed_type=$(yp_type_of "${path}.computed_at") + computed_value=$(yp_value_of "${path}.computed_at") + if ! pc_is_non_empty_string "$computed_type" "$computed_value"; then + pc_error_add "$context blast_radius.computed_at must be a non-empty string." + fi +} + +pi_validate_merge_status() { + # Validate merge_status membership and its agreement with item state. + # + # Absence is the backward-compatible case: an item with no merge_status is + # treated as not_started and yields no error, so the check is presence + # gated rather than requirement gated. + # + # Args: $1 = item node path, $2 = item-scoped context, $3 = state repr, + # $4 = raw state value. + local path="$1" context="$2" state_repr="$3" state_value="$4" + yp_has "${path}.merge_status" || return 0 + local status_type status_value + status_type=$(yp_type_of "${path}.merge_status") + status_value=$(yp_value_of "${path}.merge_status") + # An out-of-enum value short-circuits the consistency rule, because + # agreement with state is meaningless for a value that is not a status. + if [[ $status_type != str ]] || ! pc_enum_members_contains "$PC_VALID_MERGE_STATUS" "$status_value"; then + pc_error_add "$(pc_enum_error "$context" "merge_status" "$PC_VALID_MERGE_STATUS" "$(pi_repr_at "${path}.merge_status")")" + return 0 + fi + + # Invariant 8 pins the two terminal families to their item states; every + # other status places no constraint on state. + if pc_contains_word "$PC_MERGED_MERGE_STATUSES" "$status_value" && [[ $state_value != merged ]]; then + pc_error_add "$context merge_status $(pc_repr_string "$status_value") requires state 'merged'; found: $state_repr." + return 0 + fi + if pc_contains_word "$PC_BLOCKED_MERGE_STATUSES" "$status_value" && [[ $state_value != blocked ]]; then + pc_error_add "$context merge_status $(pc_repr_string "$status_value") requires state 'blocked'; found: $state_repr." + fi +} + +pi_validate_item_record() { + # Validate one work-item record against spec invariants 5 through 9. + # + # Args: $1 = item node path, $2 = item-scoped context prefix, $3 = 1 when + # kind is required (the manifest and planner surfaces carry it). + local path="$1" context="$2" require_kind="$3" + if [[ $(yp_type_of "$path") != map ]]; then + pc_error_add "$context must be an object." + return 0 + fi + + local issue_type issue_value + issue_type=$(yp_type_of "${path}.issue_num") + issue_value=$(yp_value_of "${path}.issue_num") + if ! pc_is_positive_integer "$issue_type" "$issue_value"; then + pc_error_add "$context issue_num must be a positive integer; found: $(pi_repr_at "${path}.issue_num")." + fi + + local folder_type folder_value + folder_type=$(yp_type_of "${path}.feature_folder") + folder_value=$(yp_value_of "${path}.feature_folder") + if ! pc_is_non_empty_string "$folder_type" "$folder_value"; then + pc_error_add "$context feature_folder must be a non-empty string." + fi + + local state_type state_value state_repr + state_type=$(yp_type_of "${path}.state") + state_value=$(yp_value_of "${path}.state") + state_repr=$(pi_repr_at "${path}.state") + if [[ $state_type != str ]] || ! pc_enum_members_contains "$PC_VALID_ITEM_STATES" "$state_value"; then + pc_error_add "$(pc_enum_error "$context" "state" "$PC_VALID_ITEM_STATES" "$state_repr")" + fi + + if ((require_kind == 1)); then + local kind_type kind_value + kind_type=$(yp_type_of "${path}.kind") + kind_value=$(yp_value_of "${path}.kind") + if [[ $kind_type != str ]] || ! pc_enum_members_contains "$PC_VALID_KINDS" "$kind_value"; then + pc_error_add "$(pc_enum_error "$context" "kind" "$PC_VALID_KINDS" "$(pi_repr_at "${path}.kind")")" + fi + fi + + pi_validate_merge_status "$path" "$context" "$state_repr" "$state_value" + pi_validate_blast_radius_block "${path}.blast_radius" "$context" +} + +pi_validate_items() { + # Validate the items collection, including issue_num uniqueness. + # + # Args: $1 = node path of the items collection, $2 = surface prefix, + # $3 = 1 when kind is required on every entry. + local path="$1" context="$2" require_kind="$3" + if [[ $(yp_type_of "$path") != seq ]]; then + pc_error_add "$context items must be a list." + return 0 + fi + + local count index entry_path item_ctx issue_type issue_value + local seen="" + local -a duplicates=() + count=$(yp_count_of "$path") + # Validate each entry in place, and in the same pass accumulate the primary + # keys so uniqueness is decided without a second traversal. + for ((index = 0; index < count; index++)); do + entry_path="${path}[${index}]" + item_ctx=$(pc_item_context "$context" "$index") + pi_validate_item_record "$entry_path" "$item_ctx" "$require_kind" + [[ $(yp_type_of "$entry_path") == map ]] || continue + issue_type=$(yp_type_of "${entry_path}.issue_num") + issue_value=$(yp_value_of "${entry_path}.issue_num") + [[ $issue_type == int ]] || continue + if pc_contains_word "$seen" "$issue_value"; then + duplicates+=("$issue_value") + fi + seen="$seen $issue_value" + done + + ((${#duplicates[@]} > 0)) || return 0 + # Report duplicates in ascending numeric order, de-duplicated, so a key + # repeated three times still yields exactly one message. + local ordered duplicate + ordered=$(printf '%s\n' "${duplicates[@]}" | sort -n -u) + while IFS= read -r duplicate; do + [[ -n $duplicate ]] || continue + pc_error_add "$context has duplicate items[].issue_num: $duplicate." + done <<<"$ordered" +} + +pi_scan_prohibited_keys() { + # Reject prohibited keys per manifest invariant M7. + # + # Args: $1 = surface prefix, $2 = space-separated keys rejected at any + # nesting level, $3 = space-separated keys rejected at the document root + # only. Deep results come first, in document order, then the top-level + # results in the order the third argument lists them. + local context="$1" deep_keys="$2" top_keys="$3" + local index path key total=${#YP_ORDER[@]} + # Walk the node table in document order so the message sequence reproduces + # Python's depth-first, insertion-ordered traversal exactly. + for ((index = 0; index < total; index++)); do + path="${YP_ORDER[index]}" + key="${YP_KEY[$path]-}" + [[ -n $key ]] || continue + if pc_contains_word "$deep_keys" "$key"; then + pc_error_add "$context carries prohibited key '$key' at ${YP_PARENT[$path]}." + fi + done + + # The shallow pass exists because manifest M7 bans integration_branch at + # the top level only, where a nested occurrence is legitimate child data. + local -a top_key_list=() + read -ra top_key_list <<<"$top_keys" + for key in "${top_key_list[@]}"; do + if yp_has "$key"; then + pc_error_add "$context carries prohibited key '$key' at $PC_ROOT_PATH." + fi + done +} diff --git a/.claude/lib/bash/parallel-manifest-validate.sh b/.claude/lib/bash/parallel-manifest-validate.sh new file mode 100644 index 00000000..8b5a4ceb --- /dev/null +++ b/.claude/lib/bash/parallel-manifest-validate.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# parallel-manifest-validate.sh: sourceable bash port of +# scripts/dev_tools/parallel_manifest_contract.py. Validates a parallel-run +# manifest document against invariants M1 through M7 and exposes the two +# default-resolving accessors that every consumer uses instead of reading +# `mode` and `max_concurrency` directly. +# +# Section order is load-bearing and mirrors the Python module exactly: the M1 +# frontmatter checks short-circuit with a single error; then run identity +# (M2 parallel, M3 mode, M4 max_concurrency, M5 created_at) in schema field +# order; then the M7 prohibited-key scan, deep results in document order +# followed by the top-level results; then the M6 items collection. +# +# Every error string begins with the literal prefix `Parallel manifest` and +# ends with a period. The Python module remains the repository authority. +# +# Out-of-subset input. When the YAML scanner refuses a construct it does not +# model, this module returns exit status 2 with the refusal detail in +# PM_SUBSET_DETAIL and emits no validation errors. Refusing to answer is +# deliberate: a guessed parse could disagree with the Python authority +# silently, whereas an explicit refusal is visible to the caller. +# +# shellcheck disable=SC2034 +# SC2034 is disabled file-wide because PM_SUBSET_DETAIL is written here and +# read by the entry point validate-parallel-manifest.sh, which shellcheck +# analyses as a separate file and therefore cannot see the use. + +# Resolve this file's own directory so its dependencies source regardless of +# the caller's working directory. +PM_LIB_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=.claude/lib/bash/parallel-items-validate.sh +# shellcheck disable=SC1091 +source "$PM_LIB_DIR/parallel-items-validate.sh" + +# Literal context prefix for every error this module and its helpers emit. +PM_CONTEXT="Parallel manifest" + +# Documented default run mode when the manifest omits `mode` (invariant M3). +PM_DEFAULT_MODE="closed" + +# Documented default fan-out when the manifest omits `max_concurrency` (M4). +PM_DEFAULT_MAX_CONCURRENCY=4 + +# Inclusive bounds on a present `max_concurrency` (invariant M4, A7). +PM_MIN_CONCURRENCY=1 +PM_MAX_CONCURRENCY=8 + +# Keys the manifest rejects at any nesting level (invariant M7). +PM_DEEP_PROHIBITED_KEYS="depends_on" + +# Keys the manifest rejects at the document root only (invariant M7). +PM_TOP_LEVEL_PROHIBITED_KEYS="integration_branch" + +# Detail recorded when the parser refuses an out-of-subset construct. +PM_SUBSET_DETAIL="" + +pm_parse_manifest() { + # Parse manifest text into the shared node table (invariant M1). + # + # Args: $1 = raw manifest document text. + # Returns 0 when the frontmatter parsed into a mapping. Returns 1 after + # appending the single M1 error to PC_ERRORS for a missing, unterminated, + # unparseable, or non-mapping block. Returns 2 without appending anything + # when the document uses a construct outside the supported subset. + local text="$1" + if ! yp_extract_frontmatter_body "$text" "$PM_CONTEXT"; then + pc_error_add "$YP_FENCE_ERROR" + return 1 + fi + if yp_parse_body; then + return 0 + fi + # Routing table for the three parser failure modes: a sequence or empty + # root is M1's non-mapping case, a malformed line is M1's YAML-error case, + # and an unsupported construct makes the module refuse to answer. + case "$YP_STATUS" in + not_a_mapping) + pc_error_add "$PM_CONTEXT frontmatter must be a mapping." + return 1 + ;; + yaml_error) + pc_error_add "$PM_CONTEXT frontmatter is not valid YAML: $YP_DETAIL." + return 1 + ;; + *) + PM_SUBSET_DETAIL="$YP_DETAIL" + return 2 + ;; + esac +} + +pm_validate_identity() { + # Validate run identity fields against invariants M2 through M5. + # + # The mode and max_concurrency checks are presence gated because both keys + # are optional: absence is the documented authoring shape that the + # accessors resolve, so it must contribute no error. + local slug_type slug_value + slug_type=$(yp_type_of "parallel") + slug_value=$(yp_value_of "parallel") + if ! pc_is_non_empty_string "$slug_type" "$slug_value"; then + pc_error_add "$PM_CONTEXT parallel must be a non-empty string." + fi + + if yp_has "mode"; then + local mode_type mode_value + mode_type=$(yp_type_of "mode") + mode_value=$(yp_value_of "mode") + if [[ $mode_type != str ]] || ! pc_enum_members_contains "$PC_VALID_MODES" "$mode_value"; then + pc_error_add "$(pc_enum_error "$PM_CONTEXT" "mode" "$PC_VALID_MODES" "$(pi_repr_at "mode")")" + fi + fi + + if yp_has "max_concurrency"; then + local cap_type cap_value + cap_type=$(yp_type_of "max_concurrency") + cap_value=$(yp_value_of "max_concurrency") + if ! pc_in_bounded_range "$cap_type" "$cap_value" "$PM_MIN_CONCURRENCY" "$PM_MAX_CONCURRENCY"; then + pc_error_add "$PM_CONTEXT max_concurrency must be an integer from $PM_MIN_CONCURRENCY through $PM_MAX_CONCURRENCY; found: $(pi_repr_at "max_concurrency")." + fi + fi + + local created_type created_value + created_type=$(yp_type_of "created_at") + created_value=$(yp_value_of "created_at") + if ! pc_is_non_empty_string "$created_type" "$created_value"; then + pc_error_add "$PM_CONTEXT created_at must be a non-empty string." + fi +} + +pm_validate_text() { + # Validate a manifest document against invariants M1 to M7. + # + # Args: $1 = raw manifest document text, authored with LF, CRLF, or CR + # line endings. + # Returns 0 after populating PC_ERRORS with the complete error list, which + # is empty for a valid manifest. Returns 2 without populating PC_ERRORS + # when the document uses a construct outside the supported subset. + local text="$1" parse_status=0 + pc_errors_reset + PM_SUBSET_DETAIL="" + pm_parse_manifest "$text" || parse_status=$? + # An out-of-subset refusal propagates so the caller can distinguish it from + # a validation verdict; an M1 failure returns the single-element error list + # already accumulated, because no field check is meaningful without a + # mapping to read fields from. + if ((parse_status == 2)); then + return 2 + fi + if ((parse_status != 0)); then + return 0 + fi + + pm_validate_identity + pi_scan_prohibited_keys "$PM_CONTEXT" "$PM_DEEP_PROHIBITED_KEYS" "$PM_TOP_LEVEL_PROHIBITED_KEYS" + # The manifest carries `kind` on every item, unlike the orchestrator + # checkpoint, so the shared item validator is asked to require it (S1). + pi_validate_items "items" "$PM_CONTEXT" 1 + return 0 +} + +pm_manifest_mode() { + # Echo the run mode declared by the parsed manifest (invariant M3). + # + # A present but non-string value resolves to the default so this accessor + # always returns a mode; the malformed value is reported separately by + # pm_validate_text. + if [[ $(yp_type_of "mode") == str ]]; then + yp_value_of "mode" + else + printf '%s' "$PM_DEFAULT_MODE" + fi +} + +pm_manifest_max_concurrency() { + # Echo the fan-out cap declared by the parsed manifest (invariant M4). + # + # A present but non-integer value resolves to the default so this accessor + # always returns an integer; the malformed value is reported separately by + # pm_validate_text. Booleans are classified as `bool` by the scanner and + # therefore never satisfy the integer test. + if [[ $(yp_type_of "max_concurrency") == int ]]; then + yp_value_of "max_concurrency" + else + printf '%s' "$PM_DEFAULT_MAX_CONCURRENCY" + fi +} diff --git a/.claude/lib/bash/parallel-yaml-emit.sh b/.claude/lib/bash/parallel-yaml-emit.sh new file mode 100644 index 00000000..25b00c02 --- /dev/null +++ b/.claude/lib/bash/parallel-yaml-emit.sh @@ -0,0 +1,340 @@ +#!/usr/bin/env bash +# parallel-yaml-emit.sh: structural half of the restricted block-YAML parser. +# Walks the indentation of the frontmatter body lines produced by +# parallel-yaml-scan.sh and populates that module's node table in document +# order. Sourcing this file also sources the scanning half, so a consumer needs +# only this one source line. +# +# Algorithm. A stack of open containers is kept, each carrying the indent +# column its children sit at, its rendered path, whether it is a mapping or a +# sequence, and how many children it has taken. A `key:` line with no inline +# value opens a pending node whose kind is not yet known: the next content line +# decides it, becoming a mapping or sequence when it is indented deeper, and +# leaving the pending node a null scalar when it is not. That one-line lookahead +# is the whole of the parser's ambiguity handling. +# +# Failure modes are the two the scanning half defines. A malformed line sets +# YP_STATUS=yaml_error and is rendered by the caller through the M1 message; a +# construct that is valid YAML but outside the subset sets +# YP_STATUS=out_of_subset and makes the caller refuse to answer rather than +# risk disagreeing with the Python authority. +# +# shellcheck disable=SC2034 +# SC2034 is disabled file-wide because the node-table and status globals this +# module writes -- YP_TYPE, YP_COUNT, YP_STATUS -- are declared in +# parallel-yaml-scan.sh and read by parallel-items-validate.sh and +# parallel-manifest-validate.sh, which shellcheck analyses as separate files. + +# Resolve this file's own directory so the scanning half sources regardless of +# the caller's working directory. +YP_EMIT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=.claude/lib/bash/parallel-yaml-scan.sh +# shellcheck disable=SC1091 +source "$YP_EMIT_DIR/parallel-yaml-scan.sh" + +# Path label for the document root in key-scoped errors. +YP_ROOT_LABEL="" + +# Open-container stack, parallel arrays indexed by depth. +YP_STACK_INDENT=() +YP_STACK_PATH=() +YP_STACK_KIND=() +YP_STACK_COUNT=() + +# The pending node opened by a `key:` or bare `-` line, if any. +YP_PENDING_PATH="" +YP_PENDING_INDENT=-1 +YP_PENDING_ACTIVE=0 + +yp_stack_reset() { + # Clear the container stack and the pending-node slot. + YP_STACK_INDENT=() + YP_STACK_PATH=() + YP_STACK_KIND=() + YP_STACK_COUNT=() + YP_PENDING_PATH="" + YP_PENDING_INDENT=-1 + YP_PENDING_ACTIVE=0 +} + +yp_stack_push() { + # Push one open container onto the stack. + # + # Args: $1 = indent column of the container's children, $2 = rendered path, + # $3 = kind (map or seq). + YP_STACK_INDENT+=("$1") + YP_STACK_PATH+=("$2") + YP_STACK_KIND+=("$3") + YP_STACK_COUNT+=(0) +} + +yp_stack_depth() { + # Echo the number of open containers. + printf '%s' "${#YP_STACK_INDENT[@]}" +} + +yp_stack_pop() { + # Discard the innermost open container by truncating every stack array. + local keep=$((${#YP_STACK_INDENT[@]} - 1)) + ((keep >= 0)) || return 0 + YP_STACK_INDENT=("${YP_STACK_INDENT[@]:0:keep}") + YP_STACK_PATH=("${YP_STACK_PATH[@]:0:keep}") + YP_STACK_KIND=("${YP_STACK_KIND[@]:0:keep}") + YP_STACK_COUNT=("${YP_STACK_COUNT[@]:0:keep}") +} + +yp_stack_bump() { + # Increment the innermost container's child count and mirror it into the + # node table so yp_count_of reports sequence lengths and key counts. + local last=$((${#YP_STACK_INDENT[@]} - 1)) + ((last >= 0)) || return 0 + YP_STACK_COUNT[last]=$((YP_STACK_COUNT[last] + 1)) + local path="${YP_STACK_PATH[last]}" + if [[ -n $path ]]; then + YP_COUNT["$path"]=${YP_STACK_COUNT[last]} + fi +} + +yp_render_child_path() { + # Echo the path of a mapping key inside a parent container. + # + # Args: $1 = parent path (empty at the document root), $2 = key name. + if [[ -z $1 ]]; then + printf '%s' "$2" + else + printf '%s.%s' "$1" "$2" + fi +} + +yp_render_parent_label() { + # Echo the path label Python uses for the mapping that owns a key. + # + # Args: $1 = parent path. The document root renders as `` so + # prohibited-key errors reproduce the Python wording exactly. + if [[ -z $1 ]]; then + printf '%s' "$YP_ROOT_LABEL" + else + printf '%s' "$1" + fi +} + +yp_open_pending() { + # Mark a node as awaiting the next line to decide its kind. + # + # Args: $1 = node path, $2 = indent column of the line that opened it. + YP_PENDING_PATH="$1" + YP_PENDING_INDENT="$2" + YP_PENDING_ACTIVE=1 +} + +yp_resolve_pending() { + # Decide the kind of a pending node using the next content line. + # + # Args: $1 = indent of the next content line, $2 = 1 when that line is a + # sequence entry. Returns 0 always; the pending slot is cleared. + local indent="$1" is_seq="$2" + ((YP_PENDING_ACTIVE == 1)) || return 0 + # A deeper line, or a sequence entry at the same column, opens a container; + # anything else means the key carried no value and reads as null. + if ((indent > YP_PENDING_INDENT)) || ((indent == YP_PENDING_INDENT && is_seq == 1)); then + local kind="map" + ((is_seq == 1)) && kind="seq" + YP_TYPE["$YP_PENDING_PATH"]="$kind" + YP_COUNT["$YP_PENDING_PATH"]=0 + yp_stack_push "$indent" "$YP_PENDING_PATH" "$kind" + else + YP_TYPE["$YP_PENDING_PATH"]="null" + fi + YP_PENDING_ACTIVE=0 + YP_PENDING_PATH="" + YP_PENDING_INDENT=-1 +} + +yp_emit_map_entry() { + # Parse one `key: value` or `key:` line into the node table. + # + # Args: $1 = owning mapping path (empty at the root), $2 = line content + # with indentation stripped, $3 = indent column of the line. + # Returns 0 on success, 1 with YP_STATUS set on a malformed or refused line. + local parent="$1" content="$2" indent="$3" + local key rest + if [[ $content == *": "* ]]; then + key=${content%%": "*} + rest=$(yp_trim "${content#*": "}") + elif [[ $content == *":" ]]; then + key=${content%":"} + rest="" + else + yp_yaml_error "line is neither a mapping entry nor a sequence entry: $content" + return 1 + fi + # Keys in a machine-authored manifest are bare identifiers; a quoted or + # otherwise exotic key is refused rather than guessed at. + if [[ ! $key =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then + yp_reject "mapping keys must be bare identifiers; found: $key" + return 1 + fi + local path parent_label + path=$(yp_render_child_path "$parent" "$key") + parent_label=$(yp_render_parent_label "$parent") + + # An inline empty flow collection is a complete container; an empty value + # defers the decision to the next line; anything else is a scalar. + if [[ $rest == "[]" ]]; then + yp_node_add "$path" "seq" "" "$parent_label" "$key" + elif [[ $rest == "{}" ]]; then + yp_node_add "$path" "map" "" "$parent_label" "$key" + elif [[ -z $rest && $content == *":" ]]; then + yp_node_add "$path" "null" "" "$parent_label" "$key" + yp_open_pending "$path" "$indent" + else + yp_classify_scalar "$rest" || return 1 + yp_node_add "$path" "$YP_SCALAR_TYPE" "$YP_SCALAR_VALUE" "$parent_label" "$key" + fi + yp_stack_bump + return 0 +} + +yp_emit_seq_entry() { + # Parse one `- ...` line into the node table. + # + # Args: $1 = owning sequence path, $2 = the sequence index to use, $3 = the + # text after the dash with surrounding whitespace trimmed, $4 = indent + # column of the dash. Returns 0 on success, 1 with YP_STATUS set otherwise. + local parent="$1" index="$2" rest="$3" indent="$4" + local path="${parent}[${index}]" + + # A bare dash defers to the next line; a `key: value` payload opens a + # compact mapping whose entries sit two columns right of the dash; anything + # else is a scalar element. + if [[ -z $rest ]]; then + yp_node_add "$path" "null" "" "" "" + yp_open_pending "$path" "$indent" + return 0 + fi + if [[ $rest == *": "* || $rest == *":" ]]; then + yp_node_add "$path" "map" "" "" "" + yp_stack_push $((indent + 2)) "$path" "map" + yp_emit_map_entry "$path" "$rest" $((indent + 2)) || return 1 + return 0 + fi + yp_classify_scalar "$rest" || return 1 + yp_node_add "$path" "$YP_SCALAR_TYPE" "$YP_SCALAR_VALUE" "" "" + return 0 +} + +yp_scan_line_guards() { + # Reject the line-level constructs the subset does not model. + # + # Args: $1 = the raw line, $2 = the line with indentation stripped. + # Returns 0 when the line may be parsed, 1 with YP_STATUS set otherwise. + local line="$1" content="$2" + if [[ $line == *$'\t'* ]]; then + yp_yaml_error "tab characters are not permitted in YAML indentation" + return 1 + fi + if [[ $content == *' #'* ]]; then + yp_reject "trailing comments are outside the subset: $content" + return 1 + fi + if [[ $content == '---' || $content == '...' ]]; then + yp_reject "multi-document streams are outside the subset" + return 1 + fi + return 0 +} + +yp_close_deeper_containers() { + # Pop every open container whose children sit right of the given column. + # + # Args: $1 = the current line's indent column. + local indent="$1" top + # Unwinding stops at the first container whose child column is at or left + # of this line, which is the container the line belongs to. + while ((${#YP_STACK_INDENT[@]} > 0)); do + top=$((${#YP_STACK_INDENT[@]} - 1)) + ((indent < YP_STACK_INDENT[top])) || break + yp_stack_pop + done +} + +yp_parse_body() { + # Parse YP_BODY_LINES into the node table. + # + # Returns 0 when YP_STATUS is ok. Sets YP_STATUS to not_a_mapping when the + # body is empty or its root is a sequence, and to yaml_error or + # out_of_subset for the failure modes the module header documents. + yp_reset + yp_stack_reset + local root_seen=0 + local total=${#YP_BODY_LINES[@]} + local index line content indent is_seq rest depth seq_index + + # Walk every body line, resolving the pending node from the previous line + # before deciding what the current line contributes. + for ((index = 0; index < total; index++)); do + line="${YP_BODY_LINES[index]}" + [[ -n $(yp_trim "$line") ]] || continue + content=${line#"${line%%[![:space:]]*}"} + [[ $content == '#'* ]] && continue + yp_scan_line_guards "$line" "$content" || return 1 + indent=$((${#line} - ${#content})) + is_seq=0 + [[ $content == "-" || $content == "- "* ]] && is_seq=1 + + yp_resolve_pending "$indent" "$is_seq" + + # The first content line fixes the document's root kind; a sequence + # root is reported as not-a-mapping rather than parsed. + if ((root_seen == 0)); then + root_seen=1 + if ((is_seq == 1)); then + YP_STATUS="not_a_mapping" + return 1 + fi + ((${#YP_STACK_INDENT[@]} > 0)) || yp_stack_push "$indent" "" "map" + fi + + yp_close_deeper_containers "$indent" + if ((${#YP_STACK_INDENT[@]} == 0)); then + yp_yaml_error "indentation closes the document root: $content" + return 1 + fi + depth=$((${#YP_STACK_INDENT[@]} - 1)) + if ((indent != YP_STACK_INDENT[depth])); then + yp_yaml_error "inconsistent indentation at column $indent: $content" + return 1 + fi + + # Route the line against the container it belongs to; a mismatch means + # the document mixes a sequence entry into a mapping or the reverse. + if ((is_seq == 1)); then + if [[ ${YP_STACK_KIND[depth]} != seq ]]; then + yp_yaml_error "sequence entry inside a mapping: $content" + return 1 + fi + rest=$(yp_trim "${content#-}") + seq_index="${YP_STACK_COUNT[depth]}" + yp_stack_bump + yp_emit_seq_entry "${YP_STACK_PATH[depth]}" "$seq_index" "$rest" "$indent" || return 1 + else + if [[ ${YP_STACK_KIND[depth]} != map ]]; then + yp_yaml_error "mapping entry inside a sequence: $content" + return 1 + fi + yp_emit_map_entry "${YP_STACK_PATH[depth]}" "$content" "$indent" || return 1 + fi + done + + # A pending node left open at end of input carried no value. + if ((YP_PENDING_ACTIVE == 1)); then + YP_TYPE["$YP_PENDING_PATH"]="null" + YP_PENDING_ACTIVE=0 + fi + if ((root_seen == 0)); then + YP_STATUS="not_a_mapping" + return 1 + fi + YP_STATUS="ok" + return 0 +} diff --git a/.claude/lib/bash/parallel-yaml-scan.sh b/.claude/lib/bash/parallel-yaml-scan.sh new file mode 100644 index 00000000..4e5feffa --- /dev/null +++ b/.claude/lib/bash/parallel-yaml-scan.sh @@ -0,0 +1,335 @@ +#!/usr/bin/env bash +# parallel-yaml-scan.sh: scanning half of the hand-written parser for the +# restricted block-YAML subset that a machine-authored parallel-run manifest is +# written in. It owns line splitting, frontmatter fence extraction, scalar +# lexing, and the node-table storage and accessors; the structural half that +# walks indentation and populates the table lives in parallel-yaml-emit.sh, +# which sources this file. The pair exists so the manifest validator can run in +# a destination workspace that has no Python, no yq, and no Node; it is +# deliberately NOT a general YAML implementation and fails closed on every +# construct outside the subset. +# +# The module is split across two files because the combined implementation +# exceeds the 500-line ceiling in .claude/rules/general-code-change.md. +# +# Accepted subset: +# - block mappings `key: value` and `key:` followed by an indented block, +# - block sequences `- scalar`, `- key: value` (compact mapping), and `-` +# followed by an indented block, +# - two-space indentation steps, spaces only, +# - empty flow collections `[]` and `{}`, +# - scalars: null (`~`, `null`, empty), the YAML 1.1 boolean words, decimal +# integers matching `[-+]?(0|[1-9][0-9]*)`, single-quoted and double-quoted +# strings without escape sequences, and plain strings, +# - full-line `#` comments and blank lines. +# +# Rejected fail-closed (YP_STATUS=out_of_subset): non-empty flow collections, +# anchors, aliases, tags, block scalars (`|`, `>`), floats, unquoted dates and +# timestamps, `.inf`/`.nan`, escape sequences inside double-quoted scalars, +# trailing comments, and multi-document streams. Rejecting these is the point: +# silently mis-parsing one would produce a validator verdict that disagrees with +# the Python authority, which is worse than refusing to answer. +# +# Reported as a YAML error (YP_STATUS=yaml_error): unterminated quotes, tab +# indentation, misaligned indentation, and lines that are neither a mapping +# entry nor a sequence entry. The manifest validator renders these through the +# M1 "frontmatter is not valid YAML" message, whose text is a declared +# divergence class scoped to its prefix. +# +# Output model. A successful parse populates a node table addressed by path: +# `parallel`, `items`, `items[0]`, `items[0].blast_radius.paths[1]`. Document +# order is preserved in the indexed array YP_ORDER; the associative arrays are +# used for lookup only and are never iterated, so no hash order reaches output. +# +# shellcheck disable=SC2034 +# SC2034 is disabled file-wide because this module is the storage half of a +# two-file library: the node table and the parse-status globals declared below +# are written here and read by parallel-yaml-emit.sh, parallel-items-validate.sh, +# and parallel-manifest-validate.sh, which shellcheck analyses as separate +# files and therefore cannot see the use. + +# Node paths in document order. Every ordered traversal walks this array. +YP_ORDER=() + +# Lexical type per path: map, seq, null, bool, int, float, or str. +declare -gA YP_TYPE=() + +# Decoded scalar value per path; empty string for container nodes. +declare -gA YP_VALUE=() + +# Mapping-key name per path, for nodes that came from a `key:` entry. +declare -gA YP_KEY=() + +# Rendered path of the mapping that owns each key node; `` at the top. +declare -gA YP_PARENT=() + +# Child count per container path: sequence length or mapping key count. +declare -gA YP_COUNT=() + +# Parse outcome: ok, not_a_mapping, yaml_error, or out_of_subset. +YP_STATUS="ok" + +# Human-readable detail for a yaml_error or out_of_subset outcome. +YP_DETAIL="" + +# Source lines produced by yp_split_lines. +YP_LINES=() + +# Frontmatter body lines produced by yp_extract_frontmatter_body. +YP_BODY_LINES=() + +# The single M1 error produced by a failed fence extraction. +YP_FENCE_ERROR="" + +# Scratch outputs of yp_classify_scalar. +YP_SCALAR_TYPE="" +YP_SCALAR_VALUE="" + +# The fence line that opens and closes a frontmatter block. +YP_FENCE="---" + +yp_trim() { + # Echo a string with leading and trailing whitespace removed. + # + # Args: $1 = the raw string. Mirrors Python's str.strip() for the ASCII + # whitespace the manifest subset can contain. + local value="$1" + value=${value#"${value%%[![:space:]]*}"} + value=${value%"${value##*[![:space:]]}"} + printf '%s' "$value" +} + +yp_split_lines() { + # Populate YP_LINES by splitting text on any of the three line terminators. + # + # The CRLF replacement runs first so a carriage-return/line-feed pair is + # consumed as one terminator rather than as a CR followed by an empty line, + # matching the ordered alternation in the Python module's regex. + # + # Args: $1 = raw document text. + local text="$1" + text=${text//$'\r\n'/$'\n'} + text=${text//$'\r'/$'\n'} + YP_LINES=() + local line + # A here-string appends one newline, so a text that already ends in a + # terminator yields the same trailing empty element Python's split does. + while IFS= read -r line; do + YP_LINES+=("$line") + done <<<"$text" +} + +yp_extract_frontmatter_body() { + # Populate YP_BODY_LINES with the leading frontmatter block (invariant M1). + # + # Args: $1 = raw document text. + # Returns 0 on success. On failure returns 1 and sets YP_FENCE_ERROR to the + # single M1 error string, using the caller-supplied context prefix in $2. + local text="$1" context="$2" + yp_split_lines "$text" + YP_BODY_LINES=() + YP_FENCE_ERROR="" + if ((${#YP_LINES[@]} == 0)) || [[ $(yp_trim "${YP_LINES[0]}") != "$YP_FENCE" ]]; then + YP_FENCE_ERROR="$context must open with a '$YP_FENCE' frontmatter fence." + return 1 + fi + local index + # Scan forward for the first closing fence: everything between the two + # fences is the body, and no second fence means the block is unterminated. + for ((index = 1; index < ${#YP_LINES[@]}; index++)); do + if [[ $(yp_trim "${YP_LINES[index]}") == "$YP_FENCE" ]]; then + local body_index + for ((body_index = 1; body_index < index; body_index++)); do + YP_BODY_LINES+=("${YP_LINES[body_index]}") + done + return 0 + fi + done + YP_FENCE_ERROR="$context frontmatter block is not terminated by '$YP_FENCE'." + return 1 +} + +yp_reject() { + # Record an out-of-subset rejection and return 1. + # + # Args: $1 = detail describing the refused construct. + YP_STATUS="out_of_subset" + YP_DETAIL="$1" + return 1 +} + +yp_yaml_error() { + # Record a YAML-level parse failure and return 1. + # + # Args: $1 = detail describing the malformed input. + YP_STATUS="yaml_error" + YP_DETAIL="$1" + return 1 +} + +yp_classify_scalar() { + # Classify one scalar token, setting YP_SCALAR_TYPE and YP_SCALAR_VALUE. + # + # Args: $1 = the raw token with surrounding whitespace already trimmed. + # Returns 0 when the token is inside the subset, 1 otherwise, with + # YP_STATUS and YP_DETAIL set by yp_reject or yp_yaml_error. + local raw="$1" + YP_SCALAR_TYPE="" + YP_SCALAR_VALUE="" + + # Quoted forms are decided first: a quote character makes every later + # lexical rule inapplicable, and an unterminated quote is a YAML error + # rather than an out-of-subset construct. + if [[ $raw == '"'* ]]; then + if [[ ${#raw} -lt 2 || $raw != *'"' ]]; then + yp_yaml_error "unterminated double-quoted scalar: $raw" + return 1 + fi + local body=${raw:1:${#raw}-2} + local backslash=$'\\' + if [[ $body == *"$backslash"* || $body == *'"'* ]]; then + yp_reject "escape sequences inside double-quoted scalars are outside the subset" + return 1 + fi + YP_SCALAR_TYPE="str" + YP_SCALAR_VALUE="$body" + return 0 + fi + if [[ $raw == "'"* ]]; then + if [[ ${#raw} -lt 2 || $raw != *"'" ]]; then + yp_yaml_error "unterminated single-quoted scalar: $raw" + return 1 + fi + local single_body=${raw:1:${#raw}-2} + YP_SCALAR_TYPE="str" + YP_SCALAR_VALUE="${single_body//\'\'/\'}" + return 0 + fi + + # Structural sigils that introduce constructs this parser does not model. + case "$raw" in + '&'* | '*'* | '!'* | '|'* | '>'* | '?'* | '%'* | '@'* | '\`'*) + yp_reject "value begins with the unsupported YAML indicator: $raw" + return 1 + ;; + '[]' | '{}') ;; + '['* | '{'*) + yp_reject "non-empty flow collections are outside the subset: $raw" + return 1 + ;; + esac + + # Null, then boolean, then integer: the resolver order PyYAML applies to a + # plain scalar, restricted to the members the manifest subset admits. + case "$raw" in + '' | '~' | null | Null | NULL) + YP_SCALAR_TYPE="null" + YP_SCALAR_VALUE="" + return 0 + ;; + true | True | TRUE | yes | Yes | YES | on | On | ON) + YP_SCALAR_TYPE="bool" + YP_SCALAR_VALUE="true" + return 0 + ;; + false | False | FALSE | no | No | NO | off | Off | OFF) + YP_SCALAR_TYPE="bool" + YP_SCALAR_VALUE="false" + return 0 + ;; + .inf | .Inf | .INF | -.inf | -.Inf | -.INF | .nan | .NaN | .NAN) + yp_reject "special float values are outside the subset: $raw" + return 1 + ;; + esac + + if [[ $raw =~ ^[-+]?(0|[1-9][0-9]*)$ ]]; then + YP_SCALAR_TYPE="int" + YP_SCALAR_VALUE="${raw#+}" + return 0 + fi + + # A token that looks numeric but is not a clean decimal integer is refused + # rather than silently demoted to a string, because PyYAML would resolve + # many of these to int, float, or datetime. + if [[ $raw =~ ^[-+]?[0-9][0-9_]*$ ]] || + [[ $raw =~ ^[-+]?0[xXoObB] ]] || + [[ $raw =~ ^[-+]?[0-9]*\.[0-9]* ]] || + [[ $raw =~ ^[-+]?[0-9]+[eE][-+]?[0-9]+$ ]] || + [[ $raw =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2} ]]; then + yp_reject "numeric, float, or timestamp scalar outside the subset: $raw" + return 1 + fi + + YP_SCALAR_TYPE="str" + YP_SCALAR_VALUE="$raw" + return 0 +} + +yp_node_add() { + # Register one node in the table and append it to the document order. + # + # Args: $1 = path, $2 = type, $3 = value, $4 = owning-map path or empty, + # $5 = mapping key name or empty. + local path="$1" type="$2" value="$3" parent="$4" key="$5" + YP_ORDER+=("$path") + YP_TYPE["$path"]="$type" + YP_VALUE["$path"]="$value" + if [[ -n $key ]]; then + YP_KEY["$path"]="$key" + YP_PARENT["$path"]="$parent" + fi + if [[ $type == map || $type == seq ]]; then + YP_COUNT["$path"]=0 + fi +} + +yp_reset() { + # Clear the node table so a fresh parse starts from an empty state. + YP_ORDER=() + YP_TYPE=() + YP_VALUE=() + YP_KEY=() + YP_PARENT=() + YP_COUNT=() + YP_STATUS="ok" + YP_DETAIL="" +} + +yp_has() { + # Return 0 when a path exists in the node table. + # + # Args: $1 = path. + [[ -n ${YP_TYPE["$1"]+set} ]] +} + +yp_type_of() { + # Echo the lexical type at a path, or `absent` when the path is unknown. + # + # Args: $1 = path. + if yp_has "$1"; then + printf '%s' "${YP_TYPE["$1"]}" + else + printf 'absent' + fi +} + +yp_value_of() { + # Echo the decoded scalar value at a path, or the empty string when absent. + # + # Args: $1 = path. + if yp_has "$1"; then + printf '%s' "${YP_VALUE["$1"]}" + fi +} + +yp_count_of() { + # Echo the child count at a container path, or 0 when the path is absent. + # + # Args: $1 = path. + if [[ -n ${YP_COUNT["$1"]+set} ]]; then + printf '%s' "${YP_COUNT["$1"]}" + else + printf '0' + fi +} diff --git a/.claude/lib/bash/validate-parallel-manifest.sh b/.claude/lib/bash/validate-parallel-manifest.sh new file mode 100644 index 00000000..d4ea095e --- /dev/null +++ b/.claude/lib/bash/validate-parallel-manifest.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# validate-parallel-manifest.sh: destination-portable command-line entry point +# for parallel-run manifest validation and the two default-resolving accessors. +# It exists so a workspace that received the Claude customization payload can +# validate a manifest with nothing but bash -- no Python, no Poetry, no yq, and +# no repository checkout. +# +# Usage: +# bash .claude/lib/bash/validate-parallel-manifest.sh +# bash .claude/lib/bash/validate-parallel-manifest.sh --print-mode +# bash .claude/lib/bash/validate-parallel-manifest.sh --print-max-concurrency +# +# Output contract: +# stdout validation errors one per line (empty for a valid manifest), or the +# resolved accessor value under --print-mode / --print-max-concurrency +# exit 0 the manifest is valid, or the accessor resolved +# exit 1 the manifest is invalid +# exit 2 usage error, unreadable manifest, or a YAML construct outside the +# supported subset +# +# The accessors resolve the documented defaults -- `closed` and `4` -- when the +# manifest omits the key or carries a malformed value, matching +# manifest_mode and manifest_max_concurrency in the Python authority. +set -euo pipefail + +# Resolve this script's own directory so the library sources regardless of cwd. +VM_SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source=.claude/lib/bash/parallel-manifest-validate.sh +# shellcheck disable=SC1091 +source "$VM_SCRIPT_DIR/parallel-manifest-validate.sh" + +pc_enforce_c_locale + +vm_usage() { + # Print the entry point's usage text. + cat <<'EOF' +Usage: validate-parallel-manifest.sh [--print-mode | --print-max-concurrency] + +Validates a parallel-run manifest against invariants M1 through M7 and prints +one error per line on stdout; a valid manifest prints nothing and exits 0. + +Options: + --print-mode Print the resolved run mode (default: closed). + --print-max-concurrency Print the resolved fan-out cap (default: 4). +EOF +} + +vm_require_readable() { + # Return 0 when the manifest path names a readable file. + # + # Args: $1 = manifest path. A missing manifest is an operator error rather + # than a validation verdict, so the caller exits 2 instead of reporting it + # as a manifest defect. + local path="$1" + if [[ ! -f $path || ! -r $path ]]; then + printf 'validate-parallel-manifest.sh: manifest not found or not readable: %s\n' "$path" >&2 + return 1 + fi + return 0 +} + +vm_report_subset_refusal() { + # Print the out-of-subset refusal and exit 2. + # + # Refusing to answer is deliberate: a guessed parse could disagree with the + # Python authority silently, whereas an explicit refusal is visible. + printf 'validate-parallel-manifest.sh: manifest uses a YAML construct outside the supported subset: %s\n' \ + "$PM_SUBSET_DETAIL" >&2 + exit 2 +} + +vm_main() { + # Dispatch on the optional accessor flag and run the requested operation. + local mode="validate" path="" status=0 text + # Routing table: the two accessor flags each consume the following path + # argument; anything else is treated as the manifest path itself. + case "${1-}" in + --print-mode) + mode="print-mode" + path="${2-}" + ;; + --print-max-concurrency) + mode="print-max-concurrency" + path="${2-}" + ;; + --help | -h) + vm_usage + return 0 + ;; + -*) + vm_usage >&2 + return 2 + ;; + *) + path="${1-}" + ;; + esac + [[ -n $path ]] || { + vm_usage >&2 + return 2 + } + + vm_require_readable "$path" || return 2 + text=$(cat -- "$path") + pm_validate_text "$text" || status=$? + if ((status == 2)); then + vm_report_subset_refusal + fi + + # The accessors read the parsed node table, which pm_validate_text has + # already populated, so a malformed-but-parseable manifest still resolves. + if [[ $mode == "print-mode" ]]; then + pm_manifest_mode + printf '\n' + return 0 + fi + if [[ $mode == "print-max-concurrency" ]]; then + pm_manifest_max_concurrency + printf '\n' + return 0 + fi + + pc_errors_print + (($(pc_errors_count) == 0)) || return 1 + return 0 +} + +# Guard so the file can be sourced without executing main. main's return code +# is captured and re-exited explicitly as the final statement. +if [[ ${BASH_SOURCE[0]} == "${0}" ]]; then + vm_rc=0 + vm_main "$@" || vm_rc=$? + exit "$vm_rc" +fi diff --git a/.claude/rules/parallel-orchestration.md b/.claude/rules/parallel-orchestration.md new file mode 100644 index 00000000..7063d2da --- /dev/null +++ b/.claude/rules/parallel-orchestration.md @@ -0,0 +1,184 @@ +# Parallel Orchestration Artifact Invariants + +This rule governs the three artifacts of the `parallel` orchestration surface: the parallel-run manifest at `docs/features/parallel//parallel.md`, the parallel-orchestrator checkpoint at `artifacts/orchestration/parallel-orchestrator-state.json`, and the parallel-planner checkpoint at `artifacts/orchestration/parallel-planner-state.json`. It records the invariants those artifacts must satisfy as numbered prose so that downstream features consume a fixed schema and add behavior only. + +The `parallel` surface schedules thematically unrelated items concurrently by computed blast-radius contention rather than by a human-authored dependency graph. There is no `depends_on` field anywhere, `issue_num` is the primary key for every item reference, and there is no integration branch: each item opens its own pull request against `main`. + +## Foreign Schema Warning (do not copy verbatim) + +The prohibition recorded in `.claude/rules/orchestrator-state.md` is restated here for the parallel artifacts. A hardened snapshot from another repository contains a JSON Schema for the orchestrator-state artifact whose `$id` references a foreign origin (`drmoisan.github.io/mix-calculator/`). That schema MUST NOT be copied verbatim into this repository, and it MUST NOT be adapted into a parallel-artifact schema: its `$id`, its top-level required-field set, and its cycle-level `additionalProperties: false` do not match this repository's checkpoint contract. + +No JSON Schema file is authored, imported, or read for the parallel manifest or for either parallel checkpoint. The invariants above are expressed as prose in this file and enforced by validator logic. A schema whose `$id` is repo-local is not the disqualified foreign artifact, but the repository's enforcement mechanism remains prose-and-validator-logic regardless. + +## Scope and Backward Compatibility + +These invariants apply only to the three parallel artifacts named above. They do not apply to, and do not change, the epic artifacts (`artifacts/orchestration/epic-orchestrator-state.json`, `artifacts/orchestration/epic-planner-state.json`) or the standard orchestrator-state checkpoint governed by `.claude/rules/orchestrator-state.md`. The parallel validators are additive: the existing epic validators, their helper modules, their TypeScript cores, and their tests are unmodified. + +Every validator named below returns a list of error strings, never mutates its input, returns a single-element list for text that is not parseable, and rejects a non-object (non-mapping) root. Error strings use the literal context-prefixed style with the prefixes `Parallel checkpoint`, `Parallel planner checkpoint`, and `Parallel manifest`. + +## Invariants (parallel orchestrator checkpoint) + +Enforced by `validate_parallel_orchestrator_state_text(text, *, require_complete=False)` in `scripts/dev_tools/validate_parallel_orchestrator_state.py`. Invariants 1 through 19 are enforced unconditionally; invariants 20 and 21 are enforced only under `require_complete`. + +1. **Required keys.** The checkpoint must carry `objective`, `completed_steps`, `next_step`, `last_updated`, `route_id`, `parallel_slug`, `parallel_manifest_path`, `parallel_status_doc_path`, `mode`, `max_concurrency`, `current_cohort`, `recolor_generation`, `cohorts`, `items`, `conflict_edges`, `mutations`, and `drift_events`. One error is emitted per missing key. + +2. **Route identity.** `route_id` must be exactly `'parallel'`. + +3. **Mode enum.** `mode` must be `closed` or `open`. + +4. **Bounded concurrency.** `max_concurrency` must be an integer from 1 through 8, and must not be a boolean. + +5. **Item uniqueness and shape.** Each `items[]` entry must be an object whose `issue_num` is a positive integer unique across items and whose `feature_folder` is a non-empty string. + +6. **Item state enum.** Each item's `state` must be one of the eight item-state values `proposed | admitted | prepared | scheduled | in_flight | merged | withdrawn | blocked`. + +7. **Merge-status enum.** Each item's `merge_status`, when present, must be one of the eight merge-status values `not_started | worktree_created | pr_open | ci_green | merged | worktree_removed | blocked_drift | blocked_ci_loop_limit`. An absent `merge_status` is treated as `not_started` and contributes zero errors. + +8. **State/merge-status consistency.** An item whose `merge_status` is `merged` or `worktree_removed` must have `state == 'merged'`. An item whose `merge_status` is `blocked_drift` or `blocked_ci_loop_limit` must have `state == 'blocked'`. + +9. **Blast-radius shape.** Each item's `blast_radius` must be an object carrying `paths`, `modules`, `shared_surfaces`, and `contracts` as lists of non-empty strings, a `source` in `{derived, declared, observed}`, and a non-empty `computed_at` string. `modules`, `shared_surfaces`, and `contracts` may be empty lists. + +10. **Prohibited dependency edges.** No object anywhere in the checkpoint may carry a `depends_on` key, and no top-level `depends_on` key may be present. Ordering is expressed only as blast-radius overlap. Presence is an explicit rejection, not mere absence. + +11. **Prohibited integration-branch fields.** The checkpoint must not carry `integration_branch` or `epic_merge_pr` at any level. Each parallel item opens its own pull request against `main`, so there is no integration branch and no final integration pull request. + +12. **Cohort shape and resolution.** Each `cohorts[]` entry must be an object with a non-negative integer `index`, a non-negative integer `generation` that is `<= recolor_generation`, and an `item_keys` list in which every entry resolves to an `items[].issue_num`. + +13. **Current-generation cohort uniqueness.** Among the `cohorts[]` entries whose `generation` equals `recolor_generation`, `index` values must be unique and every non-withdrawn item must appear in exactly one such cohort's `item_keys`. An item that appears in no current-generation cohort is permitted only in state `withdrawn`, `merged`, or `blocked`. The strictness is "exactly one", following the pinning model in which recoloring is a pure function over the unstarted subgraph and therefore implies full coverage. + +14. **Current-cohort bound.** `current_cohort` must be a non-negative integer. When any current-generation cohort exists, `current_cohort` must not exceed the maximum current-generation `index`. + +15. **Conflict-edge shape.** Each `conflict_edges[]` entry must be an object whose `a` and `b` resolve to distinct `items[].issue_num` values with `a < b` (numeric normalization, so edge identity is canonical and recomputation is deterministic), and whose `reason` is in `{path_overlap, module_overlap, shared_surface_overlap, contract_dependency}`. A self-edge, an unresolved endpoint, an unnormalized pair, a duplicate `(a, b)` pair, or an out-of-enum reason is a malformed edge. + +16. **Mutation shape.** Each `mutations[]` entry must satisfy the mutation table: `op` in `{add, remove, close, requeue}`; `item_key` resolving to an `items[].issue_num` for `add`, `remove`, and `requeue`, and null for `close` (a run-level operation); a non-empty `at`; `prior_state` and `new_state` either null or in the item-state enum, with `prior_state` null for `add` and `close` and `new_state` null for `close`; and `recolor_generation` a non-negative integer that is `<=` the top-level `recolor_generation`. Transition legality — which state may follow which — is downstream behavior, not schema; this validator checks shape, enum membership, and the null rules only. + +17. **In-flight removal requires a disposition.** A `mutations[]` entry with `op == 'remove'` and `prior_state == 'in_flight'` must carry `disposition` exactly `'detach'` or `'abandon'`. A `disposition` on any other entry must be null. + +18. **Drift-event shape.** Each `drift_events[]` entry must carry an `item_key` that resolves to an `items[].issue_num`, `declared` and `observed` as lists of non-empty strings, an `escaped_paths` list that is non-empty and holds non-empty strings (an event with zero escaped paths is not a drift event), a non-empty `at`, and an `action` in `{raised_blocking_finding, halted_later_started_item}`. + +19. **Receipt arrays.** `delegation_receipts`, `skill_receipts`, and `mcp_call_receipts`, when present, must each be a list. Absent receipt arrays contribute zero errors. Per-receipt content validation follows the loose tolerance of the standard checkpoint validators. + +20. **Completion gate, closed mode.** Under `require_complete` with `mode == 'closed'`, every item whose `state` is not `withdrawn` must have `merge_status` in `{merged, worktree_removed}`. + +21. **Completion gate, open mode.** Under `require_complete` with `mode == 'open'`, the checkpoint must additionally record a `mutations[]` entry with `op == 'close'` (the run-close record). Invariant 20's per-item condition applies in open mode as well. + +When `require_complete` is not passed, invariants 20 and 21 contribute zero errors and the validation result is byte-identical to a plain call. + +## Invariants (parallel planner checkpoint) + +Enforced by `validate_parallel_planner_state_text(text, *, require_ready_for_execution=False)` in `scripts/dev_tools/validate_parallel_planner_state.py`. P1 through P4 are enforced unconditionally; P6 through P9 are enforced only under `require_ready_for_execution`. + +- **P1 — Required keys.** The checkpoint must carry `objective`, `parallel_slug`, `parallel_manifest_path`, `mode`, `max_concurrency`, `items`, `cohorts`, `conflict_edges`, `recolor_generation`, `completed_steps`, `next_step`, and `last_updated`. One error is emitted per missing key. `kickoff_prompt_path` is optional outside the ready gate. + +- **P2 — Route-consistent identity.** `parallel_slug` and `parallel_manifest_path` must be non-empty strings; `mode` and `max_concurrency` satisfy orchestrator invariants 3 and 4. + +- **P3 — Item shape.** Each `items[]` entry must carry `issue_num`, `feature_folder`, `kind`, `state`, `blast_radius`, `preparation_status`, `research_path`, `plan_path`, and `preflight_status`. `issue_num` must be a positive integer unique across items; `kind` must be in `{feature, bug}`; `state` must be in the item-state enum; `blast_radius` must satisfy orchestrator invariant 9; `complexity_band`, when present, must be in `{C1, C2, C3, C4}`. The prohibited-key rejections of orchestrator invariants 10 and 11 apply. + +- **P4 — Cohort and edge shape.** `cohorts[]`, `conflict_edges[]`, and `recolor_generation` satisfy orchestrator invariants 12 through 15. + +- **P5 — Deterministic recoloring seam (deliberately absent).** This feature does not recompute the cohort coloring. Recomputation parity against the cohort-computation module is the planner-surface feature's check (the analogue of the epic planner's wave-number cross-check). The omission is recorded here explicitly so a later reader does not mistake it for an oversight. There is no P5 check in the validator. + +- **P6 — Ready gate, cardinality.** Under `require_ready_for_execution`, `items` must contain at least two entries. + +- **P7 — Ready gate, preparation.** Under `require_ready_for_execution`, each item must have `preparation_status == 'prepared'`, `preflight_status == 'PREFLIGHT: ALL CLEAR'`, non-empty `research_path` and `plan_path`, and `blast_radius.source == 'declared'`. Only the planner-computed radius is authoritative for scheduling. + +- **P8 — Ready gate, sentinel.** Under `require_ready_for_execution`, `next_step` must be exactly `'PARALLEL_EXECUTION_READY'`. + +- **P9 — Ready gate, kickoff path.** Under `require_ready_for_execution`, `kickoff_prompt_path` must be exactly `artifacts/orchestration/parallel-kickoff-.md`. + +The planner checkpoint carries no `epic_worthiness` analogue and no `NON_EPIC_RECOMMENDED` branch; the parallel surface has no worthiness verdict, and scale assessment happens before parallel planning is invoked. When `require_ready_for_execution` is not passed, P6 through P9 contribute zero errors. + +## Invariants (parallel run manifest) + +Enforced by `validate_parallel_manifest_text(text)` in `scripts/dev_tools/parallel_manifest_contract.py`, with the default-resolving accessors `manifest_mode(mapping)` and `manifest_max_concurrency(mapping)`. Manifest validation is a library call; it is deliberately not a third MCP `artifact_type`. + +- **M1 — Frontmatter block.** The document must open with a `---` YAML frontmatter block terminated by `---`, parseable by `yaml.safe_load` into a mapping. Extraction is tolerant of LF, CRLF, and CR line endings. A missing, unterminated, unparseable, or non-mapping frontmatter block is malformed. + +- **M2 — Slug.** `parallel` must be a non-empty string. + +- **M3 — Mode default.** `mode`, when present, must be `closed` or `open`. When absent it defaults to `closed`: the accessor `manifest_mode(mapping)` returns the default and the validator emits no error for absence. + +- **M4 — Concurrency default.** `max_concurrency`, when present, must be an integer from 1 through 8. When absent it defaults to `4`: the accessor `manifest_max_concurrency(mapping)` returns the default and the validator emits no error for absence. + +- **M5 — Created-at.** `created_at` must be a non-empty string. + +- **M6 — Items.** `items` must be a list. An empty list is valid at authoring time. Each entry must be an object carrying `issue_num` (positive integer, unique across items), `feature_folder` (non-empty string), `kind` in `{feature, bug}`, `state` in the item-state enum, and `blast_radius` in the shape of orchestrator invariant 9. + +- **M7 — Prohibited keys.** No `depends_on` key may appear at any level, and no `integration_branch` key may appear at top level. Presence is an explicit rejection. + +## Cache Doctrine — the checkpoint is not the source of truth + +The parallel-orchestrator checkpoint is a CACHE of durable state, not the source of truth. Every field it records is re-derivable from the repository and from GitHub: + +- `git worktree list --porcelain` — worktree existence and path (`items[].worktree_path`, `worktree_created_at`, `worktree_removed_at`). +- `git branch` — branch existence and name (`items[].branch_name`). +- `gh pr view --json state,mergedAt,headRefOid` — pull-request state, merge time, and merge commit (`items[].pr_number`, `pr_url`, `merge_status`, `merged_at`, `merge_commit_sha`). + +No downstream feature may treat the checkpoint as authoritative. When the checkpoint disagrees with those three commands, the commands win and the checkpoint is rewritten from them. The validators in this rule check the checkpoint's structural shape only; they never assert that the cached values agree with the repository, because that reconciliation is a runtime concern of the orchestrator surface, not a schema concern. + +## Omitted Epic Schema Fields (S8) + +There is no integration branch for a parallel run: each item opens its own pull request against `main`. The parallel schema therefore carries no integration-branch and no final-integration-pull-request fields. The disposition of every relevant epic field is fixed as follows. + +| Epic field | Disposition in the parallel schema | +| --- | --- | +| `integration_branch` (top level) | OMITTED — no integration branch; its presence is a prohibited-key violation (invariant 11, M7) | +| `epic_merge_pr` / `epic_merge_pr.merge_commit_sha` | OMITTED — no final integration pull request; the completion gate checks per-item terminal states instead (invariants 20-21) | +| `features[].depends_on` | OMITTED everywhere — ordering is derived from blast-radius overlap; presence is a prohibited-key violation (invariant 10, P3, M7) | +| `waves[]`, `features[].wave_number`, `current_wave` | REPLACED by `cohorts[]`, `current_cohort`, and `recolor_generation` | +| `max_parallel_features` | REPLACED by `max_concurrency` | +| `epic_feature_folder` | REPLACED by `parallel_slug`, plus `parallel_manifest_path` and `parallel_status_doc_path` | +| merge-status values `merge_conflict`, `blocked_conflict_loop_limit` | REPLACED by `blocked_drift` and `blocked_ci_loop_limit` — the fan-in merge-conflict path does not exist; drift and per-item CI loops are the parallel failure modes | +| planner `epic_worthiness`, `NON_EPIC_RECOMMENDED` branch | OMITTED — the parallel planner contract carries no worthiness verdict | + +Per-item `merge_commit_sha` is retained; only the run-level merge-pull-request block is omitted. + +## Concurrency Bound (A7) + +`max_concurrency` is bounded at 1 through 8 inclusive and defaults to `4` when absent from the manifest. The design document sets only the default of 4; the upper bound of 8 is adopted here for symmetry with the epic surface, whose `max_parallel_features` is validated as `1..8`. The bound is recorded in this rule file so that downstream features do not re-litigate it. Booleans are rejected even though `True` and `False` are integers in Python. + +The bound is enforced in three places with the same semantics: orchestrator invariant 4, planner invariant P2, and manifest invariant M4. + +## Drift-Event Recording Rule (A8) + +`drift_events[].action` is the two-member enum `{raised_blocking_finding, halted_later_started_item}`. The recording rule is: one event per drift occurrence, carrying the STRONGEST action taken. `halted_later_started_item` subsumes `raised_blocking_finding`, so an occurrence that halted a later-started item records exactly one event with `action == 'halted_later_started_item'` and does not additionally record a `raised_blocking_finding` event for the same occurrence. + +The drift-detection feature consumes this enum and this rule without extending either. + +## Enum Ownership (F6/F7/F8 consume, never extend) + +All nine enums of the parallel surface are owned by the schema-and-validator feature (F3) and are fixed by this rule file: + +| Enum | Members | +| --- | --- | +| `mode` | `closed`, `open` (default `closed`) | +| item `state` | `proposed`, `admitted`, `prepared`, `scheduled`, `in_flight`, `merged`, `withdrawn`, `blocked` | +| `merge_status` | `not_started`, `worktree_created`, `pr_open`, `ci_green`, `merged`, `worktree_removed`, `blocked_drift`, `blocked_ci_loop_limit` | +| `blast_radius.source` | `derived`, `declared`, `observed` | +| `items[].kind` | `feature`, `bug` | +| `conflict_edges[].reason` | `path_overlap`, `module_overlap`, `shared_surface_overlap`, `contract_dependency` | +| `mutations[].op` | `add`, `remove`, `close`, `requeue` | +| `mutations[].disposition` | `detach`, `abandon`, or null | +| `drift_events[].action` | `raised_blocking_finding`, `halted_later_started_item` | + +The wave-4 features — F6 (mutation protocol), F7 (enforcement hooks), and F8 (drift detection) — CONSUME these member sets and NEVER extend them. A wave-4 feature that needs a new member must amend this rule file and the validators at spec review, not add the member at implementation time. This constraint exists because the wave-4 features are prepared concurrently and would otherwise add fields to the same files at the same time. + +## F7 Seam + +The retrospective cohort-ordering invariant `PARALLEL_COHORT_BARRIER_VIOLATION` (design section 9, Layer 2) is F7's explicitly assigned addition to the orchestrator validator. It is NOT implemented here. The entry point of `scripts/dev_tools/validate_parallel_orchestrator_state.py` contains a clearly delimited, appendable helper-invocation block, marked with explicit begin and end comments that name F7 and the invariant token, so that F7's edit is one appended helper call with no reflow of existing code. The TypeScript core `extensions/drm-copilot/src/lib/validate/parallel-orchestrator-state-core.ts` carries the matching comment-delimited seam. Existing helper calls sit outside the block. + +## F3 Scope Boundary — kickoff contract deferred to F4 + +F3 deliberately excludes the kickoff-prompt contract module `scripts/dev_tools/parallel_kickoff_contract.py` and the `parallel-kickoff` `artifact_type`. Both are F4's scope, and F3 neither creates the module nor registers the artifact type on the CLI or MCP surfaces. The MCP surface grows by exactly two `artifact_type` values: `parallel-orchestrator-state` and `parallel-planner-state`. + +F3's `require_ready_for_execution` gate is STRUCTURAL ONLY. It enforces the kickoff-PATH invariant (P9: `kickoff_prompt_path` must equal `artifacts/orchestration/parallel-kickoff-.md`) and does not parse or cross-check kickoff CONTENT. The deeper readiness-integrity machinery of the epic surface — git-integrity checks, launch-evidence binding, and kickoff-contract cross-checks — is left to F4, which may layer repository-aware checks behind an additional keyword without changing the schema. F3 likewise does not recompute the cohort coloring (planner invariant P5). + +## Enforcement + +- `scripts/dev_tools/validate_parallel_orchestrator_state.py`, with the helper modules `scripts/dev_tools/_parallel_state_common.py`, `scripts/dev_tools/_parallel_state_structures.py`, and `scripts/dev_tools/_parallel_state_records.py`, appends one error per violated orchestrator invariant. The completion-gate invariants 20 and 21 run only when the caller passes `require_complete=True`. +- `scripts/dev_tools/validate_parallel_planner_state.py` appends one error per violated planner invariant. The ready-gate invariants P6 through P9 run only when the caller passes `require_ready_for_execution=True`. +- `scripts/dev_tools/parallel_manifest_contract.py` appends one error per violated manifest invariant and exposes the default-resolving accessors. Manifest validation is a library call, not an MCP artifact type. +- `scripts/dev_tools/validate_orchestration_artifacts.py` registers the CLI subparsers `parallel-orchestrator-state` (with `--require-complete`) and `parallel-planner-state` (with `--require-ready-for-execution`). An unknown artifact type continues to fail with `Unsupported artifact type: {type}`. +- The TypeScript parity port at `extensions/drm-copilot/src/lib/validate/parallel-state-shared.ts`, `parallel-state-structures.ts`, `parallel-state-records.ts`, `parallel-orchestrator-state-core.ts`, and `parallel-planner-state-core.ts` reproduces the same invariants and is dispatched from `extensions/drm-copilot/src/lib/validate/orchestration-artifacts.ts` for both new `artifact_type` values. Verified scope: 96 of 96 error strings matched across 43 constructed documents, for JSON-representable values that round-trip through both runtimes' native types. Three divergence classes are known outside that verified scope: (1) **`pythonRepr` quote selection** — `parallel-state-shared.ts:112-132` always single-quotes, while Python's `repr` switches to double quotes when the value contains a single quote (recorded repo-wide at `docs/features/potential/2026-08-07-python-repr-quote-selection-divergence.md`); (2) **integral floats** — `JSON.parse` erases Python's `int`/`float` distinction, so an integral float value produces a different Python-side error count than the TypeScript side; (3) **boolean/integer equality** — `parallel-state-structures.ts:228` uses `===`, so a boolean value is not selected the way Python's `True == 1` equality selects it, producing differing error counts. +- Enforcement is therefore Python validator logic, plus the TypeScript parity port, plus this prose file. It is NEVER an imported JSON Schema. No schema file is read at validation time. +- The `parallel` route entry lives in `config/orchestration-routing.json` with `requires_pr_gate: false` (there is no run-level pull request to gate; each child's own route checkpoint enforces its per-item pull-request gate) and is mirrored byte-for-byte in `extensions/drm-copilot/resources/config/orchestration-routing.json`. diff --git a/.claude/rules/shell.md b/.claude/rules/shell.md new file mode 100644 index 00000000..5b2a4e41 --- /dev/null +++ b/.claude/rules/shell.md @@ -0,0 +1,93 @@ +--- +paths: + - "**/*.sh" + - "**/*.bats" + - "scripts/bash/**" + - "tests/shell/**" +description: Shell (bash) toolchain and coding standards. +--- + +# Shell (Bash) Code Standards + +This rule file summarizes the shell-specific policies for this repository. The shell +quality-control toolchain is native bash and has no Python or Poetry dependency. + +## Toolchain + +Run the toolchain in this order and restart from step 1 if any step fails or rewrites files: + +1. **Formatting — shfmt**: Format all shell scripts with shfmt (write mode). Command: + `bash scripts/bash/shell-qc.sh format`. The `check` command runs shfmt in diff mode + (`shfmt -d`) as its first stage. +2. **Linting — shellcheck**: Lint all shell scripts with shellcheck. Command: + `bash scripts/bash/shell-qc.sh check`. `check` runs `shfmt -d` once over the full file + list and then `shellcheck` once per file, returning the maximum exit code. +3. **Type checking — not applicable**: Bash has no separate type-check stage. An optional + syntax check is available via `bash -n`; the VS Code task "Shell QC: 3 bash: type-check" + provides it. Skip to testing. +4. **Testing — bats**: Run bats tests with `bash scripts/bash/shell-qc.sh test`. Line coverage + via kcov with `bash scripts/bash/shell-qc.sh test --coverage`. + +Do not stop the loop until formatting, linting, and testing complete without errors in a +single pass. Do not substitute the VS Code task wrappers for the native command in automation. + +## Native Invocation and Environment + +- The toolchain is native bash: the wrapper `scripts/bash/shell-qc.sh` and its library + `scripts/bash/shell_qc_lib.sh` invoke `shfmt`, `shellcheck`, `bats`, and `kcov` directly. + No Python interpreter, no Poetry, and no `poetry run` are involved. +- On Windows, run the toolchain under WSL. +- In CI, the toolchain runs on `ubuntu-latest` (`.github/workflows/_shell-coverage.yml` for + coverage; `.github/workflows/_build-check.yml` runs `--help` as an installability smoke). +- Per-tool path overrides are available for testing via `SHELL_QC__BIN` (for `shfmt`, + `shellcheck`, `bats`, `kcov`); an empty or nonexistent value is treated as missing. The + coverage output directory is `SHELL_QC_KCOV_OUT_DIR` (default `artifacts/pester/kcov`). + +## Discovery Contract + +- Search roots: `tools/`, `scripts/`, and `.claude/lib/bash/`, relative to the current working + directory; a missing root is silently skipped. The `.claude/lib/bash/` root carries the + destination-portable bash library published by push-down, so those scripts are held to the + same format, lint, test, and coverage standards as `tools/` and `scripts/`. +- A file is a shell script when its suffix (lowercased) is `.sh` or its first line is a + shebang whose resolved interpreter is `bash` or `sh` (including `env` and `env -S`/`-flag` + forms; the shebang is lowercased before parsing, so `#!/usr/bin/env BASH` qualifies). +- Excluded directories (pruned at any depth): `.venv`, `.git`, `node_modules`, `dist`, + `build`. +- The discovered set is de-duplicated and sorted with `LC_ALL=C` for deterministic ordering. +- bats test directories: `tests/shell` and `tests/bash`, whichever exist, in that order. + +## Coverage Expectations + +- Coverage is measured with kcov, which emits a single merged Cobertura report `cov.xml` under + `artifacts/pester/kcov` (or `SHELL_QC_KCOV_OUT_DIR`). The run prints + `Bash coverage (lines): NN.N%`. +- The kcov include pattern covers all three discovery roots — `tools/`, `scripts/`, and + `.claude/lib/bash/` — so the Claude bash library is measured, not merely discovered. The + `tests/` tree remains excluded. +- kcov reports **line coverage only**. The uniform line-coverage threshold (>= 85% per + `.claude/rules/quality-tiers.md`) applies. Branch coverage is not measurable by kcov for + bash; there is no bash branch-coverage gate. + +## CI-vs-Local Version Drift + +- CI pins shfmt 3.8.0 (installed as a binary), uses apt-packaged shellcheck and bats, and + builds kcov v43 from source. Local WSL installs (winget or apt) may drift from these + versions. +- CI versions are canonical. When local and CI results disagree, defer to CI. + +## Coding Standards + +- Begin executable scripts with `set -euo pipefail`. Tools that legitimately return non-zero + (shfmt diff mode, shellcheck, bats, kcov) must be captured with `|| rc=$?` so an intended + non-zero exit does not abort under `set -e`. +- Keep scripts shellcheck-clean. Suppressions are permitted only when justified inline with a + `# shellcheck disable=SCxxxx` comment stating the reason. +- Use shfmt default formatting (tab indentation, as in `scripts/bash/coverage_lib.sh`). +- Quote all expansions; resolve tools with `command -v` (honoring the `SHELL_QC__BIN` + override seam). +- No production, test, or reusable shell file may exceed 500 lines. +- Tests live in `tests/shell/*.bats` and mirror `scripts/bash/`. Tests must not create + temporary files; use checked-in fixtures under `tests/fixtures/` and checked-in stub + binaries under `tests/fixtures/shell_qc/stub-bin/` wired through the `SHELL_QC__BIN` + seam. diff --git a/.claude/settings.json b/.claude/settings.json index 77e81ce2..67602e2e 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -5,6 +5,9 @@ "Bash(git *)", "Bash(poetry run *)", "Bash(pwsh *)", + "Bash(bash .claude/lib/bash/compute-cohorts.sh*)", + "Bash(bash .claude/lib/bash/compute-concurrency-batches.sh*)", + "Bash(bash .claude/lib/bash/validate-parallel-manifest.sh*)", "Read", "Edit(/docs/**)", "Write(/docs/**)", diff --git a/.claude/skills/parallel-add/SKILL.md b/.claude/skills/parallel-add/SKILL.md index 9075e2bb..4c39049d 100644 --- a/.claude/skills/parallel-add/SKILL.md +++ b/.claude/skills/parallel-add/SKILL.md @@ -56,11 +56,15 @@ re-derivation is mandatory and is not an optimization to skip when the checkpoin advances `proposed` -> `admitted` -> `prepared` during this step, recorded as item-state updates in `items[]` with the checkpoint's lifecycle timestamps. -3. **Compute conflict edges over ALL items, including in-flight ones.** Invoke the landed - contention relation `conflicts(a, b, config)` from `scripts/dev_tools/compute_blast_radius.py` - (defined in `scripts/dev_tools/_blast_radius_conflicts.py`). `a` and `b` are the two items' - `BlastRadius` value objects, not strings, and `config` is the required parsed - `config/blast-radius.json` mapping. Map each conflicting pair onto an `(int, int)` conflict edge +3. **Compute conflict edges over ALL items, including in-flight ones.** Invoke the contention + relation `Test-BlastRadiusConflict` from the destination-runtime PowerShell port + `.claude/lib/blast-radius/BlastRadius.psm1`, which is published by push-down and needs no Python + interpreter (`Import-Module .claude/lib/blast-radius/BlastRadius.psm1 -Force`). Its two radius + arguments are the two items' radius hashtables, not strings, and the third argument is the + required parsed `config/blast-radius.json` mapping, which push-down publishes into the + destination workspace. `conflicts(a, b, config)` in `scripts/dev_tools/compute_blast_radius.py` + (defined in `scripts/dev_tools/_blast_radius_conflicts.py`) remains the repository authority and + the parity reference. Map each conflicting pair onto an `(int, int)` conflict edge of `items[].issue_num` values, normalized so `a < b`. Do not reimplement the relation and do not compute edges over the unstarted subset only: an in-flight conflict is precisely what the admission decision turns on. diff --git a/.claude/skills/parallel-orchestrate/SKILL.md b/.claude/skills/parallel-orchestrate/SKILL.md index 00cfef87..f2fcdf3c 100644 --- a/.claude/skills/parallel-orchestrate/SKILL.md +++ b/.claude/skills/parallel-orchestrate/SKILL.md @@ -54,7 +54,8 @@ only run-level artifacts under `docs/features/parallel//`. nine parallel enums are defined once as prose invariants in `.claude/rules/parallel-orchestration.md` (manifest invariants M1 through M7) and are enforced by the F3-owned validators `scripts/dev_tools/parallel_manifest_contract.py` and -`scripts/dev_tools/validate_parallel_orchestrator_state.py`. This is a deliberate delta from +`scripts/dev_tools/validate_parallel_orchestrator_state.py`, with the manifest half reachable on the +destination-runtime path as `bash .claude/lib/bash/validate-parallel-manifest.sh`. This is a deliberate delta from `.claude/skills/epic-orchestrate/SKILL.md`, whose manifest section carries its schema inline. Read the schema from the rule file and the validators; consume it here and never redefine or extend it. @@ -72,13 +73,17 @@ Consumption rules: Presence of either is an explicit rejection, not a tolerated extra field. - A malformed manifest is rejected before any kickoff, recorded as a synthetic Blocking finding in the checkpoint. Do not guess a repair, do not silently skip the offending item, and do not launch - a partial cohort. Validate by calling `validate_parallel_manifest_text` from - `scripts/dev_tools/parallel_manifest_contract.py`, which is a library call and deliberately not - an MCP artifact type. That module exposes no CLI entry point, so the permitted mechanism for the - call is the granted interpreter invocation - `poetry run python -c "import pathlib, sys; from scripts.dev_tools.parallel_manifest_contract import validate_parallel_manifest_text; errors = validate_parallel_manifest_text(pathlib.Path(sys.argv[1]).read_text(encoding='utf-8')); print(errors); sys.exit(1 if errors else 0)" docs/features/parallel//parallel.md`, - whose non-zero exit is the rejection signal and whose printed error list is the content of the - Blocking finding. + a partial cohort. Validate with the destination-runtime bash entry point, which needs no Python + interpreter and is published by push-down alongside `.claude`: + `bash .claude/lib/bash/validate-parallel-manifest.sh docs/features/parallel//parallel.md`. + Its non-zero exit is the rejection signal and its printed error list, one error per line on + stdout, is the content of the Blocking finding; exit 1 means the manifest is invalid and exit 2 + means the file is unreadable or uses a YAML construct outside the supported subset. Consume + `mode` and `max_concurrency` through the same entry point's `--print-mode` and + `--print-max-concurrency` subcommands rather than reading the frontmatter directly. + `validate_parallel_manifest_text` in `scripts/dev_tools/parallel_manifest_contract.py` remains the + repository authority and the parity reference. Manifest validation is deliberately not an MCP + artifact type. ## Cohort Consumption and Ordering @@ -122,10 +127,13 @@ items independently of cohort size: a cohort of twelve items executes at most `m items at a time. Fill slots in ascending item-key order, keyed on `issue_num`, and refill each freed slot with the next unstarted item of the current cohort in that same ascending item-key order. A cohort larger than `max_concurrency` therefore launches in several batches from the same -recorded `main` tip. The batching is a pure function: +recorded `main` tip. The batching is a pure function, reached on the destination-runtime path as +`bash .claude/lib/bash/compute-concurrency-batches.sh --keys " ..." --max-concurrency `. +It prints a compact JSON array of arrays, returns the batches in order, and sorts the keys itself, +so determinism does not depend on caller ordering. `compute_concurrency_batches(cohort_item_keys, max_concurrency)` in -`scripts/dev_tools/parallel_cohort_computation.py` returns the batches in order and sorts the keys -itself, so determinism does not depend on caller ordering. +`scripts/dev_tools/parallel_cohort_computation.py` remains the repository authority and the parity +reference. **Mechanical enforcement of the barrier is F7 scope, not this feature's.** F7 delivers a two-layer design, because no single `PreToolUse` hook can validate a batch of concurrent `Agent` calls: hooks @@ -484,8 +492,11 @@ carries exactly ONE current-generation entry per index, so returned keys landing `current_cohort` JOIN the pinned members of that one entry instead of forming a second entry with the same index, which F3 invariant 13 rejects. -Coloring is delegated in full to the Welsh-Powell entry point `compute_cohorts` in -`scripts/dev_tools/parallel_cohort_computation.py`. No part of the coloring, the vertex ordering, or +Coloring is delegated in full to the Welsh-Powell entry point +`bash .claude/lib/bash/compute-cohorts.sh --keys " ..." --edges ": ..."`, the +destination-runtime port of `compute_cohorts` in +`scripts/dev_tools/parallel_cohort_computation.py`, which remains the repository authority and the +parity reference. No part of the coloring, the vertex ordering, or the tie-break is reimplemented by the mutation engine, and the offset is applied entirely inside the mutation engine's own recolor function. diff --git a/.claude/skills/parallel-plan/SKILL.md b/.claude/skills/parallel-plan/SKILL.md index bdb09b58..d0d0fdb0 100644 --- a/.claude/skills/parallel-plan/SKILL.md +++ b/.claude/skills/parallel-plan/SKILL.md @@ -142,15 +142,27 @@ Three residual risks are recorded rather than eliminated: ## Radius Computation and Validation -The blast-radius feature landed as an **import-only Python library with no CLI entry point**, -matching the `scripts/dev_tools/epic_wave_computation.py` precedent. Reach it through the -`"Bash(poetry run *)"` allowlist entry as an importable-library call: +Reach blast-radius derivation, validation, and contention through the **destination-runtime +PowerShell port** under `.claude/lib/blast-radius/`, which is published by push-down and needs no +Python interpreter: -```bash -poetry run python -c "from scripts.dev_tools.compute_blast_radius import derive_blast_radius" +```powershell +Import-Module .claude/lib/blast-radius/BlastRadius.psm1 -Force ``` -Landed contract, consumed as-is and never reimplemented here: +The facade re-exports the five functions this skill needs: `Get-PlanPaths` (port of +`extract_plan_paths`), `Get-BlastRadius` (port of `derive_blast_radius`), +`Get-BlastRadiusFromObservedPaths` (port of `radius_from_observed_paths`), `Test-BlastRadius` +(port of `validate_blast_radius`), and `Test-BlastRadiusConflict` (port of `conflicts`). Wrap a +call to `Test-BlastRadius` in `@(...)`: it writes its findings to the pipeline, so a zero-element +result writes nothing and a one-element result writes a single object. + +The truth table the port reads is `config/blast-radius.json`, which push-down publishes into the +destination workspace alongside `.claude`. + +The Python modules named below remain the repository authority and the parity reference; they are +cited for their contract, not invoked on the destination-runtime path. Landed contract, consumed +as-is and never reimplemented here: - **Derivation.** `derive_blast_radius(plan_text, spec_text, feature_folder, config, *, source, computed_at) -> BlastRadius` @@ -201,19 +213,26 @@ them; it defines none of them. ## Cohort Seeding -The cohort-scheduler feature likewise landed as an **import-only Python library with no CLI entry -point**, invoked through the same `"Bash(poetry run *)"` allowlist entry: +Reach cohort computation through the **destination-runtime bash entry point** under +`.claude/lib/bash/`, which is published by push-down and needs no Python interpreter: ```bash -poetry run python -c "from scripts.dev_tools.parallel_cohort_computation import compute_cohorts" +bash .claude/lib/bash/compute-cohorts.sh --keys " ..." --edges ": : ..." ``` -Landed contract: +`--edges` is optional; omitting it, or passing an empty string, means the conflict graph has no +edges. The entry point prints a compact JSON array of arrays on stdout, identical to Python +`json.dumps(..., separators=(",", ":"))`. On malformed input it prints the reference +implementation's exact message on stderr and exits 1; a token outside the accepted integer lexis +`-?(0|[1-9][0-9]*)` is rejected fail-closed with exit 2. + +Landed contract, mirrored byte for byte by the bash entry point: - `compute_cohorts(item_keys, conflict_edges) -> list[list[int]]` in - `scripts/dev_tools/parallel_cohort_computation.py`. The signature accepts exactly two parameters, - `item_keys: Iterable[int]` and `conflict_edges: Iterable[tuple[int, int]]`. There is no third - parameter and nothing further to supply at seeding time. + `scripts/dev_tools/parallel_cohort_computation.py` is the repository authority and the parity + reference. The signature accepts exactly two parameters, `item_keys: Iterable[int]` and + `conflict_edges: Iterable[tuple[int, int]]`. There is no third parameter and nothing further to + supply at seeding time. - The return value is a plain list of lists in deterministic Welsh-Powell order: vertices are visited by the composite key `(-degree, item_key)` ascending — descending distinct-neighbour degree with ties broken by ascending item key — and each vertex takes the lowest cohort index not @@ -227,17 +246,19 @@ The library returns the partition; the planner supplies the record fields. ### Seeding procedure -1. Invoke `compute_cohorts` exactly once per plan run, over the full conflict graph, after every +1. Invoke `compute-cohorts.sh` exactly once per plan run, over the full conflict graph, after every item is `prepared` and radius-validated. Derive the conflict edge set by applying - `conflicts(a, b, config)` to every unordered pair of `declared` radii. + `Test-BlastRadiusConflict` to every unordered pair of `declared` radii, then pass the pairs as + `--edges ": ..."` and the item keys as `--keys " ..."`. 2. Record `cohorts[]` at `generation: 0`, each cohort's `item_keys[]` sorted ascending. 3. Record `conflict_edges[]` as `{a, b, reason}` entries for auditability. 4. Record `recolor_generation: 0` and `current_cohort: 0`. 5. Record `max_concurrency` — default 4, bounded 1 through 8 by the F3 schema — without enforcing it. Enforcement is F5's, through - `compute_concurrency_batches(cohort_item_keys, max_concurrency)`, which fills slots in ascending - item-key order. Recoloring under add, remove, or drift mutation is F6 and F8 scope. This skill - performs seeding only. + `bash .claude/lib/bash/compute-concurrency-batches.sh --keys " ..." --max-concurrency ` + (the bash port of `compute_concurrency_batches(cohort_item_keys, max_concurrency)`), which fills + slots in ascending item-key order. Recoloring under add, remove, or drift mutation is F6 and F8 + scope. This skill performs seeding only. ### Recomputation parity (planner-owned check) @@ -276,9 +297,29 @@ field; both are prohibited-key rejections in the schema. Commit it to `parallel/ fully resolved form — every negative placeholder `issue_num` replaced by its promoted number — before the kickoff artifact is written. -Manifest validation is a library call to `scripts/dev_tools/parallel_manifest_contract.py` -(`validate_parallel_manifest_text`, with the default-resolving accessors `manifest_mode` and -`manifest_max_concurrency`). It is deliberately not an MCP `artifact_type`; do not attempt to +Validate the manifest with the **destination-runtime bash entry point**, which is published by +push-down and needs no Python interpreter: + +```bash +bash .claude/lib/bash/validate-parallel-manifest.sh +``` + +It prints validation errors one per line on stdout and exits 0 for a valid manifest, 1 for an +invalid one, and 2 for an unreadable file or a YAML construct outside the supported subset. The two +default-resolving accessors are subcommands of the same entry point: + +```bash +bash .claude/lib/bash/validate-parallel-manifest.sh --print-mode +bash .claude/lib/bash/validate-parallel-manifest.sh --print-max-concurrency +``` + +They resolve the documented defaults `closed` and `4` when the manifest omits the key or carries a +malformed value. Consume `mode` and `max_concurrency` through these accessors rather than reading +the frontmatter directly. + +`scripts/dev_tools/parallel_manifest_contract.py` (`validate_parallel_manifest_text`, +`manifest_mode`, `manifest_max_concurrency`) remains the repository authority and the parity +reference. Manifest validation is deliberately not an MCP `artifact_type`; do not attempt to validate the manifest through `mcp__drm-copilot__validate_orchestration_artifacts`. ## Checkpoint Persistence diff --git a/config/blast-radius.json b/config/blast-radius.json new file mode 100644 index 00000000..42e1a02c --- /dev/null +++ b/config/blast-radius.json @@ -0,0 +1,16 @@ +{ + "version": 1, + "shared_surfaces": [ + ".claude/settings.json", + "config/orchestration-routing.json", + "config/blast-radius.json" + ], + "shared_surface_globs": [], + "modules": { + "claude-runtime": [".claude/**"], + "config": ["config/**"], + "docs": ["docs/**"], + "tests": ["tests/**"] + }, + "over_breadth_fraction": 0.25 +} diff --git a/config/orchestration-routing.json b/config/orchestration-routing.json index 61bdd5e7..8236620c 100644 --- a/config/orchestration-routing.json +++ b/config/orchestration-routing.json @@ -118,6 +118,28 @@ "collect_pr_context", "validate_orchestration_artifacts" ] + }, + "parallel": { + "description": "Parallel path for scheduling independent items into blast-radius cohorts across parallel worktrees; each item PRs to main independently with no integration branch.", + "requires_pr_gate": false, + "required_agents": [ + "orchestrator", + "pr-author" + ], + "required_skills": [ + "parallel-orchestrate", + "orchestrate", + "feature-promotion-lifecycle", + "atomic-plan-contract", + "acceptance-criteria-tracking", + "evidence-and-timestamp-conventions", + "pr-context-artifacts", + "pr-base-branch-merge-base" + ], + "required_mcp_tools": [ + "collect_pr_context", + "validate_orchestration_artifacts" + ] } }, "model_policy": { @@ -228,22 +250,22 @@ "epic_preparation_child", "epic_execution_child" ], - "forced_root_personas": [ - "epic-planner", - "epic-orchestrator" - ], - "parallelism": { - "default_max_parallel_features": 4, - "hard_max_parallel_features": 8 - }, - "escalation_precedence": [ + "forced_root_personas": [ + "epic-planner", + "epic-orchestrator" + ], + "parallelism": { + "default_max_parallel_features": 4, + "hard_max_parallel_features": 8 + }, + "escalation_precedence": [ "epic_child_context", "invalid_estimate", "cross_language", "unsupported_language", "cross_cutting", - "direct_mode_disabled", - "production_budget_exceeded" + "direct_mode_disabled", + "production_budget_exceeded" ], "receipt_key": "codex_topology_receipts" }, @@ -286,7 +308,7 @@ "model_reasoning_effort": "max" } }, - "c3_elevated_profile": { + "c3_elevated_profile": { "suffix": "c3-elevated", "model": "gpt-5.6-sol", "model_reasoning_effort": "high", @@ -298,13 +320,13 @@ ], "orchestration_complexity_ceiling": "C4" } - }, - "ceiling_transition_policy": { - "monotonic": true, - "receipt_key": "ceiling_transition", - "affected_delegation_ids_required_on_increase": true - }, - "forced_personas": { + }, + "ceiling_transition_policy": { + "monotonic": true, + "receipt_key": "ceiling_transition", + "affected_delegation_ids_required_on_increase": true + }, + "forced_personas": { "epic-planner": { "suffix": "", "model": "gpt-5.6-sol",