From 6f1fcfd446ea9957ec930b2ea192fa3583ee71d9 Mon Sep 17 00:00:00 2001 From: "melodic-automation-lanes[bot]" <337595936+melodic-automation-lanes[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 10:25:53 +0000 Subject: [PATCH 1/3] Resolve issue #11872 Lane-Activity: work-resolve-issue/resolve-issue --- ...work-resolve-issue-issue-11872-8f4d2a6c.md | 7 + .../work-items/lib/resolve-convention-home.sh | 535 ++++++++++++++++++ .../work-items/schemas/work-items.schema.json | 44 +- .../tools/work-item-tracker/CONTRACT.md | 64 ++- .../tools/work-item-tracker/lib/binding.sh | 193 +++++-- .../work-item-tracker/lib/binding.test.sh | 144 +++++ .../work-item-tracker/work-item-tracker.sh | 2 +- .../work-item-tracker.test.sh | 3 +- scripts/shared-copies.txt | 1 + 9 files changed, 938 insertions(+), 55 deletions(-) create mode 100644 .changes/work-items/work-resolve-issue-issue-11872-8f4d2a6c.md create mode 100755 plugins/work-items/lib/resolve-convention-home.sh diff --git a/.changes/work-items/work-resolve-issue-issue-11872-8f4d2a6c.md b/.changes/work-items/work-resolve-issue-issue-11872-8f4d2a6c.md new file mode 100644 index 00000000000..14b0d85652d --- /dev/null +++ b/.changes/work-items/work-resolve-issue-issue-11872-8f4d2a6c.md @@ -0,0 +1,7 @@ +--- +bump: minor +--- + +### Added + +- work-item tracker: the seam reads the tracker binding from the `tracker` key of `/work-items.yaml`, the convention home the repository's pointer region binds (`docs/conventions` when it binds none), with the personal overlay `/work-items.local.yaml`. `work-items.schema.json` declares the binding keys. A rejected value names the file, key and value. While the migration runs, the seam still reads `.work-item-tracker.json` when the YAML has no binding and prints a one-line notice; with neither, it stops and points at `/work-items:setup` (#11872). diff --git a/plugins/work-items/lib/resolve-convention-home.sh b/plugins/work-items/lib/resolve-convention-home.sh new file mode 100755 index 00000000000..75c4fd0410d --- /dev/null +++ b/plugins/work-items/lib/resolve-convention-home.sh @@ -0,0 +1,535 @@ +#!/usr/bin/env bash +# GENERATED from lib/resolve-convention-home.sh by scripts/sync-shared-copies.sh. Do not edit this copy: +# edit the canonical source, then rerun the script. +# Resolve a consumer repository's convention home from the pointer line in its +# root instruction file. +# +# WHY. The config-cascade expression doctrine (docs/contracts/config-cascade/ +# README.md § Expression doctrine) binds a consumer's convention home with ONE +# pointer line inside a marked, machine-owned region of the root instruction +# file. The line IS the binding: there is no binding file, so every plugin that +# needs the home reads the same line. Eight plugins each parsing that line in +# their own prose would drift exactly the way retirement detection drifted, so +# the grammar lives here, once, tested, and the doctrine doc cites this helper +# as its owner. The behavior follows docs/adr/0075 (fixes 1, 2, 4, 5, 6, 13). +# +# GRAMMAR. The root instruction file carries a region delimited by two marker +# lines (surrounding whitespace, a leading UTF-8 BOM, and a trailing carriage +# return are ignored; nothing else may share the line): +# +# +# Team conventions live in `docs/conventions` — read the topic doc there +# before changing a governed surface. +# +# +# Inside the region, a POINTER LINE is any line that contains a backticked +# token; the FIRST backticked token on that line is the home. Lines in the +# region with no backticks are prose and are ignored. A backticked path +# anywhere outside the region is not a pointer. The home is a repo-relative +# directory: one or more segments of [A-Za-z0-9._-]+ joined by `/`, no segment +# equal to `.`, `..` or `.git` (any case, so the home is never git's own +# directory, trailing dots ignored as NTFS ignores them), an optional trailing +# `/` which is dropped on output. In the working tree the home is its own +# physical path: a symlink on the way to it, or a spelling whose case differs +# from the disk, is a FAIL, as --ref reads only a committed tree. +# Anything else — an absolute path, a leading `~`, a backslash, a drive letter, +# whitespace, or an empty token — is an invalid pointer, never a best-effort +# parse. A line inside the region that is exactly `declined` (surrounding +# whitespace ignored) records that the repository declined a home: the caller +# must not ask again. A `declined` line beside a pointer line is a FAIL. +# +# WHICH FILE. `AGENTS.md` is canonical; `CLAUDE.md` is consulted when AGENTS.md +# carries no region. A CLAUDE.md whose only non-blank content is `@AGENTS.md` is +# a pure import shim and is never consulted. The two files are compared +# physically (realpath): when one is a symlink to the other they are one file, +# read once, and nothing is reported. When two distinct files both carry a +# region, AGENTS.md supplies the printed value and CLAUDE.md's copy is reported +# on stderr as `duplicate:` — the doctrine calls that a finding with a +# remediation (remove the copy), not a hard stop, so the exit stays 0. +# +# WHERE A HOME CAN APPLY. The root is classified first. It is "not applicable" +# (exit 5) when it is $HOME, an ancestor of $HOME, or not inside a git work +# tree: a home directory or a loose folder is not a repository that binds a +# home. With no --root the root is the git toplevel of CLAUDE_PROJECT_DIR (else +# of the current directory), one home per git root: CLAUDE_PROJECT_DIR only says +# where to look, so a subdirectory of a repository never reads a file of its own. +# +# WHAT IS READ. By default the working tree. `--ref ` reads AGENTS.md, +# CLAUDE.md and the home directory as committed at , so a lane resolves the +# pointer at the base commit: a 40-hex commit id or `origin/`, validated +# before any git call and passed to git after --end-of-options. A file that is a +# symlink at is not read. `-` reads the region from stdin as the text of +# AGENTS.md instead (a remote reader passes text it fetched itself); it names no +# tree, so the root is not classified and the home directory is not checked. +# +# UNTRUSTED INPUT. The root file is consumer prose. Nothing read from it is +# evaluated, expanded, or interpolated into a command: a candidate token is only +# ever compared against the grammar and joined onto --root for a `-d` test. +# Every line is stripped of `\r` and truncated to MAX_LINE bytes before parsing. +# +# Usage: +# resolve-convention-home.sh [--root ] [--ref ] [--explain] [--help] [-] +# +# --root the repository root (default: the git toplevel of +# ${CLAUDE_PROJECT_DIR}, else of the current directory) +# --ref read the committed files at (40-hex id or origin/) +# - read the AGENTS.md text from stdin (not with --root or --ref) +# --explain write which file, region, and token were used to stderr +# +# Exit: +# 0 exactly one usable pointer resolved and the directory exists; the +# repo-relative home is printed on stdout (a `duplicate:` warning on +# stderr does not change this) +# 1 no region, or a region with no pointer line, in any consulted root file +# — the caller asks the operator; nothing is inferred here +# 2 usage error, an unusable --root, or an invalid or unresolvable --ref +# 3 FAIL, each with a distinct stderr message: two pointer lines in one +# region; an unterminated or nested region; an invalid pointer path; a +# pointer whose target directory is missing or reached through a symlink; +# `declined` beside a pointer +# 4 a region holds a `declined` line: the repository declined a home, so the +# caller uses the bundled defaults and does not ask again +# 5 not applicable: the root is $HOME, an ancestor of it, or not a git work +# tree; no home applies and nothing is asked +# +# A pointer with no concern file under the home is not an error here: the home +# resolves, and the cascade reports the missing file's layer as absent so the +# bundled default applies (config-cascade.mjs). +# +# Shared source: lib/resolve-convention-home.sh at the marketplace repo root is the canonical source; the copies in the plugins +# that carry it are generated by scripts/sync-shared-copies.sh and registered in +# scripts/shared-copies.txt. Bash 3.2-compatible on purpose: no associative +# arrays, no mapfile, no jq. + +set -uo pipefail +# The grammar's character class is ASCII by definition; a collating locale +# would let a bracket range admit letters outside it. +export LC_ALL=C + +BEGIN_MARKER='' +END_MARKER='' +MAX_LINE=4096 + +usage() { + cat <<'EOF' +resolve-convention-home.sh — read the convention-home pointer line. + +Prints the repo-relative convention home named by the first backticked token +inside the `` region of AGENTS.md +(canonical) or CLAUDE.md (unless it is a pure `@AGENTS.md` shim). + +Usage: + resolve-convention-home.sh [--root ] [--ref ] [--explain] [--help] [-] + + --root repository root (default: git toplevel of CLAUDE_PROJECT_DIR, else cwd) + --ref read the files as committed at (40-hex id or origin/) + - read the AGENTS.md text from stdin (not with --root or --ref) + --explain write the file, region, and token used to stderr + +Exit: 0 resolved (home on stdout); 1 no pointer anywhere (ask); 2 usage; + 3 FAIL — two pointers in one region, unterminated region, invalid path, + target directory missing or a symlink, or `declined` beside a pointer; + 4 declined (a `declined` line in the region; do not ask again); + 5 not applicable (root is $HOME, an ancestor of it, or not a git work tree). +EOF +} + +ROOT_ARG="" +HAS_ROOT=0 +REF="" +HAS_REF=0 +STDIN_MODE=0 +EXPLAIN=0 +while [[ $# -gt 0 ]]; do + case "$1" in + -h | --help) + usage + exit 0 + ;; + --root) + if [[ $# -lt 2 ]]; then + echo "ERROR: --root needs a path" >&2 + exit 2 + fi + ROOT_ARG="$2" + HAS_ROOT=1 + shift 2 + ;; + --ref) + if [[ $# -lt 2 ]]; then + echo "ERROR: --ref needs a commit id or origin/" >&2 + exit 2 + fi + HAS_REF=1 + REF="$2" + shift 2 + ;; + -) + STDIN_MODE=1 + shift + ;; + --explain) + EXPLAIN=1 + shift + ;; + *) + echo "ERROR: unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ $STDIN_MODE -eq 1 && ( $HAS_ROOT -eq 1 || $HAS_REF -eq 1 ) ]]; then + echo "ERROR: - (stdin) names no tree; it cannot be combined with --root or --ref" >&2 + exit 2 +fi +if [[ $HAS_REF -eq 1 ]]; then + # Only a full commit id or origin/ passes; nothing that git could read + # as an option, a revision range or a path. + if ! [[ "$REF" =~ ^[0-9a-f]{40}$ || ("$REF" =~ ^origin/[A-Za-z0-9._/-]+$ && "$REF" != *..* && "$REF" != origin/-*) ]]; then + echo "ERROR: invalid --ref; expected a 40-hex commit id or origin/" >&2 + exit 2 + fi +fi + +explain() { [[ $EXPLAIN -eq 1 ]] && echo "$*" >&2; return 0; } + +SCRATCH="" +cleanup() { [[ -z "$SCRATCH" ]] || rm -rf "$SCRATCH"; } +trap cleanup EXIT + +ROOT="" +if [[ $STDIN_MODE -eq 0 ]]; then + if [[ $HAS_ROOT -eq 1 ]]; then + ROOT="$ROOT_ARG" + else + START="${CLAUDE_PROJECT_DIR:-$PWD}" + # tr -d '\r': Git on Windows can return a CRLF-terminated path. + ROOT="$(git -C "$START" rev-parse --show-toplevel 2>/dev/null | tr -d '\r')" + [[ -n "$ROOT" ]] || ROOT="$START" + fi + if [[ ! -d "$ROOT" ]]; then + echo "ERROR: --root is not a directory: $ROOT" >&2 + exit 2 + fi +fi + +# phys : the physical path of an existing file or directory, symlinks +# resolved (realpath, else a readlink walk for macOS before 13). +phys() { + local p="$1" dir n=0 target + if command -v realpath >/dev/null 2>&1; then + realpath -- "$p" 2>/dev/null && return 0 + fi + while [[ -L "$p" && $n -lt 40 ]]; do + target="$(readlink "$p")" || return 1 + case "$target" in + /*) p="$target" ;; + *) p="$(dirname "$p")/$target" ;; + esac + n=$((n + 1)) + done + if [[ -d "$p" ]]; then + (CDPATH='' cd -- "$p" && pwd -P) + else + dir="$(CDPATH='' cd -- "$(dirname "$p")" && pwd -P)" || return 1 + printf '%s/%s\n' "$dir" "$(basename "$p")" + fi +} + +# classify_root: exit 5 when no home can apply to $ROOT. +classify_root() { + local root_p home_p why="" + root_p="$(CDPATH='' cd -- "$ROOT" && pwd -P)" || return 0 + if [[ -n "${HOME:-}" && -d "$HOME" ]]; then + home_p="$(CDPATH='' cd -- "$HOME" && pwd -P)" + if [[ "$root_p" == "$home_p" ]]; then + why="the root is \$HOME" + elif [[ "$root_p" == "/" || "$home_p/" == "$root_p/"* ]]; then + why="the root is an ancestor of \$HOME" + fi + fi + if [[ -z "$why" ]] && ! git -C "$ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + why="the root is not inside a git work tree" + fi + if [[ -n "$why" ]]; then + echo "not applicable: $why ($root_p); no convention home applies" >&2 + exit 5 + fi +} + +trim() { + local s="$1" + # UTF-8 BOM is U+FEFF encoded EF BB BF at the start of the file, so on + # line 1 it is the first three bytes of this string. POSIX [:space:] / + # isspace(3) is space, FF, NL, CR, HT, VT, not BOM. + # https://www.unicode.org/faq/utf_bom.html + s="${s#$'\xEF\xBB\xBF'}" + s="${s#"${s%%[![:space:]]*}"}" + s="${s%"${s##*[![:space:]]}"}" + printf '%s' "$s" +} + +# is_pure_shim : every non-blank line, after CR strip and trim, is +# `@AGENTS.md`, and there is at least one such line. +is_pure_shim() { + local line seen=0 + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%$'\r'}" + line="$(trim "$line")" + [[ -z "$line" ]] && continue + [[ "$line" == "@AGENTS.md" ]] || return 1 + seen=1 + done <"$1" + [[ $seen -eq 1 ]] +} + +# scan_file : sets SCAN_STATE to one of +# none no region in the file +# empty a region exists but holds no pointer line +# one exactly one pointer line; SCAN_TOKEN holds its first backticked token +# many two or more pointer lines +# declined a `declined` line and no pointer line +# mixed a `declined` line beside a pointer line +# unterminated / nested a BEGIN with no END, or a BEGIN inside a region +scan_file() { + local file="$1" line in_region=0 regions=0 pointers=0 declined=0 rest + SCAN_STATE="none" + SCAN_TOKEN="" + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%$'\r'}" + line="${line:0:$MAX_LINE}" + case "$(trim "$line")" in + "$BEGIN_MARKER") + if [[ $in_region -eq 1 ]]; then + SCAN_STATE="nested" + return 0 + fi + in_region=1 + regions=$((regions + 1)) + continue + ;; + "$END_MARKER") + in_region=0 + continue + ;; + declined) + [[ $in_region -eq 1 ]] && declined=1 + continue + ;; + *) ;; + esac + [[ $in_region -eq 1 ]] || continue + case "$line" in + *\`*\`*) + pointers=$((pointers + 1)) + if [[ $pointers -eq 1 ]]; then + rest="${line#*\`}" + SCAN_TOKEN="${rest%%\`*}" + fi + ;; + *) ;; + esac + done <"$file" + if [[ $in_region -eq 1 ]]; then + SCAN_STATE="unterminated" + elif [[ $regions -eq 0 ]]; then + SCAN_STATE="none" + elif [[ $declined -eq 1 && $pointers -gt 0 ]]; then + SCAN_STATE="mixed" + elif [[ $declined -eq 1 ]]; then + SCAN_STATE="declined" + elif [[ $pointers -eq 0 ]]; then + SCAN_STATE="empty" + elif [[ $pointers -eq 1 ]]; then + SCAN_STATE="one" + else + SCAN_STATE="many" + fi +} + +# valid_home : the grammar above. Returns 1 with a reason in +# INVALID_REASON otherwise. +valid_home() { + local t="$1" seg bare rest backslash + backslash=$(printf '%b' '\134') + INVALID_REASON="" + if [[ -z "$t" ]]; then INVALID_REASON="empty"; return 1; fi + case "$t" in + /*) INVALID_REASON="absolute path" ;; + '~'*) INVALID_REASON="home-relative (~) path" ;; + *"$backslash"*) INVALID_REASON="backslash" ;; + *:*) INVALID_REASON="drive letter or colon" ;; + *) ;; + esac + [[ -z "$INVALID_REASON" ]] || return 1 + t="${t%/}" + if [[ -z "$t" ]]; then INVALID_REASON="empty"; return 1; fi + rest="$t/" + while [[ -n "$rest" ]]; do + seg="${rest%%/*}" + rest="${rest#*/}" + # NTFS drops trailing dots, so `.git.` names .git there too. + bare="$seg" + while [[ "$bare" == ?*. ]]; do bare="${bare%.}"; done + case "$bare" in + [.][Gg][Ii][Tt]) INVALID_REASON="a .git segment"; return 1 ;; + *) ;; + esac + case "$seg" in + "") INVALID_REASON="empty path segment"; return 1 ;; + . | ..) INVALID_REASON="dot segment ($seg)"; return 1 ;; + *[!A-Za-z0-9._-]*) INVALID_REASON="characters outside [A-Za-z0-9._/-]"; return 1 ;; + *) ;; + esac + done + return 0 +} + +# committed_file : copy as committed at $REF into . +# Returns 1 when it is absent or not a regular file at $REF (a symlink is not read). +committed_file() { + local entry mode + entry="$(git -C "$ROOT" ls-tree "$REF" -- "$1" 2>/dev/null)" || return 1 + mode="${entry%% *}" + # ./ keeps the path relative to $ROOT, as ls-tree reads it, when $ROOT is below the toplevel. + case "$mode" in + 100644 | 100755) git -C "$ROOT" show --end-of-options "$REF:./$1" >"$2" 2>/dev/null ;; + *) return 1 ;; + esac +} + +# TOKEN here is the parser sense: the pointer line scan_file lifts out of a +# convention-home region, never a credential. gitleaks' generic-api-key rule +# keys on the identifier and then takes the next line it can reach as the +# value, so it reports CLAUDE_SHIM=0 as the secret. Annotated rather than +# renamed, because the name is right for what the variable holds. +AGENTS_STATE="none"; AGENTS_TOKEN="" +CLAUDE_STATE="none"; CLAUDE_TOKEN="" # gitleaks:allow +CLAUDE_SHIM=0 # gitleaks:allow +CLAUDE_SAME=0 # gitleaks:allow +AGENTS_FILE="" +CLAUDE_FILE="" # gitleaks:allow + +if [[ $STDIN_MODE -eq 1 ]]; then + SCRATCH="$(mktemp -d)" || exit 2 + cat >"$SCRATCH/AGENTS.md" + AGENTS_FILE="$SCRATCH/AGENTS.md" +else + classify_root + if [[ $HAS_REF -eq 1 ]]; then + if ! git -C "$ROOT" rev-parse --verify --quiet --end-of-options "$REF^{commit}" >/dev/null; then + echo "ERROR: --ref does not resolve to a commit: $REF" >&2 + exit 2 + fi + SCRATCH="$(mktemp -d)" || exit 2 + committed_file AGENTS.md "$SCRATCH/AGENTS.md" && AGENTS_FILE="$SCRATCH/AGENTS.md" + committed_file CLAUDE.md "$SCRATCH/CLAUDE.md" && CLAUDE_FILE="$SCRATCH/CLAUDE.md" + else + [[ -f "$ROOT/AGENTS.md" ]] && AGENTS_FILE="$ROOT/AGENTS.md" + [[ -f "$ROOT/CLAUDE.md" ]] && CLAUDE_FILE="$ROOT/CLAUDE.md" + if [[ -n "$AGENTS_FILE" && -n "$CLAUDE_FILE" && "$(phys "$AGENTS_FILE")" == "$(phys "$CLAUDE_FILE")" ]]; then + CLAUDE_SAME=1 + CLAUDE_FILE="" + fi + fi +fi + +if [[ -n "$AGENTS_FILE" ]]; then + scan_file "$AGENTS_FILE" + AGENTS_STATE="$SCAN_STATE"; AGENTS_TOKEN="$SCAN_TOKEN" +fi +if [[ -n "$CLAUDE_FILE" ]]; then + if is_pure_shim "$CLAUDE_FILE"; then + CLAUDE_SHIM=1 + else + scan_file "$CLAUDE_FILE" + CLAUDE_STATE="$SCAN_STATE"; CLAUDE_TOKEN="$SCAN_TOKEN" + fi +fi + +if [[ $STDIN_MODE -eq 1 ]]; then + explain "source: stdin" +elif [[ $HAS_REF -eq 1 ]]; then + explain "root: $ROOT at $REF" +else + explain "root: $ROOT" +fi +explain "AGENTS.md: $AGENTS_STATE" +if [[ $CLAUDE_SAME -eq 1 ]]; then + explain "CLAUDE.md: the same file as AGENTS.md (not read twice)" +elif [[ $CLAUDE_SHIM -eq 1 ]]; then + explain "CLAUDE.md: pure @AGENTS.md shim (not consulted)" +else + explain "CLAUDE.md: $CLAUDE_STATE" +fi + +# AGENTS.md is canonical whenever it carries a region at all, even a broken one: +# a FAIL in the canonical file is not something CLAUDE.md gets to paper over. +if [[ "$AGENTS_STATE" != "none" ]]; then + CHOSEN="AGENTS.md"; STATE="$AGENTS_STATE"; TOKEN="$AGENTS_TOKEN" + if [[ "$CLAUDE_STATE" != "none" ]]; then + echo "duplicate: CLAUDE.md also carries a convention-home region; AGENTS.md is canonical, remove the CLAUDE.md copy" >&2 + fi +else + CHOSEN="CLAUDE.md"; STATE="$CLAUDE_STATE"; TOKEN="$CLAUDE_TOKEN" +fi +[[ $STDIN_MODE -eq 1 ]] && CHOSEN="stdin" +explain "chosen: $CHOSEN ($STATE)" + +case "$STATE" in +none) + echo "no convention-home region in AGENTS.md or CLAUDE.md under ${ROOT:-stdin}; ask the operator for the home" >&2 + exit 1 + ;; +empty) + echo "convention-home region in $CHOSEN has no pointer line; ask the operator for the home" >&2 + exit 1 + ;; +declined) + echo "declined: convention-home region in $CHOSEN records that this repository declined a home; use the bundled defaults and do not ask again" >&2 + exit 4 + ;; +mixed) + echo "FAIL: convention-home region in $CHOSEN holds both a declined line and a pointer line; keep one" >&2 + exit 3 + ;; +many) + echo "FAIL: two pointer lines in one convention-home region ($CHOSEN); keep exactly one" >&2 + exit 3 + ;; +unterminated) + echo "FAIL: convention-home region in $CHOSEN has a BEGIN marker with no END marker" >&2 + exit 3 + ;; +nested) + echo "FAIL: convention-home region in $CHOSEN opens a second BEGIN marker before its END" >&2 + exit 3 + ;; +*) ;; +esac + +if ! valid_home "$TOKEN"; then + echo "FAIL: invalid pointer path in $CHOSEN (${INVALID_REASON}); a home is a repo-relative directory of [A-Za-z0-9._-] segments" >&2 + exit 3 +fi +HOME_DIR="${TOKEN%/}" +explain "token: $TOKEN" + +if [[ $STDIN_MODE -eq 1 ]]; then + : # no tree to check the directory against +elif [[ $HAS_REF -eq 1 ]]; then + if [[ "$(git -C "$ROOT" cat-file -t "$REF:./$HOME_DIR" 2>/dev/null)" != "tree" ]]; then + echo "FAIL: pointer target directory missing: $HOME_DIR (named in $CHOSEN, not found at $REF)" >&2 + exit 3 + fi +elif [[ ! -d "$ROOT/$HOME_DIR" ]]; then + echo "FAIL: pointer target directory missing: $HOME_DIR (named in $CHOSEN, not found under $ROOT)" >&2 + exit 3 +elif [[ "$(phys "$ROOT/$HOME_DIR")" != "$(phys "$ROOT")/$HOME_DIR" ]]; then + echo "FAIL: pointer target is reached through a symlink, or named in a case other than on disk: $HOME_DIR (named in $CHOSEN); bind the real directory as spelled on disk" >&2 + exit 3 +fi + +printf '%s\n' "$HOME_DIR" diff --git a/plugins/work-items/schemas/work-items.schema.json b/plugins/work-items/schemas/work-items.schema.json index f6ad42b127b..78a665fe608 100644 --- a/plugins/work-items/schemas/work-items.schema.json +++ b/plugins/work-items/schemas/work-items.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://raw.githubusercontent.com/melodic-software/claude-code-plugins/main/plugins/work-items/schemas/work-items.schema.json", "title": "work-items team config", - "description": "Schema for a repository's docs/conventions/work-items.yaml, the team layer of the work-items plugin's settings (ADR 0064). A key set here wins over the same key in the plugin's userConfig. Readers: /work-items:triage for the triage keys (the /work-items:work-loop admission gate reads the objection window's end from the marker comment triage posts; triage_stale_reporter_info_days is read by the attention view), every lane that follows reference/ai-disclaimer.md for ai_disclaimer, and /work-items:work for work_priority_order.", + "description": "Schema for a repository's docs/conventions/work-items.yaml, the team layer of the work-items plugin's settings (ADR 0064). A key set here wins over the same key in the plugin's userConfig. Readers: /work-items:triage for the triage keys (the /work-items:work-loop admission gate reads the objection window's end from the marker comment triage posts; triage_stale_reporter_info_days is read by the attention view), every lane that follows reference/ai-disclaimer.md for ai_disclaimer, and /work-items:work for work_priority_order. The tracker seam (tools/work-item-tracker/lib/binding.sh) reads tracker, and work-items.local.yaml beside this file may refine its overlayable keys.", "type": "object", "additionalProperties": false, "properties": { @@ -36,6 +36,48 @@ "uniqueItems": true, "default": ["priority: critical", "priority: high", "priority: medium", "priority: low"], "description": "The priority: label names, highest first, that /work-items:work ranks candidates by within each selection tier before the tier's own order (next_due, the guardrails preference, or oldest-first) breaks ties; the work-loop lane ranks its admitted items the same way. A candidate with no priority: label, or one this list does not name, ranks below every listed label. Quote each item (- \"priority: high\"): an unquoted item parses as a mapping and drops out of the list. Absent: the default list." + }, + "tracker": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "provider", "config"], + "description": "The work-item tracker binding: the one active provider the seam (tools/work-item-tracker/CONTRACT.md, Setup) dispatches to. Absent: the seam reads the legacy root .work-item-tracker.json while the migration runs, with a notice, and stops pointing at /work-items:setup when neither exists. In work-items.local.yaml only config.lease_ttl_hours, config.lease_ttl_minutes, the per-provider auth_email/auth_env keys and docs may be set.", + "properties": { + "schema_version": { + "type": "string", + "pattern": "^1\\.", + "description": "Binding format version; quote it (\"1.0\") so it stays a string. Only major version 1 is read." + }, + "provider": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$", + "description": "The adapter directory name: github, local-markdown, jira, gitea, linear, or a consumer-local adapter." + }, + "docs": { + "type": "string", + "description": "A free-form pointer naming what this binding is and where its contract lives; the seam never reads it." + }, + "config": { + "type": "object", + "required": ["lease_ttl_hours"], + "description": "Provider-neutral keys, plus one subtree per provider (github, jira, linear, gitea) whose keys the adapter's README documents.", + "properties": { + "lease_ttl_hours": { "type": "integer", "minimum": 0, "description": "Claim lease lifetime in hours. Required." }, + "lease_ttl_minutes": { "type": "integer", "minimum": 0, "maximum": 59, "description": "Minutes added to lease_ttl_hours. Absent: 0." }, + "storage_dir": { "type": "string", "description": "The local-markdown store, required for that provider. A relative path roots at the repository root." }, + "container_label": { "type": "string", "description": "The label that marks a spec container. Absent or empty: work-map." }, + "role_labels": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Canonical role to label remaps (human-gated, autonomous-eligible, recurring-maintenance); an absent or empty value keeps the default label." + }, + "github": { "type": "object" }, + "jira": { "type": "object" }, + "linear": { "type": "object" }, + "gitea": { "type": "object" } + } + } + } } } } diff --git a/plugins/work-items/tools/work-item-tracker/CONTRACT.md b/plugins/work-items/tools/work-item-tracker/CONTRACT.md index a44d0f3ee96..9efc6dc366c 100644 --- a/plugins/work-items/tools/work-item-tracker/CONTRACT.md +++ b/plugins/work-items/tools/work-item-tracker/CONTRACT.md @@ -111,24 +111,49 @@ as unclaimed coordination (acceptable for a publish, wrong for contended work). ## Setup (binding file) -The repo binds exactly ONE active provider via `.work-item-tracker.json` at the repo root -(tracked, since which tracker a repo uses is repo-scoped). Layering and location are locked by -[ADR 0015](../../../../docs/adr/0015-bind-the-tracker-at-repo-root-with-an-allowlisted-personal-overlay.md): -one team layer at the root, one gitignored personal overlay beside it, deliberately no -user-global layer. +The repo binds exactly ONE active provider under the `tracker` key of `/work-items.yaml` +(tracked, since which tracker a repo uses is repo-scoped), checked against +[`work-items.schema.json`](../../schemas/work-items.schema.json). `` is the convention home +the repository binds in the pointer region of `AGENTS.md` or `CLAUDE.md`, read with the plugin's +copy of `resolve-convention-home.sh`; `docs/conventions` when the repository binds none. Layering +and location follow +[ADR 0075](../../../../docs/adr/0075-resolve-plugin-settings-through-one-four-layer-cascade-and-a-bound-convention-home.md) +fix 11, which supersedes ADR 0015: one team layer in the home, one gitignored personal overlay +beside it, deliberately no user-global layer. The `tracker` mapping has the same keys as the +legacy JSON file, so every adapter reads the same effective JSON. + +```yaml +tracker: + schema_version: "1.0" + provider: github + docs: Work-item tracker binding, see the work-items plugin's tools/work-item-tracker/CONTRACT.md (Setup) + config: + lease_ttl_hours: 24 +``` -```json -{ - "schema_version": "1.0", - "provider": "github", - "docs": "Work-item tracker binding — see the work-items plugin's tools/work-item-tracker/CONTRACT.md (Setup)", - "config": { - "lease_ttl_hours": 24 +The seam reads the file with the plugin's copy of `yaml-subset.awk`, the repository's YAML +subset parser. Every scalar reads as a string (the parser drops quotes, so a quoted id stays +the id), and every reader takes a number or boolean as text. A key under `tracker` that the +schema does not declare, a document the parser rejects (named by file and line), or a rejected +value (named by file, key and value; the overlay when it set the value) is exit `3`. + +- **Legacy fallback (migration).** While the migration runs, when `/work-items.yaml` has + no `tracker` key (or the repository declined a home), the seam still reads + `.work-item-tracker.json` at the repo root and prints a one-line `notice:` on stderr naming + it. The JSON file has the same keys as the `tracker` mapping: + + ```json + { + "schema_version": "1.0", + "provider": "github", + "config": { "lease_ttl_hours": 24 } } -} -``` + ``` -- Discovery: `.work-item-tracker.json` at the **repo root**, meaning `${CLAUDE_PROJECT_DIR}` when + A `work-items.yaml` that does not parse is still read as the binding and fails, never a + silent fall back to the JSON file. A broken pointer region is reported on stderr and the + seam falls back as if no home applied. +- Discovery: the binding resolves from the **repo root**, meaning `${CLAUDE_PROJECT_DIR}` when set, else `git rev-parse --show-toplevel`. Whichever of the two resolves, that one value governs ALL of the seam's repo-relative resolution (binding read, consumer-local adapter dirs, the github adapter's bot-wrapper lookup), so a bare shell that finds the binding also @@ -143,8 +168,10 @@ user-global layer. ancestor/home binding would silently capture every repo beneath it (#2941). Nested per-subdirectory bindings are unsupported until requested. Env override `WORK_ITEM_TRACKER_BINDING=` (tests, conformance). -- **Personal overlay**: an optional gitignored `.work-item-tracker.local.json` beside the - team binding merges **per-key over an allowlist, deny-by-default**. Overlayable keys: +- **Personal overlay**: an optional gitignored overlay beside the team binding, + `/work-items.local.yaml` (its `tracker` mapping) beside a YAML binding or + `.work-item-tracker.local.json` beside the legacy file, merges **per-key over an allowlist, + deny-by-default**. An overlay with no `tracker` key changes nothing. Overlayable keys: `config.lease_ttl_hours`, `config.lease_ttl_minutes` (TTL travels inside each lease record, so a per-user value is coherent), the auth-identity keys of every credentialed adapter, namely `config.jira.auth_email`, `config.jira.auth_env`, `config.linear.auth_env`, and @@ -877,7 +904,8 @@ until those commits are merged. `wit_next_number` is the max existing file number + 1 with no file lock, so two diverged branches (or two writers against one store) can both mint the same next number. -A relative `config.storage_dir` roots against the **binding file's directory**, +A relative `config.storage_dir` roots against the **binding file's directory** +(for a `/work-items.yaml` binding under the repo root, the repo root itself), not the caller's CWD (`lib/binding.sh`). Distinct worktrees that resolve the same binding (e.g. via a shared `CLAUDE_PROJECT_DIR`, since anchored discovery gives each worktree its own git-toplevel binding otherwise) therefore share one store diff --git a/plugins/work-items/tools/work-item-tracker/lib/binding.sh b/plugins/work-items/tools/work-item-tracker/lib/binding.sh index a6ff6c689fb..92870af0fa9 100755 --- a/plugins/work-items/tools/work-item-tracker/lib/binding.sh +++ b/plugins/work-items/tools/work-item-tracker/lib/binding.sh @@ -5,8 +5,17 @@ [[ -n "${_WIT_BINDING_LOADED:-}" ]] && return 0 readonly _WIT_BINDING_LOADED=1 +_WIT_BINDING_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=labels.sh -source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/labels.sh" +source "$_WIT_BINDING_LIB_DIR/labels.sh" + +# The repository's one YAML parser and its convention-home resolver, as the plugin's +# generated copies (scripts/shared-copies.txt). A vendored seam without them reads +# only the legacy JSON binding. +WIT_YAML_PARSER="$_WIT_BINDING_LIB_DIR/../../../skills/triage/scripts/yaml-subset.awk" +WIT_HOME_RESOLVER="$_WIT_BINDING_LIB_DIR/../../../lib/resolve-convention-home.sh" +readonly WIT_DEFAULT_CONVENTION_HOME="docs/conventions" +readonly WIT_LEGACY_BINDING=".work-item-tracker.json" # wit_project_root: the one repo-root anchor every seam resolution uses (CONTRACT.md # "Setup (binding file)"). Fails outside a git repo with no CLAUDE_PROJECT_DIR. @@ -18,18 +27,90 @@ wit_project_root() { git rev-parse --show-toplevel 2>/dev/null } -# wit_find_binding: echo the binding file path. Never a CWD-upward climb: a stray -# ancestor or home binding would silently capture every repo beneath it (#2941). +# wit_convention_home : the repo-relative convention home the pointer region +# binds, or the default home when the repository binds none. Fails when it declined +# a home, when no home applies, or when the pointer is broken (that message passes +# through to stderr). +wit_convention_home() { + local home rc + [[ -f "$WIT_HOME_RESOLVER" && -f "$WIT_YAML_PARSER" ]] || return 1 + home="$(bash "$WIT_HOME_RESOLVER" --root "$1" 2>/dev/null)" + rc=$? + case "$rc" in + 0) printf '%s\n' "$home" ;; + 1) printf '%s\n' "$WIT_DEFAULT_CONVENTION_HOME" ;; + 3) + bash "$WIT_HOME_RESOLVER" --root "$1" >/dev/null + return 1 + ;; + *) return 1 ;; + esac +} + +# wit_yaml_records : the parser's `dotted.keyvalue` records. On a parse +# error, names the file and line on stderr and fails. +wit_yaml_records() { + local records + if ! records="$(LC_ALL=C awk -f "$WIT_YAML_PARSER" <"$1")"; then + printf 'work-item-tracker: %s: line %s\n' "$1" \ + "$(awk -F '\t' '$1 == "error" { print $2 ": " $3 }' <<<"$records" | tail -n 1)" >&2 + return 1 + fi + printf '%s\n' "$records" +} + +# wit_yaml_has_binding : true when the file sets a `tracker` key, or does not +# parse (so the read names the error rather than falling back to the legacy file). +wit_yaml_has_binding() { + local records + records="$(wit_yaml_records "$1" 2>/dev/null)" || return 0 + grep -q $'^tracker[.\t]' <<<"$records" +} + +# wit_yaml_binding_json : the file's `tracker` mapping as one JSON object. +# Every scalar stays a string: the parser drops quotes, so a type guessed from the +# text would turn a quoted id such as "10000" into a number. Every reader takes +# numbers and booleans as text (jq -r, then a regex or string compare). +wit_yaml_binding_json() { + local records json bad + records="$(wit_yaml_records "$1")" || return 1 + json="$(jq -R -n -c ' + [inputs | select(startswith("tracker.") or startswith("tracker\t")) + | split("\t") as $f + | {path: ($f[0] | split(".")[1:] | map(if test("^(0|[1-9][0-9]*)$") then tonumber else . end)), + value: ($f[1:] | join("\t"))}] + | reduce .[] as $r ({}; setpath($r.path; $r.value))' <<<"$records")" || return 1 + bad="$(jq -r 'if type != "object" then "tracker" + else keys_unsorted - ["schema_version", "provider", "config", "docs"] | map("tracker." + .) | join(", ") end' <<<"$json")" + if [[ -n "$bad" ]]; then + printf 'work-item-tracker: %s: %s is not a binding key (work-items.schema.json, tracker)\n' "$1" "$bad" >&2 + return 1 + fi + printf '%s\n' "$json" +} + +# wit_find_binding: echo the binding file path: the env override, else the `tracker` +# mapping of /work-items.yaml, else the legacy root .work-item-tracker.json. +# Never a CWD-upward climb: a stray ancestor or home binding would silently capture +# every repo beneath it (#2941). wit_find_binding() { if [[ -n "${WORK_ITEM_TRACKER_BINDING:-}" ]]; then [[ -f "$WORK_ITEM_TRACKER_BINDING" ]] || return 1 printf '%s\n' "$WORK_ITEM_TRACKER_BINDING" return 0 fi - local root + local root home yaml root="$(wit_project_root)" || return 1 - [[ -f "$root/.work-item-tracker.json" ]] || return 1 - printf '%s\n' "$root/.work-item-tracker.json" + [[ -n "$root" ]] || return 1 + if home="$(wit_convention_home "$root")"; then + yaml="$root/$home/work-items.yaml" + if [[ -f "$yaml" ]] && wit_yaml_has_binding "$yaml"; then + printf '%s\n' "$yaml" + return 0 + fi + fi + [[ -f "$root/$WIT_LEGACY_BINDING" ]] || return 1 + printf '%s\n' "$root/$WIT_LEGACY_BINDING" } # The only keys the per-user overlay may refine; any other overlay key is a config @@ -37,25 +118,37 @@ wit_find_binding() { readonly WIT_OVERLAY_ALLOWED_PATHS='[["config","lease_ttl_hours"],["config","lease_ttl_minutes"],["config","jira","auth_email"],["config","jira","auth_env"],["config","linear","auth_env"],["config","gitea","auth_env"],["docs"]]' # wit_find_overlay : echo the overlay path beside whatever binding -# resolved, or fail when none exists. +# resolved (work-items.local.yaml beside a YAML binding, else +# .work-item-tracker.local.json), or fail when none exists. wit_find_overlay() { - local dir + local dir name=".work-item-tracker.local.json" + [[ "$1" == *.yaml ]] && name="work-items.local.yaml" dir="$(cd "$(dirname "$1")" && pwd)" || return 1 - [[ -f "$dir/.work-item-tracker.local.json" ]] || return 1 - printf '%s\n' "$dir/.work-item-tracker.local.json" + [[ -f "$dir/$name" ]] || return 1 + printf '%s\n' "$dir/$name" +} + +# wit_binding_layer_json : one layer as a JSON value: the `tracker` mapping of +# a YAML file, else the whole JSON file. +wit_binding_layer_json() { + if [[ "$1" == *.yaml ]]; then + wit_yaml_binding_json "$1" + else + jq -e -c . "$1" 2>/dev/null + fi } # wit_effective_binding_json : the team file merged with allowlisted # overlay keys. A bad overlay fails loudly: it must never silently reshape team state. wit_effective_binding_json() { - local path="$1" overlay bad - jq -e . "$path" >/dev/null 2>&1 || return 1 + local path="$1" team overlay overlay_json bad + team="$(wit_binding_layer_json "$path")" || return 1 if ! overlay="$(wit_find_overlay "$path")"; then - jq -c . "$path" + printf '%s\n' "$team" return 0 fi - if ! jq -e . "$overlay" >/dev/null 2>&1; then - printf 'work-item-tracker: overlay %s is not valid JSON\n' "$overlay" >&2 + if ! overlay_json="$(wit_binding_layer_json "$overlay")"; then + printf 'work-item-tracker: overlay %s does not parse as a binding layer\n' "$overlay" >&2 return 1 fi # Each path must be an allowlisted SCALAR or an object prefix of one; a leaf-only check @@ -68,7 +161,7 @@ wit_effective_binding_json() { | ((any($allowed[]; . == $p) and $t != "object" and $t != "array") or ($t == "object" and any($allowed[]; (length > ($p | length)) and (.[:($p | length)] == $p))))) - | not))' "$overlay")" + | not))' <<<"$overlay_json")" if [[ "$bad" != "[]" ]]; then printf 'work-item-tracker: overlay %s sets non-overlayable key(s) %s — only lease TTL and per-provider auth identity (jira, linear, gitea) may be personal; see CONTRACT.md "Setup (binding file)"\n' \ "$overlay" "$bad" >&2 @@ -80,50 +173,82 @@ wit_effective_binding_json() { .[0] as $team | .[1] as $o | [$o | paths(type != "object")] as $present | reduce ($allowed[] | . as $p | select(any($present[]; . == $p))) as $p - ($team; setpath($p; $o | getpath($p)))' "$path" "$overlay" + ($team; setpath($p; $o | getpath($p)))' <<<"$team"$'\n'"$overlay_json" } -# wit_role_label : the configured label, or when absent -# OR empty (jq's `//` alone would let a configured empty string win). +# wit_role_label : the configured label, or +# when absent OR empty (jq's `//` alone would let a configured empty string win). wit_role_label() { local configured - configured="$(jq -r --arg role "$2" '.config.role_labels[$role] // empty' "$1")" + configured="$(jq -r --arg role "$2" '.config.role_labels[$role] // empty' <<<"$1")" printf '%s\n' "${configured:-$3}" } +# wit_invalid : name the file (the overlay when it sets the key), +# key and value of a rejected binding value on stderr (ADR 0075 "The invalid-value +# rule"), and fail. +wit_invalid() { + local file="$1" key="$2" overlay present + if overlay="$(wit_find_overlay "$1")"; then + present="$(wit_binding_layer_json "$overlay" 2>/dev/null | + jq --arg k "$2" '($k | split(".")) as $p | any(paths; . == $p)' 2>/dev/null)" + [[ "$present" == "true" ]] && file="$overlay" + fi + [[ "$file" == *.yaml ]] && key="tracker.$key" + printf 'work-item-tracker: %s: invalid %s: %s\n' "$file" "$key" "${3:-(absent)}" >&2 + return 1 +} + +# wit_storage_root : where a relative storage_dir roots: the project root for +# a YAML binding under it (the home is a subdirectory), else the binding's directory. +wit_storage_root() { + local root + root="$(wit_project_root 2>/dev/null)" + if [[ "$1" == *.yaml && -n "$root" && "$1" == "$root"/* ]]; then + printf '%s\n' "$root" + else + (cd "$(dirname "$1")" && pwd) + fi +} + # wit_read_binding : validate shape and export the WIT_* binding values. # lease_ttl_hours is REQUIRED: defaults live in the binding, never in code. wit_read_binding() { local path="$1" json version provider ttl storage human_gated autonomous_eligible recurring_maintenance container container_type minutes json="$(wit_effective_binding_json "$path")" || return 1 + if [[ "$path" == */"$WIT_LEGACY_BINDING" && "$path" != "${WORK_ITEM_TRACKER_BINDING:-}" ]]; then + printf 'work-item-tracker: notice: read the legacy binding %s; move it under tracker: in /work-items.yaml (CONTRACT.md "Setup (binding file)")\n' "$path" >&2 + fi version="$(jq -r '.schema_version // empty' <<<"$json")" - [[ "$version" == 1.* ]] || return 1 + [[ "$version" == 1.* ]] || { wit_invalid "$path" schema_version "$version"; return 1; } provider="$(jq -r '.provider // empty' <<<"$json")" - [[ -n "$provider" ]] || return 1 # The provider name becomes an adapter directory path: a bare segment only, so a # binding value can never traverse outside adapters/. - [[ "$provider" =~ ^[a-zA-Z0-9_-]+$ ]] || return 1 + [[ "$provider" =~ ^[a-zA-Z0-9_-]+$ ]] || { wit_invalid "$path" provider "$provider"; return 1; } ttl="$(jq -r '.config.lease_ttl_hours // empty' <<<"$json")" - [[ "$ttl" =~ ^[0-9]+$ ]] || return 1 + [[ "$ttl" =~ ^[0-9]+$ ]] || { wit_invalid "$path" config.lease_ttl_hours "$ttl"; return 1; } minutes="$(jq -r '.config.lease_ttl_minutes // 0' <<<"$json")" - [[ "$minutes" =~ ^[0-9]+$ && "$minutes" -le 59 ]] || return 1 + [[ "$minutes" =~ ^[0-9]+$ && "$minutes" -le 59 ]] || { wit_invalid "$path" config.lease_ttl_minutes "$minutes"; return 1; } storage="$(jq -r '.config.storage_dir // empty' <<<"$json")" if [[ "$provider" == "local-markdown" && -z "$storage" ]]; then + wit_invalid "$path" config.storage_dir "$storage" return 1 fi - # A relative storage_dir is rooted at the binding's directory, never the caller's - # CWD, so a verb run from a subdirectory reads the same store. + # A relative storage_dir is rooted at the binding's directory (a YAML binding's + # project root), never the caller's CWD, so a verb run from a subdirectory reads + # the same store. if [[ -n "$storage" && "$storage" != /* ]]; then - storage="$(cd "$(dirname "$path")" && pwd)/$storage" + storage="$(wit_storage_root "$path")/$storage" fi - human_gated="$(wit_role_label "$path" "human-gated" "$WIT_DEFAULT_HUMAN_GATED_LABEL")" - autonomous_eligible="$(wit_role_label "$path" "autonomous-eligible" "$WIT_DEFAULT_AUTONOMOUS_ELIGIBLE_LABEL")" - recurring_maintenance="$(wit_role_label "$path" "recurring-maintenance" "$WIT_DEFAULT_RECURRING_MAINTENANCE_LABEL")" + human_gated="$(wit_role_label "$json" "human-gated" "$WIT_DEFAULT_HUMAN_GATED_LABEL")" + autonomous_eligible="$(wit_role_label "$json" "autonomous-eligible" "$WIT_DEFAULT_AUTONOMOUS_ELIGIBLE_LABEL")" + recurring_maintenance="$(wit_role_label "$json" "recurring-maintenance" "$WIT_DEFAULT_RECURRING_MAINTENANCE_LABEL")" # A present non-string container_label is a config error, not a fallback: jq -r would # stringify it into a label no item carries, letting containers onto the frontier. - container_type="$(jq -r '.config.container_label | type' "$path")" - [[ "$container_type" == "null" || "$container_type" == "string" ]] || return 1 - container="$(jq -r '.config.container_label // empty' "$path")" + container_type="$(jq -r '.config.container_label | type' <<<"$json")" + [[ "$container_type" == "null" || "$container_type" == "string" ]] || + { wit_invalid "$path" config.container_label "$(jq -c '.config.container_label' <<<"$json")"; return 1; } + container="$(jq -r '.config.container_label // empty' <<<"$json")" container="${container:-$WIT_DEFAULT_CONTAINER_LABEL}" WIT_PROVIDER="$provider" WIT_LEASE_TTL_HOURS="$ttl" diff --git a/plugins/work-items/tools/work-item-tracker/lib/binding.test.sh b/plugins/work-items/tools/work-item-tracker/lib/binding.test.sh index a35ae0044af..c2076b84dea 100755 --- a/plugins/work-items/tools/work-item-tracker/lib/binding.test.sh +++ b/plugins/work-items/tools/work-item-tracker/lib/binding.test.sh @@ -279,4 +279,148 @@ assert_overlay_rejected "object-valued gitea auth_env rejected" '{"config":{"git rm -f "$OVERLAY" +# --- convention home: the `tracker` mapping of /work-items.yaml --- + +# yaml_repo : a git repository with the default home, echoed. +yaml_repo() { + local dir="$TEST_TMPDIR/$1" + mkdir -p "$dir/docs/conventions" "$dir/deep" + git init -q "$dir" + printf '%s\n' "$dir" +} + +YAML_BINDING='ai_disclaimer: false +tracker: + schema_version: "1.0" + provider: github + config: + lease_ttl_hours: 12 + role_labels: + human-gated: hold' + +YREPO="$(yaml_repo yaml-repo)" +write_binding "$YREPO/docs/conventions/work-items.yaml" "$YAML_BINDING" +write_binding "$YREPO/.work-item-tracker.json" '{"schema_version":"1.0","provider":"local-markdown","config":{"lease_ttl_hours":1,"storage_dir":"x"}}' +OUT="$(cd "$YREPO/deep" && unset WORK_ITEM_TRACKER_BINDING CLAUDE_PROJECT_DIR && wit_find_binding)" +assert_eq "YAML binding in the default home wins over the legacy JSON" "$YREPO/docs/conventions/work-items.yaml" "$OUT" +if wit_read_binding "$OUT" 2>"$TEST_TMPDIR/yaml.err"; then + pass "YAML binding accepted" + assert_eq "YAML provider exported" "github" "$WIT_PROVIDER" + assert_eq "YAML lease_ttl_hours exported" "12" "$WIT_LEASE_TTL_HOURS" + assert_eq "YAML role label remap exported" "hold" "$WIT_HUMAN_GATED_LABEL" +else + fail "YAML binding accepted" "success" "failure" +fi +assert_not_contains "YAML binding prints no legacy notice" "$(<"$TEST_TMPDIR/yaml.err")" "notice" + +# Scalars stay strings and sequences become arrays: a jira resolution id is a +# digit string the adapter requires as a string. +write_binding "$TEST_TMPDIR/ids.yaml" 'tracker: + config: + jira: + resolutions: + completed: ["10000"] + wont_do: + - 10001' +assert_eq "YAML scalars stay strings inside arrays" '{"completed":["10000"],"wont_do":["10001"]}' \ + "$(wit_yaml_binding_json "$TEST_TMPDIR/ids.yaml" | jq -c '.config.jira.resolutions')" + +# The pointer region binds a home other than the default. +PREPO="$(yaml_repo pointer-repo)" +mkdir -p "$PREPO/team/conf" +# shellcheck disable=SC2016 # the backticks are the pointer grammar, not a command substitution +printf '%s\n' '' 'Conventions live in `team/conf`.' '' >"$PREPO/AGENTS.md" +write_binding "$PREPO/team/conf/work-items.yaml" "$YAML_BINDING" +OUT="$(unset WORK_ITEM_TRACKER_BINDING && CLAUDE_PROJECT_DIR="$PREPO" wit_find_binding)" +assert_eq "YAML binding read from the home the pointer region binds" "$PREPO/team/conf/work-items.yaml" "$OUT" + +# A relative storage_dir in a YAML binding roots at the project root, not the home. +write_binding "$PREPO/team/conf/work-items.yaml" 'tracker: + schema_version: "1.0" + provider: local-markdown + config: + lease_ttl_hours: 24 + storage_dir: .work-items' +OUT="$(CLAUDE_PROJECT_DIR="$PREPO" && export CLAUDE_PROJECT_DIR && wit_read_binding "$PREPO/team/conf/work-items.yaml" && printf '%s\n' "$WIT_STORAGE_DIR")" +assert_eq "YAML relative storage_dir roots at the project root" "$PREPO/.work-items" "$OUT" + +# --- YAML plus the personal overlay work-items.local.yaml --- + +YOVERLAY="$YREPO/docs/conventions/work-items.local.yaml" +write_binding "$YOVERLAY" 'tracker: + config: + lease_ttl_hours: 2' +if wit_read_binding "$YREPO/docs/conventions/work-items.yaml" 2>/dev/null; then + assert_eq "YAML overlay lease_ttl_hours wins" "2" "$WIT_LEASE_TTL_HOURS" + assert_eq "team YAML provider survives the overlay" "github" "$WIT_PROVIDER" +else + fail "YAML plus overlay accepted" "success" "failure" +fi +write_binding "$YOVERLAY" 'tracker: + config: + lease_ttl_hours: later' +ERR="$(wit_read_binding "$YREPO/docs/conventions/work-items.yaml" 2>&1 >/dev/null)" +assert_contains "an invalid overlay value names the overlay file" "$ERR" "$YOVERLAY: invalid tracker.config.lease_ttl_hours: later" +write_binding "$YOVERLAY" 'tracker: + provider: local-markdown' +assert_fails "YAML overlay provider override rejected" "failure" "success" \ + wit_read_binding "$YREPO/docs/conventions/work-items.yaml" +write_binding "$YOVERLAY" 'ai_disclaimer: true' +assert_succeeds "YAML overlay with no tracker key is inert" "success" "failure" \ + wit_read_binding "$YREPO/docs/conventions/work-items.yaml" +rm -f "$YOVERLAY" + +# --- legacy JSON fallback, with a one-line notice, when the YAML has no binding --- + +JREPO="$(yaml_repo json-fallback-repo)" +write_binding "$JREPO/docs/conventions/work-items.yaml" 'ai_disclaimer: false' +write_binding "$JREPO/.work-item-tracker.json" "$VALID" +OUT="$(unset WORK_ITEM_TRACKER_BINDING && CLAUDE_PROJECT_DIR="$JREPO" wit_find_binding)" +assert_eq "legacy JSON read when the YAML has no tracker key" "$JREPO/.work-item-tracker.json" "$OUT" +ERR="$(unset WORK_ITEM_TRACKER_BINDING && wit_read_binding "$OUT" 2>&1 >/dev/null)" +assert_contains "legacy JSON read prints a notice" "$ERR" "notice: read the legacy binding $JREPO/.work-item-tracker.json" +assert_eq "the notice is one line" "1" "$(printf '%s\n' "$ERR" | wc -l | tr -d ' ')" + +# --- neither: no YAML binding and no legacy JSON → no binding (the caller stops) --- + +NREPO="$(yaml_repo neither-repo)" +write_binding "$NREPO/docs/conventions/work-items.yaml" 'ai_disclaimer: false' +if (unset WORK_ITEM_TRACKER_BINDING && CLAUDE_PROJECT_DIR="$NREPO" wit_find_binding >/dev/null); then + fail "neither YAML nor JSON binding → discovery fails" "failure" "success" +else + pass "neither YAML nor JSON binding → discovery fails" +fi + +# --- invalid YAML: the error names the file, the key and the value --- + +IREPO="$(yaml_repo invalid-repo)" +IYAML="$IREPO/docs/conventions/work-items.yaml" +write_binding "$IYAML" 'tracker: + schema_version: "1.0" + provider: github + config: + lease_ttl_hours: soon' +OUT="$(unset WORK_ITEM_TRACKER_BINDING && CLAUDE_PROJECT_DIR="$IREPO" wit_find_binding)" +assert_eq "an invalid YAML binding is still the binding (no JSON fallback)" "$IYAML" "$OUT" +ERR="$(wit_read_binding "$IYAML" 2>&1 >/dev/null)" +assert_eq "invalid YAML value rejected" "1" "$?" +assert_contains "invalid YAML error names the file" "$ERR" "$IYAML" +assert_contains "invalid YAML error names the key" "$ERR" "tracker.config.lease_ttl_hours" +assert_contains "invalid YAML error names the value" "$ERR" "soon" + +write_binding "$IYAML" 'tracker: + schema_version: "1.0" + provider: github + owner: someone + config: + lease_ttl_hours: 24' +ERR="$(wit_read_binding "$IYAML" 2>&1 >/dev/null)" +assert_contains "a key outside the schema is named" "$ERR" "tracker.owner" + +write_binding "$IYAML" 'tracker: + provider: github + provider: jira' +ERR="$(wit_read_binding "$IYAML" 2>&1 >/dev/null)" +assert_contains "a YAML parse error names the file and line" "$ERR" "$IYAML: line 3" + [[ $FAILED -eq 0 ]] || exit 1 diff --git a/plugins/work-items/tools/work-item-tracker/work-item-tracker.sh b/plugins/work-items/tools/work-item-tracker/work-item-tracker.sh index 0c3858a2313..226b09d4eae 100755 --- a/plugins/work-items/tools/work-item-tracker/work-item-tracker.sh +++ b/plugins/work-items/tools/work-item-tracker/work-item-tracker.sh @@ -115,7 +115,7 @@ main() { local binding_path binding_path="$(wit_find_binding)" || - fail_config "no binding found (.work-item-tracker.json) — see CONTRACT.md Setup" + fail_config "no binding found (tracker: in /work-items.yaml, or the legacy .work-item-tracker.json) — run /work-items:setup; see CONTRACT.md Setup" wit_read_binding "$binding_path" || fail_config "invalid binding at $binding_path — run /work-items:setup check for the itemized breakdown; see CONTRACT.md Setup" diff --git a/plugins/work-items/tools/work-item-tracker/work-item-tracker.test.sh b/plugins/work-items/tools/work-item-tracker/work-item-tracker.test.sh index bfb418bcc07..a6ac321a52f 100755 --- a/plugins/work-items/tools/work-item-tracker/work-item-tracker.test.sh +++ b/plugins/work-items/tools/work-item-tracker/work-item-tracker.test.sh @@ -72,8 +72,9 @@ assert_eq "unknown verb → exit 2" "2" "$?" # --- binding errors → exit 3 --- -(cd "$TEST_TMPDIR" && WORK_ITEM_TRACKER_BINDING="$TEST_TMPDIR/nope.json" bash "$DISPATCHER" capabilities >/dev/null 2>&1) +ERR="$(cd "$TEST_TMPDIR" && WORK_ITEM_TRACKER_BINDING="$TEST_TMPDIR/nope.json" bash "$DISPATCHER" capabilities 2>&1 >/dev/null)" assert_eq "missing binding → exit 3" "3" "$?" +assert_contains "missing binding points at /work-items:setup" "$ERR" "/work-items:setup" BAD="$TEST_TMPDIR/bad.json" printf 'nope\n' >"$BAD" diff --git a/scripts/shared-copies.txt b/scripts/shared-copies.txt index e18e017d956..8698945c083 100644 --- a/scripts/shared-copies.txt +++ b/scripts/shared-copies.txt @@ -103,6 +103,7 @@ lib/resolve-convention-home.sh plugins/architecture/lib/resolve-convention-home. lib/resolve-convention-home.sh plugins/planning/lib/resolve-convention-home.sh lib/resolve-convention-home.sh plugins/user-experience/lib/resolve-convention-home.sh lib/resolve-convention-home.sh plugins/developer-experience/lib/resolve-convention-home.sh +lib/resolve-convention-home.sh plugins/work-items/lib/resolve-convention-home.sh lib/parse-concern-value.sh plugins/harness-memory/skills/audit/scripts/parse-concern-value.sh lib/parse-concern-value.sh plugins/session-flow/skills/retro/scripts/parse-concern-value.sh lib/parse-concern-value.sh plugins/discovery/skills/setup/scripts/parse-concern-value.sh From b9fbd07b1b022b607601092cdcf885de2792b661 Mon Sep 17 00:00:00 2001 From: "melodic-automation-lanes[bot]" <337595936+melodic-automation-lanes[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:16:13 +0000 Subject: [PATCH 2/3] Address review feedback on work-resolve-issue/issue-11872-41293949676-1 Lane-Activity: pr-unblock/address-feedback Lane-Thread: PRRT_kwDOTCGFQM6yZ_i8 --- .../work-items/tools/work-item-tracker/CONTRACT.md | 5 ++++- .../tools/work-item-tracker/lib/binding.sh | 5 +++-- .../tools/work-item-tracker/lib/binding.test.sh | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/plugins/work-items/tools/work-item-tracker/CONTRACT.md b/plugins/work-items/tools/work-item-tracker/CONTRACT.md index e0f92c82c8a..9db453c6e6a 100644 --- a/plugins/work-items/tools/work-item-tracker/CONTRACT.md +++ b/plugins/work-items/tools/work-item-tracker/CONTRACT.md @@ -140,7 +140,10 @@ value (named by file, key and value; the overlay when it set the value) is exit - **Legacy fallback (migration).** While the migration runs, when `/work-items.yaml` has no `tracker` key (or the repository declined a home), the seam still reads `.work-item-tracker.json` at the repo root and prints a one-line `notice:` on stderr naming - it. The JSON file has the same keys as the `tracker` mapping: + it. A repo root where no home applies (it is `$HOME`, an ancestor of `$HOME`, or not inside + a git work tree: ADR 0075 fix 5) reads no `work-items.yaml` at all, not even under + `docs/conventions`; only the legacy JSON file binds it. The JSON file has the same keys as + the `tracker` mapping: ```json { diff --git a/plugins/work-items/tools/work-item-tracker/lib/binding.sh b/plugins/work-items/tools/work-item-tracker/lib/binding.sh index 92870af0fa9..e8fcbbe7117 100755 --- a/plugins/work-items/tools/work-item-tracker/lib/binding.sh +++ b/plugins/work-items/tools/work-item-tracker/lib/binding.sh @@ -29,8 +29,9 @@ wit_project_root() { # wit_convention_home : the repo-relative convention home the pointer region # binds, or the default home when the repository binds none. Fails when it declined -# a home, when no home applies, or when the pointer is broken (that message passes -# through to stderr). +# a home, when no home applies (exit 5: the root is $HOME, an ancestor of it, or not +# in a git work tree, so only the legacy JSON can bind it), or when the pointer is +# broken (that message passes through to stderr). wit_convention_home() { local home rc [[ -f "$WIT_HOME_RESOLVER" && -f "$WIT_YAML_PARSER" ]] || return 1 diff --git a/plugins/work-items/tools/work-item-tracker/lib/binding.test.sh b/plugins/work-items/tools/work-item-tracker/lib/binding.test.sh index c2076b84dea..eb2f92727b8 100755 --- a/plugins/work-items/tools/work-item-tracker/lib/binding.test.sh +++ b/plugins/work-items/tools/work-item-tracker/lib/binding.test.sh @@ -391,6 +391,20 @@ else pass "neither YAML nor JSON binding → discovery fails" fi +# --- no home applies: a root outside a git work tree reads only the legacy JSON --- + +LOOSE="$TEST_TMPDIR/loose-folder" +mkdir -p "$LOOSE/docs/conventions" +write_binding "$LOOSE/docs/conventions/work-items.yaml" "$YAML_BINDING" +if (unset WORK_ITEM_TRACKER_BINDING && CLAUDE_PROJECT_DIR="$LOOSE" wit_find_binding >/dev/null); then + fail "a non-git root never reads /work-items.yaml" "failure" "success" +else + pass "a non-git root never reads /work-items.yaml" +fi +write_binding "$LOOSE/.work-item-tracker.json" "$VALID" +OUT="$(unset WORK_ITEM_TRACKER_BINDING && CLAUDE_PROJECT_DIR="$LOOSE" wit_find_binding)" +assert_eq "a non-git root binds through the legacy JSON only" "$LOOSE/.work-item-tracker.json" "$OUT" + # --- invalid YAML: the error names the file, the key and the value --- IREPO="$(yaml_repo invalid-repo)" From 681ce3a32159a4ed20cf6050a7b275b66b303fad Mon Sep 17 00:00:00 2001 From: "melodic-automation-lanes[bot]" <337595936+melodic-automation-lanes[bot]@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:10:41 +0000 Subject: [PATCH 3/3] Address review feedback on work-resolve-issue/issue-11872-41293949676-1 Lane-Activity: pr-unblock/address-feedback Lane-Thread: PRRT_kwDOTCGFQM6yZWq6 --- .../tools/work-item-tracker/CONTRACT.md | 6 ++- .../tools/work-item-tracker/lib/binding.sh | 49 ++++++++++++++++--- .../work-item-tracker/lib/binding.test.sh | 29 +++++++++++ 3 files changed, 76 insertions(+), 8 deletions(-) diff --git a/plugins/work-items/tools/work-item-tracker/CONTRACT.md b/plugins/work-items/tools/work-item-tracker/CONTRACT.md index 9db453c6e6a..0d286bf3f5b 100644 --- a/plugins/work-items/tools/work-item-tracker/CONTRACT.md +++ b/plugins/work-items/tools/work-item-tracker/CONTRACT.md @@ -133,7 +133,11 @@ tracker: The seam reads the file with the plugin's copy of `yaml-subset.awk`, the repository's YAML subset parser. Every scalar reads as a string (the parser drops quotes, so a quoted id stays -the id), and every reader takes a number or boolean as text. A key under `tracker` that the +the id), and every reader takes a number or boolean as text. An empty flow sequence (`[]`) +reads as an empty array, as the legacy JSON file writes it, so `done_category_keys: []` is +present and judged like the JSON value (jira rejects an empty set) instead of taking the +default, and `wont_do: []` keeps its key. One difference remains: a key with no value reads +as absent, as though left out, never as a JSON `null`. A key under `tracker` that the schema does not declare, a document the parser rejects (named by file and line), or a rejected value (named by file, key and value; the overlay when it set the value) is exit `3`. diff --git a/plugins/work-items/tools/work-item-tracker/lib/binding.sh b/plugins/work-items/tools/work-item-tracker/lib/binding.sh index e8fcbbe7117..32e53906f94 100755 --- a/plugins/work-items/tools/work-item-tracker/lib/binding.sh +++ b/plugins/work-items/tools/work-item-tracker/lib/binding.sh @@ -16,6 +16,11 @@ WIT_YAML_PARSER="$_WIT_BINDING_LIB_DIR/../../../skills/triage/scripts/yaml-subse WIT_HOME_RESOLVER="$_WIT_BINDING_LIB_DIR/../../../lib/resolve-convention-home.sh" readonly WIT_DEFAULT_CONVENTION_HOME="docs/conventions" readonly WIT_LEGACY_BINDING=".work-item-tracker.json" +# What an empty flow sequence reads as while the parser reads a binding, which turns +# back into []. It opens with a control character no YAML scalar can hold (raw, it is +# outside YAML's character set; escaped, the subset rejects it), so it never matches +# a value a file sets. +readonly WIT_YAML_EMPTY_SEQ=$'\002[]' # wit_project_root: the one repo-root anchor every seam resolution uses (CONTRACT.md # "Setup (binding file)"). Fails outside a git repo with no CLAUDE_PROJECT_DIR. @@ -48,11 +53,38 @@ wit_convention_home() { esac } -# wit_yaml_records : the parser's `dotted.keyvalue` records. On a parse -# error, names the file and line on stderr and fails. +# wit_yaml_mark_empty_seqs []: stdin to stdout, line for line, with each empty +# flow sequence in a value position (`key: []`, `- key: []`, `- []`) replaced by +# as a double-quoted scalar. The parser prints nothing for `[]` and a record +# for the scalar. The line count is kept, so a parse error still names the file's own +# line. With no marker, a plain copy. +wit_yaml_mark_empty_seqs() { + if [[ -z "${1:-}" ]]; then + cat + return + fi + # The lead is the indent, then `- `, a plain or quoted key and its colon, or both, + # exactly as the parser reads them; \047 is a single quote. + LC_ALL=C awk -v marker="$1" ' + BEGIN { + key = "([A-Za-z0-9_-]+|\"([^\"\\\\]|\\\\.)*\"|\047([^\047]|\047\047)*\047)" + lead = "^ *((- +)?" key "[ \t]*:[ \t]+|- +)" + } + { + sub(/\r$/, "") + if (NR == 1) sub(/^\357\273\277/, "") + if (match($0, lead) && substr($0, RLENGTH + 1) ~ /^\[[ \t]*\][ \t]*(#.*)?$/) + $0 = substr($0, 1, RLENGTH) "\"" marker "\"" + print + }' +} + +# wit_yaml_records []: the parser's `dotted.keyvalue` records, each +# empty flow sequence printed as when one is given (wit_yaml_mark_empty_seqs). +# On a parse error, names the file and line on stderr and fails. wit_yaml_records() { local records - if ! records="$(LC_ALL=C awk -f "$WIT_YAML_PARSER" <"$1")"; then + if ! records="$({ wit_yaml_mark_empty_seqs "${2:-}" | LC_ALL=C awk -f "$WIT_YAML_PARSER"; } <"$1")"; then printf 'work-item-tracker: %s: line %s\n' "$1" \ "$(awk -F '\t' '$1 == "error" { print $2 ": " $3 }' <<<"$records" | tail -n 1)" >&2 return 1 @@ -71,15 +103,18 @@ wit_yaml_has_binding() { # wit_yaml_binding_json : the file's `tracker` mapping as one JSON object. # Every scalar stays a string: the parser drops quotes, so a type guessed from the # text would turn a quoted id such as "10000" into a number. Every reader takes -# numbers and booleans as text (jq -r, then a regex or string compare). +# numbers and booleans as text (jq -r, then a regex or string compare). An empty flow +# sequence stays [], as the legacy JSON file writes it: jira and linear tell a present +# empty list (a config error) from an absent key (the default) with has(). A key with +# no value reads as absent (CONTRACT.md "Setup (binding file)"). wit_yaml_binding_json() { local records json bad - records="$(wit_yaml_records "$1")" || return 1 - json="$(jq -R -n -c ' + records="$(wit_yaml_records "$1" "$WIT_YAML_EMPTY_SEQ")" || return 1 + json="$(jq -R -n -c --arg emptyseq "$WIT_YAML_EMPTY_SEQ" ' [inputs | select(startswith("tracker.") or startswith("tracker\t")) | split("\t") as $f | {path: ($f[0] | split(".")[1:] | map(if test("^(0|[1-9][0-9]*)$") then tonumber else . end)), - value: ($f[1:] | join("\t"))}] + value: ($f[1:] | join("\t") | if . == $emptyseq then [] else . end)}] | reduce .[] as $r ({}; setpath($r.path; $r.value))' <<<"$records")" || return 1 bad="$(jq -r 'if type != "object" then "tracker" else keys_unsorted - ["schema_version", "provider", "config", "docs"] | map("tracker." + .) | join(", ") end' <<<"$json")" diff --git a/plugins/work-items/tools/work-item-tracker/lib/binding.test.sh b/plugins/work-items/tools/work-item-tracker/lib/binding.test.sh index eb2f92727b8..cd0d0ab8844 100755 --- a/plugins/work-items/tools/work-item-tracker/lib/binding.test.sh +++ b/plugins/work-items/tools/work-item-tracker/lib/binding.test.sh @@ -325,6 +325,35 @@ write_binding "$TEST_TMPDIR/ids.yaml" 'tracker: assert_eq "YAML scalars stay strings inside arrays" '{"completed":["10000"],"wont_do":["10001"]}' \ "$(wit_yaml_binding_json "$TEST_TMPDIR/ids.yaml" | jq -c '.config.jira.resolutions')" +# An empty flow sequence stays [], as the legacy JSON writes it, where the parser +# alone prints nothing: jira tells a present empty done_category_keys (a config +# error) from an absent one (the default) by presence, and requires both resolutions +# keys. A `[]` inside a quoted scalar or a comment is text, and a key with no value +# reads as absent. +write_binding "$TEST_TMPDIR/empty.yaml" 'tracker: + config: + jira: + site: a.atlassian.net # not a list: [] + blocked_by_link_type: "[]" + auth_email: + done_category_keys: [] + resolutions: + completed: ["10000"] + wont_do: [ ] # none yet' +assert_eq "an empty YAML flow sequence stays [], as in the legacy JSON" \ + '{"site":"a.atlassian.net","blocked_by_link_type":"[]","done_category_keys":[],"resolutions":{"completed":["10000"],"wont_do":[]}}' \ + "$(wit_yaml_binding_json "$TEST_TMPDIR/empty.yaml" | jq -c '.config.jira')" +printf 'tracker:\r\n config:\r\n jira:\r\n done_category_keys: []\r\n' >"$TEST_TMPDIR/crlf.yaml" +assert_eq "an empty flow sequence on a CRLF line stays []" '[]' \ + "$(wit_yaml_binding_json "$TEST_TMPDIR/crlf.yaml" | jq -c '.config.jira.done_category_keys')" +write_binding "$TEST_TMPDIR/dup.yaml" 'tracker: + config: + jira: + done_category_keys: [] + done_category_keys: ["done"]' +ERR="$(wit_yaml_binding_json "$TEST_TMPDIR/dup.yaml" 2>&1 >/dev/null)" +assert_contains "a parse error after an empty flow sequence names the file's own line" "$ERR" "$TEST_TMPDIR/dup.yaml: line 5" + # The pointer region binds a home other than the default. PREPO="$(yaml_repo pointer-repo)" mkdir -p "$PREPO/team/conf"