diff --git a/docs/specs/d1-model-already-knows-measurement/d1_proxy.py b/docs/specs/d1-model-already-knows-measurement/d1_proxy.py index 9a02702846..d84a95f167 100755 --- a/docs/specs/d1-model-already-knows-measurement/d1_proxy.py +++ b/docs/specs/d1-model-already-knows-measurement/d1_proxy.py @@ -275,12 +275,8 @@ def is_instruction(s): plain = re.sub(r"\*\*|__|\*|_", "", plain).strip() if not plain: return False - first = re.split(r"[\s,;:]", plain, 1)[0].lower().strip(".*_-—:") - if first in IMPERATIVE_OPENERS: - return True - if MODALS.search(s): - return True - return False + first = re.split(r"[\s,;:]", plain, maxsplit=1)[0].lower().strip(".*_-—:") + return first in IMPERATIVE_OPENERS or bool(MODALS.search(s)) # ---------------------------------------------------------------- stage 4 diff --git a/plugins/autonomy/.claude-plugin/plugin.json b/plugins/autonomy/.claude-plugin/plugin.json index be3aeeedb6..d700e564f1 100644 --- a/plugins/autonomy/.claude-plugin/plugin.json +++ b/plugins/autonomy/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "autonomy", - "version": "0.22.14", + "version": "0.22.15", "description": "Governed autonomous agent operation: role-topology, binding-seam, wiring-vs-advisor, telemetry, return-accounting, trigger-dispatch, per-work-class guardrail-matrix, standing-routine-catalog, and design-only runner-charter contracts for climbing the AI-adoption ladder, plus a guided-setup skill that discovers an adopting org's state, writes its schema-versioned binding, wires standards-pinned OTLP emission with a zero-cost file-artifact default, wires human-attested return capture at the task boundary, wires signal adapters with one governed dispatch entrypoint, binds the five-class guardrail matrix to an org's isolation substrates with an in-boundary live-validation probe before recording each fail-closed binding, and stands up standing-routine-catalog classes as scheduled temporal signal adapters behind the one governed queue with free scheduling defaults wired as reviewable changes and each routine's work-class mapping homed on the security surface.", "author": { "name": "Melodic Software", diff --git a/plugins/autonomy/CHANGELOG.md b/plugins/autonomy/CHANGELOG.md index 39108e3bd2..d2bc8ca7b3 100644 --- a/plugins/autonomy/CHANGELOG.md +++ b/plugins/autonomy/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to the `autonomy` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.22.15] + +### Changed + +- **Behavior-preserving simplification sweep, wave 11 (batch-simplify).** Setup-gate scripts, + each adversarially refutation-verified with byte-identical output and exit codes: + check-signal-envelope.mjs collapses a nested single-branch conditional into one `&&` + (three-envelope differential drive identical); resolve-prerequisites.mjs pushes findings via + spread instead of a loop (findings arrays proven bounded and plain); a dead intermediate + local removed from the fixtures test runner. Fixture suites 22/22 and slice checks 4/4 + green; generated/ tree byte-unchanged. + ## [0.22.14] ### Changed diff --git a/plugins/autonomy/skills/setup/scripts/check-signal-envelope.mjs b/plugins/autonomy/skills/setup/scripts/check-signal-envelope.mjs index 4704b127a2..b39393d3c1 100755 --- a/plugins/autonomy/skills/setup/scripts/check-signal-envelope.mjs +++ b/plugins/autonomy/skills/setup/scripts/check-signal-envelope.mjs @@ -281,12 +281,10 @@ function checkEnvelope(envelope, where, { surfaces, sections, duplicates, routin // agent-internal: serialized provenance is REQUIRED — an unverifiable // self-stamped class would bypass admission. const parentItem = envelope["signal.parent_item"]; - if (signalClass === "agent-internal") { - if (!isNormalizedCanonicalUrl(parentItem)) { - findings.push( - `${where}: signal.parent_item ${JSON.stringify(parentItem)} must be the emitting session's admitted source item as a normalized canonical https URL (required for agent-internal)`, - ); - } + if (signalClass === "agent-internal" && !isNormalizedCanonicalUrl(parentItem)) { + findings.push( + `${where}: signal.parent_item ${JSON.stringify(parentItem)} must be the emitting session's admitted source item as a normalized canonical https URL (required for agent-internal)`, + ); } // raw_link form branches DETERMINISTICALLY on the serialized origin. diff --git a/plugins/autonomy/skills/setup/scripts/resolve-prerequisites.fixtures.test.mjs b/plugins/autonomy/skills/setup/scripts/resolve-prerequisites.fixtures.test.mjs index 15d748d736..5792943d66 100755 --- a/plugins/autonomy/skills/setup/scripts/resolve-prerequisites.fixtures.test.mjs +++ b/plugins/autonomy/skills/setup/scripts/resolve-prerequisites.fixtures.test.mjs @@ -37,7 +37,7 @@ const fail = (name, detail) => { }; function runResolver(repoDir, surface) { - const result = spawnSync( + return spawnSync( process.execPath, [resolver, "--repo", repoDir, "--surface", surface], { @@ -46,7 +46,6 @@ function runResolver(repoDir, surface) { timeout: 30_000, }, ); - return result; } function parseOut(result) { diff --git a/plugins/autonomy/skills/setup/scripts/resolve-prerequisites.mjs b/plugins/autonomy/skills/setup/scripts/resolve-prerequisites.mjs index 24a3a47b88..339d454357 100755 --- a/plugins/autonomy/skills/setup/scripts/resolve-prerequisites.mjs +++ b/plugins/autonomy/skills/setup/scripts/resolve-prerequisites.mjs @@ -832,7 +832,7 @@ function resolveIdentity(record, ctx) { result: resolved.result, provenance: resolved.provenance, }); - for (const finding of resolved.findings) findings.push(finding); + findings.push(...resolved.findings); } // Identity-level disable declaration. diff --git a/plugins/claude-config/.claude-plugin/plugin.json b/plugins/claude-config/.claude-plugin/plugin.json index b7f9eb4da2..28b4658aac 100644 --- a/plugins/claude-config/.claude-plugin/plugin.json +++ b/plugins/claude-config/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-config", - "version": "0.40.12", + "version": "0.40.13", "description": "Nine configuration-health skills (plus setup) for a repo's Claude Code configuration: audit (settings.json / .mcp.json / hooks / plugins / permissions drift), audit-automation-gaps (evidence-gated verdicts on automation gaps), audit-permission-grants (allow-rule / allowed-tools grants for auto-mode durability and portability), audit-permission-state (the permission rules actually in effect \u2014 every settings scope merged with per-rule provenance, what auto mode drops on entry, config written where nothing reads it, and which managed intents are enforced versus loosenable), draft-auto-mode-rules (interview and draft a paste-ready autoMode classifier block; prints only, never writes), audit-instructions (locally-owned instruction surfaces vs current model capability \u2014 proposes removals/rewrites of instructions the model no longer needs, and detects cross-surface instruction conflicts), audit-prompting-postures (the additive lane \u2014 posture guidance the prompting guide says a component's purpose needs but the component does not carry), audit-pass (one coordinated, ordered, resumable pass over a named target \u2014 three-scope inventory, run-time-derived exclusion set, stable finding identity, suppression memory, resume, one human gate \u2014 delegating every check to the plugin that owns it), and unhobble (the empirical bare-baseline experiment: reversibly strip a repo's standing instructions, log real stumbles against the current model, re-add only what evidence earns).", "author": { "name": "Melodic Software", diff --git a/plugins/claude-config/CHANGELOG.md b/plugins/claude-config/CHANGELOG.md index 5606740c9e..e1d0413c00 100644 --- a/plugins/claude-config/CHANGELOG.md +++ b/plugins/claude-config/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to the `claude-config` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.40.13] + +### Changed + +- **Behavior-preserving simplification sweep (batch-simplify).** `audit-instructions`'s + restatement-scan.py dropped a redundant `nunit and` truthiness test (the preceding length + guard already excludes empty units) and its `main()` empty-file-list special case (the + general path is byte-identical for an empty list); `audit-permission-state`'s + automode-block-lint.test.sh dropped a duplicate `command -v bash` reassignment. All output + bytes, exit codes, and assertions unchanged; each change adversarially refutation-verified + (20k-case differential fuzz on the scanner). + ## [0.40.12] ### Changed diff --git a/plugins/claude-config/skills/audit-instructions/scripts/restatement-scan.py b/plugins/claude-config/skills/audit-instructions/scripts/restatement-scan.py index eb22438320..69b9fb4ddb 100755 --- a/plugins/claude-config/skills/audit-instructions/scripts/restatement-scan.py +++ b/plugins/claude-config/skills/audit-instructions/scripts/restatement-scan.py @@ -228,7 +228,7 @@ def recoverable(unit: str, source: str) -> bool: nsrc = normalize(source) if len(nunit) < 24: return False - if nunit and nunit in nsrc: + if nunit in nsrc: return True utoks = content_tokens(unit) stoks = content_tokens(source) @@ -309,10 +309,6 @@ def main(argv: list[str]) -> int: help="accepted for flag-parity with instruction-scan.sh; I29 is body-scoped by construction", ) args = parser.parse_args(argv) - if not args.files: - if args.count: - print(0) - return 0 rows: list[str] = [] for raw in args.files: p = Path(raw) diff --git a/plugins/claude-config/skills/audit-permission-state/scripts/automode-block-lint.test.sh b/plugins/claude-config/skills/audit-permission-state/scripts/automode-block-lint.test.sh index 5efee244d2..615d54c051 100755 --- a/plugins/claude-config/skills/audit-permission-state/scripts/automode-block-lint.test.sh +++ b/plugins/claude-config/skills/audit-permission-state/scripts/automode-block-lint.test.sh @@ -215,12 +215,11 @@ done # spawn is the surprise the opt-in flag exists to prevent. NOTICE_STUB="$TEST_TMPDIR/notice-stub" mkdir -p "$NOTICE_STUB" -real_bash_n="$(command -v bash)" printf '#!%s printf "stub critique output.\n" -' "$real_bash_n" >"$NOTICE_STUB/claude" +' "$real_bash" >"$NOTICE_STUB/claude" chmod +x "$NOTICE_STUB/claude" -OUT_NOTICE=$(env AUTOMODE_CONFIG_FIXTURE="$CONFIG" AUTOMODE_DEFAULTS_FIXTURE="$DEFAULTS" PATH="$NOTICE_STUB:$PATH" bash "$SCRIPT" --critique 2>&1) +OUT_NOTICE=$(env AUTOMODE_CONFIG_FIXTURE="$CONFIG" AUTOMODE_DEFAULTS_FIXTURE="$DEFAULTS" PATH="$NOTICE_STUB:$PATH" bash "$SCRIPT" --critique 2>&1) assert_contains "the critique spawn prints a cost notice" "$OUT_NOTICE" "CRITIQUE COST NOTICE" assert_contains "the notice says nothing has been spawned yet" "$OUT_NOTICE" "nothing has been spawned yet" diff --git a/plugins/claude-ops/.claude-plugin/plugin.json b/plugins/claude-ops/.claude-plugin/plugin.json index fdb4027442..474d91df8e 100644 --- a/plugins/claude-ops/.claude-plugin/plugin.json +++ b/plugins/claude-ops/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "claude-ops", - "version": "0.38.12", + "version": "0.38.13", "description": "Claude Code operations toolkit. Twelve skills: audit-skill-visibility (audit whether each installed skill is actually VISIBLE to the model, and diagnose why most of a fleet never gets used \u2014 a skill is invisible when its description is dropped by Claude Code's skill-listing context budget, which drops descriptions least-invoked-first so an unused skill loses the keywords that would let it be matched, from skills genuinely not wanted, from skills the run cannot observe at all; computes whether the listing overflows from documented settings, and withholds every cold verdict the data cannot support rather than reporting absence of data as absence of use), inventory (read-only enumeration of the complete invocable surface \u2014 every built-in CLI command with aliases and hidden/gated status, every bundled skill, and every component of every installed plugin across all marketplaces; reads the shipped binary because upstream publishes no built-in command list, and carries an integrity verdict so a drifted build reports counts as floors rather than silently short totals), audit-install-state (read-only audit of the machine-scope ~/.claude installation directory and ~/.claude.json \u2014 full inventory split into an authored surface and rolled-up bulk trees, product-managed retention vs genuinely unmanaged state, filename-scheme resolution before any process-liveness check, and deliberate/mid-experiment detection; reports, never deletes), audit-performance (read-only slowness-diagnostic capture run at the moment the machine or a session feels slow: CLI version, retention-sweep health including the silent unparsable-settings pause, a timed census walk of the install tree as a sweep-cost proxy, active-session and plugin-fleet counts, a process census, and the fan-out layer, which covers a load-labelled no-op spawn baseline, every hook that will fire bucketed per-tool-call versus per-turn with its invocation shape, the configured statusline, subagent concurrency and spawn-depth ceilings against documented defaults, whether running sessions predate the settings file they are judged by, and orphan attribution by parent liveness rather than age; read against a bundled known-performance-issues reference that also records the causes tested and cleared; separates the four documented suspects of accumulated state, version regression, component bloat, and per-spawn fan-out cost, and routes remediation out; reports, never mutates, and never executes a discovered hook or statusline command), audit-native-overlap (map native Claude Code surfaces \u2014 built-in CLI commands, bundled skills, plugin-backed built-ins, session-provided skills \u2014 against the current repo's plugin skills and agents, so a custom component never silently duplicates what Claude Code itself ships; bare invocation is a read-only overlap report carrying the extraction's integrity floors and a shared-listing-budget exposure section, verdicts are human-gated in a committed store rendered into a generated registry whose every row carries an observable recheck trigger, and only an explicit apply step bakes presence-gated native references into descriptions and Boundary sections), observability (read locally captured telemetry \u2014 OTEL store, collector, hook-event JSONL, ccusage \u2014 with trend reports and store pruning), known-issues (search known Claude product GitHub bugs, check service health, maintain a persistent tracked-issue registry), changelog (ingest Claude Code changelog entries and integrate them into the current repo), plugins (bring a machine's plugin fleet current on demand \u2014 marketplace refresh, effective-scope updates including in-repo project/local installs, new-plugin install per policy, scope-divergence detection and explicit convergence), morning-brief (read-only gh-based operator morning view \u2014 queue-label counts, merge-ready PRs, parked decisions with their RECOMMENDED lines, and loop-lane telemetry freshness), lanes (start/restart/stop/status loop lanes as named background Claude Code sessions seeded from canonical prompt files, with per-lane model/effort, a repo-pull + marketplace-refresh launch step, and a consume-restarts action \u2014 an OS-schedulable reader that relaunches stopped lanes whose telemetry carries a restart_request), and a re-runnable setup action that settles where the known-issues registry lives. Plus a family of eight advisory *-audit hooks (API errors, config changes, instruction loads, permission denials, pre-compaction, skill usage, tool failures, and unsurfaced hook failures \u2014 the last also warns the user via systemMessage, since a hook that fails to launch enforces nothing and Claude Code surfaces the failure to nobody) that emit the shared hook-telemetry envelope, and a reference sink that maps envelopes into the hook-events.jsonl the observability skill reads.", "author": { "name": "Melodic Software", diff --git a/plugins/claude-ops/CHANGELOG.md b/plugins/claude-ops/CHANGELOG.md index 9810c3a56f..974cf9dd5a 100644 --- a/plugins/claude-ops/CHANGELOG.md +++ b/plugins/claude-ops/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to the `claude-ops` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.38.13] + +### Changed + +- **Behavior-preserving simplification sweep (batch-simplify).** `inventory`'s inventory.py + hoists the duplicated `candidate_binaries()` call into one reused snapshot and normalizes a + docstring's em dashes to the file's hyphen idiom (plus formatter-hook layout churn, AST-verified + neutral); `plugins`'s normalize-enabled-plugins.sh drops a dead `shellcheck disable=SC2064` + directive above a single-quoted trap. Emitted JSON, exit codes, and suite assertions unchanged; + refutation-verified byte-identical on smoke runs. + ## [0.38.12] ### Changed diff --git a/plugins/claude-ops/skills/inventory/scripts/inventory.py b/plugins/claude-ops/skills/inventory/scripts/inventory.py index 33ed7cb6bc..a0bf2b4d66 100755 --- a/plugins/claude-ops/skills/inventory/scripts/inventory.py +++ b/plugins/claude-ops/skills/inventory/scripts/inventory.py @@ -79,13 +79,35 @@ "commands": {"dir": "commands", "manifest": "commands", "kind": "dir-of-files"}, "agents": {"dir": "agents", "manifest": "agents", "kind": "dir-of-files"}, "workflows": {"dir": "workflows", "manifest": "workflows", "kind": "dir-of-files"}, - "output-styles": {"dir": "output-styles", "manifest": "outputStyles", "kind": "dir-of-files"}, - "themes": {"dir": "themes", "manifest": "experimental.themes", "kind": "dir-of-files"}, - "monitors": {"dir": "monitors", "manifest": "experimental.monitors", "kind": "dir-of-files"}, + "output-styles": { + "dir": "output-styles", + "manifest": "outputStyles", + "kind": "dir-of-files", + }, + "themes": { + "dir": "themes", + "manifest": "experimental.themes", + "kind": "dir-of-files", + }, + "monitors": { + "dir": "monitors", + "manifest": "experimental.monitors", + "kind": "dir-of-files", + }, "hooks": {"dir": "hooks", "manifest": "hooks", "kind": "dir-of-files"}, "bin": {"dir": "bin", "manifest": "", "kind": "dir-of-files"}, - "mcp-servers": {"dir": "", "manifest": "mcpServers", "kind": "file", "file": ".mcp.json"}, - "lsp-servers": {"dir": "", "manifest": "lspServers", "kind": "file", "file": ".lsp.json"}, + "mcp-servers": { + "dir": "", + "manifest": "mcpServers", + "kind": "file", + "file": ".mcp.json", + }, + "lsp-servers": { + "dir": "", + "manifest": "lspServers", + "kind": "file", + "file": ".lsp.json", + }, "settings": {"dir": "", "manifest": "", "kind": "file", "file": "settings.json"}, } @@ -141,7 +163,8 @@ def pick_binary(explicit: str | None) -> tuple[Path | None, str]: return None, f"--binary {explicit} is not a file" return p, "explicit --binary" - for p in candidate_binaries(): + candidates = candidate_binaries() + for p in candidates: try: if not p.is_file(): continue @@ -151,10 +174,13 @@ def pick_binary(explicit: str | None) -> tuple[Path | None, str]: if size > 20_000_000: return p, "auto-detected native build" # Keep looking; a shim may precede the real binary on PATH. - for p in candidate_binaries(): + for p in candidates: try: if p.is_file(): - return p, "auto-detected (small file - likely an npm launcher, not a native build)" + return ( + p, + "auto-detected (small file - likely an npm launcher, not a native build)", + ) except OSError: continue return None, "no claude executable found on PATH or in the usual install roots" @@ -250,8 +276,19 @@ def detect_container(data: bytes) -> str: _ID_CHARS = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$") _REGEX_PRECEDERS = set("(,=:[!&|?{};+-*%^~<>") | {"\n"} _REGEX_KEYWORDS = { - "return", "typeof", "instanceof", "in", "of", "new", "delete", "void", - "case", "do", "else", "yield", "await", + "return", + "typeof", + "instanceof", + "in", + "of", + "new", + "delete", + "void", + "case", + "do", + "else", + "yield", + "await", } @@ -413,15 +450,21 @@ def _unescape(raw: str) -> str: # -------------------------------------------------------------------------- _TYPE_RE = re.compile(r'type:"(local|local-jsx|prompt)"') -_NAME_RE = re.compile(r'(?:^|[,{])name:' + _STR) +_NAME_RE = re.compile(r"(?:^|[,{])name:" + _STR) _UFN_RE = re.compile(r"userFacingName\(\)\{return" + _STR) -_DESC_RE = re.compile(r'(?:^|[,{])description:' + _STR) +_DESC_RE = re.compile(r"(?:^|[,{])description:" + _STR) _MENUDESC_RE = re.compile(r"(?:menuDescription|description):" + _STR) _ALIAS_RE = re.compile(r"aliases:\[([^\]]*)\]") _NAME_OK = re.compile(r"[a-zA-Z0-9][a-zA-Z0-9:_-]{0,40}") # Names that exist in the build but are never typed by a user. -INTERNAL_NAMES = {"mcp__", "workflow-launch-exec", "pro-trial-expired", "rate-limit-options", "stub"} +INTERNAL_NAMES = { + "mcp__", + "workflow-launch-exec", + "pro-trial-expired", + "rate-limit-options", + "stub", +} def extract_builtin_commands(src: str, braces: BraceMap) -> dict[str, dict[str, Any]]: @@ -728,8 +771,8 @@ def scan_installed(root: Path) -> dict[str, Any]: """Plugins actually installed under the config dir's plugin cache. A marketplace checkout is a catalog of what is *available*; this is what is - present locally. The two can disagree — a plugin can be installed from a - marketplace that is no longer cached — so neither substitutes for the other. + present locally. The two can disagree - a plugin can be installed from a + marketplace that is no longer cached - so neither substitutes for the other. """ cache = root / "plugins" / "cache" out: dict[str, Any] = {} @@ -740,7 +783,10 @@ def scan_installed(root: Path) -> dict[str, Any]: for entry in sorted(p for p in marketplace.iterdir() if p.is_dir()): # Installs may nest one version directory below the plugin name. candidate = entry - if not (entry / ".claude-plugin").is_dir() and not (entry / "skills").is_dir(): + if ( + not (entry / ".claude-plugin").is_dir() + and not (entry / "skills").is_dir() + ): subdirs = [d for d in sorted(entry.iterdir()) if d.is_dir()] if len(subdirs) == 1: candidate = subdirs[0] @@ -804,7 +850,9 @@ def _manifest_paths(manifest: dict[str, Any], key: str) -> list[str] | None: return None -def _scan_component(root: Path, spec: dict[str, str], manifest: dict[str, Any]) -> list[str]: +def _scan_component( + root: Path, spec: dict[str, str], manifest: dict[str, Any] +) -> list[str]: declared = _manifest_paths(manifest, spec.get("manifest", "")) kind = spec["kind"] @@ -843,7 +891,9 @@ def scan_config_scope(root: Path) -> dict[str, Any]: skills = root / "skills" if skills.is_dir(): out["skills"] = sorted( - e.name for e in skills.iterdir() if e.is_dir() and (e / "SKILL.md").is_file() + e.name + for e in skills.iterdir() + if e.is_dir() and (e / "SKILL.md").is_file() ) for name, sub in (("agents", "agents"), ("commands", "commands")): d = root / sub @@ -907,11 +957,15 @@ def build_report(args: argparse.Namespace) -> dict[str, Any]: report["bundled_skills"] = skills report["bundled_skill_notes"] = skill_notes report["plugin_backed"] = plugin_backed - report["integrity"] = check_integrity(src, commands, skills, skill_notes) + report["integrity"] = check_integrity( + src, commands, skills, skill_notes + ) if not args.binary_only: report["sources"]["disk"] = {"available": True} - report["disk"] = scan_disk(Path(args.config_dir) if args.config_dir else config_dir()) + report["disk"] = scan_disk( + Path(args.config_dir) if args.config_dir else config_dir() + ) # Project scope is a third place components come from, and it is the one # that changes as you move between repos: a project's .claude tree adds @@ -921,7 +975,9 @@ def build_report(args: argparse.Namespace) -> dict[str, Any]: report["project"] = { "root": str(project_root), "present": project_claude.is_dir(), - "components": scan_config_scope(project_claude) if project_claude.is_dir() else {}, + "components": scan_config_scope(project_claude) + if project_claude.is_dir() + else {}, } return report @@ -939,8 +995,12 @@ def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser( description="Enumerate the Claude Code ecosystem on this machine (read-only)." ) - ap.add_argument("--binary", help="path to the claude executable (default: auto-detect)") - ap.add_argument("--config-dir", help="config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude)") + ap.add_argument( + "--binary", help="path to the claude executable (default: auto-detect)" + ) + ap.add_argument( + "--config-dir", help="config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude)" + ) ap.add_argument( "--project-dir", help="project root whose .claude tree to scan (default: cwd)" ) @@ -976,8 +1036,10 @@ def main(argv: list[str] | None = None) -> int: ) print(f"BROKEN: {reason}") return 1 - print(f"{integrity['status'].upper()}: cli {integrity['cli_version']}, " - f"validated against {integrity['validated_against']}") + print( + f"{integrity['status'].upper()}: cli {integrity['cli_version']}, " + f"validated against {integrity['validated_against']}" + ) for p in integrity["problems"]: print(f" problem: {p}") for a in integrity["advisories"]: diff --git a/plugins/claude-ops/skills/plugins/scripts/normalize-enabled-plugins.sh b/plugins/claude-ops/skills/plugins/scripts/normalize-enabled-plugins.sh index ff36fce3ff..372c06eb7f 100755 --- a/plugins/claude-ops/skills/plugins/scripts/normalize-enabled-plugins.sh +++ b/plugins/claude-ops/skills/plugins/scripts/normalize-enabled-plugins.sh @@ -78,7 +78,7 @@ while [[ $# -gt 0 ]]; do esac done -if [[ -n "$REPORT_PROJECT" && ( -n "$FILE" || "$CHECK" -eq 1 ) ]]; then +if [[ -n "$REPORT_PROJECT" && (-n "$FILE" || "$CHECK" -eq 1) ]]; then echo "normalize-enabled-plugins.sh: --report-project cannot be combined with --file or --check" >&2 exit 2 fi @@ -171,7 +171,6 @@ workdir="$(mktemp -d)" || { tmp="$workdir/rewritten.json" before_sorted="$workdir/before.json" after_sorted="$workdir/after.json" -# shellcheck disable=SC2064 trap 'rm -rf "$workdir"' EXIT # Preserve compact vs pretty and CRLF vs LF so a one-key reorder does not diff --git a/plugins/code-tidying/.claude-plugin/plugin.json b/plugins/code-tidying/.claude-plugin/plugin.json index f8328a8fa1..df06d2a66f 100644 --- a/plugins/code-tidying/.claude-plugin/plugin.json +++ b/plugins/code-tidying/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "code-tidying", - "version": "0.14.7", + "version": "0.14.8", "description": "Code tidying and comment hygiene: /code-tidying:tidy proactively hunts a rotated, glob-scoped lane for Beck-style tidyings under a research-backed scope budget and ships one tight PR; /code-tidying:batch-simplify sweeps a time window, a branch, or an entire repository through grouped, dependency-ordered simplification waves with a never-drop deferred-items contract; /code-tidying:dissolve-comments enforces self-describing expressive code over a diff or target, widening to the branch diff and then the whole repository when the tree is clean — deletes zero-information comments, dissolves code-expressible ones into names and structure behind a tests gate (safe mode restricts applied edits to removals), and keeps only terse load-bearing comments code cannot express; /code-tidying:audit-comment-residue is a read-only classifier that flags history, plan, conversational, and ticket/PR residue in code comments for author-applied deletion; /code-tidying:audit-dead-code is a read-only whole-repo dead-code hunter running four labelled lanes of unequal confidence (knip for TS/JS, vulture for Python, gopls for Go, and a portable grep lane for shell and other symbol languages), adjudicating every candidate against dynamic-usage evidence into a dead, uncertain, or alive verdict. Project-specific tidy lanes are scaffolded into a tracked .claude/tidy-lanes/ config folder by a re-runnable setup skill.", "author": { "name": "Melodic Software", diff --git a/plugins/code-tidying/CHANGELOG.md b/plugins/code-tidying/CHANGELOG.md index 529684f912..969a913f08 100644 --- a/plugins/code-tidying/CHANGELOG.md +++ b/plugins/code-tidying/CHANGELOG.md @@ -3,6 +3,15 @@ All notable changes to the `code-tidying` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.14.8] + +### Changed + +- **Behavior-preserving simplification sweep, wave 11 (batch-simplify).** dead-code-scan.sh + drops a dead store (`ts=""` inside the branch that only runs when `ts` is already empty). + Adversarially refutation-verified (branch-body trace plus git and no-git fixture runs + byte-identical); suite 162/162 green; portability gate clean. + ## [0.14.7] ### Changed diff --git a/plugins/code-tidying/skills/audit-dead-code/scripts/dead-code-scan.sh b/plugins/code-tidying/skills/audit-dead-code/scripts/dead-code-scan.sh index 4825239399..eb19a397ff 100755 --- a/plugins/code-tidying/skills/audit-dead-code/scripts/dead-code-scan.sh +++ b/plugins/code-tidying/skills/audit-dead-code/scripts/dead-code-scan.sh @@ -702,7 +702,6 @@ while IFS="$TAB" read -r c_file c_line c_shape c_excerpt; do [[ -n "$c_shape" ]] || continue ts="${FILE_TS[$c_file]:-}" if [[ -z "$ts" ]]; then - ts="" if [[ "$GIT_OK" == '1' && -f "$c_file" ]]; then ts="$(git log -1 --format=%ct -- "$c_file" 2>/dev/null | tr -d '\r')" fi diff --git a/plugins/disk-hygiene/.claude-plugin/plugin.json b/plugins/disk-hygiene/.claude-plugin/plugin.json index aadf480112..f4c770597b 100644 --- a/plugins/disk-hygiene/.claude-plugin/plugin.json +++ b/plugins/disk-hygiene/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "disk-hygiene", - "version": "0.20.28", + "version": "0.20.29", "description": "Context-aware disk hygiene for arbitrary directory trees: inventories orphaned and temporary artifacts, classifies evidence into review tiers, and offers exact-path cleanup only after a fresh safety preview and explicit per-tier approval. The target is read-only by default; OS-managed paths, links and mount points, VCS-tracked content without the complete checkout evidence bundle, changed entries, and live-handle uncertainty fail closed.", "author": { "name": "Melodic Software", diff --git a/plugins/disk-hygiene/CHANGELOG.md b/plugins/disk-hygiene/CHANGELOG.md index 2fb09e2da8..3ce91ca190 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `disk-hygiene` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.20.29] + +### Changed + +- **Behavior-preserving simplification sweep, wave 11 (batch-simplify).** hygiene.py: + `reclaimable_local_bytes` uses the same walrus-filter `sum(...)` idiom `preview` already + uses (callee pure, one evaluation per entry, empty input still int 0), and + `entry_is_empty_directory` merges two branches with byte-identical bodies into one + `isinstance(inventory, (dict, set))`; the repo's ruff-format hook reflowed two token-identical + regions in the same file. test_python3_alias_probe.py moves a backslash-continued `with` to + the parenthesized form. Adversarially refutation-verified; suites 317 + 10 green; guard, + kill-switch, and destructive-boundary code untouched. + ## [0.20.28] ### Changed diff --git a/plugins/disk-hygiene/skills/clean/scripts/hygiene.py b/plugins/disk-hygiene/skills/clean/scripts/hygiene.py index 5b33a7481d..8e3d621bb5 100755 --- a/plugins/disk-hygiene/skills/clean/scripts/hygiene.py +++ b/plugins/disk-hygiene/skills/clean/scripts/hygiene.py @@ -776,13 +776,11 @@ def entry_reclaimable_local_bytes(entry: dict[str, Any]) -> int | None: def reclaimable_local_bytes(entries: list[dict[str, Any]]) -> int: """Sum of per-entry reclaimable local bytes across an inventory.""" - total = 0 - for entry in entries: - value = entry_reclaimable_local_bytes(entry) - if value is not None: - total += value - return total - + return sum( + value + for entry in entries + if (value := entry_reclaimable_local_bytes(entry)) is not None + ) def entry_is_empty_directory( entry: dict[str, Any], @@ -820,9 +818,7 @@ def entry_is_empty_directory( return relative not in parents_with_children if inventory is None: paths: Iterable[str] = () - elif isinstance(inventory, dict): - paths = inventory - elif isinstance(inventory, set): + elif isinstance(inventory, (dict, set)): paths = inventory else: paths = ( diff --git a/plugins/disk-hygiene/skills/setup/scripts/test_python3_alias_probe.py b/plugins/disk-hygiene/skills/setup/scripts/test_python3_alias_probe.py index 1082222169..bdb201bb97 100755 --- a/plugins/disk-hygiene/skills/setup/scripts/test_python3_alias_probe.py +++ b/plugins/disk-hygiene/skills/setup/scripts/test_python3_alias_probe.py @@ -130,8 +130,10 @@ def test_probe_never_executes_the_candidate(self) -> None: # Inspect-before-execute: detecting the stub must not run it (which would # pop the Store or hang). Guard against any subprocess use in the probe. stub = self.make_file("Microsoft/WindowsApps/python3.exe", 0) - with mock.patch("subprocess.run", side_effect=AssertionError("executed")), \ - mock.patch("subprocess.Popen", side_effect=AssertionError("executed")): + with ( + mock.patch("subprocess.run", side_effect=AssertionError("executed")), + mock.patch("subprocess.Popen", side_effect=AssertionError("executed")), + ): result = self.run_probe(["--path", str(stub)]) self.assertEqual("store-alias-stub", result["verdict"]) diff --git a/plugins/docs-hygiene/.claude-plugin/plugin.json b/plugins/docs-hygiene/.claude-plugin/plugin.json index 6b52bf88d8..6beed2197d 100644 --- a/plugins/docs-hygiene/.claude-plugin/plugin.json +++ b/plugins/docs-hygiene/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "docs-hygiene", - "version": "0.21.18", + "version": "0.21.19", "description": "Documentation-hygiene toolkit: compress (flavor-trim markdown with a semantic-diff safety net), audit-noise (classify markdown noise), extract-ssot (deduplicate repeated content into a single source of truth), audit-encapsulation (detect citations into skill-private surfaces), rename-references (sweep stale references after renames), audit-derivability (classify whether a whole document earns its existence \u2014 could a fresh agent re-derive it from the code?), audit-progressive-disclosure (grade instruction files against a load-tier model for split opportunities and hub/spoke disclosure defects), write-for-agents (authoring-time doctrine that fires while agent-consumed markdown is being written), and write-for-humans (the same moment for the other reader \u2014 end-user READMEs, RFCs, release notes and guides \u2014 resolving the consuming project's own style guide first).", "author": { "name": "Melodic Software", diff --git a/plugins/docs-hygiene/CHANGELOG.md b/plugins/docs-hygiene/CHANGELOG.md index 1cc1497b5d..9cf5b8dbf3 100644 --- a/plugins/docs-hygiene/CHANGELOG.md +++ b/plugins/docs-hygiene/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog — docs-hygiene plugin +## [0.21.19] + +### Changed + +- **`compress`'s `audit-scan.sh` drops a duplicate glob alternative.** One pattern list matched the + same files twice; the scan's selection is unchanged. Code-tidying sweep, behavior-preserving. +- **`audit-noise`'s `detect.sh` slices the sorted array directly.** A 14-line manual chunking loop + is now `"${SORTED[@]:OFFSET:LIMIT}"` slices, and an unused `local tier` is gone. Same chunks, + same output. Code-tidying sweep, behavior-preserving. +- **`audit-noise`'s `emit-findings.sh` keeps one awk counter.** A duplicate `seen` counter that + shadowed the first was removed; emitted findings are unchanged. Code-tidying sweep, + behavior-preserving. + ## [0.21.18] ### Fixed diff --git a/plugins/docs-hygiene/skills/audit-noise/scripts/detect.sh b/plugins/docs-hygiene/skills/audit-noise/scripts/detect.sh index 6b8eae677e..54d5d3b041 100755 --- a/plugins/docs-hygiene/skills/audit-noise/scripts/detect.sh +++ b/plugins/docs-hygiene/skills/audit-noise/scripts/detect.sh @@ -177,20 +177,10 @@ mapfile -d '' -t SORTED < <(printf '%s\0' "${TARGETS[@]}" | LC_ALL=C sort -uz) # Chunk affordance: slice the sorted list so a parent can fan out without a # per-file shell loop (hook-bypass-safe single process per chunk). -if [[ "$OFFSET" -gt 0 || "$LIMIT" -gt 0 ]]; then - CHUNKED=() - idx=0 - for file in "${SORTED[@]}"; do - if [[ "$idx" -ge "$OFFSET" ]]; then - if [[ "$LIMIT" -eq 0 || ${#CHUNKED[@]} -lt "$LIMIT" ]]; then - CHUNKED+=("$file") - else - break - fi - fi - idx=$((idx + 1)) - done - SORTED=(${CHUNKED[@]+"${CHUNKED[@]}"}) +if [[ "$LIMIT" -gt 0 ]]; then + SORTED=("${SORTED[@]:OFFSET:LIMIT}") +elif [[ "$OFFSET" -gt 0 ]]; then + SORTED=("${SORTED[@]:OFFSET}") fi if [[ ${#SORTED[@]} -eq 0 ]]; then @@ -260,7 +250,7 @@ audit_file() { # finding_rows is written via nameref in audit_noise_record_finding. # shellcheck disable=SC2034 local -a shapes=() finding_rows=() - local shape tier excerpt line heading_text + local shape excerpt line heading_text local fence_delim fence_dchar fence_dlen local is_heading=0 # Negation is paragraph-scoped: accumulate soft-wrapped lines, then classify. diff --git a/plugins/docs-hygiene/skills/audit-noise/scripts/emit-findings.sh b/plugins/docs-hygiene/skills/audit-noise/scripts/emit-findings.sh index cf4e44e6e2..1c65b6fc41 100755 --- a/plugins/docs-hygiene/skills/audit-noise/scripts/emit-findings.sh +++ b/plugins/docs-hygiene/skills/audit-noise/scripts/emit-findings.sh @@ -322,7 +322,6 @@ LC_ALL=C awk \ rows[++nemit] = "| " rule_tier() " | high | " loc ":" cur_line " | docs-hygiene:audit-noise | " \ esc("docs-hygiene/audit-noise/rule-negation-without-positive prohibition=\"" fired_marker(cur_marker, text) "\" with no positive alternative in the sentence -- " trim(cur_excerpt)) \ " | " esc(rule_action()) " |" - seen++ reset() next } @@ -356,7 +355,7 @@ LC_ALL=C awk \ print "## Surfaces" print "" ran = "Ran: [docs-hygiene:audit-noise (detect.sh)]." - if (seen == 0) ran = ran " Returned no result: [docs-hygiene/audit-noise/rule-negation-without-positive]." + if (nemit == 0) ran = ran " Returned no result: [docs-hygiene/audit-noise/rule-negation-without-positive]." print ran printf "Detect blocks read: %d. Emitted: %d.\n", nblocks, nemit for (k in declined_nocrosswalk) diff --git a/plugins/docs-hygiene/skills/compress/scripts/audit-scan.sh b/plugins/docs-hygiene/skills/compress/scripts/audit-scan.sh index e7a5b4822f..fabef077bf 100755 --- a/plugins/docs-hygiene/skills/compress/scripts/audit-scan.sh +++ b/plugins/docs-hygiene/skills/compress/scripts/audit-scan.sh @@ -49,7 +49,7 @@ FLAVOR_RE='just|really|basically|actually|simply|perhaps|somewhat|very|quite|mig is_signal1_path() { local f="$1" base base="$(basename "$f")" - [[ "$f" == *'/.claude/rules/'* || "$f" == */.claude/rules/* ]] && return 0 + [[ "$f" == *'/.claude/rules/'* ]] && return 0 [[ "$base" == 'AGENTS.md' || "$base" == 'CLAUDE.md' || "$base" == 'SKILL.md' ]] && return 0 return 1 } diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index 71a5bfd07e..c8f9a3779a 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -147,5 +147,5 @@ "min": 1 } }, - "version": "0.29.17" + "version": "0.29.18" } diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 09e4e531fc..e73dba6775 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to the `guardrails` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.29.18] + +### Changed + +- **`lib/powershell/ps-command.sh` whitespace and comment cleanup.** No code change: the generated + PowerShell pretty-prints identically before and after. Code-tidying sweep, behavior-preserving. + ## [0.29.17] ### Fixed diff --git a/plugins/guardrails/lib/powershell/ps-command.sh b/plugins/guardrails/lib/powershell/ps-command.sh index ab5bdf0d39..2fccd489f6 100644 --- a/plugins/guardrails/lib/powershell/ps-command.sh +++ b/plugins/guardrails/lib/powershell/ps-command.sh @@ -315,7 +315,7 @@ ps::blank_quoted_spans() { # Ambiguous escape context in a double-quoted span — stop looking and # fall through to the delete-nothing branch below. if [[ "$q" == '"' && "$c" == '`' ]]; then - for ((; j < n; j++)); do [[ "${text:j:1}" == $'\n' ]] && break; done + for (( ; j < n; j++)); do [[ "${text:j:1}" == $'\n' ]] && break; done break fi if [[ "$c" == "$q" ]]; then @@ -323,7 +323,7 @@ ps::blank_quoted_spans() { # (`'it''s'`, `"say ""hi"""`), so this candidate closer may not be one. # Same resolution as the backtick: refuse the question, delete nothing. if [[ "${text:j+1:1}" == "$q" ]]; then - for ((; j < n; j++)); do [[ "${text:j:1}" == $'\n' ]] && break; done + for (( ; j < n; j++)); do [[ "${text:j:1}" == $'\n' ]] && break; done break fi found=1 @@ -390,12 +390,12 @@ ps::opaque_quoted_spans() { c="${text:j:1}" [[ "$c" == $'\n' ]] && break if [[ "$q" == '"' && "$c" == '`' ]]; then - for ((; j < n; j++)); do [[ "${text:j:1}" == $'\n' ]] && break; done + for (( ; j < n; j++)); do [[ "${text:j:1}" == $'\n' ]] && break; done break fi if [[ "$c" == "$q" ]]; then if [[ "${text:j+1:1}" == "$q" ]]; then - for ((; j < n; j++)); do [[ "${text:j:1}" == $'\n' ]] && break; done + for (( ; j < n; j++)); do [[ "${text:j:1}" == $'\n' ]] && break; done break fi found=1 @@ -1153,11 +1153,11 @@ ps::might_write_via_python3() { # target (so the launcher/token tests miss it) and it is not a launcher, so match # it here on the quote-INTACT text and fail closed. A SINGLE-quoted target does # NOT interpolate in PowerShell (`& '$x'` is the literal name `$x`), so it is not -# matched. ps::write_bypass catches an UNQUOTED `& $`/`& (` only together with a -# write indicator or special construct (#2722); this closes the -# quoted-interpolated form for the python-write lane — which is why this lane -# takes only the interpolating-string half of the shared call-target predicate -# and not the bare-computed half. + # matched. ps::write_bypass catches an UNQUOTED `& $`/`& (` only together with a + # write indicator or special construct (#2722); this closes the + # quoted-interpolated form for the python-write lane — which is why this lane + # takes only the interpolating-string half of the shared call-target predicate + # and not the bare-computed half. ps::call_target_is_interpolating_string "$recovered" && return 0 # Must name a python interpreter token at all (quote-intact, backtick-recovered). [[ "$lc" =~ (^|[^[:alnum:]_.])(pypy|python|py)[0-9]*([.][0-9]+)*([.]exe)?([^[:alnum:]_.]|$) ]] || return 1 diff --git a/plugins/instruction-placement/.claude-plugin/plugin.json b/plugins/instruction-placement/.claude-plugin/plugin.json index 51106cb9a5..21eec47fc8 100644 --- a/plugins/instruction-placement/.claude-plugin/plugin.json +++ b/plugins/instruction-placement/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "instruction-placement", - "version": "0.11.8", + "version": "0.11.9", "description": "Routes agent-instruction content to the surface that loads it at the right moment. The audit skill sweeps a repository's instruction layer and its ordinary markdown for content whose scope is narrower than the surface carrying it \u2014 conventions keyed to one file type or one subtree sitting in an always-loaded CLAUDE.md or AGENTS.md \u2014 and for normative conventions stranded in documentation Claude never loads at all, then classifies each against a routing rubric and proposes a destination whose `paths:` glob is machine-validated before it is ever offered. Safety-class content (irreversible actions, secrets, data integrity, external publication, compliance, agent authority) is hard-denied from demotion and reported as held back rather than proposed, because demotion trades guaranteed presence for conditional presence and deferred surfaces are invisible inside subagents and absent after compaction until re-triggered. Every accepted move regenerates an always-loaded index of deferred surfaces, which is what keeps a demoted rule reachable from a subagent that never receives its injection. The audit is read-only and emits a diffable findings artifact; realignment is a separate skill gated per item with no blanket-approve path; a deterministic check skill gates that every rule glob still resolves and the index is current; and a setup skill verifies the one thing no other gate can see \u2014 that the index target is a file Claude Code will actually read, since it reads CLAUDE.md and not AGENTS.md.", "author": { "name": "Melodic Software", diff --git a/plugins/instruction-placement/CHANGELOG.md b/plugins/instruction-placement/CHANGELOG.md index 0e2f1eed25..408d4c285f 100644 --- a/plugins/instruction-placement/CHANGELOG.md +++ b/plugins/instruction-placement/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to the `instruction-placement` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.11.9] + +### Changed + +- **`glob-tools.sh` collapses a bracket-negation if/elif chain.** The two branches computed the + same result; one expression now does. `render-index.sh` merges two consecutive `sed` passes + into one. Output is byte-identical in both scripts. Code-tidying sweep, behavior-preserving. + ## [0.11.8] ### Changed diff --git a/plugins/instruction-placement/scripts/glob-tools.sh b/plugins/instruction-placement/scripts/glob-tools.sh index bc5376af08..b1e68dfba1 100755 --- a/plugins/instruction-placement/scripts/glob-tools.sh +++ b/plugins/instruction-placement/scripts/glob-tools.sh @@ -248,10 +248,9 @@ glob_to_ere() { "[") # Copy the bracket expression through verbatim; validity was checked first. local j=$((i + 1)) body="[" - if [[ "${pattern:j:1}" == "!" ]]; then - body+="^" - ((j++)) - elif [[ "${pattern:j:1}" == "^" ]]; then + # `!` and `^` both negate, and ERE spells both `^`. brackets_valid() reads + # the same pair, so the two must agree on where the expression starts. + if [[ "${pattern:j:1}" == "!" || "${pattern:j:1}" == "^" ]]; then body+="^" ((j++)) fi diff --git a/plugins/instruction-placement/scripts/render-index.sh b/plugins/instruction-placement/scripts/render-index.sh index 24f1524f0c..82188a688d 100755 --- a/plugins/instruction-placement/scripts/render-index.sh +++ b/plugins/instruction-placement/scripts/render-index.sh @@ -219,7 +219,7 @@ PREAMBLE $((${#rows[@]} - MAX_ROWS)) # shellcheck disable=SC2016 # strips the markdown code span, not an expansion printf '%s\n' "${rows[@]}" | LC_ALL=C sort | tail -n +$((MAX_ROWS + 1)) | - sed 's/^| `//; s/`.*$//' | sed 's|/[^/]*$||' | + sed 's/^| `//; s/`.*$//; s|/[^/]*$||' | LC_ALL=C sort | uniq -c | awk '{ printf "- `%s/` — %d surface(s)\n", $2, $1 }' printf '\n%s\n\n' \ diff --git a/plugins/knowledge/.claude-plugin/plugin.json b/plugins/knowledge/.claude-plugin/plugin.json index daa42a3c3d..e1b73dcffc 100644 --- a/plugins/knowledge/.claude-plugin/plugin.json +++ b/plugins/knowledge/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "knowledge", - "version": "0.13.16", + "version": "0.13.19", "description": "Ingest external knowledge into durable, synthesized artifacts. Ships a book-distillation pipeline (PDF/EPUB into concept-organized, author-attributed skill reference files), a video-digest pipeline (watch a single public video from YouTube or X, formerly Twitter: transcript, link harvest, and repo-applicability synthesis), a course-digest pipeline (extract and synthesize online video courses \u2014 Dometrain, Teachable \u2014 into repo-applicable recommendations), a docpage-digest pipeline (single online documentation page into a verified knowledge slice with dual verification \u2014 one cross-vendor verifier \u2014 and an interview handoff), and a map-corpus pipeline (multi-resource corpus into a classified link map, deterministic node manifests, gate-verified relevance inventory, and an approved queue of docpage-digest runs), plus a re-runnable setup action; a configurable library directory governs where synthesized artifacts land in the consuming repo.", "author": { "name": "Melodic Software", diff --git a/plugins/knowledge/CHANGELOG.md b/plugins/knowledge/CHANGELOG.md index d26328378f..1ffc3d3443 100644 --- a/plugins/knowledge/CHANGELOG.md +++ b/plugins/knowledge/CHANGELOG.md @@ -4,6 +4,48 @@ All notable changes to the `knowledge` plugin are recorded here. The `version` i `.claude-plugin/plugin.json` is the delivery vehicle — a consumer receives a change only after that version increases. +## [0.13.19] + +### Changed + +- **Behavior-preserving simplification sweep, wave 6 (batch-simplify).** `video-digest` + extraction: the `isMainModule()` helper (lib/cli-entrypoint.js) rollout is completed + package-wide — all 30 remaining hand-rolled ESM main-module checks across watch/, watching/, + transcript/, evals/, and acquisition/ now use the helper, and the imports they orphaned + (`node:url`, sometimes `node:path`) are dropped; the only main-module check left in the + package is the helper itself. Two watch test files also consolidate: four hand-rolled + Map-backed fs fakes into one `memoryStore(seed)` helper (assertion-for-assertion identical) + and a byte-identical fixture setup hoisted into `beforeEach`. Every batch adversarially + refutation-verified (expression-identity tables, URL-normalization probes, HEAD-baseline + spot-runs); full package suite 71 files / 494 tests green; `tsc --noEmit` clean. + +## [0.13.18] + +### Changed + +- **Behavior-preserving simplification sweep, wave 5 (batch-simplify).** `video-digest` + extraction cleanups, each adversarially refutation-verified with emitted bytes unchanged: + adapters/x.js folds the twice-built degraded-acquisition failure message into one + `failDegraded()` helper and drops a per-iteration `Set` + spread inside a filter for a plain + array; adapters/adapter-contract.js collapses the two near-identical + `extractorArgs`/`allowedExtractors` validation branches into one loop (violation strings and + order byte-identical); adapters/youtube.js replaces a single-use `/^\//` regex with + `.slice(1)` and hoists a per-call `Set` literal to module scope; + transcript/transcript-strategy.js folds two byte-identical no-transcript degradation returns + into a `noTranscriptPlan()` helper; acquisition/build-yt-dlp-args.js collapses a redundant + `&& length > 0` conjunct to `||`. Package suites green (adapters 112, transcript 58, + acquisition 93, watch consumers spot-checked); `tsc --noEmit` clean. + +## [0.13.17] + +### Changed + +- **Behavior-preserving simplification sweep (batch-simplify).** `course-digest`'s + extract-course-run.js drops a dead `return { modulesDir }` from `runLessonExtraction` + (repo-wide call-site census shows the sole caller awaits without reading the value) and the + destructure that fed only that return. No emitted output, JSON shape, or exit-code change; + package suite 91/91 green; adversarially refutation-verified. + ## [0.13.16] ### Changed diff --git a/plugins/knowledge/skills/course-digest/extraction/extract-course-run.js b/plugins/knowledge/skills/course-digest/extraction/extract-course-run.js index 76b13a2c44..dcd99a43cd 100644 --- a/plugins/knowledge/skills/course-digest/extraction/extract-course-run.js +++ b/plugins/knowledge/skills/course-digest/extraction/extract-course-run.js @@ -229,7 +229,7 @@ async function processLesson(module, lesson, ctx) { } export async function runLessonExtraction(ctx) { - const { course, log, modulesDir } = ctx; + const { course, log } = ctx; for (const module of course.modules) { log.info(` Module ${module.position}: ${module.title}`); @@ -237,8 +237,6 @@ export async function runLessonExtraction(ctx) { await processLesson(module, lesson, ctx); } } - - return { modulesDir }; } export function createRunStats() { diff --git a/plugins/knowledge/skills/video-digest/extraction/acquisition/build-yt-dlp-args.js b/plugins/knowledge/skills/video-digest/extraction/acquisition/build-yt-dlp-args.js index 5cf5086fec..a13db311ca 100644 --- a/plugins/knowledge/skills/video-digest/extraction/acquisition/build-yt-dlp-args.js +++ b/plugins/knowledge/skills/video-digest/extraction/acquisition/build-yt-dlp-args.js @@ -111,7 +111,7 @@ export function resolveYtDlpAuthArgs( if (jsRuntimes === "off" || jsRuntimes === "0") { return args; } - args.push("--js-runtimes", jsRuntimes && jsRuntimes.length > 0 ? jsRuntimes : "node"); + args.push("--js-runtimes", jsRuntimes || "node"); return args; } diff --git a/plugins/knowledge/skills/video-digest/extraction/acquisition/preflight-metadata.js b/plugins/knowledge/skills/video-digest/extraction/acquisition/preflight-metadata.js index 3f1df0058b..07ea85343c 100755 --- a/plugins/knowledge/skills/video-digest/extraction/acquisition/preflight-metadata.js +++ b/plugins/knowledge/skills/video-digest/extraction/acquisition/preflight-metadata.js @@ -22,14 +22,12 @@ * Exit code 2 when any URL resolves to `reject` (lets a caller fail-fast on bad input). */ -import path from "node:path"; -import { fileURLToPath } from "node:url"; - import { spawnAsync } from "@melodic/video-digestion/shared/process"; import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; import { classifyErrorDetail, UnsupportedSourceError } from "../adapters/adapter-contract.js"; import { resolveSourceAdapter } from "../adapters/registry.js"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { adapterSourceDeclarations } from "./acquire.js"; import { spawnFailureDetail } from "./acquire-with-retry.js"; import { resolveYtDlpAuthArgs } from "./build-yt-dlp-args.js"; @@ -343,7 +341,7 @@ async function main(argv) { } } -if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +if (isMainModule(import.meta.url)) { main(process.argv).catch((error) => { writeStderr(error instanceof Error ? error.message : String(error)); process.exit(1); diff --git a/plugins/knowledge/skills/video-digest/extraction/adapters/adapter-contract.js b/plugins/knowledge/skills/video-digest/extraction/adapters/adapter-contract.js index 0b76e139e7..7e9f9b548a 100644 --- a/plugins/knowledge/skills/video-digest/extraction/adapters/adapter-contract.js +++ b/plugins/knowledge/skills/video-digest/extraction/adapters/adapter-contract.js @@ -466,11 +466,11 @@ export function validateAdapter(spec) { // string silently disables the declaration — for allowedExtractors that // would silently drop the SSRF guard. Fail loud instead: null is the one // way to declare "none". - if (record.extractorArgs !== null && (typeof record.extractorArgs !== "string" || record.extractorArgs.length === 0)) { - violations.push('attribute "extractorArgs" must be a non-empty string or null'); - } - if (record.allowedExtractors !== null && (typeof record.allowedExtractors !== "string" || record.allowedExtractors.length === 0)) { - violations.push('attribute "allowedExtractors" must be a non-empty string or null'); + for (const attribute of ["extractorArgs", "allowedExtractors"]) { + const value = record[attribute]; + if (value !== null && (typeof value !== "string" || value.length === 0)) { + violations.push(`attribute "${attribute}" must be a non-empty string or null`); + } } if ( typeof record.captionClass !== "string" || diff --git a/plugins/knowledge/skills/video-digest/extraction/adapters/x.js b/plugins/knowledge/skills/video-digest/extraction/adapters/x.js index 2e18751127..6a6c15dab1 100644 --- a/plugins/knowledge/skills/video-digest/extraction/adapters/x.js +++ b/plugins/knowledge/skills/video-digest/extraction/adapters/x.js @@ -611,6 +611,12 @@ export async function acquireXMedia(url, context) { fail(message, "acquire-x-media", { label: statusId }, Date.now() - started) ); + /** @param {SyndicationDegradation} degradation @returns {AcquireOutcome} */ + const failDegraded = (degradation) => + failX( + `${X_DEGRADED_ACQUISITION_PREFIX} (rate-limited syndication fallback is a partial response, retry later): ${JSON.stringify(degradation)}`, + ); + // allowedExtractors and ignoreNoFormatsError arrive via the adapter's own // declarations (allow-list attribute + mediaOptional capability), so every // consumer — probe, media, preflight — carries them identically. @@ -656,9 +662,7 @@ export async function acquireXMedia(url, context) { entryCount: 0, }); if (degradation) { - return failX( - `${X_DEGRADED_ACQUISITION_PREFIX} (rate-limited syndication fallback is a partial response, retry later): ${JSON.stringify(degradation)}`, - ); + return failDegraded(degradation); } const envelope = createAcquisitionEnvelope({ entries: [], @@ -745,9 +749,7 @@ export async function acquireXMedia(url, context) { if (!degradation) { return null; } - return failX( - `${X_DEGRADED_ACQUISITION_PREFIX} (rate-limited syndication fallback is a partial response, retry later): ${JSON.stringify(degradation)}`, - ); + return failDegraded(degradation); }; let gated = await inspectAndGate(); @@ -799,11 +801,10 @@ export async function acquireXMedia(url, context) { files = await listFiles(workDir); // Only srt files belonging to the post's own media entries are eligible // for conversion back to VTT — never any other file in the directory. - const mediaIds = new Set(inspected.twitterVideos.map((video) => String(video.info.id))); + const mediaIds = inspected.twitterVideos.map((video) => String(video.info.id)); const srtPaths = files.filter( (entry) => - entry.endsWith(".srt") && - [...mediaIds].some((id) => path.basename(entry).startsWith(`${id}.`)), + entry.endsWith(".srt") && mediaIds.some((id) => path.basename(entry).startsWith(`${id}.`)), ); // Backups are deletable only when every tagged VTT has its own converted // .srt — a partial conversion (multi-video post) must not pass on the diff --git a/plugins/knowledge/skills/video-digest/extraction/adapters/youtube.js b/plugins/knowledge/skills/video-digest/extraction/adapters/youtube.js index 3ab56cc06d..24f5a3cb94 100644 --- a/plugins/knowledge/skills/video-digest/extraction/adapters/youtube.js +++ b/plugins/knowledge/skills/video-digest/extraction/adapters/youtube.js @@ -38,7 +38,8 @@ export const YOUTUBE_UNAVAILABLE_PATTERNS = Object.freeze([ const YOUTUBE_EXTRACTOR_ARGS = "youtube:max_comments=20,all,top;comment_sort=top"; const YOUTUBE_VIDEO_ID_PATTERN = /^[\w-]{11}$/; -const YOUTU_BE_PATH_PREFIX = /^\//; +/** Path prefixes whose NEXT segment carries the video id (`/live/`, `/embed/`, …). */ +const YOUTUBE_ID_PATH_PREFIXES = new Set(["live", "embed", "shorts", "v"]); /** * @param {string} segment @@ -57,7 +58,7 @@ export function extractVideoId(url) { try { const parsed = new URL(url); if (parsed.hostname.includes("youtu.be")) { - return normalizeYouTubeVideoIdSegment(parsed.pathname.replace(YOUTU_BE_PATH_PREFIX, "")); + return normalizeYouTubeVideoIdSegment(parsed.pathname.slice(1)); } const queryId = parsed.searchParams.get("v"); @@ -66,8 +67,7 @@ export function extractVideoId(url) { } const pathSegments = parsed.pathname.split("/").filter(Boolean); - const pathPrefixes = new Set(["live", "embed", "shorts", "v"]); - if (pathSegments.length >= 2 && pathPrefixes.has(pathSegments[0])) { + if (pathSegments.length >= 2 && YOUTUBE_ID_PATH_PREFIXES.has(pathSegments[0])) { return normalizeYouTubeVideoIdSegment(pathSegments[1]); } diff --git a/plugins/knowledge/skills/video-digest/extraction/evals/check-research-complete.js b/plugins/knowledge/skills/video-digest/extraction/evals/check-research-complete.js index 789a6a9a5c..29f1738c48 100755 --- a/plugins/knowledge/skills/video-digest/extraction/evals/check-research-complete.js +++ b/plugins/knowledge/skills/video-digest/extraction/evals/check-research-complete.js @@ -7,10 +7,10 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { writeStderr } from "@melodic/video-digestion/shared/terminal"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { LANES, lanePath } from "../lib/slice-lanes.js"; /** @@ -75,10 +75,7 @@ export function checkResearchComplete(sliceDir) { return 0; } -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); - -if (isMain) { +if (isMainModule(import.meta.url)) { const sliceDir = process.argv[2]; if (!sliceDir) { writeStderr("Usage: node evals/check-research-complete.js "); diff --git a/plugins/knowledge/skills/video-digest/extraction/evals/check-transcript-goldens.js b/plugins/knowledge/skills/video-digest/extraction/evals/check-transcript-goldens.js index a19fd2f343..ec09de3659 100755 --- a/plugins/knowledge/skills/video-digest/extraction/evals/check-transcript-goldens.js +++ b/plugins/knowledge/skills/video-digest/extraction/evals/check-transcript-goldens.js @@ -7,8 +7,8 @@ */ import fs from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; + +import { isMainModule } from "../lib/cli-entrypoint.js"; /** * @param {string} value @@ -126,10 +126,7 @@ export async function runCheckTranscriptGoldensCli(argv) { return 0; } -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); - -if (isMain) { +if (isMainModule(import.meta.url)) { runCheckTranscriptGoldensCli(process.argv) .then((code) => process.exit(code)) .catch((err) => { diff --git a/plugins/knowledge/skills/video-digest/extraction/evals/check-transcript-readability.js b/plugins/knowledge/skills/video-digest/extraction/evals/check-transcript-readability.js index c781b38154..163da88375 100755 --- a/plugins/knowledge/skills/video-digest/extraction/evals/check-transcript-readability.js +++ b/plugins/knowledge/skills/video-digest/extraction/evals/check-transcript-readability.js @@ -6,11 +6,11 @@ */ import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; import { writeStderr } from "@melodic/video-digestion/shared/terminal"; +import { isMainModule } from "../lib/cli-entrypoint.js"; + const DEFAULT_MAX_BYTES = 25_600; const MIN_PHRASE_WORDS = 4; const MAX_CONSECUTIVE_PHRASE_REPEATS = 2; @@ -92,8 +92,7 @@ export function checkTranscriptReadability(transcriptPath, { maxBytes = DEFAULT_ return 0; } -const scriptPath = fileURLToPath(import.meta.url); -if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { +if (isMainModule(import.meta.url)) { const transcriptPath = process.argv[2]; let maxBytes = DEFAULT_MAX_BYTES; const maxIdx = process.argv.indexOf("--max-bytes"); diff --git a/plugins/knowledge/skills/video-digest/extraction/evals/check-watch-outcomes.js b/plugins/knowledge/skills/video-digest/extraction/evals/check-watch-outcomes.js index 8d8b9c688d..283650c4d9 100755 --- a/plugins/knowledge/skills/video-digest/extraction/evals/check-watch-outcomes.js +++ b/plugins/knowledge/skills/video-digest/extraction/evals/check-watch-outcomes.js @@ -7,10 +7,10 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { LANES, lanePath } from "../lib/slice-lanes.js"; import { forbiddenSynthesisFileNameReason } from "../lib/synthesis-filename.js"; import { parseSessionsFromClaimInventory } from "../lib/watch-slice-sessions.js"; @@ -725,10 +725,7 @@ export function runCheckWatchOutcomes(sliceDir, options = {}) { return result.pass ? 0 : 1; } -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); - -if (isMain) { +if (isMainModule(import.meta.url)) { const sliceDir = process.argv[2]; const writeReport = process.argv.includes("--write-report"); if (!sliceDir) { diff --git a/plugins/knowledge/skills/video-digest/extraction/transcript/run-transcript.js b/plugins/knowledge/skills/video-digest/extraction/transcript/run-transcript.js index 76dcec4f6b..7cd62936f1 100755 --- a/plugins/knowledge/skills/video-digest/extraction/transcript/run-transcript.js +++ b/plugins/knowledge/skills/video-digest/extraction/transcript/run-transcript.js @@ -15,13 +15,13 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; import { primaryEntry, UnsupportedSourceError } from "../adapters/adapter-contract.js"; import { acquireMedia, resolveSourceAdapter } from "../adapters/registry.js"; import { harvestMetadataLinks } from "../harvesting/harvest-links.js"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { resolveWorkRoot } from "../lib/work-root.js"; import { deriveVideoSlug, resolveWorkSliceDir } from "./derive-video-slug.js"; import { parseTranscriptStrategyOverride } from "./transcript-strategy.js"; @@ -125,10 +125,7 @@ export async function runTranscriptCli(argv) { } } -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); - -if (isMain) { +if (isMainModule(import.meta.url)) { runTranscriptCli(process.argv) .then((code) => { process.exitCode = code; diff --git a/plugins/knowledge/skills/video-digest/extraction/transcript/transcript-strategy.js b/plugins/knowledge/skills/video-digest/extraction/transcript/transcript-strategy.js index 327b098bb0..c7fd53acdf 100644 --- a/plugins/knowledge/skills/video-digest/extraction/transcript/transcript-strategy.js +++ b/plugins/knowledge/skills/video-digest/extraction/transcript/transcript-strategy.js @@ -76,6 +76,22 @@ function asrBlockedReason(mediaAvailable) { return "the optional ASR capability (faster-whisper) is not installed — install the documented optional prerequisite to enable the ASR rung"; } +/** + * The terminal degradation: no caption to consume and the ASR rung cannot run, + * so the digest proceeds without a transcript. Reached from both the explicit + * `asr` request and the caption-absent adapter default, which must report the + * identical reason. + * + * @param {boolean} mediaAvailable + * @returns {TranscriptPlan} + */ +function noTranscriptPlan(mediaAvailable) { + return { + strategy: null, + degradation: `no captions were selected and ${asrBlockedReason(mediaAvailable)} — digest proceeds without a transcript`, + }; +} + /** * Resolve the transcript strategy for one media entry. * @@ -120,10 +136,7 @@ export function resolveTranscriptStrategy({ degradation: `transcript strategy "asr" could not run (${asrBlockedReason(mediaAvailable)}); fell back to "${fallback}" over the selected caption`, }; } - return { - strategy: null, - degradation: `no captions were selected and ${asrBlockedReason(mediaAvailable)} — digest proceeds without a transcript`, - }; + return noTranscriptPlan(mediaAvailable); } if (captionPresent) { @@ -142,8 +155,5 @@ export function resolveTranscriptStrategy({ if (asrAvailable && mediaAvailable) { return { strategy: "asr", degradation: null }; } - return { - strategy: null, - degradation: `no captions were selected and ${asrBlockedReason(mediaAvailable)} — digest proceeds without a transcript`, - }; + return noTranscriptPlan(mediaAvailable); } diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/detect-recoverable-bootstrap.js b/plugins/knowledge/skills/video-digest/extraction/watch/detect-recoverable-bootstrap.js index 520f9bd251..60955fb30a 100755 --- a/plugins/knowledge/skills/video-digest/extraction/watch/detect-recoverable-bootstrap.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/detect-recoverable-bootstrap.js @@ -7,10 +7,10 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { LANES, lanePath } from "../lib/slice-lanes.js"; import { resolveTempSession } from "../lib/temp-session-paths.js"; import { watchStatePath } from "./watch-state.js"; @@ -140,9 +140,6 @@ export function runDetectRecoverableBootstrapCli(argv) { return result.recoverable ? 0 : 1; } -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); - -if (isMain) { +if (isMainModule(import.meta.url)) { process.exitCode = runDetectRecoverableBootstrapCli(process.argv); } diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/expand-visual-gaps.js b/plugins/knowledge/skills/video-digest/extraction/watch/expand-visual-gaps.js index 9a303f547b..0f0bab6b5c 100755 --- a/plugins/knowledge/skills/video-digest/extraction/watch/expand-visual-gaps.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/expand-visual-gaps.js @@ -5,11 +5,11 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; import { parsePromotedTimestampsSec } from "../evals/check-watch-outcomes.js"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { LANES, lanePath } from "../lib/slice-lanes.js"; /** @@ -49,10 +49,8 @@ ${gapRows.join("\n")} } const sliceDir = process.argv[2]; -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); -if (isMain) { +if (isMainModule(import.meta.url)) { if (!sliceDir) { writeStderr("Usage: node watch/expand-visual-gaps.js "); process.exit(2); diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/export-sheet-frame-index.js b/plugins/knowledge/skills/video-digest/extraction/watch/export-sheet-frame-index.js index a8533a50e6..8e9ba5c3c1 100755 --- a/plugins/knowledge/skills/video-digest/extraction/watch/export-sheet-frame-index.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/export-sheet-frame-index.js @@ -7,10 +7,10 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { LANES, lanePath } from "../lib/slice-lanes.js"; import { resolveTempSession, serializeTempPath } from "../lib/temp-session-paths.js"; import { watchStatePath } from "./watch-state.js"; @@ -79,10 +79,8 @@ export function exportSheetFrameIndex(sliceDir) { } const sliceDir = process.argv[2]; -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); -if (isMain) { +if (isMainModule(import.meta.url)) { if (!sliceDir) { writeStderr("Usage: node watch/export-sheet-frame-index.js "); process.exit(2); diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/finalize-vision.js b/plugins/knowledge/skills/video-digest/extraction/watch/finalize-vision.js index fc953445fb..bc29d1c201 100755 --- a/plugins/knowledge/skills/video-digest/extraction/watch/finalize-vision.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/finalize-vision.js @@ -10,11 +10,9 @@ * verdicts) stay inline in the skill session — they are NOT part of this orchestrator. */ -import path from "node:path"; -import { fileURLToPath } from "node:url"; - import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { mergeTriageJson } from "./merge-triage-json.js"; import { renderKeyFramesManifest } from "./render-key-frames-manifest.js"; import { renderQualityAudit } from "./render-quality-audit.js"; @@ -83,10 +81,7 @@ export function finalizeVision( return 0; } -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); - -if (isMain) { +if (isMainModule(import.meta.url)) { const args = process.argv.slice(2); const dryRun = args.includes("--dry-run"); const sliceDir = args.find((a) => !a.startsWith("--")); diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/init-watch-checklist.js b/plugins/knowledge/skills/video-digest/extraction/watch/init-watch-checklist.js index 337ea63cd2..7637ab5bbb 100755 --- a/plugins/knowledge/skills/video-digest/extraction/watch/init-watch-checklist.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/init-watch-checklist.js @@ -12,6 +12,7 @@ import { fileURLToPath } from "node:url"; import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; import { detectContentClass, outcomeFloors } from "../evals/check-watch-outcomes.js"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { LANES, lanePath } from "../lib/slice-lanes.js"; import { parseSessionsFromClaimInventory } from "../lib/watch-slice-sessions.js"; import { watchStatePath } from "./watch-state.js"; @@ -163,10 +164,7 @@ export function initWatchChecklist(sliceDir, { force = false } = {}) { return outPath; } -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); - -if (isMain) { +if (isMainModule(import.meta.url)) { const sliceDir = process.argv[2]; const force = process.argv.includes("--force"); diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/list-promotion-candidates.js b/plugins/knowledge/skills/video-digest/extraction/watch/list-promotion-candidates.js index 705cfdb903..b9e5e70429 100755 --- a/plugins/knowledge/skills/video-digest/extraction/watch/list-promotion-candidates.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/list-promotion-candidates.js @@ -7,10 +7,10 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { LANES, lanePath } from "../lib/slice-lanes.js"; import { parseSessionsFromClaimInventory } from "../lib/watch-slice-sessions.js"; @@ -105,10 +105,8 @@ export function listPromotionCandidates(sliceDir) { } const sliceDir = process.argv[2]; -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); -if (isMain) { +if (isMainModule(import.meta.url)) { if (!sliceDir) { writeStderr("Usage: node watch/list-promotion-candidates.js "); process.exit(2); diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/merge-triage-json.js b/plugins/knowledge/skills/video-digest/extraction/watch/merge-triage-json.js index 2336de75e9..0f4ddece22 100755 --- a/plugins/knowledge/skills/video-digest/extraction/watch/merge-triage-json.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/merge-triage-json.js @@ -7,10 +7,10 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { LANES, lanePath } from "../lib/slice-lanes.js"; import { validateTriageSheet } from "../lib/watch-vision-validation.js"; @@ -78,10 +78,8 @@ export function mergeTriageJson(sliceDir, batchPaths) { const sliceDir = process.argv[2]; const batchPaths = process.argv.slice(3); -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); -if (isMain) { +if (isMainModule(import.meta.url)) { if (!sliceDir) { writeStderr("Usage: node watch/merge-triage-json.js [batch.json ...]\n"); process.exit(2); diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/promote-key-frames.js b/plugins/knowledge/skills/video-digest/extraction/watch/promote-key-frames.js index 3b0908f908..54d92eda24 100644 --- a/plugins/knowledge/skills/video-digest/extraction/watch/promote-key-frames.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/promote-key-frames.js @@ -4,10 +4,10 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { LANES, lanePath } from "../lib/slice-lanes.js"; const KEY_FRAME_IMAGE_EXTENSION_PATTERN = /\.(png|jpe?g|webp)$/i; @@ -122,10 +122,7 @@ export async function runPromoteKeyFramesCli(argv) { return 0; } -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); - -if (isMain) { +if (isMainModule(import.meta.url)) { runPromoteKeyFramesCli(process.argv) .then((code) => process.exit(code)) .catch((err) => { diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/queue-claim.js b/plugins/knowledge/skills/video-digest/extraction/watch/queue-claim.js index bb98fadd1e..430164f89b 100755 --- a/plugins/knowledge/skills/video-digest/extraction/watch/queue-claim.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/queue-claim.js @@ -12,10 +12,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { resolveWorkRoot } from "../lib/work-root.js"; import { YOUTUBE_WATCH_EPIC_DIR } from "../transcript/derive-video-slug.js"; @@ -263,6 +263,6 @@ function main(argv) { process.exit(1); } -if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +if (isMainModule(import.meta.url)) { main(process.argv); } diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/rebuild-visual-frames.js b/plugins/knowledge/skills/video-digest/extraction/watch/rebuild-visual-frames.js index 467adb8c9e..fdea9821a3 100755 --- a/plugins/knowledge/skills/video-digest/extraction/watch/rebuild-visual-frames.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/rebuild-visual-frames.js @@ -5,10 +5,10 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { LANES, lanePath } from "../lib/slice-lanes.js"; /** @@ -94,10 +94,8 @@ export function rebuildVisualFrames(sliceDir) { } const sliceDir = process.argv[2]; -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); -if (isMain) { +if (isMainModule(import.meta.url)) { if (!sliceDir) { writeStderr("Usage: node watch/rebuild-visual-frames.js "); process.exit(2); diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/recover-watch-bootstrap.js b/plugins/knowledge/skills/video-digest/extraction/watch/recover-watch-bootstrap.js index 9e32183ed3..8ecaef2416 100755 --- a/plugins/knowledge/skills/video-digest/extraction/watch/recover-watch-bootstrap.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/recover-watch-bootstrap.js @@ -9,7 +9,6 @@ import fs from "node:fs"; import fsPromises from "node:fs/promises"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { probeVideoDuration } from "@melodic/video-digestion/media/ffprobe-duration"; import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; @@ -18,6 +17,7 @@ import { parseVttSegment } from "@melodic/video-digestion/transcript/vtt-parser" import { resolveSourceAdapter } from "../adapters/registry.js"; import { parseVideoMetadata } from "../acquisition/video-metadata.js"; import { harvestMetadataLinks } from "../harvesting/harvest-links.js"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { LANES, lanePath } from "../lib/slice-lanes.js"; import { computeCoveragePlan } from "../watching/compute-coverage-plan.js"; import { findDensificationWindows, scoreFramePriority } from "../watching/densification.js"; @@ -339,10 +339,7 @@ export async function recoverWatchBootstrapCli(argv) { return 0; } -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); - -if (isMain) { +if (isMainModule(import.meta.url)) { recoverWatchBootstrapCli(process.argv) .then((code) => { process.exitCode = code; diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/render-key-frames-manifest.js b/plugins/knowledge/skills/video-digest/extraction/watch/render-key-frames-manifest.js index 62be438a38..fca8c881b0 100755 --- a/plugins/knowledge/skills/video-digest/extraction/watch/render-key-frames-manifest.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/render-key-frames-manifest.js @@ -7,10 +7,10 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { LANES, lanePath } from "../lib/slice-lanes.js"; /** @@ -51,10 +51,8 @@ export function renderKeyFramesManifest(sliceDir) { } const sliceDir = process.argv[2]; -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); -if (isMain) { +if (isMainModule(import.meta.url)) { if (!sliceDir) { writeStderr("Usage: node watch/render-key-frames-manifest.js "); process.exit(2); diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/render-quality-audit.js b/plugins/knowledge/skills/video-digest/extraction/watch/render-quality-audit.js index ac88058099..8f5e94701e 100755 --- a/plugins/knowledge/skills/video-digest/extraction/watch/render-quality-audit.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/render-quality-audit.js @@ -7,10 +7,10 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { LANES, lanePath } from "../lib/slice-lanes.js"; /** @@ -49,10 +49,8 @@ export function renderQualityAudit(sliceDir) { } const sliceDir = process.argv[2]; -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); -if (isMain) { +if (isMainModule(import.meta.url)) { if (!sliceDir) { writeStderr("Usage: node watch/render-quality-audit.js "); process.exit(2); diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/render-triage-log.js b/plugins/knowledge/skills/video-digest/extraction/watch/render-triage-log.js index 3e08136030..a52909cb62 100755 --- a/plugins/knowledge/skills/video-digest/extraction/watch/render-triage-log.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/render-triage-log.js @@ -7,10 +7,10 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { LANES, lanePath } from "../lib/slice-lanes.js"; /** @@ -47,10 +47,8 @@ export function renderTriageLog(sliceDir) { } const sliceDir = process.argv[2]; -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); -if (isMain) { +if (isMainModule(import.meta.url)) { if (!sliceDir) { writeStderr("Usage: node watch/render-triage-log.js "); process.exit(2); diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/repair-synthesis-promotions.js b/plugins/knowledge/skills/video-digest/extraction/watch/repair-synthesis-promotions.js index 54f14b7962..c5441ee9aa 100755 --- a/plugins/knowledge/skills/video-digest/extraction/watch/repair-synthesis-promotions.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/repair-synthesis-promotions.js @@ -9,10 +9,10 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { LANES, lanePath } from "../lib/slice-lanes.js"; import { deriveSemanticNameFromGapNote, @@ -288,10 +288,8 @@ export function repairSynthesisPromotions(sliceDir, { dryRun = false } = {}) { const sliceDir = process.argv[2]; const dryRun = process.argv.includes("--dry-run"); -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); -if (isMain) { +if (isMainModule(import.meta.url)) { if (!sliceDir) { writeStderr("Usage: node watch/repair-synthesis-promotions.js [--dry-run]\n"); process.exit(2); diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/run-resume.js b/plugins/knowledge/skills/video-digest/extraction/watch/run-resume.js index e6a49e4f29..2cd54d310e 100755 --- a/plugins/knowledge/skills/video-digest/extraction/watch/run-resume.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/run-resume.js @@ -5,11 +5,9 @@ * Usage: node watch/run-resume.js */ -import path from "node:path"; -import { fileURLToPath } from "node:url"; - import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { resolveWorkRoot } from "../lib/work-root.js"; import { resolveWorkSliceDir } from "../transcript/derive-video-slug.js"; import { @@ -67,10 +65,7 @@ export async function runResumeCli(argv) { return 0; } -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); - -if (isMain) { +if (isMainModule(import.meta.url)) { runResumeCli(process.argv) .then((code) => { process.exitCode = code; diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/run-watch.js b/plugins/knowledge/skills/video-digest/extraction/watch/run-watch.js index dc55ad8d1e..60a253387c 100755 --- a/plugins/knowledge/skills/video-digest/extraction/watch/run-watch.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/run-watch.js @@ -22,7 +22,6 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; import { parseVttSegment } from "@melodic/video-digestion/transcript/vtt-parser"; @@ -34,6 +33,7 @@ import { } from "../adapters/adapter-contract.js"; import { acquireMedia, resolveSourceAdapter } from "../adapters/registry.js"; import { harvestMetadataLinks } from "../harvesting/harvest-links.js"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { LANES, lanePath } from "../lib/slice-lanes.js"; import { resolveWorkRoot } from "../lib/work-root.js"; import { deriveVideoSlug, resolveWorkSliceDir } from "../transcript/derive-video-slug.js"; @@ -352,10 +352,7 @@ export async function runWatchCli(argv) { } } -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); - -if (isMain) { +if (isMainModule(import.meta.url)) { runWatchCli(process.argv) .then((code) => { process.exitCode = code; diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/sanitize-slice-temp-paths.js b/plugins/knowledge/skills/video-digest/extraction/watch/sanitize-slice-temp-paths.js index 3f31ebc531..bc6913ac9f 100755 --- a/plugins/knowledge/skills/video-digest/extraction/watch/sanitize-slice-temp-paths.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/sanitize-slice-temp-paths.js @@ -7,10 +7,10 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { LANES, lanePath } from "../lib/slice-lanes.js"; import { normalizePortableTempPath, serializeTempSession } from "../lib/temp-session-paths.js"; import { watchStatePath } from "./watch-state.js"; @@ -120,10 +120,7 @@ export function sanitizeSliceTempPaths(sliceDir) { return touched; } -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); - -if (isMain) { +if (isMainModule(import.meta.url)) { const sliceDir = process.argv[2]; if (!sliceDir) { writeStderr("Usage: node watch/sanitize-slice-temp-paths.js "); diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/snapshot-bootstrap.js b/plugins/knowledge/skills/video-digest/extraction/watch/snapshot-bootstrap.js index be62df2e93..5f9d5d9881 100755 --- a/plugins/knowledge/skills/video-digest/extraction/watch/snapshot-bootstrap.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/snapshot-bootstrap.js @@ -7,10 +7,10 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { LANES, lanePath } from "../lib/slice-lanes.js"; import { resolveTempSession, serializeTempPath } from "../lib/temp-session-paths.js"; import { watchStatePath } from "./watch-state.js"; @@ -62,10 +62,7 @@ export function snapshotBootstrapContactSheets(sliceDir) { return { copied: sheets.length, destDir }; } -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); - -if (isMain) { +if (isMainModule(import.meta.url)) { const sliceDir = process.argv[2]; if (!sliceDir) { writeStderr("Usage: node watch/snapshot-bootstrap.js \n"); diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/snapshot-bootstrap.test.js b/plugins/knowledge/skills/video-digest/extraction/watch/snapshot-bootstrap.test.js index 02f4d61e0a..f41a621695 100644 --- a/plugins/knowledge/skills/video-digest/extraction/watch/snapshot-bootstrap.test.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/snapshot-bootstrap.test.js @@ -14,21 +14,21 @@ describe("snapshotBootstrapContactSheets", () => { sliceDir = mkdtempSync(join(tmpdir(), "slice-")); sheetsDir = mkdtempSync(join(tmpdir(), "video-sheets-")); writeFileSync(join(sheetsDir, "sheet_001.jpg"), "fake-jpg-bytes"); - }); - - afterEach(() => { - rmSync(sliceDir, { recursive: true, force: true }); - rmSync(sheetsDir, { recursive: true, force: true }); - }); - it("tokenizes snapshot-meta.json sourceDir — no machine-local temp path leaks", () => { mkdirSync(join(sliceDir, "run-state"), { recursive: true }); writeFileSync( join(sliceDir, "run-state", "watch.json"), `${JSON.stringify({ tempSession: { contactSheetsDir: sheetsDir } }, null, 2)}\n`, "utf8", ); + }); + afterEach(() => { + rmSync(sliceDir, { recursive: true, force: true }); + rmSync(sheetsDir, { recursive: true, force: true }); + }); + + it("tokenizes snapshot-meta.json sourceDir — no machine-local temp path leaks", () => { const result = snapshotBootstrapContactSheets(sliceDir); expect(result.copied).toBe(1); @@ -40,13 +40,6 @@ describe("snapshotBootstrapContactSheets", () => { }); it("writes a local .gitignore excluding the snapshot JPG binaries", () => { - mkdirSync(join(sliceDir, "run-state"), { recursive: true }); - writeFileSync( - join(sliceDir, "run-state", "watch.json"), - `${JSON.stringify({ tempSession: { contactSheetsDir: sheetsDir } }, null, 2)}\n`, - "utf8", - ); - snapshotBootstrapContactSheets(sliceDir); const ignore = readFileSync( diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/validate-promotion-decisions.js b/plugins/knowledge/skills/video-digest/extraction/watch/validate-promotion-decisions.js index dfa8cbcc03..4d6388db5d 100755 --- a/plugins/knowledge/skills/video-digest/extraction/watch/validate-promotion-decisions.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/validate-promotion-decisions.js @@ -5,11 +5,9 @@ * Usage: node watch/validate-promotion-decisions.js */ -import path from "node:path"; -import { fileURLToPath } from "node:url"; - import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { validatePromotionDecisionsForSlice } from "../lib/watch-vision-validation.js"; /** @@ -28,10 +26,7 @@ export function runValidatePromotionDecisions(sliceDir) { return 1; } -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); - -if (isMain) { +if (isMainModule(import.meta.url)) { const sliceDir = process.argv[2]; if (!sliceDir) { writeStderr("Usage: node watch/validate-promotion-decisions.js \n"); diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/validate-triage-json.js b/plugins/knowledge/skills/video-digest/extraction/watch/validate-triage-json.js index fc238140ed..594c8b48b6 100755 --- a/plugins/knowledge/skills/video-digest/extraction/watch/validate-triage-json.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/validate-triage-json.js @@ -5,11 +5,9 @@ * Usage: node watch/validate-triage-json.js */ -import path from "node:path"; -import { fileURLToPath } from "node:url"; - import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { validateTriageManifestForSlice } from "../lib/watch-vision-validation.js"; /** @@ -28,10 +26,7 @@ export function runValidateTriageJson(sliceDir) { return 1; } -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); - -if (isMain) { +if (isMainModule(import.meta.url)) { const sliceDir = process.argv[2]; if (!sliceDir) { writeStderr("Usage: node watch/validate-triage-json.js \n"); diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/vision-gated-promote.js b/plugins/knowledge/skills/video-digest/extraction/watch/vision-gated-promote.js index 3c929b5118..214201fb1a 100755 --- a/plugins/knowledge/skills/video-digest/extraction/watch/vision-gated-promote.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/vision-gated-promote.js @@ -7,10 +7,10 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { LANES, lanePath } from "../lib/slice-lanes.js"; import { forbiddenSynthesisFileNameReason } from "../lib/synthesis-filename.js"; import { resolveTempSession } from "../lib/temp-session-paths.js"; @@ -96,10 +96,7 @@ export async function visionGatedPromote(sliceDir, { dryRun = false } = {}) { }; } -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); - -if (isMain) { +if (isMainModule(import.meta.url)) { const sliceDir = process.argv[2]; if (!sliceDir) { writeStderr("Usage: node watch/vision-gated-promote.js [--dry-run]\n"); diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/watch-state.js b/plugins/knowledge/skills/video-digest/extraction/watch/watch-state.js index 0f7d5ac8ea..c0beb9727e 100644 --- a/plugins/knowledge/skills/video-digest/extraction/watch/watch-state.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/watch-state.js @@ -6,11 +6,10 @@ */ import fs from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { LANES, lanePath } from "../lib/slice-lanes.js"; import { normalizePortableTempPath, serializeTempSession } from "../lib/temp-session-paths.js"; @@ -334,10 +333,7 @@ export async function runMarkPhase(sliceDir, phase, { readFile, writeFile, mkdir return 0; } -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); - -if (isMain) { +if (isMainModule(import.meta.url)) { const [command, sliceDir, phase] = process.argv.slice(2); if (command !== "mark-phase" || !sliceDir || !phase) { writeStderr("Usage: node watch/watch-state.js mark-phase \n"); diff --git a/plugins/knowledge/skills/video-digest/extraction/watch/watch-state.test.js b/plugins/knowledge/skills/video-digest/extraction/watch/watch-state.test.js index 1f2c0ad080..b7ff7e1844 100644 --- a/plugins/knowledge/skills/video-digest/extraction/watch/watch-state.test.js +++ b/plugins/knowledge/skills/video-digest/extraction/watch/watch-state.test.js @@ -30,12 +30,11 @@ const sampleTalk = () => // resolved dir, not from the storage constant. const SLICE_DIR = path.join("/custom-root", ".work", "any-epic", "talk-abc"); -// In-memory fs fakes seeded with a fresh sample watch.json, for runMarkPhase tests. -function seededStore() { - const sliceDir = "/tmp/slice"; - const store = new Map([ - [watchStatePath(sliceDir), `${JSON.stringify(sampleTalk(), null, 2)}\n`], - ]); +// In-memory fs fakes: a Map-backed readFile/writeFile/mkdir triple, optionally +// pre-seeded so a test can read a watch.json it never wrote. +/** @param {Iterable<[string, string]>} [seed] */ +function memoryStore(seed = []) { + const store = new Map(seed); const readFile = vi.fn(async (p) => { const value = store.get(p); if (value === undefined) throw new Error("ENOENT"); @@ -45,7 +44,16 @@ function seededStore() { store.set(p, data); }); const mkdir = vi.fn(async () => {}); - return { sliceDir, store, readFile, writeFile, mkdir }; + return { store, readFile, writeFile, mkdir }; +} + +// Seeded with a fresh sample watch.json, for runMarkPhase tests. +function seededStore() { + const sliceDir = "/tmp/slice"; + return { + sliceDir, + ...memoryStore([[watchStatePath(sliceDir), `${JSON.stringify(sampleTalk(), null, 2)}\n`]]), + }; } describe("watch state phase map", () => { @@ -160,16 +168,7 @@ describe("synthesis target (resolved --target, resume recovery)", () => { }); it("round-trips target through writeWatchState/readWatchState", async () => { - const store = new Map(); - const writeFile = vi.fn(async (path, data) => { - store.set(path, data); - }); - const readFile = vi.fn(async (path) => { - const value = store.get(path); - if (value === undefined) throw new Error("ENOENT"); - return value; - }); - + const { readFile, writeFile, mkdir } = memoryStore(); const sliceDir = "/tmp/slice"; const initial = createWatchState({ videoId: "abc", @@ -178,33 +177,15 @@ describe("synthesis target (resolved --target, resume recovery)", () => { title: "Talk", target: "acme/webapp", }); - await writeWatchState( - sliceDir, - initial, - writeFile, - vi.fn(async () => {}), - ); + await writeWatchState(sliceDir, initial, writeFile, mkdir); const loaded = await readWatchState(sliceDir, readFile); expect(loaded?.target).toBe("acme/webapp"); }); }); describe("source metadata persistence (source:* envelope subset)", () => { - const persistenceFns = () => { - const store = new Map(); - const writeFile = vi.fn(async (path, data) => { - store.set(path, data); - }); - const readFile = vi.fn(async (path) => { - const value = store.get(path); - if (value === undefined) throw new Error("ENOENT"); - return value; - }); - return { writeFile, readFile }; - }; - it("persists a flagged snowflake aliasing to disk", async () => { - const { writeFile, readFile } = persistenceFns(); + const { writeFile, readFile, mkdir } = memoryStore(); const aliasing = { deltaMs: 4_000_000, suspectedKind: "quote-or-retweet" }; const initial = createWatchState({ videoId: "1001551417340022785", @@ -216,19 +197,14 @@ describe("source metadata persistence (source:* envelope subset)", () => { "source:snowflakeAliasing": aliasing, }, }); - await writeWatchState( - "/tmp/slice", - initial, - writeFile, - vi.fn(async () => {}), - ); + await writeWatchState("/tmp/slice", initial, writeFile, mkdir); const loaded = await readWatchState("/tmp/slice", readFile); expect(loaded?.sourceMetadata?.["source:snowflakeAliasing"]).toEqual(aliasing); expect(loaded?.sourceMetadata?.["source:displayId"]).toBe("1001551623938805763"); }); it("writes no sourceMetadata key for an unflagged run", async () => { - const { writeFile, readFile } = persistenceFns(); + const { writeFile, readFile, mkdir } = memoryStore(); const initial = createWatchState({ videoId: "abc", videoSlug: "talk-abc", @@ -237,12 +213,7 @@ describe("source metadata persistence (source:* envelope subset)", () => { sourceMetadata: {}, }); expect(JSON.stringify(initial)).not.toContain('"sourceMetadata"'); - await writeWatchState( - "/tmp/slice", - initial, - writeFile, - vi.fn(async () => {}), - ); + await writeWatchState("/tmp/slice", initial, writeFile, mkdir); const loaded = await readWatchState("/tmp/slice", readFile); expect(loaded && "sourceMetadata" in loaded).toBe(false); }); @@ -250,24 +221,10 @@ describe("source metadata persistence (source:* envelope subset)", () => { describe("watch state persistence (resume scaffolding)", () => { it("round-trips watch.json via injected writeFile/readFile", async () => { - const store = new Map(); - const writeFile = vi.fn(async (path, data) => { - store.set(path, data); - }); - const readFile = vi.fn(async (path) => { - const value = store.get(path); - if (value === undefined) throw new Error("ENOENT"); - return value; - }); - + const { readFile, writeFile, mkdir } = memoryStore(); const sliceDir = "/tmp/slice"; const initial = sampleTalk(); - await writeWatchState( - sliceDir, - initial, - writeFile, - vi.fn(async () => {}), - ); + await writeWatchState(sliceDir, initial, writeFile, mkdir); const loaded = await readWatchState(sliceDir, readFile); expect(loaded?.videoSlug).toBe("talk-abc"); expect(writeFile).toHaveBeenCalledWith( @@ -286,22 +243,14 @@ describe("watch state persistence (resume scaffolding)", () => { }); it("writes continuation-prompt.md from interrupted state", async () => { - const store = new Map(); - const writeFile = vi.fn(async (path, data) => { - store.set(path, data); - }); + const { store, writeFile, mkdir } = memoryStore(); let state = sampleTalk(); state = markPhaseComplete(state, "acquire"); state = markPhaseComplete(state, "transcript"); state = markPhaseComplete(state, "watching", { selectedCount: 42 }); - const prompt = await writeContinuationPrompt( - "/tmp/slice", - state, - writeFile, - vi.fn(async () => {}), - ); + const prompt = await writeContinuationPrompt("/tmp/slice", state, writeFile, mkdir); expect(prompt).toContain("vision"); expect(store.has(continuationPromptPath("/tmp/slice"))).toBe(true); }); @@ -432,10 +381,9 @@ describe("terminal phase completes the slice", () => { for (const phase of ["acquire", "transcript", "watching", "vision", "harvest", "research"]) { state = markPhaseComplete(state, phase); } - const store = new Map([[watchStatePath(sliceDir), `${JSON.stringify(state, null, 2)}\n`]]); - const readFile = vi.fn(async (p) => store.get(p)); - const writeFile = vi.fn(async (p, data) => store.set(p, data)); - const mkdir = vi.fn(async () => {}); + const { store, readFile, writeFile, mkdir } = memoryStore([ + [watchStatePath(sliceDir), `${JSON.stringify(state, null, 2)}\n`], + ]); await runMarkPhase(sliceDir, "synthesis", { readFile, writeFile, mkdir }); diff --git a/plugins/knowledge/skills/video-digest/extraction/watching/run-watching-pipeline.js b/plugins/knowledge/skills/video-digest/extraction/watching/run-watching-pipeline.js index 76dc40e6b1..c416ead98b 100755 --- a/plugins/knowledge/skills/video-digest/extraction/watching/run-watching-pipeline.js +++ b/plugins/knowledge/skills/video-digest/extraction/watching/run-watching-pipeline.js @@ -10,11 +10,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { writeStderr, writeStdout } from "@melodic/video-digestion/shared/terminal"; import { parseVttSegment } from "@melodic/video-digestion/transcript/vtt-parser"; +import { isMainModule } from "../lib/cli-entrypoint.js"; import { orchestrateWatching } from "./orchestrate-watching.js"; /** @@ -76,10 +76,7 @@ export async function runWatchingPipelineCli(argv) { return 0; } -const isMain = - process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); - -if (isMain) { +if (isMainModule(import.meta.url)) { runWatchingPipelineCli(process.argv) .then((code) => { process.exitCode = code; diff --git a/plugins/machine-health/.claude-plugin/plugin.json b/plugins/machine-health/.claude-plugin/plugin.json index 202d776784..78563be6bf 100644 --- a/plugins/machine-health/.claude-plugin/plugin.json +++ b/plugins/machine-health/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "machine-health", - "version": "0.11.11", + "version": "0.11.12", "description": "Workstation health audit: OS-specific checks (disk, OS updates, security posture, CISA KEV correlation) run from a versioned catalog with trend-aware severity, approval-gated remediations, and dated markdown reports. Windows fully implemented; macOS/Linux scaffolded (report UNKNOWN and stop). Machine state persists in the plugin data directory; the report directory and check catalog are configurable.", "author": { "name": "Melodic Software", diff --git a/plugins/machine-health/CHANGELOG.md b/plugins/machine-health/CHANGELOG.md index c1901cded1..a9052984b4 100644 --- a/plugins/machine-health/CHANGELOG.md +++ b/plugins/machine-health/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `machine-health` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.11.12] + +### Changed + +- **Behavior-preserving simplification sweep, wave 7 (batch-simplify).** Two edits, each + adversarially refutation-verified by empirical pwsh probes: Clear-TempFiles.ps1 drops a + dead `$skippedReparse = $skipCounter.Value` sync-back (the `[ref]` writes through — probe + confirmed identical `after.skipped_reparse` with and without); Invoke-MachineHealthTests.ps1 + hoists the duplicated failed-container predicate into one `$failedContainers` computed once + behind the existing `$result.Containers` guard (7-case differential harness under + StrictMode 3.0 shows identical annotation and summary output). All other audit lib, + checks, catalog, and test-infra files reviewed clean. + ## [0.11.11] ### Changed diff --git a/plugins/machine-health/skills/audit/scripts/windows/remediations/Clear-TempFiles.ps1 b/plugins/machine-health/skills/audit/scripts/windows/remediations/Clear-TempFiles.ps1 index c0acfaac89..0444e2699b 100644 --- a/plugins/machine-health/skills/audit/scripts/windows/remediations/Clear-TempFiles.ps1 +++ b/plugins/machine-health/skills/audit/scripts/windows/remediations/Clear-TempFiles.ps1 @@ -120,6 +120,7 @@ $deletedCount = 0 $deletedBytes = 0L $errorMessages = [System.Collections.Generic.List[string]]::new() +# [ref] writes through to $skippedReparse, so the counter needs no sync-back. $skipCounter = [ref]$skippedReparse foreach ($p in $paths) { try { @@ -139,7 +140,6 @@ foreach ($p in $paths) { $errorMessages.Add("Scan of '$p' failed: $($_.Exception.Message)") } } -$skippedReparse = $skipCounter.Value $after = @{} foreach ($p in $paths) { $after[$p] = Measure-PathUsage -Path $p } diff --git a/plugins/machine-health/skills/audit/tests/Invoke-MachineHealthTests.ps1 b/plugins/machine-health/skills/audit/tests/Invoke-MachineHealthTests.ps1 index e2a302bee0..9a25f30e32 100644 --- a/plugins/machine-health/skills/audit/tests/Invoke-MachineHealthTests.ps1 +++ b/plugins/machine-health/skills/audit/tests/Invoke-MachineHealthTests.ps1 @@ -197,14 +197,16 @@ if ($env:GITHUB_ACTIONS -eq 'true' -and $result.FailedCount -gt 0) { # Also emit a block-start annotation with BeforeAll/Container failures # since those show up in a different collection than Failed tests. + # Guard the pipeline: a $null Containers collection still sends one $null + # item through Where-Object, which trips Set-StrictMode on the predicate. + $failedContainers = @() if ($result.Containers) { - foreach ($c in $result.Containers) { - if ($c.Result -eq 'Failed' -and $c.ErrorRecord) { - foreach ($err in $c.ErrorRecord) { - Write-WorkflowError -Title ('Pester-Container: ' + $c.Item) ` - -Message (Get-ErrorRecordMessage $err) - } - } + $failedContainers = @($result.Containers | Where-Object { $_.Result -eq 'Failed' -and $_.ErrorRecord }) + } + foreach ($c in $failedContainers) { + foreach ($err in $c.ErrorRecord) { + Write-WorkflowError -Title ('Pester-Container: ' + $c.Item) ` + -Message (Get-ErrorRecordMessage $err) } } @@ -228,16 +230,13 @@ if ($env:GITHUB_ACTIONS -eq 'true' -and $result.FailedCount -gt 0) { Add-FencedBlock -Lines $lines -Text $info.Message } } - if ($result.Containers) { - $failedContainers = @($result.Containers | Where-Object { $_.Result -eq 'Failed' -and $_.ErrorRecord }) - if ($failedContainers.Count -gt 0) { - $lines.Add('### Failed containers (BeforeAll / discovery)') - $lines.Add('') - foreach ($c in $failedContainers) { - $lines.Add("- **$($c.Item)**") - foreach ($err in $c.ErrorRecord) { - Add-FencedBlock -Lines $lines -Text (Get-ErrorRecordMessage $err) - } + if ($failedContainers.Count -gt 0) { + $lines.Add('### Failed containers (BeforeAll / discovery)') + $lines.Add('') + foreach ($c in $failedContainers) { + $lines.Add("- **$($c.Item)**") + foreach ($err in $c.ErrorRecord) { + Add-FencedBlock -Lines $lines -Text (Get-ErrorRecordMessage $err) } } } diff --git a/plugins/rate-limit-guard/.claude-plugin/plugin.json b/plugins/rate-limit-guard/.claude-plugin/plugin.json index b1d14b1fd0..8f9266c5da 100644 --- a/plugins/rate-limit-guard/.claude-plugin/plugin.json +++ b/plugins/rate-limit-guard/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "rate-limit-guard", - "version": "0.7.14", + "version": "0.7.15", "description": "Shared rate-limit guard for loop lanes: a statusline wrapper tees the subscription rate-limit windows to a fixed machine-scope file, a StopFailure hook records rate-limit stops reactively, and a reader contract fixes how consuming sessions pause and resume.", "author": { "name": "Melodic Software", diff --git a/plugins/rate-limit-guard/CHANGELOG.md b/plugins/rate-limit-guard/CHANGELOG.md index 4ca95ed1af..16a975e705 100644 --- a/plugins/rate-limit-guard/CHANGELOG.md +++ b/plugins/rate-limit-guard/CHANGELOG.md @@ -3,6 +3,16 @@ All notable changes to the `rate-limit-guard` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.7.15] + +### Changed + +- **`statusline-tee.sh` names its bash-version and settings-read logic.** Repeated inline + `BASH_VERSINFO` comparisons became a `_rlg_bash_at_least` helper (keeping each call site's own + 4.1/4.2 floor), the duplicated settings-JSON read became a `_rlg_read_settings_json` helper + (non-caching, same reads), and an append uses `lines+=()`. Behavior is unchanged. Code-tidying + sweep, behavior-preserving. + ## [0.7.14] ### Changed diff --git a/plugins/rate-limit-guard/scripts/statusline-tee.sh b/plugins/rate-limit-guard/scripts/statusline-tee.sh index e27c65bfe9..78636977a4 100755 --- a/plugins/rate-limit-guard/scripts/statusline-tee.sh +++ b/plugins/rate-limit-guard/scripts/statusline-tee.sh @@ -116,6 +116,15 @@ set -uo pipefail INPUT="" + +# True when the running bash is at least MAJOR.MINOR. Three separate floors +# matter in this file (`read -N` at 4.1, printf's `%()T` at 4.2), and each is the +# same compound comparison, easy to get subtly wrong; naming it once keeps them +# spelled identically. A builtin arithmetic test, so no call site pays a process. +_rlg_bash_at_least() { + ((BASH_VERSINFO[0] > $1 || (BASH_VERSINFO[0] == $1 && BASH_VERSINFO[1] >= $2))) +} + # Bounded buffered read of the whole stdin payload (Win32 pipes can stall # before EOF; a truncated payload just fails jq below and tees nothing this # refresh). read -N buffers in blocks, which matters on Windows/MSYS pipes @@ -128,7 +137,7 @@ INPUT="" # guard at the bottom so its enablement gate can be driven by the test suite, and # a top-level read would consume the sourcing shell's stdin before `main` runs. read_tee_input() { - if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))); then + if _rlg_bash_at_least 4 1; then IFS= read -r -N 1048576 -t 5 INPUT || true else IFS= read -r -d '' -t 5 INPUT || true @@ -460,6 +469,26 @@ _RLG_BODY_JQ='def rlg_body($ts): {captured_at: $ts} or .key == "session_name" or (.key | test("account"; "i")))) | from_entries);' +# The USER-scope settings document, read into _RLG_SETTINGS_JSON for the jq +# passes below to hand over in the ENVIRONMENT. Shared verbatim by _rlg_probe and +# _rlg_drain for the same anti-drift reason the filter text above is shared. +# +# BASH opens the file (`$(/dev/null 2>&1 || return 0 - local ts settings_file settings_json out + local ts out # printf's %()T is a bash 4.2+ builtin; macOS statusline Bash 3.2 needs date -u. ts="" - if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 2))); then + if _rlg_bash_at_least 4 2; then TZ=UTC printf -v ts '%(%Y-%m-%dT%H:%M:%SZ)T' -1 2>/dev/null || ts="" fi if [[ -z "$ts" ]]; then @@ -585,12 +614,7 @@ _rlg_probe() { fi [[ -n "$ts" ]] || return 0 - settings_file="${CLAUDE_CONFIG_DIR:-${HOME:-}/.claude}/settings.json" - settings_json='null' - if [[ -r "$settings_file" ]]; then - settings_json="$(<"$settings_file")" - [[ -n "$settings_json" ]] || settings_json='null' - fi + _rlg_read_settings_json # Settings ride in the environment so credentials never land in jq's argv and no # temp file is created; the payload stays on stdin. @@ -598,7 +622,7 @@ _rlg_probe() { # Three lines out, in a fixed order: payload, window-bearing, user verdict. # `jq -c` escapes any newline inside a string and the other two are bare # tokens, so every line is single-line by construction and the split is exact. - out="$(RLG_SETTINGS_DOC="$settings_json" jq -rc --arg ts "$ts" " + out="$(RLG_SETTINGS_DOC="$_RLG_SETTINGS_JSON" jq -rc --arg ts "$ts" " $_RLG_BODY_JQ $_RLG_DOC_JQ"' | (rlg_body($ts)) as $p @@ -752,13 +776,8 @@ _rlg_drain() { batch+="$line"$'\n' done - local settings_file settings_json out - settings_file="${CLAUDE_CONFIG_DIR:-${HOME:-}/.claude}/settings.json" - settings_json='null' - if [[ -r "$settings_file" ]]; then - settings_json="$(<"$settings_file")" - [[ -n "$settings_json" ]] || settings_json='null' - fi + local out + _rlg_read_settings_json # ONE jq pass, same three lines in the same order _rlg_probe emits, so # everything downstream is shared code. Lines are read raw and parsed under @@ -768,7 +787,7 @@ _rlg_drain() { # one-second stamp are broken in favour of THIS render's own shard, whose # observation is the freshest by construction. if [[ -n "$batch" ]]; then - out="$(RLG_SETTINGS_DOC="$settings_json" jq -Rrcn --arg self "$self" " + out="$(RLG_SETTINGS_DOC="$_RLG_SETTINGS_JSON" jq -Rrcn --arg self "$self" " $_RLG_BODY_JQ $_RLG_DOC_JQ"' | [ inputs @@ -873,7 +892,7 @@ _rlg_spool_dispatch() { rlg_tee_dispatch() { if [[ "${RLG_TEE_ASYNC:-0}" != "1" ]]; then - if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 2))); then + if _rlg_bash_at_least 4 2; then _rlg_spool_dispatch else _rlg_tee_run diff --git a/plugins/session-flow/.claude-plugin/plugin.json b/plugins/session-flow/.claude-plugin/plugin.json index 40ff6afd17..114469380f 100644 --- a/plugins/session-flow/.claude-plugin/plugin.json +++ b/plugins/session-flow/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "session-flow", - "version": "0.34.9", + "version": "0.34.10", "description": "Session-lifecycle toolkit of fourteen skills: workflow (navigate a staged dev workflow and suggest the next stage), handoff (write a save-point and resume prompt for /clear-and-resume), continue-in-background (delegate the task to a fresh background agent that continues it now \u2014 same save-point engine as handoff, delivered by launching a detached claude --bg session seeded with the resume prompt; launches only on explicit user request), keep-going (recover and continue after any interruption OR when live off-thread work looks stalled \u2014 inventory off-thread work, inspect its real output, act only on evidence, then continue; after a usage limit lifts it continues rather than summarizing-and-stalling), find-handoff (recover a lost handoff after /clear \u2014 when the resume prompt was written but never copied \u2014 via a read-only detection ladder: known-location glob of the handoffs dir, then a bounded, recency-ranked transcript scan for the handoff directive and dashed-rail markers, then a confirm-before-resume gate; surfaces only the resume prompt + metadata, never raw transcript content), clean-stop (get to a durable, linked stopping point before the machine may go away \u2014 sweep every repo/worktree for uncommitted, unpushed, or PR-less work, push it durable, put breadcrumbs in PR/issue bodies, then give a free-and-clear verdict), retro (structured end-of-session retrospective with transcript metrics and learning codification), running-retro (in-flight retrospective checkpoints that spawn a subagent to analyze the transcript so far and append classified findings to a cumulative running ledger \u2014 capture and route only, the live counterpart to retro; also owns a detached-observer substrate that can watch a session out-of-band and run the checkpoint autonomously after the session ends), orient (read-only session orientation \u2014 synthesize where we stand, what we are doing, and why, from durable + off-thread state the built-in /recap never sees: ledgers, handoffs, workflow checklists, running-retro ledgers, open PRs and work-items, and git), orchestrate (arm a session or worker with proactive-orchestration imperatives), reanchor (verify a session's working assumptions are still true against live reality \u2014 referenced PRs/issues/branches, base-branch drift, renamed/version-drifted surfaces, stale memory-tier files, and the goal a handoff records, compared across the chain so a re-derived goal reports as drift \u2014 before building on them), reconcile (retire finished off-thread work and reconcile this session's task ledger with reality \u2014 the prune-and-reconcile counterpart to keep-going's resume: inventory the work this session spawned, inspect its real state, retire the finished and close proven-done tasks, auto-settling the finished and gating any kill of still-running work; sibling sessions in the project are reported read-only), setup (check-centric verification of the observer's runtime prerequisites and configuration), and show-options (lay out which skills fit this moment as a ranked, nothing-hidden menu \u2014 a shortlist per bucket plus the complete remainder by name, resolved from the full installed catalog rather than the truncated in-context listing, so the human decides and no option is withheld for looking already-done).", "author": { "name": "Melodic Software", diff --git a/plugins/session-flow/CHANGELOG.md b/plugins/session-flow/CHANGELOG.md index bdd9bddb60..60b33894dd 100644 --- a/plugins/session-flow/CHANGELOG.md +++ b/plugins/session-flow/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog — session-flow plugin +## [0.34.10] + +### Changed + +- **`keep-going`'s `check-usage-limit-reset.py` collapses a boolean ladder.** An if/else returning + booleans is now the expression itself, and the UTC-fallback branch is + `resolve_zone(name) or resolve_zone("UTC")`. Same results on every input. Code-tidying sweep, + behavior-preserving. + ## [0.34.9] ### Changed diff --git a/plugins/session-flow/skills/keep-going/scripts/check-usage-limit-reset.py b/plugins/session-flow/skills/keep-going/scripts/check-usage-limit-reset.py index 7d4d6f6725..b1c589c23e 100755 --- a/plugins/session-flow/skills/keep-going/scripts/check-usage-limit-reset.py +++ b/plugins/session-flow/skills/keep-going/scripts/check-usage-limit-reset.py @@ -125,9 +125,7 @@ def _cache_is_trusted(cache_root: Path) -> bool: except OSError: return False getuid = getattr(os, "getuid", None) - if getuid is not None and stat_result.st_uid != getuid(): - return False - return True + return getuid is None or stat_result.st_uid == getuid() _ensure_bundled_tzdata() @@ -185,8 +183,7 @@ def parse_reset(text: str, *, now: datetime | None = None) -> datetime: elif now is not None: zone = now.tzinfo or resolve_zone("UTC") else: - local = datetime.now().astimezone().tzinfo - zone = local if local is not None else resolve_zone("UTC") + zone = datetime.now().astimezone().tzinfo or resolve_zone("UTC") current = now.astimezone(zone) if now is not None else datetime.now(zone) reset_at = current.replace(hour=hour, minute=minute, second=0, microsecond=0) # After midnight, an evening reset already passed yesterday; an early-morning diff --git a/plugins/skill-quality/.claude-plugin/plugin.json b/plugins/skill-quality/.claude-plugin/plugin.json index ab588512ee..3ac8243111 100644 --- a/plugins/skill-quality/.claude-plugin/plugin.json +++ b/plugins/skill-quality/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "skill-quality", - "version": "0.20.4", + "version": "0.20.5", "description": "Skill-authoring QA tooling: a static contract checker that runs twenty-five deterministic checks over a Claude Code skill (frontmatter, explicit invocation mode, description/verb-contract polarity, per-skill listing-entry cap, trigger-keyword preservation, line caps, broken internal refs, markdownlint, gotchas surface, evals presence, precompute opportunity, completion-criteria signal, injection shell-declaration, fresh-eyes declaration conformance), a shared skill-listing budget reporter across a set of skills, and a bundled evals.json schema plus a deterministic eval-quality lint (duplicate case identities, missing fixtures, empty or vague grading criteria, set-coverage warnings). Runs against any repo's skills directory via the convention-resolution ladder \u2014 no baked layout.", "author": { "name": "Melodic Software", diff --git a/plugins/skill-quality/CHANGELOG.md b/plugins/skill-quality/CHANGELOG.md index 4e83672865..baaa4c2530 100644 --- a/plugins/skill-quality/CHANGELOG.md +++ b/plugins/skill-quality/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `skill-quality` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.20.5] + +### Changed + +- **Behavior-preserving simplification sweep, wave 10 (batch-simplify).** check-skill.sh's + `vc_lead_mutate` pipes its helper's stdout straight into `grep -qE` instead of + re-materializing it through `printf '%s' "$(...)"`, dropping one fork per call on a + predicate CI evaluates up to three times per skill across the fleet. Adversarially + refutation-verified: the helper's pipeline exit is deterministically 0 under `pipefail` + (probed to 200KB inputs), the trailing-newline delta is unobservable to `grep -q` + (11-input probe against the verbatim regex), and the full check-skill suite including the + verb-contract cases is green. + ## [0.20.4] ### Changed diff --git a/plugins/skill-quality/scripts/check-skill.sh b/plugins/skill-quality/scripts/check-skill.sh index 6a985f9dc2..1b9caace0e 100755 --- a/plugins/skill-quality/scripts/check-skill.sh +++ b/plugins/skill-quality/scripts/check-skill.sh @@ -1529,7 +1529,7 @@ vc_strip_negated_mutate() { vc_lead_mutate() { # Positive action verbs only. "remediation" (noun) is not advertising. - printf '%s' "$(vc_strip_negated_mutate "$1")" | grep -qE \ + vc_strip_negated_mutate "$1" | grep -qE \ '(^|[[:space:]])remediates[[:space:]]|and[[:space:]]+remediate([^[:alnum:]]|$)|(^|[[:space:]])rewrites[[:space:]]+(the|your|files)|(^|[[:space:]])fixes[[:space:]]+(the|your|files)|applies[[:space:]]+(fixes|edits|changes|patches)|mutates[[:space:]]+(the|on|files)|and[[:space:]]+fix([^[:alnum:]]|$)' } diff --git a/plugins/source-control/.claude-plugin/plugin.json b/plugins/source-control/.claude-plugin/plugin.json index 24147b526f..6bf7549da3 100644 --- a/plugins/source-control/.claude-plugin/plugin.json +++ b/plugins/source-control/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "source-control", - "version": "0.55.23", + "version": "0.55.24", "description": "Git and GitHub delivery workflow: /commit (Conventional Commits + Co-authored-by trailer via safe heredoc mechanics), /pull-request (prep, create, CI monitoring, review-comment triage, merge, CI-log fetch), /babysit-prs (self-pacing fleet loop \u2014 safe by default; opt-in worker/autopilot tiers add gate-checked merge and thread resolution behind a deterministic Python engine), /babysit-loop (the loop-lane merge lane: a standing or drain loop that invokes babysit-prs per cycle, configured through repo-scoped babysit_loop_* keys on the layered source-control.md seam, with merge authority human-only until the target repo's tracked config adopts the lane, a gate-proven C2-mechanical baseline once adopted, and standing merge-rung raises binding from the team-tracked layer only \u2014 with one named exception, where an invocation line explicitly typing both the autopilot tier keyword and the dedicated raise argument --merge c3-this-run widens that single invocation's merge authority up to C3 behind a fresh independent frontier-tier resolver, while C4-structural and C5-untrusted-provenance stay unconditionally human-merge), /worktree (create, status, cleanup, audit for parallel-session isolation), /setup (check the effective commit-subject / PR-title convention merged across its config layers and the babysit-prs config, or apply \u2014 interview the repo and write the convention config to a chosen layer), and /resolve-conflicts (intent-first merge/rebase conflict resolution with a semantic-conflict sweep \u2014 never --abort). The commit-subject / PR-title convention is configurable via a source-control.md config written by a re-runnable setup skill, layered across a ~/.claude user-global file, the tracked team file, and a gitignored .claude/source-control.local.md personal overlay merged per key; Conventional Commits is the default when no convention is declared.", "author": { "name": "Melodic Software", diff --git a/plugins/source-control/CHANGELOG.md b/plugins/source-control/CHANGELOG.md index b3deed28f2..fd75e8b44e 100644 --- a/plugins/source-control/CHANGELOG.md +++ b/plugins/source-control/CHANGELOG.md @@ -3,6 +3,27 @@ All notable changes to the `source-control` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.55.24] + +### Changed + +- **Behavior-preserving simplification sweep, wave 8 (batch-simplify).** Each change + adversarially refutation-verified with emitted bytes unchanged and the full 643-test babysit + suite green. babysit-prs: `human_stop_from_feedback` extracted as the single definition of + the human-stop record, with `classify_pr` delegating to it (1,800-case differential matrix + confirmed field-, key-set-, and key-order-identical outputs, closing a real two-copy drift + risk on the `external_required` presence branch); `babysit_resolve_thread` folds seven + repeated summary-count comprehensions into one `acted()` counter; `request_review` folds + eight `record_attempt_problem` call sites over the same five fixed arguments into a + `record_problem` closure; `babysit_review_trigger` extracts the thrice-spelled gate-context + predicate into `is_gate()` (400-fixture differential, De Morgan asymmetry preserved); a + stale line-number anchor in a merge-test docstring replaced with a symbol anchor. scripts: + `worktree-claim.sh` collapses the main-vs-linked flush branches; the readiness-gate suite + resolves its Python probe once; `worktree-root-doctor.test.sh` gains `fgit()` wrapping the + git-config-isolation prefix at ten fixture sites; the reap suite hoists `uname -s`; + `reap-project-plugin-records.sh` uses `$'\t'` directly. Suite counts identical throughout + (53/163/37/45); shellcheck and the portability gate clean. + ## [0.55.23] ### Fixed diff --git a/plugins/source-control/scripts/babysit-readiness-gate.test.sh b/plugins/source-control/scripts/babysit-readiness-gate.test.sh index 5c714ac5fd..7d77934979 100755 --- a/plugins/source-control/scripts/babysit-readiness-gate.test.sh +++ b/plugins/source-control/scripts/babysit-readiness-gate.test.sh @@ -417,11 +417,18 @@ probe_py() { >/dev/null 2>&1 } +# Resolved once: the same three-interpreter probe gates every Python-only case +# below, and re-running it per case only re-spawns interpreters. +HAVE_PY311=0 +if probe_py py -3 || probe_py python3 || probe_py python; then + HAVE_PY311=1 +fi + # --- Case: PYTHONUTF8=1 actually reaches the Python child process (#597) ------ # A stubbed `py -3` records the PYTHONUTF8 value it inherited when # babysit_python execs it, proving the export reaches the child process (not # just present as dead source text in the static check above). -if probe_py py -3 || probe_py python3 || probe_py python; then +if ((HAVE_PY311)); then PYSTUB_BIN="$TEST_TMPDIR/bin-pystub" mkdir -p "$PYSTUB_BIN" PYUTF8_PROBE_FILE="$TEST_TMPDIR/pyutf8-probe.txt" @@ -447,7 +454,7 @@ else pass "#597: PYTHONUTF8 child-inheritance probe skipped (no Python 3.11+)" fi -if probe_py py -3 || probe_py python3 || probe_py python; then +if ((HAVE_PY311)); then F=$(mkjson lifetime-open '[ {author:"codex[bot]", body:"[CRITICAL] resolved earlier", isResolved:true}, {author:"codex[bot]", body:"[CRITICAL] outdated round", isOutdated:true}, @@ -469,7 +476,7 @@ fi # -> BLOCKED. Thread-aware, so asserted only under Python; the bash degrade is # reply-thread-blind and false-passes here (the accepted degrade coarseness, same # as the #465 discount above). -if probe_py py -3 || probe_py python3 || probe_py python; then +if ((HAVE_PY311)); then F=$(mkjson stale-pr-classification '[ {author:"codex[bot]", body:"[CRITICAL] fresh unclassified finding", in_review_thread:true}, {author:"me[bot]", body:"| 1 | old resolved finding | VALID | fixed |"} @@ -488,7 +495,7 @@ fi # cross-credit the inline finding -> classified=0 < findings=1 -> BLOCKED. # Thread-aware (tag-driven), so Python-gated; the bash degrade greps all bodies # and false-passes here, the accepted degrade coarseness. -if probe_py py -3 || probe_py python3 || probe_py python; then +if ((HAVE_PY311)); then F=$(mkjson reuse-inline-type '[ {type:"inline", author:"codex[bot]", body:"[CRITICAL] inline finding"}, {type:"review", author:"me[bot]", body:"| 1 | old | VALID | fixed |"} @@ -505,7 +512,7 @@ fi # isolated in the unknown bucket, where a PR-level (`type:"review"`) # classification row cannot offset it -> classified=0 < findings=1 -> BLOCKED. # Defensive (production paths always signal); Python-gated like the cases above. -if probe_py py -3 || probe_py python3 || probe_py python; then +if ((HAVE_PY311)); then F=$(mkjson unsignalled-provenance '[ {author:"codex[bot]", body:"[CRITICAL] unsignalled finding"}, {type:"review", author:"me[bot]", body:"| 1 | x | VALID | y |"} diff --git a/plugins/source-control/scripts/reap-project-plugin-records.sh b/plugins/source-control/scripts/reap-project-plugin-records.sh index 80625a173b..034442f406 100755 --- a/plugins/source-control/scripts/reap-project-plugin-records.sh +++ b/plugins/source-control/scripts/reap-project-plugin-records.sh @@ -201,7 +201,7 @@ list_ids() { collect_matches() { local rows id path rows="$(list_ids)" || return 1 - printf '%s\n' "$rows" | while IFS="$(printf '\t')" read -r id path; do + printf '%s\n' "$rows" | while IFS=$'\t' read -r id path; do [[ -n "$id" ]] || continue [[ -n "$path" ]] || continue [[ "$(norm_path "$path")" == "$here_key" ]] || continue diff --git a/plugins/source-control/scripts/reap-project-plugin-records.test.sh b/plugins/source-control/scripts/reap-project-plugin-records.test.sh index 7bd71b35a2..f29f0d5e3a 100755 --- a/plugins/source-control/scripts/reap-project-plugin-records.test.sh +++ b/plugins/source-control/scripts/reap-project-plugin-records.test.sh @@ -23,6 +23,10 @@ command -v jq >/dev/null 2>&1 || skip_suite "jq not available" TEST_TMPDIR="$(mktemp -d)" trap 'rm -rf "$TEST_TMPDIR"' EXIT +# Resolved once; the platform cannot change mid-suite, and three of the cases +# below branch on it. +UNAME_S="$(uname -s 2>/dev/null || true)" + STUB_DIR="$TEST_TMPDIR/stub" mkdir -p "$STUB_DIR" @@ -181,7 +185,7 @@ assert_contains "always passes --keep-data" "$(cat "$LOG")" "--keep-data" # separator-variant of the same directory must still match. An early probe of # this behaviour read as a null purely because two spellings of one path were # compared raw. -if [[ "$(uname -s 2>/dev/null)" == MINGW* || "$(uname -s 2>/dev/null)" == MSYS* || "$(uname -s 2>/dev/null)" == CYGWIN* ]]; then +if [[ "$UNAME_S" == MINGW* || "$UNAME_S" == MSYS* || "$UNAME_S" == CYGWIN* ]]; then variant="$(printf '%s' "$WT_NATIVE" | tr '/' "\134" | tr '[:lower:]' '[:upper:]')" seed_state "$(jq -n --arg v "$variant" '[{id:"a@m",scope:"project",projectPath:$v}]')" reset_log @@ -328,14 +332,14 @@ fi # manipulation, so it is pinned even where the symlink fixture must skip. normalize_candidate() { local path="$1" - case "$(uname -s 2>/dev/null)" in + case "$UNAME_S" in MINGW* | MSYS* | CYGWIN*) while [[ "$path" == */ || "$path" == *\\ ]]; do path="${path%?}"; done ;; *) while [[ "$path" == */ ]]; do path="${path%?}"; done ;; esac printf '%s' "$path" } assert_eq "normalization strips every trailing forward slash" "x/link" "$(normalize_candidate "x/link//")" -if [[ "$(uname -s 2>/dev/null)" == MINGW* || "$(uname -s 2>/dev/null)" == MSYS* || "$(uname -s 2>/dev/null)" == CYGWIN* ]]; then +if [[ "$UNAME_S" == MINGW* || "$UNAME_S" == MSYS* || "$UNAME_S" == CYGWIN* ]]; then assert_eq "windows shell: a trailing backslash is a separator and strips" "x/link" "$(normalize_candidate "x/link\\")" assert_eq "windows shell: mixed trailing separators all strip" "x/link" "$(normalize_candidate "x/link/\\")" else diff --git a/plugins/source-control/scripts/worktree-claim.sh b/plugins/source-control/scripts/worktree-claim.sh index ebb691d039..12b6011474 100755 --- a/plugins/source-control/scripts/worktree-claim.sh +++ b/plugins/source-control/scripts/worktree-claim.sh @@ -211,16 +211,12 @@ parse_worktrees() { flush_record() { if [[ -n "$path" && "$bare" -eq 0 ]]; then - if ((first)); then - WT_PATHS+=("$path") - WT_REASONS+=("$reason") - WT_IS_LINKED+=(0) - first=0 - else - WT_PATHS+=("$path") - WT_REASONS+=("$reason") - WT_IS_LINKED+=(1) - fi + # The first non-bare record git emits is the MAIN worktree; every later + # one is linked. + WT_PATHS+=("$path") + WT_REASONS+=("$reason") + WT_IS_LINKED+=("$((first ? 0 : 1))") + first=0 elif [[ "$bare" -eq 1 ]]; then first=0 fi diff --git a/plugins/source-control/scripts/worktree-root-doctor.test.sh b/plugins/source-control/scripts/worktree-root-doctor.test.sh index e50c8ed7e5..a9f1d87b9a 100755 --- a/plugins/source-control/scripts/worktree-root-doctor.test.sh +++ b/plugins/source-control/scripts/worktree-root-doctor.test.sh @@ -42,14 +42,18 @@ run() { mkrepo() { local repo repo="$(mktemp -d "$TEST_TMPDIR/${1:-repo}XXXXXX")" - GIT_CONFIG_GLOBAL="$TEST_TMPDIR/empty-gitconfig" GIT_CONFIG_NOSYSTEM=1 \ - git -C "$repo" init -q -b main >/dev/null 2>&1 + fgit -C "$repo" init -q -b main >/dev/null 2>&1 printf '%s' "$repo" } : >"$TEST_TMPDIR/empty-gitconfig" EMPTY_GCFG="$TEST_TMPDIR/empty-gitconfig" +# Every fixture-building git call runs against the pinned empty global config, so +# the host's real ~/.gitconfig can never leak an includeIf into a fixture. One +# wrapper rather than the prefix repeated per call, so no site can omit it. +fgit() { GIT_CONFIG_GLOBAL="$EMPTY_GCFG" GIT_CONFIG_NOSYSTEM=1 git "$@"; } + # --- CLI contract --------------------------------------------------------------- help_out=$(bash "$DOCTOR" --help 2>&1) @@ -83,8 +87,7 @@ assert_contains "the unset finding names the fallthrough rungs" "$OUT" "worktree # --- A set key: named with its origin, exit 0 ------------------------------------ KEYED="$(mkrepo keyed)" -GIT_CONFIG_GLOBAL="$EMPTY_GCFG" GIT_CONFIG_NOSYSTEM=1 \ - git -C "$KEYED" config melodic.worktreeroot "$TEST_TMPDIR/wt-root" +fgit -C "$KEYED" config melodic.worktreeroot "$TEST_TMPDIR/wt-root" run "$EMPTY_GCFG" "$KEYED" assert_exit "a conforming keyed repo exits 0" 0 "$RC" assert_contains "the winning value is reported ok with its value" "$OUT" \ @@ -98,8 +101,7 @@ assert_contains "the winner names the file that supplied it" "$OUT" "supplied by INCREPO_PARENT="$(mktemp -d "$TEST_TMPDIR/incparentXXXXXX")" INCREPO="$INCREPO_PARENT/increpo-fixture" mkdir -p "$INCREPO" -GIT_CONFIG_GLOBAL="$EMPTY_GCFG" GIT_CONFIG_NOSYSTEM=1 \ - git -C "$INCREPO" init -q -b main >/dev/null 2>&1 +fgit -C "$INCREPO" init -q -b main >/dev/null 2>&1 INC_FILE="$TEST_TMPDIR/identity-work.inc" printf '[melodic]\n\tworktreeroot = %s\n' "$TEST_TMPDIR/work-root" >"$INC_FILE" INC_GCFG="$TEST_TMPDIR/gitconfig-include" @@ -181,8 +183,7 @@ assert_contains "the layered winner is the include's value" "$OUT" \ # --- The root itself must not sit inside a working tree -------------------------- NESTED_ROOT_REPO="$(mkrepo nestroot)" -GIT_CONFIG_GLOBAL="$EMPTY_GCFG" GIT_CONFIG_NOSYSTEM=1 \ - git -C "$NESTED_ROOT_REPO" config melodic.worktreeroot "$NESTED_ROOT_REPO/worktrees" +fgit -C "$NESTED_ROOT_REPO" config melodic.worktreeroot "$NESTED_ROOT_REPO/worktrees" mkdir -p "$NESTED_ROOT_REPO/worktrees" run "$EMPTY_GCFG" "$NESTED_ROOT_REPO" assert_exit "a root inside a working tree is a finding (exit 1)" 1 "$RC" @@ -194,8 +195,7 @@ assert_contains "the nested root names the invariant it violates" "$OUT" \ IDREPO_PARENT="$(mktemp -d "$TEST_TMPDIR/idparentXXXXXX")" IDREPO="$IDREPO_PARENT/identity-fixture" mkdir -p "$IDREPO" -GIT_CONFIG_GLOBAL="$EMPTY_GCFG" GIT_CONFIG_NOSYSTEM=1 \ - git -C "$IDREPO" init -q -b main >/dev/null 2>&1 +fgit -C "$IDREPO" init -q -b main >/dev/null 2>&1 PARTIAL_INC="$TEST_TMPDIR/identity-partial.inc" printf '[user]\n\temail = work@example.com\n' >"$PARTIAL_INC" PARTIAL_GCFG="$TEST_TMPDIR/gitconfig-partial" @@ -223,15 +223,14 @@ assert_not_contains "a complete identity include is not flagged as partial" "$OU # --- A linked worktree classifies with its repository ----------------------------- WTREPO="$(mkrepo wthost)" -GIT_CONFIG_GLOBAL="$EMPTY_GCFG" GIT_CONFIG_NOSYSTEM=1 git -C "$WTREPO" config user.email t@t.t -GIT_CONFIG_GLOBAL="$EMPTY_GCFG" GIT_CONFIG_NOSYSTEM=1 git -C "$WTREPO" config user.name t -GIT_CONFIG_GLOBAL="$EMPTY_GCFG" GIT_CONFIG_NOSYSTEM=1 git -C "$WTREPO" config commit.gpgsign false +fgit -C "$WTREPO" config user.email t@t.t +fgit -C "$WTREPO" config user.name t +fgit -C "$WTREPO" config commit.gpgsign false printf 'seed\n' >"$WTREPO/README.md" -GIT_CONFIG_GLOBAL="$EMPTY_GCFG" GIT_CONFIG_NOSYSTEM=1 git -C "$WTREPO" add README.md >/dev/null 2>&1 -GIT_CONFIG_GLOBAL="$EMPTY_GCFG" GIT_CONFIG_NOSYSTEM=1 git -C "$WTREPO" commit -qm init >/dev/null 2>&1 +fgit -C "$WTREPO" add README.md >/dev/null 2>&1 +fgit -C "$WTREPO" commit -qm init >/dev/null 2>&1 LINKED="$TEST_TMPDIR/linked-wt" -if GIT_CONFIG_GLOBAL="$EMPTY_GCFG" GIT_CONFIG_NOSYSTEM=1 \ - git -C "$WTREPO" worktree add -q "$LINKED" -b feat/doctor >/dev/null 2>&1; then +if fgit -C "$WTREPO" worktree add -q "$LINKED" -b feat/doctor >/dev/null 2>&1; then run "$EMPTY_GCFG" "$LINKED" assert_contains "a linked worktree's classification rule is surfaced" "$OUT" \ "classify this worktree WITH its repository" diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_delta.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_delta.py index d38796b2e6..8afa7b9658 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_delta.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_delta.py @@ -26,7 +26,7 @@ is_self_login, normalize_self_logins, ) -from babysit_feedback import collect_feedback +from babysit_feedback import collect_feedback, human_stop_from_feedback from babysit_gh import find_open_prs_for_head_ref from babysit_review_trigger import ( DEFAULT_REVIEW_TRIGGER_CONFIG, @@ -466,21 +466,7 @@ def classify_pr( feedback = collect_feedback(pr, inline_comments, dispositions, config.feedback) review_decision = str(pr.get("reviewDecision") or "") self_logins = normalize_self_logins(config.self_logins) - human_changes_requested = any( - item.get("kind") == "review" and item.get("state") == "CHANGES_REQUESTED" - for item in feedback["human_blocking"] - ) - external_human_blocking = [ - item - for item in feedback["human_blocking"] - if not is_self_login(item.get("author"), self_logins) - ] - human_stop = { - "required": bool(feedback["human_blocking"]), - "external_required": bool(external_human_blocking), - "human_changes_requested": human_changes_requested, - "human_blocking_count": len(feedback["human_blocking"]), - } + human_stop = human_stop_from_feedback(feedback, self_logins) merge_state = str(pr.get("mergeStateStatus") or "").upper() # Stuck-check detection reuses the already-normalized checks (no new fetch). # Attached to the same `checks` dict returned below as `checks["stuck"]`, diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_feedback.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_feedback.py index 0337bb4479..5171e0f102 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_feedback.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_feedback.py @@ -58,6 +58,7 @@ "human_stop_blocks_automation", "has_blocking_severity", "has_blocking_text", + "human_stop_from_feedback", "human_stop_state", "is_bot", "is_dependency_author", @@ -235,9 +236,9 @@ def collect_feedback( material.append(record) else: ignored.append(record) - elif normalized_bot_login( - item - ) in skip_downgrade_logins and skip_downgrade(text): + elif normalized_bot_login(item) in skip_downgrade_logins and skip_downgrade( + text + ): record["downgrade"] = "review_skip" material.append(record) else: @@ -255,6 +256,40 @@ def collect_feedback( } +def human_stop_from_feedback( + feedback: dict[str, list[dict[str, str]]], + normalized_self: frozenset[str] = frozenset(), +) -> dict[str, Any]: + """The human-stop record derived from an already-bucketed feedback split. + + The single definition of that record. `human_stop_state` below reaches it by + collecting the feedback first; the snapshot classifier + (`babysit_delta.classify_pr`) has already collected the same feedback and + calls this directly rather than restating the four fields. The two must + agree field for field: `human_stop_blocks_automation` branches on + `external_required` being PRESENT, so a field added on one side alone would + silently change which automation halts. + + `normalized_self` is already casefolded (`normalize_self_logins`); it + defaults empty so an unconfigured caller keeps every human blocker external. + """ + human_blocking = feedback["human_blocking"] + return { + "required": bool(human_blocking), + # Self-authored classification replies must not block freshness refresh or + # review triggers (#902); merge/triage still consult `required`. + "external_required": any( + not is_self_login(item.get("author"), normalized_self) + for item in human_blocking + ), + "human_changes_requested": any( + item.get("kind") == "review" and item.get("state") == "CHANGES_REQUESTED" + for item in human_blocking + ), + "human_blocking_count": len(human_blocking), + } + + def human_stop_state( pr: dict[str, Any], inline_comments: list[dict[str, Any]] | None, @@ -263,26 +298,10 @@ def human_stop_state( self_logins: frozenset[str] | None = None, ) -> dict[str, Any]: feedback = collect_feedback(pr, inline_comments, config=config) - human_changes_requested = any( - item.get("kind") == "review" and item.get("state") == "CHANGES_REQUESTED" - for item in feedback["human_blocking"] - ) normalized_self = ( normalize_self_logins(self_logins) if self_logins is not None else frozenset() ) - external_blocking = [ - item - for item in feedback["human_blocking"] - if not is_self_login(item.get("author"), normalized_self) - ] - return { - "required": bool(feedback["human_blocking"]), - # Self-authored classification replies must not block freshness refresh or - # review triggers (#902); merge/triage still consult `required`. - "external_required": bool(external_blocking), - "human_changes_requested": human_changes_requested, - "human_blocking_count": len(feedback["human_blocking"]), - } + return human_stop_from_feedback(feedback, normalized_self) def fetch_current_human_stop( @@ -297,9 +316,7 @@ def fetch_current_human_stop( hydrated["comments"] = fetch_issue_comments(repo, number) rest_hydrate_reviews(hydrated, repo, number) inline_comments = fetch_unresolved_review_comments(repo, number) - return human_stop_state( - hydrated, inline_comments, config, self_logins=self_logins - ) + return human_stop_state(hydrated, inline_comments, config, self_logins=self_logins) def human_stop_blocks_automation(human_stop: dict[str, Any]) -> bool: diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_resolve_thread.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_resolve_thread.py index ac4d2379cb..ce52e3fc20 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_resolve_thread.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_resolve_thread.py @@ -1316,7 +1316,10 @@ def _usage_error(message: str) -> int: ) results.append(entry) - resolved_count = len([r for r in results if r["action"] == "resolved"]) + def acted(*actions: str) -> int: + return sum(1 for row in results if row["action"] in actions) + + resolved_count = acted("resolved") print( json.dumps( @@ -1334,46 +1337,28 @@ def _usage_error(message: str) -> int: # reported a human-thread action that never happened (#512). The # opening-author test matches the `--include-human` eligibility # decision, via the shared `is_bot` authorship classifier. - "humanThreadsActed": len( - [ - r - for r in results - if not is_bot(r["author"], r["authorType"], extra_bot_logins) - and r["action"] in ("would-resolve", "resolved") - ] - ), - "eligibleCount": len( - [r for r in results if r["action"] in ("would-resolve", "resolved")] + "humanThreadsActed": sum( + 1 + for row in results + if not is_bot(row["author"], row["authorType"], extra_bot_logins) + and row["action"] in ("would-resolve", "resolved") ), + "eligibleCount": acted("would-resolve", "resolved"), "resolvedCount": resolved_count, - "skippedNotOutdated": len( - [r for r in results if r["action"] == "skipped-not-outdated"] - ), - "skippedSeverityMarked": len( - [r for r in results if r["action"] == "skipped-severity-marked"] - ), - "skippedMultiFinding": len( - [ - r - for r in results - if r["action"] == "skipped-multi-finding-thread" - ] - ), + "skippedNotOutdated": acted("skipped-not-outdated"), + "skippedSeverityMarked": acted("skipped-severity-marked"), + "skippedMultiFinding": acted("skipped-multi-finding-thread"), # Independent-resolver refusals, rolled up alongside the other # skip counters so a caller sees "the evidence did not hold" # without re-deriving it from the per-thread action values. - "refusedEvidence": len( - [ - r - for r in results - if isinstance(r["action"], str) - and cast(str, r["action"]).startswith("refused-") - and r["action"] != "refused-stale-pin" - ] - ), - "humanThreads": len( - [r for r in results if r["action"] == "skipped-human-thread"] + "refusedEvidence": sum( + 1 + for row in results + if isinstance(row["action"], str) + and cast(str, row["action"]).startswith("refused-") + and row["action"] != "refused-stale-pin" ), + "humanThreads": acted("skipped-human-thread"), "threads": results, }, indent=2, diff --git a/plugins/source-control/skills/babysit-prs/scripts/babysit_review_trigger.py b/plugins/source-control/skills/babysit-prs/scripts/babysit_review_trigger.py index 053be75535..0ef1502f26 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/babysit_review_trigger.py +++ b/plugins/source-control/skills/babysit-prs/scripts/babysit_review_trigger.py @@ -68,9 +68,7 @@ def trigger_regex(phrase: str) -> re.Pattern[str] | None: tokens = [re.escape(token) for token in phrase.split()] if not tokens: return None - return re.compile( - r"^\s*" + r"\s+".join(tokens) + r"(?:\s+.*)?\s*$", re.I | re.S - ) + return re.compile(r"^\s*" + r"\s+".join(tokens) + r"(?:\s+.*)?\s*$", re.I | re.S) def is_review_bot_item(item: dict[str, Any], config: ReviewTriggerConfig) -> bool: @@ -308,33 +306,25 @@ def review_gate_state( all_checks = [ check for check in json_array(checks.get("checks")) if is_json_object(check) ] - gate_statuses = [ - check - for check in all_checks - if gate_context - and check["type"] == "StatusContext" - and check["name"].casefold() == gate_context - ] - gate_workflow_checks = [ - check - for check in all_checks - if gate_context - and check["type"] == "CheckRun" - and check["name"].casefold() == gate_context - ] + + def is_gate(check: dict[str, Any], check_type: str) -> bool: + return ( + bool(gate_context) + and check["type"] == check_type + and check["name"].casefold() == gate_context + ) + + gate_statuses = [check for check in all_checks if is_gate(check, "StatusContext")] + gate_workflow_checks = [check for check in all_checks if is_gate(check, "CheckRun")] ci_gateway_checks = [ check for check in all_checks if ci_gateway_context and check["name"].casefold() == ci_gateway_context ] + # The gate StatusContext is the one check excluded here -- a gate CheckRun of + # the same name is an ordinary check for this rollup, as it always was. non_review_checks = [ - check - for check in all_checks - if not ( - gate_context - and check["type"] == "StatusContext" - and check["name"].casefold() == gate_context - ) + check for check in all_checks if not is_gate(check, "StatusContext") ] return { "gate_state": summarized_state(gate_statuses), diff --git a/plugins/source-control/skills/babysit-prs/scripts/request_review.py b/plugins/source-control/skills/babysit-prs/scripts/request_review.py index 700ebf44ba..28284051e1 100755 --- a/plugins/source-control/skills/babysit-prs/scripts/request_review.py +++ b/plugins/source-control/skills/babysit-prs/scripts/request_review.py @@ -423,6 +423,13 @@ def run_locked( state["updated_at"] = attempted_at write_state(state_path, state) + # Every post-attempt failure records against the same PR, state, and head + # pin; only the error text and the evidence differ. + def record_problem(error: str, **evidence: dict[str, Any] | None) -> None: + record_attempt_problem( + state_path, state, key, pr_state, expected_head_sha, error, **evidence + ) + try: require_worker_lease(args, state_dir, repo, number) validate_current_candidate( @@ -430,26 +437,11 @@ def run_locked( ) late_trigger = existing_trigger(repo, number, recognizer, known_comment_ids) except Exception as exc: - record_attempt_problem( - state_path, - state, - key, - pr_state, - expected_head_sha, - str(exc), - ) + record_problem(str(exc)) raise if late_trigger: error = "found a new unattributed review trigger immediately before posting" - record_attempt_problem( - state_path, - state, - key, - pr_state, - expected_head_sha, - error, - found_trigger=late_trigger, - ) + record_problem(error, found_trigger=late_trigger) raise RuntimeError(error) try: @@ -465,40 +457,18 @@ def run_locked( ] ) except Exception as exc: - record_attempt_problem( - state_path, - state, - key, - pr_state, - expected_head_sha, - str(exc), - ) + record_problem(str(exc)) raise if not is_json_object(comment) or not comment.get("id"): error = "GitHub did not return the created trigger comment" - record_attempt_problem( - state_path, - state, - key, - pr_state, - expected_head_sha, - error, - ) + record_problem(error) raise RuntimeError(error) try: after = view_pr(repo, number) except Exception as exc: error = f"unable to verify PR head after posting: {exc}" - record_attempt_problem( - state_path, - state, - key, - pr_state, - expected_head_sha, - error, - created_comment=comment, - ) + record_problem(error, created_comment=comment) raise RuntimeError(error) from exc after_head = str(after.get("headRefOid") or "") after_state = str(after.get("state") or "").upper() @@ -507,15 +477,7 @@ def run_locked( "PR changed while posting the review request: " f"state={after_state or 'UNKNOWN'} head={after_head or 'UNKNOWN'}" ) - record_attempt_problem( - state_path, - state, - key, - pr_state, - expected_head_sha, - error, - created_comment=comment, - ) + record_problem(error, created_comment=comment) raise RuntimeError(error) post_known_comment_ids = {*known_comment_ids, str(comment["id"])} @@ -526,15 +488,7 @@ def run_locked( post_reactions = fetch_reaction_signals(repo, number, after, config) except Exception as exc: error = f"unable to verify trigger uniqueness after posting: {exc}" - record_attempt_problem( - state_path, - state, - key, - pr_state, - expected_head_sha, - error, - created_comment=comment, - ) + record_problem(error, created_comment=comment) raise RuntimeError(error) from exc # Scope to this head before flagging "unexpected" -- otherwise a stale # earlier-head reaction still sitting on the PR (or on an older, @@ -559,16 +513,7 @@ def run_locked( error = "trigger uniqueness became ambiguous after posting: " + ", ".join( details ) - record_attempt_problem( - state_path, - state, - key, - pr_state, - expected_head_sha, - error, - created_comment=comment, - found_trigger=concurrent_trigger, - ) + record_problem(error, created_comment=comment, found_trigger=concurrent_trigger) raise RuntimeError(error) record_request(state_path, state, key, pr_state, expected_head_sha, comment) result.update( diff --git a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_merge.py b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_merge.py index ee9f7fa6bd..bf675d0612 100644 --- a/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_merge.py +++ b/plugins/source-control/skills/babysit-prs/scripts/tests/test_babysit_merge.py @@ -142,20 +142,30 @@ def issue_comments_side_effect(repo: str, n: int) -> list[dict[str, Any]]: mock.patch.object(merge, "gh_json", side_effect=gh_json), mock.patch.object(merge, "fetch_review_threads", return_value=[]), mock.patch.object( - merge, "fetch_pull_request_reviews", + merge, + "fetch_pull_request_reviews", return_value=(CLEAN_APPROVAL if reviews is None else reviews), ) as reviews_mock, mock.patch.object( - merge, "fetch_issue_comments", + merge, + "fetch_issue_comments", side_effect=issue_comments_side_effect, ) as comments_mock, mock.patch.object( - merge, "fetch_pull_request_review_comments", + merge, + "fetch_pull_request_review_comments", return_value=(review_comments or []), ) as review_comments_mock, ): result = merge.evaluate( - "owner/repo", PR_NUMBER, HEAD, {"owner"}, frozenset(), False, False, tier, + "owner/repo", + PR_NUMBER, + HEAD, + {"owner"}, + frozenset(), + False, + False, + tier, ) result["_reviews_called"] = reviews_mock.called result["_comments_called"] = comments_mock.called @@ -180,16 +190,14 @@ def test_all_criteria_met_is_ready(self) -> None: def test_ratified_decision_default_marker_passes(self) -> None: # A maintainer (OWNER) comment strictly after the marker ratifies it. ratify = _comment( - "maintainer", "Ratified — proceed.", association="OWNER", + "maintainer", + "Ratified — proceed.", + association="OWNER", created_at="2026-02-01T00:00:00Z", ) - result = self._evaluate( - _pr(), linked_issue_comments=[DECISION_MARKER, ratify] - ) + result = self._evaluate(_pr(), linked_issue_comments=[DECISION_MARKER, ratify]) self.assertTrue(result["ready"], result["blockers"]) - self.assertEqual( - result["autopilotMergeTier"]["decisionDefaultHeldIssues"], [] - ) + self.assertEqual(result["autopilotMergeTier"]["decisionDefaultHeldIssues"], []) class TierFallsBackPerCriterion(TierEvaluateHarness): @@ -238,9 +246,7 @@ def test_human_approval_is_not_a_bot_approver(self) -> None: def test_stale_approval_off_head_blocks(self) -> None: # An approval left on a superseded commit is not "unchanged since review". - result = self._evaluate( - _pr(), reviews=[_approval(f"{APPROVER}[bot]", STALE)] - ) + result = self._evaluate(_pr(), reviews=[_approval(f"{APPROVER}[bot]", STALE)]) self.assertIsNone(result["autopilotMergeTier"]["distinctBotApproval"]) self.assertTrue(any("distinct-bot" in b for b in result["blockers"])) @@ -251,9 +257,7 @@ def test_no_approving_review_blocks(self) -> None: def test_unconfigured_bot_approval_is_rejected(self) -> None: # An unrelated installed App's [bot] approval must not satisfy the tier: # being a bot is not enough, it must be the configured approver identity. - result = self._evaluate( - _pr(), reviews=[_approval("random-app[bot]", HEAD)] - ) + result = self._evaluate(_pr(), reviews=[_approval("random-app[bot]", HEAD)]) self.assertIsNone(result["autopilotMergeTier"]["distinctBotApproval"]) self.assertFalse(result["ready"]) @@ -264,7 +268,9 @@ def test_human_blocking_comment_blocks(self) -> None: } result = self._evaluate(_pr(), issue_comments=[comment]) self.assertFalse(result["ready"]) - self.assertIn("maintainer", result["autopilotMergeTier"]["humanBlockingComments"]) + self.assertIn( + "maintainer", result["autopilotMergeTier"]["humanBlockingComments"] + ) self.assertTrue(any("human blocking comment" in b for b in result["blockers"])) def test_superseded_bot_approval_is_ignored(self) -> None: @@ -274,9 +280,13 @@ def test_superseded_bot_approval_is_ignored(self) -> None: result = self._evaluate( _pr(), reviews=[ - _approval(f"{APPROVER}[bot]", HEAD, submitted_at="2026-01-01T00:00:00Z"), _approval( - f"{APPROVER}[bot]", HEAD, state="CHANGES_REQUESTED", + f"{APPROVER}[bot]", HEAD, submitted_at="2026-01-01T00:00:00Z" + ), + _approval( + f"{APPROVER}[bot]", + HEAD, + state="CHANGES_REQUESTED", submitted_at="2026-01-02T00:00:00Z", ), ], @@ -290,7 +300,9 @@ def test_plain_do_not_merge_comment_blocks(self) -> None: "body": "Please do not merge this PR yet.", } result = self._evaluate(_pr(), issue_comments=[comment]) - self.assertIn("maintainer", result["autopilotMergeTier"]["humanBlockingComments"]) + self.assertIn( + "maintainer", result["autopilotMergeTier"]["humanBlockingComments"] + ) self.assertFalse(result["ready"]) def test_inline_review_comment_veto_blocks(self) -> None: @@ -302,7 +314,9 @@ def test_inline_review_comment_veto_blocks(self) -> None: } result = self._evaluate(_pr(), review_comments=[row]) self.assertTrue(result["_review_comments_called"]) - self.assertIn("maintainer", result["autopilotMergeTier"]["humanBlockingComments"]) + self.assertIn( + "maintainer", result["autopilotMergeTier"]["humanBlockingComments"] + ) self.assertFalse(result["ready"]) def test_unratified_decision_default_marker_blocks(self) -> None: @@ -316,12 +330,12 @@ def test_unratified_decision_default_marker_blocks(self) -> None: def test_non_maintainer_comment_does_not_ratify(self) -> None: # A later comment from a non-maintainer is not the veto-holder's ratification. later = _comment( - "drive-by", "looks fine to me", association="NONE", + "drive-by", + "looks fine to me", + association="NONE", created_at="2026-02-01T00:00:00Z", ) - result = self._evaluate( - _pr(), linked_issue_comments=[DECISION_MARKER, later] - ) + result = self._evaluate(_pr(), linked_issue_comments=[DECISION_MARKER, later]) self.assertFalse(result["ready"]) self.assertEqual( result["autopilotMergeTier"]["decisionDefaultHeldIssues"], [LINKED_REF] @@ -362,12 +376,12 @@ def test_unrelated_maintainer_comment_does_not_ratify(self) -> None: # A later maintainer comment with no explicit ratification signal (a bare # "thanks") must not clear the veto. chatter = _comment( - "maintainer", "thanks, nice work here", association="OWNER", + "maintainer", + "thanks, nice work here", + association="OWNER", created_at="2026-02-01T00:00:00Z", ) - result = self._evaluate( - _pr(), linked_issue_comments=[DECISION_MARKER, chatter] - ) + result = self._evaluate(_pr(), linked_issue_comments=[DECISION_MARKER, chatter]) self.assertFalse(result["ready"]) self.assertEqual( result["autopilotMergeTier"]["decisionDefaultHeldIssues"], [LINKED_REF] @@ -376,12 +390,12 @@ def test_unrelated_maintainer_comment_does_not_ratify(self) -> None: def test_ratification_signal_before_marker_does_not_clear(self) -> None: # A ratification signal that predates the marker is not a ratification of it. early = _comment( - "maintainer", "approved", association="OWNER", + "maintainer", + "approved", + association="OWNER", created_at="2025-12-01T00:00:00Z", ) - result = self._evaluate( - _pr(), linked_issue_comments=[early, DECISION_MARKER] - ) + result = self._evaluate(_pr(), linked_issue_comments=[early, DECISION_MARKER]) self.assertFalse(result["ready"]) self.assertEqual( result["autopilotMergeTier"]["decisionDefaultHeldIssues"], [LINKED_REF] @@ -391,23 +405,27 @@ def test_negated_approval_does_not_ratify(self) -> None: # A withheld/negated approval must not clear the veto even though it # contains an approval token. negated = _comment( - "maintainer", "not approved yet — hold this", association="OWNER", + "maintainer", + "not approved yet — hold this", + association="OWNER", created_at="2026-02-01T00:00:00Z", ) - result = self._evaluate( - _pr(), linked_issue_comments=[DECISION_MARKER, negated] - ) + result = self._evaluate(_pr(), linked_issue_comments=[DECISION_MARKER, negated]) self.assertFalse(result["ready"]) def test_ratified_then_revoked_holds(self) -> None: # The latest decisive maintainer signal wins: a later revocation ("do not # merge") re-holds a marker an earlier comment had ratified. ratify = _comment( - "maintainer", "Ratified — proceed.", association="OWNER", + "maintainer", + "Ratified — proceed.", + association="OWNER", created_at="2026-02-01T00:00:00Z", ) revoke = _comment( - "maintainer", "Actually, do not merge — hold this.", association="OWNER", + "maintainer", + "Actually, do not merge — hold this.", + association="OWNER", created_at="2026-02-02T00:00:00Z", ) result = self._evaluate( @@ -422,49 +440,57 @@ def test_revoked_then_ratified_clears(self) -> None: # Latest wins in the other direction: a ratification newer than an earlier # revocation clears the veto. revoke = _comment( - "maintainer", "not approved yet — hold this", association="OWNER", + "maintainer", + "not approved yet — hold this", + association="OWNER", created_at="2026-02-01T00:00:00Z", ) ratify = _comment( - "maintainer", "Re-reviewed — ratified, proceed.", association="OWNER", + "maintainer", + "Re-reviewed — ratified, proceed.", + association="OWNER", created_at="2026-02-02T00:00:00Z", ) result = self._evaluate( _pr(), linked_issue_comments=[DECISION_MARKER, revoke, ratify] ) self.assertTrue(result["ready"], result["blockers"]) - self.assertEqual( - result["autopilotMergeTier"]["decisionDefaultHeldIssues"], [] - ) + self.assertEqual(result["autopilotMergeTier"]["decisionDefaultHeldIssues"], []) def test_non_decisive_comment_after_ratification_leaves_it_standing(self) -> None: # A non-decisive later comment (a bare "thanks") does not disturb a prior # ratification. ratify = _comment( - "maintainer", "Ratified — proceed.", association="OWNER", + "maintainer", + "Ratified — proceed.", + association="OWNER", created_at="2026-02-01T00:00:00Z", ) chatter = _comment( - "maintainer", "thanks, nice work here", association="OWNER", + "maintainer", + "thanks, nice work here", + association="OWNER", created_at="2026-02-02T00:00:00Z", ) result = self._evaluate( _pr(), linked_issue_comments=[DECISION_MARKER, ratify, chatter] ) self.assertTrue(result["ready"], result["blockers"]) - self.assertEqual( - result["autopilotMergeTier"]["decisionDefaultHeldIssues"], [] - ) + self.assertEqual(result["autopilotMergeTier"]["decisionDefaultHeldIssues"], []) def test_same_timestamp_ratify_and_revoke_holds(self) -> None: # Ambiguity is fail-closed: a ratify/revoke tie at the same timestamp holds # (ratification must be strictly newer than every revocation to clear). ratify = _comment( - "maintainer", "Ratified — proceed.", association="OWNER", + "maintainer", + "Ratified — proceed.", + association="OWNER", created_at="2026-02-01T00:00:00Z", ) revoke = _comment( - "maintainer", "do not merge", association="OWNER", + "maintainer", + "do not merge", + association="OWNER", created_at="2026-02-01T00:00:00Z", ) result = self._evaluate( @@ -508,12 +534,13 @@ def test_configured_user_lane_does_not_ratify_decision_default(self) -> None: # its own default). The OWNER association proves the bot classification -- # not the association -- holds it. ratify = _comment( - LANE, "Ratified — proceed.", typename="User", association="OWNER", + LANE, + "Ratified — proceed.", + typename="User", + association="OWNER", created_at="2026-02-01T00:00:00Z", ) - result = self._evaluate( - _pr(), linked_issue_comments=[DECISION_MARKER, ratify] - ) + result = self._evaluate(_pr(), linked_issue_comments=[DECISION_MARKER, ratify]) self.assertFalse(result["ready"]) self.assertEqual( result["autopilotMergeTier"]["decisionDefaultHeldIssues"], [LINKED_REF] @@ -658,7 +685,7 @@ class DependencyHoldIntegrationTests(unittest.TestCase): through the real evaluate() with gh seams stubbed -- a pure-function test of `is_dependency_author` would pass even if the config never reached the call, so this exercises the actual wiring: CLI-arg-shaped frozenset -> evaluate() - param -> the line-715 hold. + param -> the dependency-manager hold in `babysit_merge.evaluate`. """ DEP_BOT = "acme-bot" @@ -677,17 +704,26 @@ def gh_json(args: list[str]) -> Any: mock.patch.object(merge, "gh_json", side_effect=gh_json), mock.patch.object(merge, "fetch_review_threads", return_value=[]), mock.patch.object( - merge, "fetch_pull_request_reviews", + merge, + "fetch_pull_request_reviews", return_value=[_approval("some-reviewer[bot]", HEAD)], ), mock.patch.object(merge, "fetch_issue_comments", return_value=[]), mock.patch.object( - merge, "fetch_pull_request_review_comments", return_value=[], + merge, + "fetch_pull_request_review_comments", + return_value=[], ), ): return merge.evaluate( - "owner/repo", PR_NUMBER, HEAD, {"owner"}, frozenset(), - False, False, None, + "owner/repo", + PR_NUMBER, + HEAD, + {"owner"}, + frozenset(), + False, + False, + None, extra_dependency_manager_logins=extra, ) @@ -750,16 +786,26 @@ def gh_json(args: list[str]) -> Any: mock.patch.object(merge, "gh_json", side_effect=gh_json), mock.patch.object(merge, "fetch_review_threads", return_value=[]), mock.patch.object( - merge, "fetch_pull_request_reviews", return_value=[], + merge, + "fetch_pull_request_reviews", + return_value=[], ), mock.patch.object(merge, "fetch_issue_comments", return_value=[]), mock.patch.object( - merge, "fetch_pull_request_review_comments", return_value=[], + merge, + "fetch_pull_request_review_comments", + return_value=[], ), ): result = merge.evaluate( - "owner/repo", PR_NUMBER, HEAD, {"owner"}, - frozenset({self.SELF}), False, allow_unprotected, tier, + "owner/repo", + PR_NUMBER, + HEAD, + {"owner"}, + frozenset({self.SELF}), + False, + allow_unprotected, + tier, ) result["_repo_reads"] = len(repo_calls) return result @@ -980,9 +1026,15 @@ def test_merge_abbreviation_is_rejected(self) -> None: # resolved behavior diverge. with ( mock.patch.object( - sys, "argv", - ["babysit_merge.py", "owner/repo#1", - "--allowed-owners", "owner", "--mer"], + sys, + "argv", + [ + "babysit_merge.py", + "owner/repo#1", + "--allowed-owners", + "owner", + "--mer", + ], ), contextlib.redirect_stdout(io.StringIO()), mock.patch("sys.stderr", new=io.StringIO()), diff --git a/plugins/work-items/.claude-plugin/plugin.json b/plugins/work-items/.claude-plugin/plugin.json index 9b69cb2e7e..55c052967e 100644 --- a/plugins/work-items/.claude-plugin/plugin.json +++ b/plugins/work-items/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "work-items", - "version": "0.39.33", + "version": "0.39.35", "description": "Manages development work items through a provider-neutral tracker seam that ships with the plugin (bundled dispatcher plus github, local-markdown, jira, gitea, and linear adapters; seam plugin-dir canonical, adapters consumer-local-first): dashboard, taxonomy-labeled creation, a race-safe assignee-plus-lease claim protocol, recurring-schedule checks, TODO scanning, stale-lease auditing, plan decomposition into vertical-slice items, a macro-journey router over spec containers (rollup, per-container execution shape, next-step routing), raw-intake triage (issues and unsolicited PRs through raw, verified, briefed, autonomous-eligible states), plus the two work-items loop lanes of the loop-lane convention: a self-paced autonomous work-loop drain (work-class admission gate, adaptive item cap, PR-only) and an attended attend-queue escalation lane. The re-runnable setup skill binds the provider (.work-item-tracker.json), seeds the recurring-schedule seam (.github/recurring-schedule.json), and remaps canonical role labels.", "author": { "name": "Melodic Software", diff --git a/plugins/work-items/CHANGELOG.md b/plugins/work-items/CHANGELOG.md index 8cddc77037..6e4a85a926 100644 --- a/plugins/work-items/CHANGELOG.md +++ b/plugins/work-items/CHANGELOG.md @@ -3,6 +3,38 @@ All notable changes to the `work-items` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.39.35] + +### Changed + +- **Behavior-preserving simplification sweep, wave 10 (batch-simplify).** Six local-markdown + adapter verbs (add-sub-item, claim, get-item, link-blocks, list-sub-items, renew-lease) + hoist their usage string — previously duplicated verbatim at the help and usage-error + sites — into one `usage=` literal, matching the sibling adapters' idiom; the repo's shfmt + hook normalized case-arm indentation in the same files. Adversarially refutation-verified: + the paired literals were byte-identical at every site, expansion-safety confirmed, and a + 24-invocation differential drive (help/no-arg/unknown-flag per verb) is byte-identical on + stdout, stderr, and exit codes; all 8 adapter suites green (79 checks). The linear adapter, + conformance harness, and cross-plugin audit-check bundles were reviewed clean with no + changes. + +## [0.39.34] + +### Changed + +- **Behavior-preserving simplification sweep, wave 9 (batch-simplify).** Each change + adversarially refutation-verified with byte-identical output and exit codes: + work-item-tracker.sh gains a `fail_usage()` helper replacing five open-coded + `usage; exit` sites (five-invocation byte comparison against the prior version); + lib/binding.sh collapses `wit_role_label`'s if/else to `${configured:-$3}` (falsy-value + drive over jq `// empty` outputs identical); onboard-adapter's generate-adapter.sh routes + three open-coded spec reads through its own `sget` helper (generated trees `diff -r` + identical across three spec shapes; 140-case suite green); gitea create-item.sh captures + the per-page label count once instead of re-running `jq length` (four-path pagination + harness byte-identical); github renew-lease.sh uses `[[ -n ]]` for the lease-comment + check (token-stream identity proven). Tracker, binding, adapter, and coordination suites + all green at baseline counts. + ## [0.39.33] ### Changed diff --git a/plugins/work-items/skills/onboard-adapter/scripts/generate-adapter.sh b/plugins/work-items/skills/onboard-adapter/scripts/generate-adapter.sh index f2deab0355..c0c0c6766d 100755 --- a/plugins/work-items/skills/onboard-adapter/scripts/generate-adapter.sh +++ b/plugins/work-items/skills/onboard-adapter/scripts/generate-adapter.sh @@ -159,17 +159,17 @@ DISPLAY_NAME="$(sget '.display_name')" [[ "$DISPLAY_NAME" =~ ^[A-Za-z0-9][A-Za-z0-9\ ._/+-]*$ ]] || die_spec "display_name must be letters, digits, spaces, and . _ / + - only, starting alphanumeric (found: $DISPLAY_NAME) — it is substituted literally into generated shell, including a double-quoted expansion where \$(…) would execute" -BASE_PATH="$(jq -r '.api.base_path // ""' <<<"$SPEC_JSON")" +BASE_PATH="$(sget '.api.base_path')" [[ "$BASE_PATH" =~ ^(/[A-Za-z0-9._~-]+)*$ ]] || die_spec "api.base_path must be empty or a slash-led path of [A-Za-z0-9._~-] segments (found: $BASE_PATH)" -HOST_SUFFIX="$(jq -r '.api.host_suffix // ""' <<<"$SPEC_JSON")" +HOST_SUFFIX="$(sget '.api.host_suffix')" if [[ -n "$HOST_SUFFIX" ]]; then [[ "$HOST_SUFFIX" =~ ^\.[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]] || die_spec "api.host_suffix must be empty or a dot-led domain suffix (found: $HOST_SUFFIX)" fi -AUTH_SCHEME="$(jq -r '.api.auth_scheme // ""' <<<"$SPEC_JSON")" +AUTH_SCHEME="$(sget '.api.auth_scheme')" case "$AUTH_SCHEME" in bearer | token | basic | raw) ;; *) die_spec "api.auth_scheme must be one of: bearer, token, basic, raw (found: ${AUTH_SCHEME:-none})" ;; diff --git a/plugins/work-items/tools/work-item-tracker/adapters/gitea/create-item.sh b/plugins/work-items/tools/work-item-tracker/adapters/gitea/create-item.sh index 2d339c188e..c454de09eb 100755 --- a/plugins/work-items/tools/work-item-tracker/adapters/gitea/create-item.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/gitea/create-item.sh @@ -142,10 +142,11 @@ if [[ -n "$LABELS" ]]; then printf 'create-item.sh: could not accumulate label page %s for %s\n' "$PAGE" "$REPO" >&2 exit "$EX_INTERNAL" } - LABEL_SEEN=$((LABEL_SEEN + $(jq 'length' <<<"$WIT_GITEA_BODY" 2>/dev/null || echo 0))) + LABEL_GOT="$(jq 'length' <<<"$WIT_GITEA_BODY" 2>/dev/null || echo 0)" + LABEL_SEEN=$((LABEL_SEEN + LABEL_GOT)) # An empty page ends the walk whatever the header claimed — a count that never gets # satisfied must not turn into an unbounded loop. - (($(jq 'length' <<<"$WIT_GITEA_BODY" 2>/dev/null || echo 0) == 0)) && break + ((LABEL_GOT == 0)) && break PAGE=$((PAGE + 1)) done # ORGANIZATION-WIDE labels are usable on this repo's issues and are NOT in the repo label diff --git a/plugins/work-items/tools/work-item-tracker/adapters/github/renew-lease.sh b/plugins/work-items/tools/work-item-tracker/adapters/github/renew-lease.sh index 53c8bb2691..a682030198 100755 --- a/plugins/work-items/tools/work-item-tracker/adapters/github/renew-lease.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/github/renew-lease.sh @@ -14,12 +14,12 @@ shift lease_comment_id="" while [[ $# -gt 0 ]]; do case "$1" in - --lease-comment-id) - [[ $# -ge 2 ]] || wit_usage_error "--lease-comment-id needs a value" - lease_comment_id="$2" - shift 2 - ;; - *) wit_usage_error "unknown argument: $1" ;; + --lease-comment-id) + [[ $# -ge 2 ]] || wit_usage_error "--lease-comment-id needs a value" + lease_comment_id="$2" + shift 2 + ;; + *) wit_usage_error "unknown argument: $1" ;; esac done wit_require_github_id "$id" || wit_usage_error "malformed or non-github id: $id" @@ -44,7 +44,7 @@ if [[ -z "$lease_json" ]]; then printf 'renew-lease: comment %s is not a work-item lease\n' "$lease_comment_id" >&2 exit "$EX_CONFLICT" fi -if [[ "$(jq -r '.superseded_at // empty' <<<"$lease_json")" != "" ]]; then +if [[ -n "$(jq -r '.superseded_at // empty' <<<"$lease_json")" ]]; then printf 'renew-lease: lease %s is superseded\n' "$lease_comment_id" >&2 exit "$EX_CONFLICT" fi diff --git a/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/add-sub-item.sh b/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/add-sub-item.sh index d40f1a6c7f..abaa8b1f12 100755 --- a/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/add-sub-item.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/add-sub-item.sh @@ -5,20 +5,21 @@ set -uo pipefail # shellcheck source=common.sh source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" -wit_help_if_requested "usage: add-sub-item --parent " "$@" +usage='usage: add-sub-item --parent ' +wit_help_if_requested "$usage" "$@" id="${1:-}" -[[ -n "$id" ]] || wit_usage_error "usage: add-sub-item --parent " +[[ -n "$id" ]] || wit_usage_error "$usage" shift parent="" while [[ $# -gt 0 ]]; do case "$1" in - --parent) - [[ $# -ge 2 ]] || wit_usage_error "--parent needs a value" - parent="$2" - shift 2 - ;; - *) wit_usage_error "unknown argument: $1" ;; + --parent) + [[ $# -ge 2 ]] || wit_usage_error "--parent needs a value" + parent="$2" + shift 2 + ;; + *) wit_usage_error "unknown argument: $1" ;; esac done [[ -n "$parent" ]] || wit_usage_error "--parent is required" diff --git a/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/claim.sh b/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/claim.sh index 10ce3b91c6..92cff34b3b 100755 --- a/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/claim.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/claim.sh @@ -8,30 +8,31 @@ set -uo pipefail # shellcheck source=common.sh source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" -wit_help_if_requested "usage: claim [--ttl-hours ] [--ttl-minutes ] [--session-id ]" "$@" +usage='usage: claim [--ttl-hours ] [--ttl-minutes ] [--session-id ]' +wit_help_if_requested "$usage" "$@" id="${1:-}" -[[ -n "$id" ]] || wit_usage_error "usage: claim [--ttl-hours ] [--ttl-minutes ] [--session-id ]" +[[ -n "$id" ]] || wit_usage_error "$usage" shift ttl="${WIT_LEASE_TTL_HOURS:-}" ttl_minutes="${WIT_LEASE_TTL_MINUTES:-0}" session_id="" while [[ $# -gt 0 ]]; do case "$1" in - --ttl-hours) - [[ $# -ge 2 ]] || wit_usage_error "--ttl-hours needs a value" - ttl="$2" - shift 2 - ;; - --ttl-minutes) - [[ $# -ge 2 ]] || wit_usage_error "--ttl-minutes needs a value" - ttl_minutes="$2" - shift 2 - ;; - --session-id) - [[ $# -ge 2 ]] || wit_usage_error "--session-id needs a value" - session_id="$2" - shift 2 - ;; - *) wit_usage_error "unknown argument: $1" ;; + --ttl-hours) + [[ $# -ge 2 ]] || wit_usage_error "--ttl-hours needs a value" + ttl="$2" + shift 2 + ;; + --ttl-minutes) + [[ $# -ge 2 ]] || wit_usage_error "--ttl-minutes needs a value" + ttl_minutes="$2" + shift 2 + ;; + --session-id) + [[ $# -ge 2 ]] || wit_usage_error "--session-id needs a value" + session_id="$2" + shift 2 + ;; + *) wit_usage_error "unknown argument: $1" ;; esac done wit_require_local_id "$id" || wit_usage_error "malformed or non-local-markdown id: $id" diff --git a/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/get-item.sh b/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/get-item.sh index 983611cb73..e68c77756e 100755 --- a/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/get-item.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/get-item.sh @@ -5,10 +5,11 @@ set -uo pipefail # shellcheck source=common.sh source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" -wit_help_if_requested "usage: get-item " "$@" +usage='usage: get-item ' +wit_help_if_requested "$usage" "$@" id="${1:-}" -[[ -n "$id" && $# -eq 1 ]] || wit_usage_error "usage: get-item " +[[ -n "$id" && $# -eq 1 ]] || wit_usage_error "$usage" wit_require_local_id "$id" || wit_usage_error "malformed or non-local-markdown id: $id (expected local-markdown:/#)" wit_need_storage diff --git a/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/link-blocks.sh b/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/link-blocks.sh index dce60e5979..5e7282260a 100755 --- a/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/link-blocks.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/link-blocks.sh @@ -7,20 +7,21 @@ set -uo pipefail # shellcheck source=common.sh source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" -wit_help_if_requested "usage: link-blocks --blocked-by " "$@" +usage='usage: link-blocks --blocked-by ' +wit_help_if_requested "$usage" "$@" id="${1:-}" -[[ -n "$id" ]] || wit_usage_error "usage: link-blocks --blocked-by " +[[ -n "$id" ]] || wit_usage_error "$usage" shift blocker="" while [[ $# -gt 0 ]]; do case "$1" in - --blocked-by) - [[ $# -ge 2 ]] || wit_usage_error "--blocked-by needs a value" - blocker="$2" - shift 2 - ;; - *) wit_usage_error "unknown argument: $1" ;; + --blocked-by) + [[ $# -ge 2 ]] || wit_usage_error "--blocked-by needs a value" + blocker="$2" + shift 2 + ;; + *) wit_usage_error "unknown argument: $1" ;; esac done [[ -n "$blocker" ]] || wit_usage_error "--blocked-by is required" diff --git a/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/list-sub-items.sh b/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/list-sub-items.sh index 21d8e6d5d1..f5fe2d4591 100755 --- a/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/list-sub-items.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/list-sub-items.sh @@ -10,25 +10,26 @@ set -uo pipefail # shellcheck source=common.sh source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" -wit_help_if_requested "usage: list-sub-items [--state open|closed|all]" "$@" +usage='usage: list-sub-items [--state open|closed|all]' +wit_help_if_requested "$usage" "$@" id="${1:-}" -[[ -n "$id" ]] || wit_usage_error "usage: list-sub-items [--state open|closed|all]" +[[ -n "$id" ]] || wit_usage_error "$usage" shift state="all" while [[ $# -gt 0 ]]; do case "$1" in - --state) - [[ $# -ge 2 ]] || wit_usage_error "--state needs a value" - state="$2" - shift 2 - ;; - *) wit_usage_error "unknown argument: $1" ;; + --state) + [[ $# -ge 2 ]] || wit_usage_error "--state needs a value" + state="$2" + shift 2 + ;; + *) wit_usage_error "unknown argument: $1" ;; esac done case "$state" in - open | closed | all) ;; - *) wit_usage_error "--state must be open|closed|all (got: $state)" ;; +open | closed | all) ;; +*) wit_usage_error "--state must be open|closed|all (got: $state)" ;; esac wit_require_local_id "$id" || wit_usage_error "malformed or non-local-markdown id: $id (expected local-markdown:/#)" diff --git a/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/renew-lease.sh b/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/renew-lease.sh index a353ce9ff1..caf8eb861a 100755 --- a/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/renew-lease.sh +++ b/plugins/work-items/tools/work-item-tracker/adapters/local-markdown/renew-lease.sh @@ -7,20 +7,21 @@ set -uo pipefail # shellcheck source=common.sh source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" -wit_help_if_requested "usage: renew-lease --lease-comment-id " "$@" +usage='usage: renew-lease --lease-comment-id ' +wit_help_if_requested "$usage" "$@" id="${1:-}" -[[ -n "$id" ]] || wit_usage_error "usage: renew-lease --lease-comment-id " +[[ -n "$id" ]] || wit_usage_error "$usage" shift lease_comment_id="" while [[ $# -gt 0 ]]; do case "$1" in - --lease-comment-id) - [[ $# -ge 2 ]] || wit_usage_error "--lease-comment-id needs a value" - lease_comment_id="$2" - shift 2 - ;; - *) wit_usage_error "unknown argument: $1" ;; + --lease-comment-id) + [[ $# -ge 2 ]] || wit_usage_error "--lease-comment-id needs a value" + lease_comment_id="$2" + shift 2 + ;; + *) wit_usage_error "unknown argument: $1" ;; esac done wit_require_local_id "$id" || wit_usage_error "malformed or non-local-markdown id: $id" 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 454ab41346..74c1887ff0 100755 --- a/plugins/work-items/tools/work-item-tracker/lib/binding.sh +++ b/plugins/work-items/tools/work-item-tracker/lib/binding.sh @@ -119,11 +119,7 @@ wit_effective_binding_json() { wit_role_label() { local configured configured="$(jq -r --arg role "$2" '.config.role_labels[$role] // empty' "$1")" - if [[ -n "$configured" ]]; then - printf '%s\n' "$configured" - else - printf '%s\n' "$3" - fi + printf '%s\n' "${configured:-$3}" } # wit_read_binding — validate shape and export WIT_PROVIDER, 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 25210915fb..c37cb11c79 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 @@ -66,6 +66,11 @@ Contract: tools/work-item-tracker/CONTRACT.md EOF } +fail_usage() { + usage + exit "$EX_USAGE" +} + fail_config() { printf 'work-item-tracker: %s\n' "$1" >&2 exit "$EX_CONFIG" @@ -99,10 +104,7 @@ main() { usage 2>&1 exit 0 fi - if [[ -z "$verb" ]]; then - usage - exit "$EX_USAGE" - fi + [[ -n "$verb" ]] || fail_usage shift command -v jq >/dev/null 2>&1 || @@ -157,10 +159,7 @@ main() { fi done ;; - *) - usage - exit "$EX_USAGE" - ;; + *) fail_usage ;; esac # gh >= 2.94 buys exactly one thing: the native sub-issue/dependency surface @@ -204,25 +203,16 @@ main() { shift ;; --parent) - [[ $# -ge 2 ]] || { - usage - exit "$EX_USAGE" - } + [[ $# -ge 2 ]] || fail_usage parent="$2" shift 2 ;; --repo) - [[ $# -ge 2 ]] || { - usage - exit "$EX_USAGE" - } + [[ $# -ge 2 ]] || fail_usage list_args+=(--repo "$2") shift 2 ;; - *) - usage - exit "$EX_USAGE" - ;; + *) fail_usage ;; esac done if [[ -n "$parent" && ${#list_args[@]} -gt 0 ]]; then diff --git a/scripts/check-changelog-parity.sh b/scripts/check-changelog-parity.sh index 7747af9a34..4674c18310 100755 --- a/scripts/check-changelog-parity.sh +++ b/scripts/check-changelog-parity.sh @@ -582,11 +582,10 @@ preexisting=0 nonmonotonic=0 published_reuse=0 absorbed=0 -declare -A absorbed_reported +# touched_changelogs is already unique (the seen_changelog guard where it is +# built), so each changelog is inspected exactly once. for changelog in "${touched_changelogs[@]}"; do - [[ -n "${absorbed_reported[$changelog]:-}" ]] && continue - absorbed_reported["$changelog"]=1 [[ -f "$changelog" ]] || continue missing_headings="$(missing_preserved_headings "$merge_base" "$changelog")" || exit 2 diff --git a/scripts/lib/read-list.test.sh b/scripts/lib/read-list.test.sh index 49df322073..1c419675fe 100755 --- a/scripts/lib/read-list.test.sh +++ b/scripts/lib/read-list.test.sh @@ -24,7 +24,7 @@ mk() { printf '%s' "$f" } -# expect