From 10eeafcfe0353c80b27a08229b3ccfe97c2ed67a Mon Sep 17 00:00:00 2001 From: ArchonVII Date: Thu, 11 Jun 2026 17:40:34 -0500 Subject: [PATCH] chore(governance): install repo baseline Closes #38. --- .agent/check-map.yml | 69 +++ .agent/coordination/README.md | 36 ++ .agent/startup-baseline.json | 30 ++ .../unreleased/38-governance-baseline.md | 8 + .changelog/unreleased/README.md | 39 ++ .gitattributes | 6 + .githooks/commit-msg | 176 +++++++ .githooks/pre-commit | 150 ++++++ .githooks/scripts/checkout-doctor.sh | 40 ++ .githooks/scripts/checkout-role.sh | 43 ++ .githooks/scripts/install-githooks.sh | 48 ++ .githooks/scripts/owner-maintenance.sh | 97 ++++ .githooks/scripts/test-checkout-role.sh | 97 ++++ .githooks/scripts/test-owner-maintenance.sh | 149 ++++++ .github/CODEOWNERS | 1 + .github/PULL_REQUEST_TEMPLATE.md | 48 ++ .github/archon-setup.json | 189 +++++++ .github/dependabot.yml | 40 ++ .gitignore | 13 + AGENTS.md | 284 +++++++++++ CHANGELOG.md | 9 + CLAUDE.md | 7 + GEMINI.md | 7 + LICENSE | 21 + README.md | 2 + docs/agent-process/doc-sweep.md | 357 +++++++++++++ docs/plans/README.md | 15 + docs/repo-update-log.md | 25 + examples/minimal-ci.yml | 3 +- package.json | 6 +- scripts/agent/lib.mjs | 240 +++++++++ scripts/agent/pr-body.mjs | 40 ++ scripts/agent/prune.mjs | 122 +++++ scripts/agent/start-task.mjs | 79 +++ scripts/agent/status.mjs | 49 ++ scripts/doc-sweep/git.mjs | 274 ++++++++++ scripts/doc-sweep/lib.mjs | 213 ++++++++ scripts/doc-sweep/sweep.mjs | 481 ++++++++++++++++++ 38 files changed, 3511 insertions(+), 2 deletions(-) create mode 100644 .agent/check-map.yml create mode 100644 .agent/coordination/README.md create mode 100644 .agent/startup-baseline.json create mode 100644 .changelog/unreleased/38-governance-baseline.md create mode 100644 .changelog/unreleased/README.md create mode 100644 .gitattributes create mode 100644 .githooks/commit-msg create mode 100644 .githooks/pre-commit create mode 100644 .githooks/scripts/checkout-doctor.sh create mode 100644 .githooks/scripts/checkout-role.sh create mode 100644 .githooks/scripts/install-githooks.sh create mode 100644 .githooks/scripts/owner-maintenance.sh create mode 100644 .githooks/scripts/test-checkout-role.sh create mode 100644 .githooks/scripts/test-owner-maintenance.sh create mode 100644 .github/CODEOWNERS create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/archon-setup.json create mode 100644 .github/dependabot.yml create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 GEMINI.md create mode 100644 LICENSE create mode 100644 docs/agent-process/doc-sweep.md create mode 100644 docs/plans/README.md create mode 100644 docs/repo-update-log.md create mode 100644 scripts/agent/lib.mjs create mode 100644 scripts/agent/pr-body.mjs create mode 100644 scripts/agent/prune.mjs create mode 100644 scripts/agent/start-task.mjs create mode 100644 scripts/agent/status.mjs create mode 100644 scripts/doc-sweep/git.mjs create mode 100644 scripts/doc-sweep/lib.mjs create mode 100644 scripts/doc-sweep/sweep.mjs diff --git a/.agent/check-map.yml b/.agent/check-map.yml new file mode 100644 index 0000000..0f1af84 --- /dev/null +++ b/.agent/check-map.yml @@ -0,0 +1,69 @@ +version: 1 + +required_gate: + check_name: repo-required-gate / decision + workflow: .github/workflows/repo-required-gate.yml + +defaults: + stack: minimal + runner: github-hosted + optimize_for: lower-spend + +paths: + code: + requires: + - language-ci + patterns: + - src/** + - lib/** + - bin/** + - scripts/** + - test/** + - tests/** + - "**/*.js" + - "**/*.mjs" + - "**/*.cjs" + - "**/*.ts" + - "**/*.tsx" + - "**/*.py" + workflows: + requires: + - workflow-validation + patterns: + - .github/workflows/** + - .githooks/** + policy: + requires: + - policy-validation + patterns: + - AGENTS.md + - CLAUDE.md + - GEMINI.md + - .agent/** + - .github/** + dependencies: + requires: + - dependency-review + - language-ci + patterns: + - package.json + - package-lock.json + - pnpm-lock.yaml + - yarn.lock + - pyproject.toml + - requirements.txt + - requirements-dev.txt + - uv.lock + - poetry.lock + release: + requires: + - policy-validation + patterns: + - CHANGELOG.md + - .changelog/** + docs: + requires: [] + patterns: + - docs/** + - README.md + - "**/*.md" diff --git a/.agent/coordination/README.md b/.agent/coordination/README.md new file mode 100644 index 0000000..94cef6f --- /dev/null +++ b/.agent/coordination/README.md @@ -0,0 +1,36 @@ +# Coordination + +This repo is **coordination-isolated**. It coordinates only itself. + +- Do not read from or write to machine-global coordination boards. +- Do not assume sibling repositories exist. +- Do not reference another repo unless this repo explicitly documents that dependency. + +## Where coordination lives + +All coordination state for this repo lives under `.agent/coordination/`: + +``` +.agent/coordination/ + README.md # this contract (always present) + board.md # active multi-agent board (only if this repo does active coordination) + claims/ # per-agent file claims / locks (optional) + handoffs/ # cross-session handoff notes (optional) + references/ # documented dependencies on other repos, if any (optional) +``` + +`README.md` is the only file guaranteed to exist. Everything else is created on demand, +when this repo actually needs active coordination. + +## Enabling an active board + +If multiple agents (or people) work this repo concurrently, create or keep `board.md` +here and record: claim format, high-contention files that need sequencing, stale-claim +cleanup rules, and worktree conventions. A starter template ships via the archon-setup +`coordination-board` feature; you can also write your own. + +## Tracked vs. untracked + +This repo owns the **contract** (`README.md`). Whether live coordination state +(`board.md`, `claims/`, locks) is committed, `.gitignore`d, or handled through issues and +PRs is this repo's choice — setup does not assume one collaboration model. diff --git a/.agent/startup-baseline.json b/.agent/startup-baseline.json new file mode 100644 index 0000000..fabe126 --- /dev/null +++ b/.agent/startup-baseline.json @@ -0,0 +1,30 @@ +{ + "version": "2026-06-08-agent-start-map", + "required": [ + "AGENTS.md", + "docs/plans/README.md", + ".agent/check-map.yml", + ".agent/coordination/README.md", + ".github/PULL_REQUEST_TEMPLATE.md", + "docs/repo-update-log.md", + "package.json", + "scripts/agent/lib.mjs", + "scripts/agent/start-task.mjs", + "scripts/agent/status.mjs", + "scripts/agent/prune.mjs", + "scripts/agent/pr-body.mjs", + "scripts/doc-sweep/lib.mjs", + "scripts/doc-sweep/git.mjs", + "scripts/doc-sweep/sweep.mjs", + "docs/agent-process/doc-sweep.md" + ], + "expectedDirectories": [ + "docs/plans/", + "docs/agent-process/", + "scripts/agent/", + "scripts/doc-sweep/" + ], + "legacy": [ + "docs/superpowers/plans/" + ] +} diff --git a/.changelog/unreleased/38-governance-baseline.md b/.changelog/unreleased/38-governance-baseline.md new file mode 100644 index 0000000..0ee8d19 --- /dev/null +++ b/.changelog/unreleased/38-governance-baseline.md @@ -0,0 +1,8 @@ +### Added + +- Installed the ArchonVII governance baseline for this repository, including AGENTS/Claude/Gemini contracts, lifecycle helpers, hooks, CODEOWNERS, Dependabot, PR template, doc-sweep files, and the repo update log. + +### Changed + +- Documented that this repo owns reusable workflow bodies and examples, while generated-repo lifecycle and hook baselines remain sourced from `repo-template` and installed through `archon-setup`. +- Fixed the minimal CI example command quoting so the examples pass actionlint as valid YAML. diff --git a/.changelog/unreleased/README.md b/.changelog/unreleased/README.md new file mode 100644 index 0000000..1dd8c0a --- /dev/null +++ b/.changelog/unreleased/README.md @@ -0,0 +1,39 @@ +# `.changelog/unreleased/` + +Per-PR CHANGELOG fragments. Each PR adds one file here named: + +``` +-.md +``` + +Example: `42-oauth-device-flow.md`. + +## Fragment format + +```markdown +### Added + +- New thing in clear prose. + +### Changed + +- What changed. + +### Fixed + +- What you fixed. +``` + +Use one or more of the standard [Keep a Changelog](https://keepachangelog.com/) sections: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. + +## Fold cadence + +Periodically (manually for now), all fragments are concatenated into `CHANGELOG.md` under `## [Unreleased]` and the fragment files are deleted in one commit. The PR author does not edit `CHANGELOG.md` directly. + +## Opting out + +If a PR genuinely does not warrant an entry (pure refactor, test-only, chore), apply the `no-changelog` label and the CI gate (`ArchonVII/github-workflows/.github/workflows/changelog-fragment.yml`) will skip it. + +## Delete this directory if you're using "Mode 1" + +If your repo edits `CHANGELOG.md` directly, this whole `.changelog/` tree should not exist. See `STARTER.md` for the two modes. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..16297dc --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Force LF for shell scripts so the .githooks/ baseline runs correctly +# regardless of the cloning OS. Bash hooks fail with CRLF on Git Bash +# ("bad interpreter: /usr/bin/env\r: No such file or directory") and on +# CI Linux runners. +*.sh text eol=lf +.githooks/* text eol=lf diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100644 index 0000000..2f18566 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# +# commit-msg — Repo-template baseline. +# +# Enforces two policies on the commit message: +# +# 1. Conventional-commit prefix from a fixed allow-list. +# 2. Reference to a tracked issue or task — `(#NNN)` or `task/` — +# unless the commit is scoped to throwaway scratch work, an append-log +# ledger, or the Owner Maintenance Lane: +# - scratch: `chore(scratch):` / `docs(scratch):` prefix, OR every +# staged path lives under `docs/scratch/**`; +# - append-log ledger: every staged path is a named ledger +# (`.claude/noticed.md`, `.claude/napkin.md`) — any subject; +# - Owner Maintenance: `docs(owner):` / `chore(owner):` plus add-only +# safe maintenance paths. +# +# Bypass for the issue-ref rule (logged in commit metadata via the env +# var name itself — reviewers can grep for it): +# +# ALLOW_NO_ISSUE_REF=1 git commit ... +# +# The conventional-commit prefix is non-bypassable here on purpose. +# Reformat the message instead. +# + +set -euo pipefail + +msg_file="${1:?commit-msg hook expects the message file as argument 1}" +hook_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=.githooks/scripts/owner-maintenance.sh +source "${hook_dir}/scripts/owner-maintenance.sh" + +# Strip comment lines (git includes the diff/template comments) before +# inspecting the first non-empty line. +first_line="$(grep -vE '^\s*#' "$msg_file" | sed -n '/[^[:space:]]/{p;q;}')" + +if [[ -z "${first_line}" ]]; then + echo "[commit-msg] Empty commit message; aborting." >&2 + exit 1 +fi + +# --- Conventional commit prefix check ---------------------------------- +# Allowed types per repo policy. Scopes and breaking-change `!` are +# optional. Matches: feat: x | fix(api): x | chore(scratch)!: x +cc_re='^(feat|fix|docs|chore|refactor|test|ci|build|perf|revert|style)(\([^)]+\))?!?: .+' +if ! [[ "${first_line}" =~ ${cc_re} ]]; then + cat >&2 <()?: + +Allowed types: feat, fix, docs, chore, refactor, test, ci, build, perf, + revert, style. + +Examples: + feat(hooks): add commit-msg baseline (#16) + fix: handle empty diff in close-scan + chore(scratch): noodling on idea X + +This rule is not bypassable. Reformat the message and try again. +EOF + exit 1 +fi + +# --- Scratch-scope exemptions ------------------------------------------ +# A commit is "scratch" if either: +# (a) the message prefix is chore(scratch): or docs(scratch):, OR +# (b) every staged path lives under docs/scratch/. +is_scratch=0 +if [[ "${first_line}" =~ ^(chore|docs)\(scratch\) ]]; then + is_scratch=1 +else + # `--cached` lists staged paths. Empty output (e.g. amend with no + # staged changes) shouldn't be treated as scratch — fall through to + # the issue-ref check. + staged="$(git diff --cached --name-only --diff-filter=ACMRTD 2>/dev/null || true)" + if [[ -n "${staged}" ]]; then + # Treat as scratch only when *every* path is under docs/scratch/. + non_scratch="$(printf '%s\n' "${staged}" | grep -vE '^docs/scratch/' || true)" + if [[ -z "${non_scratch}" ]]; then + is_scratch=1 + fi + fi +fi + +if [[ "${is_scratch}" -eq 1 ]]; then + exit 0 +fi + +# --- Append-log ledger exemption --------------------------------------- +# Agent-local note ledgers (see owner_maintenance_is_append_log in +# owner-maintenance.sh) are written to frequently by standing conventions. +# When *every* staged path is such a ledger, skip the issue-ref requirement — +# the path allowlist is the safety boundary, exactly like the docs/scratch/** +# lane above. Any conventional-commit subject is fine; no (owner) scope needed. +ledger_staged="$(git diff --cached --name-only --diff-filter=ACMRTD 2>/dev/null || true)" +if [[ -n "${ledger_staged}" ]]; then + all_ledger=1 + while IFS= read -r ledger_path; do + [[ -n "${ledger_path}" ]] || continue + if ! owner_maintenance_is_append_log "${ledger_path}"; then + all_ledger=0 + break + fi + done <<< "${ledger_staged}" + if [[ "${all_ledger}" -eq 1 ]]; then + exit 0 + fi +fi + +# --- Owner Maintenance Lane exemption ---------------------------------- +# This lane is intentionally narrow: lightweight direct-main maintenance with +# add-only safe files and an explicit docs(owner): / chore(owner): subject. +if owner_maintenance_subject "${first_line}"; then + if owner_maintenance_staged_paths_safe; then + exit 0 + fi + + cat >&2 < reference (alnum / dash / dot / +# underscore / slash after the prefix). Searches the whole message body, +# not just the subject — trailers count. +body="$(grep -vE '^\s*#' "$msg_file" | tr '\n' ' ')" +if [[ "${body}" =~ \#[0-9]+ ]] || [[ "${body}" =~ task/[A-Za-z0-9._/-]+ ]]; then + exit 0 +fi + +if [[ "${ALLOW_NO_ISSUE_REF:-0}" == "1" ]]; then + echo "[commit-msg] ALLOW_NO_ISSUE_REF=1 — bypassing issue-ref requirement." >&2 + exit 0 +fi + +cat >&2 < + +Scratch-scope exemptions: + - Message prefixed chore(scratch): or docs(scratch): + - All staged paths under docs/scratch/ + +Append-log ledger exemption (any conventional-commit subject): + - All staged paths are append-log ledgers (.claude/noticed.md, + .claude/napkin.md) — added or modified + +Owner Maintenance exemption: + - Message prefixed docs(owner): or chore(owner): + - All staged paths are add-only safe maintenance files + +Bypass (leaves an audit trail via the env-var name): + ALLOW_NO_ISSUE_REF=1 git commit ... + +See .githooks/commit-msg for the rule source. +EOF +exit 1 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..454fd01 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# +# pre-commit — Repo-template baseline. +# +# Single purpose: block direct commits to `main` / `master`, except for the +# narrow Owner Maintenance Lane. +# +# This hook gates on the *branch*, not on individual commands. The branch +# check is unambiguous and has no echo/find/printf bypass. +# +# Bypass (logged to .agent/bypass.log so it leaves an audit trail): +# +# ALLOW_MAIN_COMMIT=1 git commit ... +# +# Rebase / merge / cherry-pick states are exempt — we don't want to +# break a `git rebase main` or `git pull --rebase` on main during +# legitimate maintenance. +# + +set -euo pipefail + +hook_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=.githooks/scripts/owner-maintenance.sh +source "${hook_dir}/scripts/owner-maintenance.sh" +# shellcheck source=.githooks/scripts/checkout-role.sh +source "${hook_dir}/scripts/checkout-role.sh" + +# --- Detect in-progress git operations and skip the branch check ------- +# During rebase/merge/cherry-pick, HEAD may transiently be on main even +# though the user isn't authoring a fresh commit there. +git_dir="$(git rev-parse --git-dir)" +for marker in MERGE_HEAD REBASE_HEAD CHERRY_PICK_HEAD; do + if [[ -f "${git_dir}/${marker}" ]]; then + exit 0 + fi +done +# git-rebase also uses these directory markers depending on mode. +if [[ -d "${git_dir}/rebase-merge" ]] || [[ -d "${git_dir}/rebase-apply" ]]; then + exit 0 +fi + +# --- Branch check ------------------------------------------------------ +# git symbolic-ref resolves the branch name even on an unborn branch (a fresh +# repo's first commit), where `git rev-parse --abbrev-ref HEAD` prints "HEAD" to +# stdout AND exits non-zero — which would otherwise double to "HEAD\nHEAD" and +# trip the worktree guard. -q yields empty + rc1 on a genuinely detached HEAD, +# so the `|| echo HEAD` fallback still fires for the detached case below. +branch="$(git symbolic-ref --short -q HEAD || echo HEAD)" + +# Detached HEAD is fine — typically a checkout of a tag or SHA, not a +# direct main commit. +if [[ "${branch}" == "HEAD" ]]; then + exit 0 +fi + +# --- Worktree guard ------------------------------------------ +# The primary checkout is the stable lane: it stays on the default branch. +# Feature work belongs in a linked worktree. A feature-branch commit in the +# primary checkout is the failure mode this guard exists to stop. +default_branch="$(checkout_default_branch)" +if checkout_is_primary && [[ "${branch}" != "${default_branch}" ]]; then + if [[ "${ALLOW_PRIMARY_FEATURE_COMMIT:-0}" == "1" ]]; then + repo_root="$(git rev-parse --show-toplevel)" + mkdir -p "${repo_root}/.agent" + # Best-effort forensic metadata — same field set as the ALLOW_MAIN_COMMIT + # bypass so a single parser reads both lines. None of these are fatal. + ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + who="$(git config user.email 2>/dev/null || echo unknown)" + head_sha="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" + diff_stat="$(git diff --cached --shortstat 2>/dev/null || echo '(no stat)')" + printf '%s\tALLOW_PRIMARY_FEATURE_COMMIT\t%s\t%s\t%s\t%s\n' \ + "${ts}" "${who}" "${branch}" "${head_sha}" "${diff_stat}" \ + >> "${repo_root}/.agent/bypass.log" + echo "[pre-commit] ALLOW_PRIMARY_FEATURE_COMMIT=1 — bypass logged to .agent/bypass.log" >&2 + else + cat >&2 <-- + +(If '${branch}' already exists, drop the '-b'.) See AGENTS.md, section +"Checkout role / worktrees". + +Override with an audit trail only if you truly mean to: + ALLOW_PRIMARY_FEATURE_COMMIT=1 git commit ... +EOF + exit 1 + fi +fi + +# Non-default branches in a linked worktree are the normal feature path. +if [[ "${branch}" != "main" && "${branch}" != "master" ]]; then + exit 0 +fi + +if owner_maintenance_staged_paths_safe; then + echo "[pre-commit] Owner Maintenance Lane — add-only safe maintenance paths allowed on ${branch}." >&2 + exit 0 +fi + +if [[ "${ALLOW_MAIN_COMMIT:-0}" != "1" ]]; then + cat >&2 </ ../-- +Keep the primary checkout on '${branch}'. See AGENTS.md "Checkout role / worktrees". + +Owner Maintenance Lane direct commits are allowed only when every staged path +is add-only and safe (docs/**, image files, or .changelog), or is a named +append-log ledger (.claude/noticed.md, .claude/napkin.md — these may also be +modified). Other unsafe files, deletes, renames, and copies require the normal +issue -> branch -> PR lifecycle. + +If you really need to commit here (e.g. Release-Admiral fixup, F11), +bypass with an audit trail: + ALLOW_MAIN_COMMIT=1 git commit ... + +See .githooks/pre-commit for the rule source. +EOF + exit 1 +fi + +# --- Bypass: log the override to .agent/bypass.log --------------------- +# .agent/ is the convention named by F9/F11/F12/F16/F17. We create it +# on demand so the hook doesn't fail in repos that haven't set it up. +repo_root="$(git rev-parse --show-toplevel)" +agent_dir="${repo_root}/.agent" +mkdir -p "${agent_dir}" +log_file="${agent_dir}/bypass.log" + +# Best-effort metadata — none of these are fatal if missing. +ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +who="$(git config user.email 2>/dev/null || echo unknown)" +head_sha="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" + +# Commit message file is $1 only for commit-msg; pre-commit doesn't get +# it. Best we can do is peek at staged diff stats for context. +diff_stat="$(git diff --cached --shortstat 2>/dev/null || echo '(no stat)')" + +printf '%s\tALLOW_MAIN_COMMIT\t%s\t%s\t%s\t%s\n' \ + "${ts}" "${who}" "${branch}" "${head_sha}" "${diff_stat}" \ + >> "${log_file}" + +echo "[pre-commit] ALLOW_MAIN_COMMIT=1 — bypass logged to .agent/bypass.log" >&2 +exit 0 diff --git a/.githooks/scripts/checkout-doctor.sh b/.githooks/scripts/checkout-doctor.sh new file mode 100644 index 0000000..7cf37f1 --- /dev/null +++ b/.githooks/scripts/checkout-doctor.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# +# checkout-doctor.sh — print this checkout's role and what the worktree guard +# allows here. Read-only orientation aid before committing. +# +# Usage: +# bash .githooks/scripts/checkout-doctor.sh +# + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=.githooks/scripts/checkout-role.sh +source "${script_dir}/checkout-role.sh" + +branch="$(git symbolic-ref --short -q HEAD || echo HEAD)" +default_branch="$(checkout_default_branch)" +hooks_path="$(git config --get core.hooksPath 2>/dev/null || echo '(unset)')" + +if checkout_is_primary; then + role="primary" + if [[ "${branch}" == "${default_branch}" ]]; then + feature_commits="blocked (owner-maintenance safe paths only)" + else + feature_commits="blocked — create a worktree: git worktree add" + fi +else + role="linked worktree" + if [[ "${branch}" == "${default_branch}" ]]; then + feature_commits="blocked (owner-maintenance safe paths only; default branch in a worktree is unusual)" + else + feature_commits="allowed" + fi +fi + +printf 'Checkout role: %s\n' "${role}" +printf 'Current branch: %s\n' "${branch}" +printf 'Default branch: %s\n' "${default_branch}" +printf 'Hooks path: %s\n' "${hooks_path}" +printf 'Feature commits: %s\n' "${feature_commits}" diff --git a/.githooks/scripts/checkout-role.sh b/.githooks/scripts/checkout-role.sh new file mode 100644 index 0000000..033747c --- /dev/null +++ b/.githooks/scripts/checkout-role.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# +# Shared checkout-role predicates for the repo-template hooks (worktree guard). +# +# Distinguishes the primary checkout from a linked worktree and resolves the +# repository's default branch, so pre-commit can keep the primary checkout on +# the default branch and push feature work into linked worktrees. +# + +# checkout_is_primary: 0 (true) in the primary checkout, 1 in a linked worktree. +# +# A linked worktree has a per-worktree git dir (.git/worktrees/) that +# differs from the shared common dir; in the primary checkout they are the same +# path. Both are resolved to absolute form so the comparison is format-stable. +# (git rev-parse --absolute-git-dir: git >= 2.13; --path-format: git >= 2.31.) +checkout_is_primary() { + local git_dir common_dir + git_dir="$(git rev-parse --absolute-git-dir 2>/dev/null || echo '')" + common_dir="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || echo '')" + [[ -n "${git_dir}" && "${git_dir}" == "${common_dir}" ]] +} + +# checkout_default_branch: echo the repo's default branch. +# +# Prefer the remote HEAD pointer (set on most clones). Fresh local repos and +# some clones lack refs/remotes/origin/HEAD, so fall back to the first of +# main/master that exists — matching the allowlist the existing hooks use — +# and finally to "main". +checkout_default_branch() { + local ref candidate + ref="$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null || echo '')" + if [[ -n "${ref}" ]]; then + printf '%s\n' "${ref#origin/}" + return 0 + fi + for candidate in main master; do + if git show-ref --verify --quiet "refs/heads/${candidate}"; then + printf '%s\n' "${candidate}" + return 0 + fi + done + printf 'main\n' +} diff --git a/.githooks/scripts/install-githooks.sh b/.githooks/scripts/install-githooks.sh new file mode 100644 index 0000000..85c9175 --- /dev/null +++ b/.githooks/scripts/install-githooks.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# +# install-githooks.sh — Idempotently point this clone at .githooks/. +# +# Sets `core.hooksPath = .githooks` so the commit-msg + pre-commit +# baselines run for every commit in this repo. Safe to re-run. +# +# Usage: +# ./.githooks/scripts/install-githooks.sh +# + +set -euo pipefail + +# Resolve repo root from this script's location so the install works +# regardless of cwd. +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/../.." && pwd)" + +cd "${repo_root}" + +if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "[install-githooks] Not inside a git work tree (cwd=${repo_root}); aborting." >&2 + exit 1 +fi + +target=".githooks" + +if [[ ! -d "${target}" ]]; then + echo "[install-githooks] Expected ${target}/ directory at repo root; aborting." >&2 + exit 1 +fi + +current="$(git config --get core.hooksPath 2>/dev/null || echo '')" +if [[ "${current}" == "${target}" ]]; then + echo "[install-githooks] core.hooksPath already set to ${target} — nothing to do." + exit 0 +fi + +git config core.hooksPath "${target}" +verified="$(git config --get core.hooksPath)" +if [[ "${verified}" != "${target}" ]]; then + echo "[install-githooks] Failed to set core.hooksPath (got '${verified}')." >&2 + exit 1 +fi + +echo "[install-githooks] core.hooksPath = ${target}" +echo "[install-githooks] Hooks active in this clone:" +ls -1 "${target}" | grep -vE '^(scripts|README)' | sed 's/^/ - /' diff --git a/.githooks/scripts/owner-maintenance.sh b/.githooks/scripts/owner-maintenance.sh new file mode 100644 index 0000000..3756f19 --- /dev/null +++ b/.githooks/scripts/owner-maintenance.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# +# Shared Owner Maintenance Lane predicates for repo-template hooks. + +owner_maintenance_subject() { + local subject="${1:-}" + [[ "${subject}" =~ ^(docs|chore)\(owner\)!?:[[:space:]].+ ]] +} + +# Append-log ledgers: agent-local note files that standing agent conventions +# tell every session to write to frequently, so a full issue->PR lane (or an +# audited bypass) for each one-line update is friction with no safety benefit. +# These named files may be Added OR Modified directly on main under the Owner +# Maintenance Lane. The allowlist is explicit and narrow on purpose — add a path +# only when a documented convention mandates frequent low-ceremony writes to it. +# +# .claude/noticed.md — per-repo observation log (CLAUDE.md "Observations": +# "append one-liner to .claude/noticed.md") +# .claude/napkin.md — per-repo curated runbook (napkin skill, curated each +# session) +# +# Source: ArchonVII owner conventions; repo-template#50 (page-gm incident +# gm-20260605-113318 — flushing .claude/noticed.md required a double bypass: +# ALLOW_MAIN_COMMIT=1 + ALLOW_NO_ISSUE_REF=1). +owner_maintenance_is_append_log() { + local path="${1:-}" + case "${path}" in + .claude/noticed.md|.claude/napkin.md) + return 0 + ;; + esac + return 1 +} + +owner_maintenance_staged_paths_safe() { + local staged + staged="$(git diff --cached --name-status --diff-filter=ACMRTD 2>/dev/null || true)" + [[ -n "${staged}" ]] || return 1 + + local status path rest + while IFS=$'\t' read -r status path rest; do + [[ -n "${status}" ]] || continue + + # Append-log ledgers may be added OR modified directly on main. Renames, + # copies, and deletes still require the normal branch/PR lifecycle, so a + # ledger can't be relocated or removed without review. + if owner_maintenance_is_append_log "${path}"; then + case "${status}" in + A|M) + continue + ;; + *) + return 1 + ;; + esac + fi + + # Everything else in the lane is add-only. Renames, copies, deletes, and + # modifications require the normal branch/PR lifecycle. + if [[ "${status}" != "A" ]]; then + return 1 + fi + + if ! owner_maintenance_path_safe "${path}"; then + return 1 + fi + done <<< "${staged}" + + return 0 +} + +owner_maintenance_path_safe() { + local path="${1:-}" + + # Explicit unsafe set from ArchonVII/.github#14. Unsafe wins even if a path + # would otherwise match a broad safe pattern, such as an image under + # .github/. + case "${path}" in + README.md|AGENTS.md|CLAUDE.md|GEMINI.md|package.json|package-lock.json) + return 1 + ;; + .github/*|.githooks/*|.claude/*|.agent/schema/*|src/*|scripts/*|docs/process/*|docs/architecture/*) + return 1 + ;; + esac + + case "${path}" in + docs/*|.changelog/*) + return 0 + ;; + *.png|*.jpg|*.jpeg|*.gif|*.webp|*.svg) + return 0 + ;; + esac + + return 1 +} diff --git a/.githooks/scripts/test-checkout-role.sh b/.githooks/scripts/test-checkout-role.sh new file mode 100644 index 0000000..d28728c --- /dev/null +++ b/.githooks/scripts/test-checkout-role.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# +# Verification harness for the checkout-role worktree guard. + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +tmp="$(mktemp -d)" +trap 'rm -rf "${tmp}"' EXIT + +pre_commit_hook="${repo_root}/.githooks/pre-commit" +role_lib="${repo_root}/.githooks/scripts/checkout-role.sh" + +git_q() { git -C "$1" "${@:2}" >/dev/null 2>&1; } + +new_primary_repo() { + local dir="$1" + rm -rf "${dir}" + mkdir -p "${dir}" + git_q "${dir}" init -b main + git_q "${dir}" config user.name "Hook Test" + git_q "${dir}" config user.email "hook-test@example.invalid" + printf 'base\n' > "${dir}/README.md" + git_q "${dir}" add README.md + git_q "${dir}" commit -m "docs: seed (#1)" --no-verify +} + +# --- checkout-role.sh predicates -------------------------------------- +new_primary_repo "${tmp}/repo" +# shellcheck source=/dev/null +( cd "${tmp}/repo" && source "${role_lib}" && checkout_is_primary ) \ + || { echo "FAIL: primary checkout not detected as primary" >&2; exit 1; } + +( cd "${tmp}/repo" && source "${role_lib}" && [ "$(checkout_default_branch)" = "main" ] ) \ + || { echo "FAIL: default branch should resolve to main" >&2; exit 1; } + +git_q "${tmp}/repo" worktree add "${tmp}/wt" -b feat/x +( cd "${tmp}/wt" && source "${role_lib}" && ! checkout_is_primary ) \ + || { echo "FAIL: linked worktree detected as primary" >&2; exit 1; } + +echo "checkout-role predicate tests passed" + +# --- pre-commit behavior ---------------------------------------------- +expect_block() { # label, dir, [env assignment] + local label="$1" dir="$2" env_kv="${3:-}" + if ( cd "${dir}" && env ${env_kv} "${pre_commit_hook}" >/dev/null 2>&1 ); then + echo "FAIL expected block: ${label}" >&2; exit 1 + fi +} +expect_pass() { # label, dir, [env assignment] + local label="$1" dir="$2" env_kv="${3:-}" + if ! ( cd "${dir}" && env ${env_kv} "${pre_commit_hook}" >/dev/null 2>&1 ); then + echo "FAIL expected pass: ${label}" >&2; exit 1 + fi +} + +# Primary checkout on a feature branch -> blocked. +new_primary_repo "${tmp}/r2" +git_q "${tmp}/r2" switch -c feat/in-primary +printf 'x\n' > "${tmp}/r2/src.txt"; git_q "${tmp}/r2" add src.txt +expect_block "primary+feature" "${tmp}/r2" + +# Same, with the documented bypass -> allowed, and the audit log must record it. +expect_pass "primary+feature+bypass" "${tmp}/r2" "ALLOW_PRIMARY_FEATURE_COMMIT=1" +grep -q "ALLOW_PRIMARY_FEATURE_COMMIT" "${tmp}/r2/.agent/bypass.log" \ + || { echo "FAIL: bypass did not write .agent/bypass.log" >&2; exit 1; } + +# Linked worktree on a feature branch -> allowed. +new_primary_repo "${tmp}/r3" +git_q "${tmp}/r3" worktree add "${tmp}/r3-wt" -b feat/in-worktree +printf 'x\n' > "${tmp}/r3-wt/src.txt"; git_q "${tmp}/r3-wt" add src.txt +expect_pass "worktree+feature" "${tmp}/r3-wt" + +# Primary checkout on default branch, owner-safe path -> allowed (lane intact). +new_primary_repo "${tmp}/r4" +mkdir -p "${tmp}/r4/docs/research"; printf 'n\n' > "${tmp}/r4/docs/research/note.md" +git_q "${tmp}/r4" add docs/research/note.md +expect_pass "primary+default+owner-safe" "${tmp}/r4" + +# Primary checkout on default branch, unsafe path -> blocked. +new_primary_repo "${tmp}/r5" +printf 'x\n' > "${tmp}/r5/src.txt"; git_q "${tmp}/r5" add src.txt +expect_block "primary+default+unsafe" "${tmp}/r5" + +# Unborn branch (fresh repo, first commit) on the default branch with an +# owner-safe path -> allowed. Before the symbolic-ref fix, the unborn branch +# resolved to a doubled "HEAD" string and the worktree guard wrongly blocked +# the very first commit. This is the regression guard for that. +rm -rf "${tmp}/r6"; mkdir -p "${tmp}/r6" +git_q "${tmp}/r6" init -b main +git_q "${tmp}/r6" config user.name "Hook Test" +git_q "${tmp}/r6" config user.email "hook-test@example.invalid" +mkdir -p "${tmp}/r6/docs/research"; printf 'n\n' > "${tmp}/r6/docs/research/note.md" +git_q "${tmp}/r6" add docs/research/note.md +expect_pass "unborn-default-owner-safe" "${tmp}/r6" + +echo "checkout-role + pre-commit tests passed" diff --git a/.githooks/scripts/test-owner-maintenance.sh b/.githooks/scripts/test-owner-maintenance.sh new file mode 100644 index 0000000..ff69e21 --- /dev/null +++ b/.githooks/scripts/test-owner-maintenance.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# +# Verification harness for Owner Maintenance Lane hook behavior. + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +tmp="$(mktemp -d)" +trap 'rm -rf "${tmp}"' EXIT + +run_in_tmp() { + (cd "${tmp}/repo" && "$@") +} + +stage_file() { + local path="$1" + local content="${2:-content}" + mkdir -p "${tmp}/repo/$(dirname "${path}")" + printf '%s\n' "${content}" > "${tmp}/repo/${path}" + run_in_tmp git add "${path}" +} + +message_file() { + local message="$1" + local file="${tmp}/message.txt" + printf '%s\n' "${message}" > "${file}" + printf '%s\n' "${file}" +} + +expect_success() { + local label="$1" + shift + if ! "$@" >"${tmp}/${label}.out" 2>"${tmp}/${label}.err"; then + echo "FAIL expected success: ${label}" >&2 + cat "${tmp}/${label}.err" >&2 || true + exit 1 + fi +} + +expect_failure() { + local label="$1" + shift + if "$@" >"${tmp}/${label}.out" 2>"${tmp}/${label}.err"; then + echo "FAIL expected failure: ${label}" >&2 + exit 1 + fi +} + +reset_tmp_repo() { + rm -rf "${tmp}/repo" + mkdir -p "${tmp}/repo" + run_in_tmp git init -b main >/dev/null + run_in_tmp git config user.name "Hook Test" + run_in_tmp git config user.email "hook-test@example.invalid" + printf 'base\n' > "${tmp}/repo/README.md" + run_in_tmp git add README.md + run_in_tmp git commit -m "docs: seed repo (#1)" --no-verify >/dev/null +} + +commit_msg_hook="${repo_root}/.githooks/commit-msg" +pre_commit_hook="${repo_root}/.githooks/pre-commit" + +reset_tmp_repo +stage_file "docs/research/agent-note.md" +expect_success "commit-msg-owner-docs" run_in_tmp "${commit_msg_hook}" "$(message_file "docs(owner): add research note")" +expect_success "pre-commit-owner-docs" run_in_tmp "${pre_commit_hook}" + +reset_tmp_repo +stage_file "docs/archon/specs/capture-pane.md" +expect_success "commit-msg-owner-generic-docs" run_in_tmp "${commit_msg_hook}" "$(message_file "docs(owner): add capture spec")" +expect_success "pre-commit-owner-generic-docs" run_in_tmp "${pre_commit_hook}" + +reset_tmp_repo +stage_file "docs/assets/screenshot.png" "png" +expect_success "commit-msg-owner-chore" run_in_tmp "${commit_msg_hook}" "$(message_file "chore(owner): add screenshot")" +expect_success "pre-commit-owner-image" run_in_tmp "${pre_commit_hook}" + +reset_tmp_repo +mkdir -p "${tmp}/repo/docs/research" +printf 'old\n' > "${tmp}/repo/docs/research/existing.md" +run_in_tmp git add docs/research/existing.md +run_in_tmp git commit -m "docs: add existing note (#1)" --no-verify >/dev/null +printf 'new\n' >> "${tmp}/repo/docs/research/existing.md" +run_in_tmp git add docs/research/existing.md +expect_failure "pre-commit-owner-modify" run_in_tmp "${pre_commit_hook}" + +reset_tmp_repo +stage_file "README.md" "unsafe" +expect_failure "pre-commit-owner-unsafe" run_in_tmp "${pre_commit_hook}" + +reset_tmp_repo +stage_file "docs/process/policy.md" "unsafe" +expect_failure "pre-commit-owner-unsafe-docs-process" run_in_tmp "${pre_commit_hook}" + +reset_tmp_repo +stage_file "docs/research/no-owner-scope.md" +expect_failure "commit-msg-owner-scope-required" run_in_tmp "${commit_msg_hook}" "$(message_file "docs: add research note")" + +# --- Append-log ledger lane (repo-template#50) ------------------------- +# A named ledger may be MODIFIED on main with any conventional subject and no +# issue ref — both hooks pass, no bypass. +reset_tmp_repo +mkdir -p "${tmp}/repo/.claude" +printf 'seed\n' > "${tmp}/repo/.claude/noticed.md" +run_in_tmp git add .claude/noticed.md +run_in_tmp git commit -m "chore: seed ledger (#1)" --no-verify >/dev/null +printf -- '- [idea] foo: bar\n' >> "${tmp}/repo/.claude/noticed.md" +run_in_tmp git add .claude/noticed.md +expect_success "pre-commit-ledger-noticed-modify" run_in_tmp "${pre_commit_hook}" +expect_success "commit-msg-ledger-noticed-modify" run_in_tmp "${commit_msg_hook}" "$(message_file "chore(noticed): flush observations")" + +# A named ledger may also be ADDED with any conventional subject and no issue ref. +reset_tmp_repo +stage_file ".claude/napkin.md" "runbook" +expect_success "pre-commit-ledger-napkin-add" run_in_tmp "${pre_commit_hook}" +expect_success "commit-msg-ledger-napkin-add" run_in_tmp "${commit_msg_hook}" "$(message_file "docs(napkin): seed runbook")" + +# A non-allowlisted .claude file is still blocked on main. +reset_tmp_repo +stage_file ".claude/settings.json" '{"x":1}' +expect_failure "pre-commit-claude-nonledger-blocked" run_in_tmp "${pre_commit_hook}" + +# Deleting a ledger still requires the normal branch/PR lane. +reset_tmp_repo +mkdir -p "${tmp}/repo/.claude" +printf 'seed\n' > "${tmp}/repo/.claude/noticed.md" +run_in_tmp git add .claude/noticed.md +run_in_tmp git commit -m "chore: seed ledger (#1)" --no-verify >/dev/null +run_in_tmp git rm .claude/noticed.md >/dev/null +expect_failure "pre-commit-ledger-delete-blocked" run_in_tmp "${pre_commit_hook}" + +# A ledger mixed with an unsafe path is blocked (the whole commit must qualify). +reset_tmp_repo +mkdir -p "${tmp}/repo/.claude" "${tmp}/repo/src" +printf 'note\n' > "${tmp}/repo/.claude/noticed.md" +printf 'code\n' > "${tmp}/repo/src/app.ts" +run_in_tmp git add .claude/noticed.md src/app.ts +expect_failure "pre-commit-ledger-mixed-unsafe-blocked" run_in_tmp "${pre_commit_hook}" + +# A ledger mixed with a non-ledger (even a safe doc) is not ledger-exempt, so +# the issue-ref requirement still applies under a plain subject. +reset_tmp_repo +mkdir -p "${tmp}/repo/.claude" "${tmp}/repo/docs" +printf 'note\n' > "${tmp}/repo/.claude/noticed.md" +printf 'doc\n' > "${tmp}/repo/docs/thing.md" +run_in_tmp git add .claude/noticed.md docs/thing.md +expect_failure "commit-msg-ledger-mixed-needs-issue" run_in_tmp "${commit_msg_hook}" "$(message_file "chore: mixed change")" + +echo "owner-maintenance hook tests passed" diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..f894b4a --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @ArchonVII diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1610dc8 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,48 @@ + + +## Summary + +TODO: What changed and why? + +## Verification + +- [ ] TODO: Replace with an exact command, CI check, or manual smoke test. + + ```evidence + command: TODO + location: local + result: TODO + timestamp: TODO + ``` + +### Verification Notes + +Each checked box below must be backed by exactly one fenced `evidence` block. The PR-policy parser (warning-only in Phase 1, will hard-fail in Phase 2+) reads them. + +Required fields: `command`, `location` (one of `local` / `ci` / `manual`), `result`, `timestamp`. Optional: `check` (used when `location: ci` and the check-run name differs from the command). + +TODO: Summarize the exact verification evidence and any manual review. + +## Docs / Changelog + +TODO: Record the changelog fragment, direct CHANGELOG edit, docs update, or no-changelog label. + +Plan/status artifacts: TODO: closed, narrowed to remaining scoped work, marked deprecated/superseded with the current source of truth, or not applicable because none were created or used by this lane. + +## Linked Issue + +TODO: Closes #___ + +## Risks + +- Risk level: +- Rollback: +- Follow-ups: diff --git a/.github/archon-setup.json b/.github/archon-setup.json new file mode 100644 index 0000000..a459f72 --- /dev/null +++ b/.github/archon-setup.json @@ -0,0 +1,189 @@ +{ + "tool": "archon-setup", + "toolVersion": "0.1.0-pre", + "createdAt": "2026-06-11T22:26:04.518Z", + "owner": "ArchonVII", + "repo": "github-workflows", + "visibility": "private", + "sourceSnapshots": { + "githubWorkflows": { + "source": "ArchonVII/github-workflows", + "ref": "v1", + "sha": "af0ac6e99683c107d5a420607642ff341e92713e", + "capturedAt": "2026-06-11T20:29:04.914Z", + "path": "src/snapshots/github-workflows/" + }, + "repoTemplate": { + "source": "ArchonVII/repo-template", + "ref": "main", + "sha": "d74d23cdddb735de505302b92db6d5acf187e601", + "capturedAt": "2026-06-11T20:29:05.009Z", + "path": "src/snapshots/repo-template/" + }, + "orgDefaults": { + "source": "ArchonVII/.github", + "ref": "main", + "sha": "1962f27c6ef13c07c755706144e8bb12a6f5203e", + "capturedAt": "2026-06-11T20:29:05.012Z", + "path": "src/snapshots/org-defaults/" + } + }, + "selectedFeatures": [ + "foundation.readme", + "foundation.license", + "foundation.gitignore", + "foundation.agents", + "foundation.claude-md", + "foundation.gemini-md", + "foundation.coordination", + "foundation.hooks", + "foundation.gitattributes", + "foundation.changelog", + "foundation.actionlint", + "foundation.codeowners", + "foundation.dependabot", + "foundation.pr-template", + "foundation.git-init", + "agent-workflow.check-map", + "agent-lifecycle.baseline", + "agent-workflow.doc-sweep" + ], + "createdFiles": [ + { + "path": "LICENSE", + "source": "github:licenses/MIT", + "spdx": "MIT" + }, + { + "path": "AGENTS.md", + "source": "snapshot:repo-template/AGENTS.md" + }, + { + "path": "docs/repo-update-log.md", + "source": "snapshot:repo-template/docs/repo-update-log.md" + }, + { + "path": ".agent/startup-baseline.json", + "source": "snapshot:repo-template/.agent/startup-baseline.json" + }, + { + "path": "docs/plans/README.md", + "source": "snapshot:repo-template/docs/plans/README.md" + }, + { + "path": "CLAUDE.md", + "source": "template:claude-md" + }, + { + "path": "GEMINI.md", + "source": "template:gemini-md" + }, + { + "path": ".agent/coordination/README.md", + "source": "snapshot:repo-template/.agent/coordination/README.md" + }, + { + "path": ".githooks/commit-msg", + "source": "snapshot:repo-template/.githooks/commit-msg" + }, + { + "path": ".githooks/pre-commit", + "source": "snapshot:repo-template/.githooks/pre-commit" + }, + { + "path": ".githooks/scripts/install-githooks.sh", + "source": "snapshot:repo-template/.githooks/scripts/install-githooks.sh" + }, + { + "path": ".githooks/scripts/owner-maintenance.sh", + "source": "snapshot:repo-template/.githooks/scripts/owner-maintenance.sh" + }, + { + "path": ".githooks/scripts/test-owner-maintenance.sh", + "source": "snapshot:repo-template/.githooks/scripts/test-owner-maintenance.sh" + }, + { + "path": ".githooks/scripts/checkout-role.sh", + "source": "snapshot:repo-template/.githooks/scripts/checkout-role.sh" + }, + { + "path": ".githooks/scripts/checkout-doctor.sh", + "source": "snapshot:repo-template/.githooks/scripts/checkout-doctor.sh" + }, + { + "path": ".githooks/scripts/test-checkout-role.sh", + "source": "snapshot:repo-template/.githooks/scripts/test-checkout-role.sh" + }, + { + "path": ".gitattributes", + "source": "snapshot:repo-template/.gitattributes" + }, + { + "path": "CHANGELOG.md", + "source": "snapshot:repo-template/CHANGELOG.md" + }, + { + "path": ".changelog/unreleased/README.md", + "source": "snapshot:repo-template/.changelog/unreleased/README.md" + }, + { + "path": ".github/CODEOWNERS", + "source": "template:owner" + }, + { + "path": ".github/dependabot.yml", + "source": "snapshot:repo-template/.github/dependabot.yml" + }, + { + "path": ".github/PULL_REQUEST_TEMPLATE.md", + "source": "snapshot:repo-template/.github/PULL_REQUEST_TEMPLATE.md" + }, + { + "path": ".agent/check-map.yml", + "source": "snapshot:repo-template/.agent/check-map.yml" + }, + { + "path": "scripts/agent/lib.mjs", + "source": "snapshot:repo-template/scripts/agent/lib.mjs" + }, + { + "path": "scripts/agent/start-task.mjs", + "source": "snapshot:repo-template/scripts/agent/start-task.mjs" + }, + { + "path": "scripts/agent/status.mjs", + "source": "snapshot:repo-template/scripts/agent/status.mjs" + }, + { + "path": "scripts/agent/prune.mjs", + "source": "snapshot:repo-template/scripts/agent/prune.mjs" + }, + { + "path": "scripts/agent/pr-body.mjs", + "source": "snapshot:repo-template/scripts/agent/pr-body.mjs" + }, + { + "path": "package.json", + "source": "merged:agent-lifecycle" + }, + { + "path": "scripts/doc-sweep/lib.mjs", + "source": "snapshot:repo-template/scripts/doc-sweep/lib.mjs" + }, + { + "path": "scripts/doc-sweep/git.mjs", + "source": "snapshot:repo-template/scripts/doc-sweep/git.mjs" + }, + { + "path": "scripts/doc-sweep/sweep.mjs", + "source": "snapshot:repo-template/scripts/doc-sweep/sweep.mjs" + }, + { + "path": "docs/agent-process/doc-sweep.md", + "source": "snapshot:repo-template/docs/agent-process/doc-sweep.md" + } + ], + "skippedFiles": [], + "remoteActions": [], + "postChecks": [] +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..10e2053 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,40 @@ +version: 2 + +updates: + # Always pin GitHub Actions. + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + actions: + patterns: ["*"] + + # ---- Per-language ecosystems — uncomment and tune as needed. ---- + + # - package-ecosystem: npm + # directory: '/' + # schedule: + # interval: weekly + # open-pull-requests-limit: 10 + # groups: + # prod-deps: + # dependency-type: production + # dev-deps: + # dependency-type: development + + # - package-ecosystem: pip + # directory: '/' + # schedule: + # interval: weekly + + # - package-ecosystem: cargo + # directory: '/' + # schedule: + # interval: weekly + + # - package-ecosystem: docker + # directory: '/' + # schedule: + # interval: weekly diff --git a/.gitignore b/.gitignore index c2658d7..c668116 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,14 @@ node_modules/ + +# Agent / local runtime state +.claude/ +.gemini/ +.codex/ +task_plan.md +findings.md +progress.md +.agent/current-task.json + +# archon-setup runtime event logs are local; anomaly handoff files may be tracked. +.archon/* +!.archon/anomalies-thispr.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2c181da --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,284 @@ +# AGENTS.md + +Cross-tool contract for AI agents (Claude, Codex, Copilot, Gemini, etc.) working in this repo. + +> **Per-tool addenda** live in `CLAUDE.md`, `GEMINI.md` etc. when they exist. This file holds the rules every agent must follow. + +## Read First + +- `README.md` — what this project is and how to run it +- `docs/repo-update-log.md` — operational change ledger +- `docs/release-workflow-breakdown.md` — release workflow responsibility map +- `.agent/check-map.yml` — path-to-check expectations + + +## Agent Start Map + +Agents should not spend time rediscovering the process files. Start here: + +- Plans: `docs/plans/` +- Agent process: `docs/agent-process/` +- Repo update log: `docs/repo-update-log.md` +- Check map: `.agent/check-map.yml` +- Coordination: `.agent/coordination/README.md` +- PR process: `.github/PULL_REQUEST_TEMPLATE.md` +- Agent scripts: `scripts/agent/` +- Doc sweep: `scripts/doc-sweep/` +- Legacy plans: `docs/superpowers/plans/` is history only; do not add new implementation plans there. + +If these files are missing or unclear, stop searching and run: + +```text +node /bin/onboard.mjs --audit +``` + + +## What This Repo Owns + +`github-workflows` owns reusable GitHub Actions workflow bodies, example caller files, shared PR contract helpers, and the repo setup script: + +- Reusable workflow bodies: `.github/workflows/*.yml` +- Example caller workflows for consumers: `examples/*.yml` +- Shared PR/gate helper scripts: `scripts/*.mjs` outside `scripts/agent/` and `scripts/doc-sweep/` +- PR body contract scaffold: `contracts/pr-template.md` +- Repo setup labels/branch-protection helper: `scripts/setup-repo.mjs` + +## Source-Of-Truth Boundaries + +- `ArchonVII/github-workflows` is the source of truth for reusable workflow bodies and matching `examples/*.yml` caller files. +- `ArchonVII/repo-template` is the source of truth for generated-repo agent lifecycle scripts, hooks, AGENTS baseline text, doc-sweep files, and default copied governance files. +- `ArchonVII/archon-setup` snapshots both source repos and installs them into consumers. Do not hand-edit `archon-setup/src/snapshots/**`; change the provider first, then refresh snapshots. +- The `scripts/agent/**` and `scripts/doc-sweep/**` files in this repo are local agent workflow helpers copied from `repo-template`. They are not the canonical implementation for generated repos. If they need behavioral changes, make the change in `repo-template` first and refresh through `archon-setup`. +- `actionlint.yml` in this repo is the reusable workflow body. The consumer caller for actionlint lives in `repo-template/.github/workflows/actionlint.yml`. + +## Workflow + +1. **Issue first.** Create a GitHub issue with explicit `Acceptance Criteria` before branching. Use the `Task` issue form. +2. **One issue → one active branch (in a linked worktree) → one PR per phase.** Branch name: `agent//-` (e.g. `agent/claude/42-oauth-flow`); quick fixes without an issue use `agent//-`. Create the branch with `git worktree add`, not `git switch -c` in the primary checkout — see "Checkout role / worktrees". A branch with a merged or closed PR is retired: do not commit, push, add docs/plans/handoffs, perform cleanup, or open another PR from it. Follow-up phases for the same issue must start from the default branch in a new phase-specific branch/worktree. Before reusing an existing issue branch, check `gh pr list --head --state all --json number,state,url,mergedAt`. +3. **Never commit to `main`.** Branch protection enforces this. Repo-facing docs, planning notes, prompts, ADRs, and shared markdown use the same branch/PR path when they are committed to the repo. +4. **Conventional Commits** for messages: `(): ` where `` is one of `feat fix refactor test docs style chore perf ci build revert`. +5. **PR metadata must pass the shared contract before ready-for-review.** Non-doc PRs must use this exact body order: `## Summary`, `## Verification`, `### Verification Notes`, `## Docs / Changelog`, and an issue link (`Closes #N`, `Fixes #N`, or `Refs #N`). The PR title must use Conventional Commits. Each checked verification box must be backed by concrete command/check/manual evidence, and placeholders such as TODO/TBD/N/A must be gone. Doc-only PRs (every file matches `*.md`, `*.txt`, an image extension, or `.changelog/**`) skip the body ceremony but still need a valid title and branch. When `agent:start-task` creates `.pr-body.md`, keep using that file for `gh pr create --body-file` / `gh pr edit --body-file`; if it is missing, regenerate it from the committed `.github/PULL_REQUEST_TEMPLATE.md`, not from notes or memory. +6. **Repo update log.** Every PR that changes code, config, behavior, protected docs, tracked workflows, or repository policy must append one entry to `docs/repo-update-log.md` before review. Include the date, issue/PR, branch, changed paths, verification, and whether follow-up propagation is needed. Doc-only typo fixes may skip the log only when the PR body says why. +7. **Plan/status artifact closeout.** Delivery is incomplete while any plan, task file, progress file, findings file, handoff, audit, roadmap/status tracker, or coordination note created or used by the lane still reads as active execution guidance. Before PR ready/merge, close it, narrow it to remaining scoped work, or mark it deprecated/superseded with the current source of truth. Do this in the same branch/PR for repo-facing artifacts. + +## Checkout role / worktrees + +This repo uses the **checkout-role** model, enforced by `.githooks/pre-commit`: + +- The **primary checkout** stays on the default branch. It is the stable owner/admin + lane and accepts only owner-maintenance commits (see below). It is **not** where + feature work is committed. +- **Feature work happens in a linked worktree.** Create one per issue: + + ``` + git worktree add -b agent//- ../-- + ``` + + Commit, push, and open the PR from that folder. After the PR merges, retire the + worktree with **`npm run agent:prune`** (see below) — not bare `git worktree remove`. + +- **Do not run `git switch -c` in the primary checkout.** A feature-branch commit + there is blocked and redirected to `git worktree add`. +- Unsure where you are? Run `bash .githooks/scripts/checkout-doctor.sh`. + +### Agent lifecycle commands + +Repo-owned helpers (zero-dep, `node`): + +- `npm run agent:start-task -- [--agent ] [--slug ]` — fetch the + default branch, create `agent//-` in a sibling worktree, and write + `.agent/current-task.json` (gitignored). Refuses if the checkout is dirty or off the + default branch, or if the issue already has a branch. +- `npm run agent:pr-body -- [issue]` — print the committed `.github/PULL_REQUEST_TEMPLATE.md` + with `Closes #` filled in, to **stdout**. The issue defaults to `.agent/current-task.json` + then the branch name. Pipe it straight into a PR — `npm run agent:pr-body -- 58 | gh pr create --body-file -` + (or `gh pr edit --body-file -`). Read the committed template directly rather than keeping a + scratch copy: the template is always present, CI auto-injects it on PR open, and no file is written + so the working tree stays clean for the close/preflight gates. +- `npm run agent:status` — branch, default branch, upstream, PR, issue, dirty state, + worktree path, claims (if installed), and the next recommended action. +- `npm run agent:prune` — **the way to retire finished worktrees.** Removes every retired + + clean agent worktree/branch in one sweep; never touches dirty work, locked worktrees, or + the primary/current checkout. A lane is retired only when **GitHub PR state** proves its PR + merged into the default branch **and** the worktree's local tip equals the merged PR head. + This covers merge, squash, and rebase lanes without relying on ancestry alone. A fresh + no-diff task branch may also be reachable from the default branch after it advances, so + ancestry without merged-PR proof is kept. An open PR, a different merge base, extra local + commits, no PR, or unavailable `gh` keep the lane. Preview first with + `npm run agent:prune -- --dry-run`, which prints every lane with a reason + (`github-pr` / `dirty` / `open-pr` / `tip-ahead-of-merged` / `no-pr` / `gh-unavailable` / …) and + changes nothing. Survives Windows `git worktree remove` failures: when a + worktree still holds ignored build residue (`node_modules/`, `dist/`) or long paths, bare + `git worktree remove` aborts with "Directory not empty" and strands a half-removed lane, + whereas `agent:prune` finishes the delete and skips any single un-removable lane instead + of aborting the batch. Idempotent. + +Optional capabilities (claims #14, close-scan #28) are reported as "not installed" when absent. + +## Owner Maintenance Lane + +When the working tree contains only add-only safe maintenance files, agents must not invoke Issue-Admiral, Project-Captain, Project-Lieutenant, Release-Admiral, claim records, handoff blocks, or full CI. Either report `owner maintenance present, no action required` or, if explicitly asked to commit, commit directly on `main` with `docs(owner): ...` or `chore(owner): ...`. + +Safe owner-maintenance paths are: + +- `docs/**` +- image files (`png`, `jpg`, `jpeg`, `gif`, `webp`, `svg`) +- `.changelog/**` + +The lane is otherwise add-only. If any unsafe file is staged, or any non-ledger file is modified, deleted, renamed, or copied, stop and report. Unsafe paths include `README.md`, `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `.github/**`, `.githooks/**`, `.claude/**` (except the named append-log ledgers below), `.agent/schema/**`, `package*.json`, `src/**`, `scripts/**`, `docs/process/**`, and `docs/architecture/**`. + +### Append-log ledgers + +A narrow, named set of agent-local note files may be **added or modified** directly on `main` — standing conventions write to them constantly, so a full issue → PR lane for each one-line update is friction with no safety benefit: + +- `.claude/noticed.md` — per-repo observation log (the `Observations` convention) +- `.claude/napkin.md` — per-repo curated runbook (the napkin skill, curated each session) + +These need no `(owner)` scope — any Conventional Commit subject works (e.g. `chore(noticed): flush observations`), and the issue-ref requirement is waived when every staged path is a ledger. Renames, copies, and deletes of a ledger still require the normal branch/PR lane. The allowlist lives in `.githooks/scripts/owner-maintenance.sh` (`owner_maintenance_is_append_log`); extend it only for a file a documented convention mandates frequent low-ceremony writes to. + +## Anomaly triage + +While working on a PR, you'll often notice off-task bugs, stale files, or tech debt that **don't belong in the current change** but shouldn't be lost. The convention: write a structured entry to `.archon/anomalies-thispr.md` on the PR branch. A reusable workflow (`anomaly-triage.yml` from [`ArchonVII/github-workflows`](https://github.com/ArchonVII/github-workflows)) reads that file on every PR event and routes each entry — related entries become sticky PR review comments, unrelated entries become new GitHub issues. Re-runs are idempotent (each entry carries a fingerprint). + +### Entry format + +```markdown +## + +- **Severity:** low | medium | high | critical +- **File:** path/to/file.ext (optional; helps the classifier) +- **Related to PR:** yes | no | unknown (optional; default unknown) +- **Downstream repo:** owner/repo (optional; only when the root cause lives in another repo) + + +``` + +- **Title** uses `## ` so the workflow can split entries cleanly. +- **Severity** is your best read. `medium` is fine when unsure. +- **File** is one path; if multiple files are implicated, pick the most representative one. +- **Related to PR** — say `yes` if your in-flight change touches the same code, `no` if the issue is wholly elsewhere, `unknown` if you're not sure. The workflow infers from the PR diff when set to `unknown`. +- **Downstream repo** is only used when the anomaly's fix lives in a different repo (e.g. an upstream library you happen to call). The workflow will file the issue there if a `triage-token` secret is configured on this repo. + +### Rules + +- The file lives at `.archon/anomalies-thispr.md` on the PR branch. The `.archon/` directory should be in `.gitignore` **except** for this one file (use `!anomalies-thispr.md`). +- Append rather than overwrite — multiple entries are normal across a PR's commits. +- Don't summarize the anomaly in your PR description. The triage workflow does that automatically in its summary comment. +- If you notice something while working but realize it _is_ part of the PR's scope, fix it directly in the PR — don't write an anomaly entry for work you're about to do anyway. +- Removing an entry from the file before merge cancels its routing (no issue gets filed). Use this to retract on second thought. + +### What the workflow does + +- Parses each `## ` block. +- Classifies as related-to-PR (file in PR diff or `Related to PR: yes`) or unrelated. +- **Related** → posts a sticky PR review comment with the entry body. Re-runs update the same comment. +- **Unrelated** → opens a new GitHub issue in this repo (or `Downstream repo` if specified) with a back-link to this PR and the `auto-triaged` label. +- Posts a single summary comment on the PR listing everything filed. + +Set this up by adding the caller workflow from [`github-workflows/examples/anomaly-triage.yml`](https://github.com/ArchonVII/github-workflows/blob/main/examples/anomaly-triage.yml) to `.github/workflows/`. + +## Verification + +Before marking a PR ready for review: + +- Treat `repo-required-gate / decision` as the stable branch-protection check. Do not make path-filtered leaf workflows required. +- Use `.agent/check-map.yml` to record repo-specific path-to-check expectations. If the repo stack changes, update both `.agent/check-map.yml` and `.github/workflows/repo-required-gate.yml` in the same PR. +- Confirm every plan/status artifact created or used by the lane is closed, narrowed, deprecated/superseded, or explicitly not applicable. +- Run the repo's lint, typecheck, and test commands. Record exact commands in `### Verification Notes`. +- If the change is user-visible, smoke-test it. Record what you exercised. +- Tick a `- [x]` box **only after** the command actually passed. +- Do **not** run `gh pr ready` directly. Run the blessed wrapper so malformed PRs cannot trigger paid or expensive ready-for-review checks: + + ```powershell + npm run agent:close-preflight -- --repo OWNER/REPO --pr <number> + npm run agent:pr-ready -- --repo OWNER/REPO --pr <number> + ``` + + If the npm wrapper scripts are missing, add the repo's portable wrapper + setup first. Do not substitute direct `gh pr ready` or machine-local + command paths. + +## Local delivery guards + +If this repo adds a local close-scan, pre-push, or CI-delivery guard, keep the +guard strict for pushes that would update the remote branch: verification and +any close-scan completion marker must bind to the exact final `HEAD` after the +last commit. + +The guard should still allow true no-op finalization pushes, where local `HEAD` +already matches the upstream branch and no remote update or CI-triggering +delivery action would occur. This keeps Copilot/Codex automatic cleanup pushes +from failing after the branch is already synced, without creating a bypass for +real changes. + +## Closeout + +- Preparing a PR for review and shipping it are different states. +- Closeout includes plan/status artifact hygiene: no lane-created or lane-used + plan, task, progress, findings, handoff, audit, roadmap/status, or + coordination artifact may remain active-looking unless it has been narrowed to + still-open scoped work. +- Use `close:review` for verify -> push -> PR body -> ready-for-review handoff. +- Use `close:ship` only when the user explicitly says `/close`, `ship it`, + `land it`, `merge to main`, or equivalent delivery language. +- If this repo adds a local close-scan guard, run it before `git push`, + `npm run agent:pr-ready`, and `gh pr merge`. + +## CHANGELOG + +This repo uses **Mode 2: `.changelog/unreleased/` fragments**. + +- Add a file at `.changelog/unreleased/<issue>-<slug>.md` for changes that warrant release notes. +- Do not edit `CHANGELOG.md` directly from ordinary PR lanes; fold fragments in a release-cut lane. + +For PRs that don't warrant a CHANGELOG entry (refactor, tests, chore), apply the `no-changelog` label. + +## Commit hygiene + +- One logical unit per commit. If the message needs "and," split into two commits. +- Stage specific files: `git add <path> <path>`. Never `git add -A` or `git add .` — that's how `.env` files get committed. +- Don't bypass hooks (`--no-verify`, `--no-gpg-sign`). If a hook fails, fix the underlying issue. + +## Reference precision + +In durable written artifacts — decision logs, ADRs, PR bodies, `docs/repo-update-log.md`, and verification notes — name git refs unambiguously. When a statement turns on the local-vs-remote distinction, write `origin/main` for the remote branch and "the local default branch" (or a specific local ref) for local state. Never write bare `main` when the local-vs-remote distinction is load-bearing: it reads as both and undermines the rule being recorded (e.g. "verify against `origin/main`"). + +This generalizes: when a workflow rule turns on a distinction — ref, environment, scope, or time — use the fully-qualified term in the artifact, not the shorthand. + +## When stuck + +If the same approach fails twice, stop. Switch tactics, ask the user, or document what you tried in the issue. + +## Coordination + +This repo is **coordination-isolated**. It coordinates only itself. + +- Do not read from or write to machine-global coordination boards. +- Do not assume sibling repositories exist. +- Do not reference another repo unless this repo explicitly documents that dependency. + +When coordination is needed, use this repo's local coordination area: `.agent/coordination/` +(see `.agent/coordination/README.md` for the convention). Active boards, claims, locks, or +handoffs belong there — or in another repo-local location this repo documents. The active +board template lives at `.agent/coordination/board.md` and is opt-in; delete it if this +repo does not do active multi-agent coordination. + +## Doc Sweep-Up + +Agents recover and preserve docs across sessions. Run `scripts/doc-sweep/` at session +boundaries; full spec: `docs/agent-process/doc-sweep.md`. + +- **sweep-on-open:** at session start, run `node scripts/doc-sweep/sweep.mjs --repo <repo>` to + surface add-only docs that prior/dead sessions stranded; commit the provably-safe ones + (`--apply`), leave+log the rest. +- **flush-on-close:** before ending a session, commit your own pending add-only docs (after the + secret scan) so they are never stranded. +- **Allow-list only:** new add-only docs under `docs/**` (except `docs/process/**`, + `docs/architecture/**`), `.changelog/**`, `.html-artifacts/**`, and image assets. Never sweep + code, CI, hooks, `.claude/`, `AGENTS.md`/`CLAUDE.md`/`README.md`, or `package*.json`. +- **Liveness:** auto-commit only docs stranded on the primary default branch (stale >12h) OR a + worktree doc whose coordination claim is EXPIRED. Active claim → never touch. No claim, fresh + (<12h), detached HEAD, gitignored, symlink, or any ambiguity → leave + log, never force. +- **Safety:** the sweep takes a lock and files its own claim; selective file-by-file staging + only; deterministic secret scan before any commit; never push recovery branches. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1d677dc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +Unreleased changes are collected as fragments in `.changelog/unreleased/` and folded during release-cut work. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2816430 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ +# CLAUDE.md + +Read [`AGENTS.md`](./AGENTS.md) first. Claude-specific addenda below. + +## Claude-specific notes + +(None yet. Add here only when Claude's behavior should diverge from the cross-tool contract.) diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..4e41c73 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,7 @@ +# GEMINI.md + +Read [`AGENTS.md`](./AGENTS.md) first. Gemini-specific addenda below. + +## Gemini-specific notes + +(None yet. Add here only when Gemini's behavior should diverge from the cross-tool contract.) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7f80e92 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ArchonVII + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index d589911..9fd5d14 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Reusable GitHub Actions workflows + a per-repo setup script, shared across all r Each workflow uses `on: workflow_call`. A consumer repo opts in by adding a tiny caller workflow that holds the actual trigger (`pull_request`, `schedule`, etc.) and `uses:` the reusable version. Pin to `@v1` so an upstream change doesn't deploy silently everywhere. +Agent and maintainer governance for this repository lives in [`AGENTS.md`](AGENTS.md). Start there before changing workflows, setup scripts, or PR policy helpers; operational changes are logged in [`docs/repo-update-log.md`](docs/repo-update-log.md). + Companion repos: - **[`ArchonVII/.github`](https://github.com/ArchonVII/.github)** — auto-applied community health files (PR template, issue forms, `SECURITY.md`, `release.yml`). See [`STARTER.md`](https://github.com/ArchonVII/.github/blob/main/STARTER.md) for the full document-policy guide. diff --git a/docs/agent-process/doc-sweep.md b/docs/agent-process/doc-sweep.md new file mode 100644 index 0000000..833dd4c --- /dev/null +++ b/docs/agent-process/doc-sweep.md @@ -0,0 +1,357 @@ +# Doc Sweep-Up — Standard & Design Spec + +> **Status:** Design approved with amendments (2026-06-02); hardened after an adversarial +> discriminator pass (2026-06-03, see §3 D8–D10 and the Adversarial Findings appendix). +> Pilot in `archon`, then propagate via the standard 3-PR sequence. +> **Owner decision date:** 2026-06-02 (brainstorming); 2026-06-03 (adversarial amendments). +> **Canonical home:** `repo-template/docs/agent-process/doc-sweep.md` (this is the `archon` +> pilot copy). The short contract lives in `AGENTS.md`; this is the full spec. + +--- + +## 1. Problem + +Docs that agents produce as **side artifacts** (roadmaps, handoffs, research notes, +changelog fragments, `.html-artifacts`) are valuable but routinely stranded uncommitted: + +1. **No flush-on-exit.** Selective-staging + "one logical unit per commit" means a doc that + isn't part of the feature's logical change never gets staged. Sessions die (power outage, + `/clear`, crash) before a deliberate doc commit. +2. **No abandoned-vs-live discriminator.** The next agent sees the dirty file and — correctly, + under the "Concurrent Agents" rule — refuses to touch it, because it cannot tell an + abandoned doc from a live sibling's in-progress work. Orphans accumulate forever. + +**Live proof (2026-06-02):** `archon`'s primary `main` checkout carried two untracked roadmap +docs (`docs/archon/ROADMAP.md`, `docs/archon/window-interaction-roadmap.md`) plus a modified +`README.md`, none committed. + +## 2. Understanding Summary + +- **What:** an ecosystem capability that **prevents** stranded docs (flush-on-close) and + **recovers** docs that dead/outage-killed sessions left behind (sweep-on-open + dual + backstop), auto-committing the _provably-safe_ ones and surfacing the rest. +- **Who:** every agent (Claude / Codex / Gemini), via an `AGENTS.md` contract that points + agents at a runner + this spec + an archon-setup registry feature + a github-workflows + backstop. **Agents run it; the user runs no commands** — so the contract wiring is what makes + the capability real, not the script alone. +- **Constraints:** must not break the concurrent-agents safety rule. Add-only; conservative + allow-list; ambiguity → leave + log; **deterministic** secret scan before commit; commits + ride the owner-maintenance lane — no PR/issue ceremony. +- **Non-goals:** sweeping code/CI/hooks/`.claude/`/README/AGENTS/CLAUDE/`package*.json`; + touching modified tracked files; auto-committing gitignored files; auto-pushing recovery + branches; deleting or relocating anything. + +## 3. Decision Log + +| # | Decision | Rejected alternatives | +| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| D1 | Two-halves + local: `AGENTS.md` short contract → runner + this full spec + archon-setup registry + github-workflows backstop. | Personal `~/.claude/CLAUDE.md` tweak (tool-specific); full contract inline in `AGENTS.md`. | +| D2 | All three triggers: flush-on-close, sweep-on-open, **dual** backstop. | Any single trigger. | +| D3 | Layered discriminator, no new infra; 12h freshness applies to the primary default branch too. | Instant-eligible on default branch; heartbeat infra now. | +| D3b | Claim liveness = repo + branch/worktree + path coverage + `status: active` + not past `expiresAt`. | "A claim file exists"; parallel liveness model. | +| D4 | Conservative allow-list, **add-only**. | Modified-tracked files; broad extension match. | +| D5 | Auto-commit _provably-safe_, surface rest; commit destination tiered by PR state. | Always-confirm; auto-commit-all. | +| D6 | Enumeration NUL-safe. | Raw `git status --porcelain` parsing. | +| D7 | gh-cron detector scans `origin/main...branch` diffs, not just HEAD. | "Newest commit touches docs." | +| **D8** | **Worktree recovery requires a POSITIVE death signal (Option A, 2026-06-03).** mtime alone is necessary-not-sufficient on a worktree. A worktree doc is eligible only when a \*covering claim is **expired\***; claim **active** → skip (live); claim **absent / board off** → leave + log + surface. Only the **primary-default-branch lane** auto-commits on staleness alone (F19 ⇒ no concurrent agent expected there). | mtime-only worktree recovery (adversarial pass: clobbers a blocked-but-live >12h agent, or one whose mtimes were reset by checkout/backup/clock-skew). Heartbeat-in-v1 (bigger build; deferred to §9 as the _second_ positive signal). | +| **D9** | **Enumeration unions disjoint git views (2026-06-03):** untracked (`ls-files --others`) **+** staged-but-uncommitted adds (`diff --cached --diff-filter=A`); a separate ignored-but-present pass over allow-list roots routes to **surface-only** (never auto-commit a gitignored file). Skip collapsed `dir/` entries (nested repos), symlinks/junctions. Stage **file-by-file** with per-file error isolation. | Single enumerator (drops the author's `git add` "save my work" signal — adversarial C1, verified); directory-level staging (one nested repo aborts the batch — H4). | +| **D10** | **Robust checkout-state detection + TOCTOU close (2026-06-03):** classify on `git symbolic-ref -q HEAD` (3 states: primary-default / primary-non-default-or-detached / worktree). Detached HEAD → never eligible. Primary-but-not-default (F19 guard broken) → treat as worktree (positive signal required) or abort+log. Capture mtime+size at classify; **re-stat immediately before `git add`**; any change → abort that doc → leave + log; stage from the captured state. Case-**insensitive** allow/exclude matching (NTFS); exclude wins ties. Per-repo **sweep lock** (the sweep files its own `doc-sweep` claim) held across classify+commit. Deterministic secret scanner (e.g. gitleaks) as a hard gate in addition to the read. | `abbrev-ref HEAD` (returns `HEAD` when detached — adversarial C3, verified); classify-then-commit with no re-stat (TOCTOU C4); lowercase-only matching (NTFS case bypass H2); no mutual exclusion (concurrent sweepers double-commit H5); "the agent reads it" as the only secret control (M2). | + +## 4. Specification + +### 4.1 Scope — sweepable docs + +**Allow-list (candidate roots; sweep only NEW add-only docs matching):** + +- `docs/**` — **except** `docs/process/**` and `docs/architecture/**` (review-required) +- `.changelog/**` +- `.html-artifacts/**` +- image assets: `**/*.{png,jpg,jpeg,gif,webp,svg}` + +**Hard-exclude (NEVER swept — leave + log):** + +- code, CI, hooks: `src/**`, `scripts/**`, `.github/**`, `.githooks/**` +- agent/tool config: `.claude/**`, `.codex/**`, `.gemini/**`, `.agent/schema/**` +- governance: `README.md`, `AGENTS.md`, `CLAUDE.md`, `GEMINI.md` +- manifests: `package*.json` (any depth) +- **anything modified** (tracked-file changes are not add-only) + +**Matching rules (D10):** normalize every path from **git output** (not the filesystem) to +repo-relative POSIX (forward slashes, decode `core.quotePath` quoting). Match +**case-insensitively**; when both allow and exclude match, **exclude wins**. + +### 4.2 Enumeration (NUL-safe, unioned — D6 / D9) + +Add-only candidates = the **union** of two disjoint git views, both NUL-delimited: + +```bash +# (a) untracked, gitignore-respecting +git -C <wt> ls-files --others --exclude-standard -z +# (b) staged-but-uncommitted ADDs (the author's "git add = save my work" signal — C1) +git -C <wt> diff --cached --name-only --diff-filter=A -z +``` + +> The two views are **disjoint, not interchangeable.** `ls-files --others` does NOT show a +> staged file; `diff --cached` shows only staged. Both are required. + +**Ignored-but-present pass (surface only — C2):** allow-listed roots (esp. `.html-artifacts/`) +are commonly gitignored. Separately enumerate them and route to **leave + log + surface**, +never auto-commit (committing a gitignored file is itself a policy violation): + +```bash +git -C <wt> ls-files --others --ignored --exclude-standard -z -- docs .changelog .html-artifacts +``` + +**Per-candidate filtering before classify:** + +- Skip any entry ending in `/` (collapsed untracked directory / nested repo) → leave + log. +- Skip symlinks/junctions (`lstat`; never follow) → leave + log. +- Apply §4.1 allow-list / hard-exclude (case-insensitive). + +### 4.3 Liveness discriminator (D3 / D8 / D10) + +**Step 1 — checkout state** (per working tree), via `git symbolic-ref -q HEAD`: + +```text +symbolic-ref -q HEAD succeeds, ref == default branch, AND this is the PRIMARY checkout + → lane = primary-default +symbolic-ref -q HEAD succeeds, ref != default (a feature branch) + → lane = worktree +symbolic-ref -q HEAD fails (DETACHED HEAD) → never eligible → leave + log +PRIMARY checkout NOT on default branch (F19 guard broken) + → treat as worktree (positive signal required), + or abort + log repo as "guard-violation" +``` + +**Step 2 — eligibility:** + +```text +lane = primary-default: + if mtime < 12h → leave + log (possibly-live) + else → eligible # F19 ⇒ no concurrent agent expected here + +lane = worktree: # D8 — positive death signal REQUIRED + covering claim status == active (unexpired) → skip (live) + covering claim status == expired → eligible # the lane wasn't renewed + no covering claim / board off → leave + log + surface # cannot prove dead + (mtime is necessary-not-sufficient here; it never makes a worktree doc eligible alone) +``` + +All "eligible" results still pass the §4.4 review gate and the §4.5 TOCTOU re-stat before commit. + +**12h staleness threshold** — _source: owner decision 2026-06-02. Rationale: long enough that +a live-but-slow session never trips it; short enough to recover within a workday._ Single +named constant. It is **local-clock-relative**; never compare an mtime written by another host. + +**Claim coverage (D3b, fail-safe — adversarial H3):** a claim covers a doc iff ALL of: claim +repo == repo; claim branch/worktree == the doc's worktree; claim path-glob (same normalizer as +§4.1) includes the doc; and (for the "active" verdict) `status: active` and not past +`expiresAt`. **On any ambiguity** — path-glob parse failure, or a claim that matches +repo+branch+`active` but is unclear on path — **fail safe: treat as covering (live) → skip.** +Claims err toward over-blocking. Align with the `.agent/coordination/claims/` lifecycle +(`active | released | expired | merged`, `expiresAt`); do not invent a parallel model. + +### 4.4 Review gate (D5 / D10) + +Before committing any eligible doc, ALL must pass (any failure → leave + log): + +1. **Coherence read.** Reject stubs/templates/placeholders/empty files. +2. **Deterministic secret/PII scan** (e.g. `gitleaks`/`trufflehog`/`detect-secrets`) as a + **hard gate**, in addition to the read. Any hit → leave + log, never commit. The LLM read + supplements the scanner; it does not replace it. +3. **Re-confirm** add-only + allow-list + not hard-excluded + not a symlink/dir. + +This gate applies to **flush-on-close too** (M4): "I authored it" waives the liveness check, +not the secret scan. + +### 4.5 Commit rules (D5 / D10 / amendment 4) + +- **Sweep lock first (H5):** acquire a per-repo lock (the sweep files its own + `.agent/coordination/` claim with a reserved `doc-sweep` actor) and hold it across + classify+commit. If the lock is held → **skip, don't queue.** +- **TOCTOU re-stat (C4):** capture (mtime, size) at classify; immediately before `git add`, + re-stat — if changed, **abort that doc → leave + log.** Stage from the captured state. +- **Selective, file-by-file staging (H4):** `git add -- <one path>` per file with per-file + error isolation (one failure → leave + log that file, continue). **Never** `git add -A`/`.`. +- **Destination, tiered by PR state (H1 — no invisible local-only durability):** + +```text +- worktree branch WITH an open PR → commit to that branch +- primary checkout / default branch → owner-maintenance path: gated by an explicit + owner/actor check (L2), selective staging, clean + pre/post `git status`, respect hooks/signing +- stale worktree branch, NO open PR → do NOT create a local-only commit on a branch no PR + tracks (that re-strands it — H1). v1: leave + log to a + DISCOVERABLE, pushed location (the tracking issue / a + pushed recovery manifest). Auto-push of recovery + branches is out of scope for v1. +``` + +- **Convention:** `docs(sweep): recover orphaned docs from prior session`, file list in body. +- **Attribution:** actor's git identity (no per-doc author known; no `--author` spoofing). +- **Hooks/signing (L1):** never `--no-verify` / bypass signing. On hook rejection → leave + log. + +### 4.6 Triggers (D2) + +- **sweep-on-open** — at session start (`/open`, `/session`), the agent runs §4.2–§4.5 for the + current repo. The `AGENTS.md` contract is what makes the agent do this. +- **flush-on-close** — at `/close` and `/bookmark`, the agent applies §4.1 + the §4.4 secret + scan to **its own** uncommitted docs and commits them (`docs(...)`, not `docs(sweep):`). + +### 4.7 Backstops (D2 / D7) + +**gh-cron detector** (`github-workflows/doc-orphan-detector.yml@v1`) — detection only, never +commits; **paths only, never contents**: + +```text +For every branch with no open PR: + diff origin/main...branch + if the diff contains allow-list docs + AND the latest commit touching those paths is older than 12h + → open/update a tracking issue listing the paths +``` + +> A gh-cron sees only **pushed** commits — committed-but-orphaned docs on stale branches. It +> cannot see uncommitted working-tree files. + +**Local backstop** — a scheduled local agent (`/schedule` + Cron) or SessionStart hook runs the +sweep-on-open algorithm across ecosystem repos periodically. Tool-specific (operator config); +the tool-agnostic contract keeps the behavior portable. + +### 4.8 Runner interface & implemented hardening (2026-06-04) + +The `scripts/doc-sweep/` runner implements §4.1–§4.5. CLI: + +```text +node scripts/doc-sweep/sweep.mjs --repo <path> [--apply] [--owner] + [--allow-main-commit] [--issue <n>] [--json] +``` + +- `--apply` — acquire the lock and commit eligible docs (default: read-only report). +- `--owner` — owner gate (§4.5 L2): authorizes commits on the **primary-default** lane. + Without it, primary-default eligibles route to leave + log (`reason: owner-gate`). +- `--allow-main-commit` — pass the repo's audited `ALLOW_MAIN_COMMIT=1` override on the + primary-default lane, for sweepable docs outside a repo's narrow owner-maintenance-safe + set. Hooks still run; the override is logged to `.agent/bypass.log`. Never `--no-verify`. +- `--issue <n>` — issue ref for the commit message; on a worktree branch it is otherwise + derived from the `agent/<tool>/<issue>-<slug>` branch name. + +The four pre-`--apply` follow-ups, folded in on 2026-06-04: + +- **Owner gate + destination tiering (§4.5 L2/H1).** primary-default → owner-gated + owner-maintenance commit; worktree → commit only with an open PR (else `no-open-pr`); + any hook rejection → unstage + `hook-rejected` (never `--no-verify`). +- **Lane-aware commit message.** primary-default with no issue ref → `docs(owner): …` + (the commit-msg issue-ref **exemption**, no fabricated number); otherwise + `docs(sweep): … (#N)`. Replaces the pilot's hardcoded `(#92)`. +- **Stale-lock TTL.** the lock records `{ts,pid,host}`; a lock older than `LOCK_TTL_MS` + (15 min — owner decision 2026-06-04) is reclaimed, fixing the original no-expiry deadlock + where a crashed sweep stranded the O_EXCL lock forever. +- **Brace-glob fail-closed.** an unsupported `{a,b}` claim glob now throws, so the §4.3 + fail-safe (H3) over-blocks an active claim, instead of silently failing **open** by + matching only the literal `{a,b}` string. + +## 5. Where it lives (D1) + +| Half | Location | Content | +| ---------------------------- | ------------------------------------------------------------------ | ------------------------------------ | +| Contract (every agent reads) | `repo-template/AGENTS.md` → `## Doc Sweep-Up` | pointer block → runner + spec (§5.1) | +| Full spec | `repo-template/docs/agent-process/doc-sweep.md` | this document | +| Runner | `scripts/doc-sweep/` (Node, depless `node --test`) | §4 algorithm | +| gh-cron backstop | `github-workflows/doc-orphan-detector.yml` (`@v1`) | §4.7 detector | +| Registry feature | `archon-setup` → `agent-workflow.doc-sweep` (locked) + cron opt-in | exposes it | +| Local backstop | operator's local config (NOT in the ecosystem PRs) | scheduled agent / hook | + +### 5.1 `AGENTS.md` short contract block (verbatim) + +```markdown +## Doc Sweep-Up + +Agents recover and preserve docs across sessions. Run `scripts/doc-sweep/` at session +boundaries; full spec: `docs/agent-process/doc-sweep.md`. + +- **sweep-on-open:** at session start, run the sweep to recover add-only docs that prior/dead + sessions stranded; commit the provably-safe ones, leave+log the rest. +- **flush-on-close:** before ending a session, commit your own pending add-only docs (after the + secret scan) so they are never stranded. +- **Allow-list only:** new add-only docs under `docs/**` (except `docs/process/**`, + `docs/architecture/**`), `.changelog/**`, `.html-artifacts/**`, and image assets. Never sweep + code, CI, hooks, `.claude/`, `AGENTS.md`/`CLAUDE.md`/`README.md`, or `package*.json`. +- **Liveness:** auto-commit only docs stranded on the primary default branch (stale >12h) OR a + worktree doc whose coordination claim is EXPIRED. Active claim → never touch. No claim, fresh + (<12h), detached HEAD, gitignored, symlink, or any ambiguity → leave + log, never force. +- **Safety:** the sweep takes a lock and files its own claim; selective file-by-file staging + only; deterministic secret scan before any commit; never push recovery branches. +``` + +## 6. Rollout (pilot → propagate) + +1. **Pilot in `archon`:** land this spec + the short `AGENTS.md` contract + the runner; + validate against the §7 fixture. +2. **Propagate** via the standard sequence (`playbook-ecosystem-capability-rollout`): + `github-workflows` detector PR → force-move `v1` → `repo-template` PR (contract + spec) → + refresh `archon-setup` snapshots → merge → pull main everywhere, delete agent branches. + +## 7. Pilot acceptance fixture + +The classifier must produce these verdicts. Build as deterministic `node --test` fixtures +(pin mtimes/claims; assert with `\r?\n` for CRLF tolerance): + +```text +# Primary default-branch lane (the live archon orphans) +docs/archon/ROADMAP.md mtime 13h → eligible (commit) +docs/archon/window-interaction-roadmap.md mtime 13h → eligible (commit) +README.md modified → not enumerated (not add-only) → leave +.claude/noticed.md untracked → hard-excluded → leave +.claude/settings.local.json untracked → hard-excluded → leave + +# Discriminator edge cases (adversarial fixtures) +docs/a.md primary-default mtime 11h59m → leave (fresh) +docs/b.md primary-default mtime 12h01m → eligible +docs/staged.md staged-not-committed → enumerated (C1) → classify normally +docs/Process/x.md mixed-case excluded → leave (exclude wins, case-insensitive) +.html-artifacts/r.html gitignored → surface-only, never commit (C2) +docs/notes.md → <external> symlink → leave (skip symlink) +docs/w.md worktree, claim ACTIVE → skip (live) +docs/w.md worktree, claim EXPIRED → eligible +docs/w.md worktree, NO claim → leave + log (cannot prove dead) +docs/w.md worktree, detached HEAD → leave + log +docs/race.md eligible, touched after classify → abort → leave (TOCTOU re-stat) +``` + +## 8. Failure modes & fail-safes + +- **Idempotent:** committed docs aren't re-enumerated; the sweep lock prevents concurrent + double-commits. +- **Fail-safe default:** any error, parse ambiguity, classification doubt, claim ambiguity, + detached HEAD, symlink, nested repo, or TOCTOU change → abort _that doc_ and log; never + destructive. +- **No clobber:** add-only; worktree auto-commit requires a positive death signal; TOCTOU + re-stat closes the resurrection window. +- **No invisible durability:** never a local-only commit on an untracked branch; surface to a + pushed, discoverable location. +- **No silent caps:** any bounded/skipped work is logged. + +## 9. Future hardening (out of scope for v1) + +- **Session heartbeat** (`.agent/coordination/heartbeat/<session>.json`) — the _second_ + positive worktree-liveness signal (a dead heartbeat marks abandonment without an expired + claim). Until adopted across all agent tools, worktree auto-recovery needs an **expired + claim** (D8). This is the path to broadening worktree coverage safely. +- Auto-pushed recovery refs for stale-branch-no-PR orphans, if a discoverable scheme is agreed. + +--- + +## Appendix — Adversarial Findings (2026-06-03) + +A pre-build adversarial review (most-capable model) stress-tested §4.2/§4.3/§4.5 against live +git. Folded in: **C1** staged-but-uncommitted invisible + disjoint enumerators (→ D9); +**C2** gitignored allow-listed docs invisible (→ D9 surface pass); **C3** detached-HEAD/broken- +guard misrouting (→ D10 `symbolic-ref -q`); **C4** classify→commit TOCTOU (→ D10 re-stat); +**H1** "commit local, don't push" re-strands (→ §4.5 discoverable pushed log); **H2** NTFS +case bypass (→ §4.1 case-insensitive); **H3** claim glob fail-open (→ §4.3 fail-safe); **H4** +nested-repo aborts batch (→ D9 file-by-file); **H5** concurrent sweepers (→ §4.5 lock); +**M2** secret scan hand-waving (→ §4.4 deterministic scanner); **M3** symlink escape (→ §4.2 +skip). **Verdict adopted:** 12h-mtime is NOT sound as the primary worktree-liveness signal → +Option A (D8): positive death signal required on worktrees; mtime-alone gates only the +primary-default lane. diff --git a/docs/plans/README.md b/docs/plans/README.md new file mode 100644 index 0000000..27cc423 --- /dev/null +++ b/docs/plans/README.md @@ -0,0 +1,15 @@ +# Plans + +Active repo-facing implementation plans live here. + +Use: + +```text +docs/plans/YYYY-MM-DD-<slug>.md +``` + +Update plan state in the plan file as work proceeds, keeping completed and +remaining steps clear enough for another agent to resume. + +`docs/superpowers/plans/` is legacy/history only. Do not add new implementation +plans there unless a repo-specific migration note says otherwise. diff --git a/docs/repo-update-log.md b/docs/repo-update-log.md new file mode 100644 index 0000000..24f9691 --- /dev/null +++ b/docs/repo-update-log.md @@ -0,0 +1,25 @@ +# Repository Update Log + +This log records agent-visible repository changes that should be easy to audit later. It complements `CHANGELOG.md`: the changelog is user-facing release history, while this file is the operational ledger for what changed in this repo and whether more propagation is needed. + +## Entry Template + +```markdown +## YYYY-MM-DD - <short title> + +- **Issue/PR:** #issue / #pr +- **Branch:** agent/<tool>/<issue>-<slug> +- **Changed paths:** path, path +- **What changed:** One or two sentences. +- **Verification:** Exact commands/results, or docs-only rationale. +- **Propagation:** none | pending <repo/path> | completed <repo/path> +``` + +## 2026-06-11 - Governance baseline onboarding + +- **Issue/PR:** #38 / (pending) +- **Branch:** agent/codex/38-governance-baseline +- **Changed paths:** AGENTS.md, CLAUDE.md, GEMINI.md, LICENSE, CHANGELOG.md, .changelog/unreleased/README.md, .changelog/unreleased/38-governance-baseline.md, .agent/**, .githooks/**, .github/CODEOWNERS, .github/PULL_REQUEST_TEMPLATE.md, .github/archon-setup.json, .github/dependabot.yml, .gitattributes, .gitignore, docs/agent-process/doc-sweep.md, docs/plans/README.md, docs/repo-update-log.md, package.json, scripts/agent/**, scripts/doc-sweep/**, examples/minimal-ci.yml +- **What changed:** Installed the ArchonVII governance baseline through `archon-setup` onboarding, merged local agent lifecycle commands into `package.json`, added tool pointer files and hooks, and documented this repo's reusable-workflow source-of-truth boundary. Also fixed `examples/minimal-ci.yml` so the no-op shell command parses under YAML/actionlint. Remaining audit drift is intentional: README.md is the workflow catalog, AGENTS.md contains this repo's source-boundary contract, docs/repo-update-log.md is this repo's operational ledger, CHANGELOG.md uses fragment-mode release notes, and `.github/workflows/actionlint.yml` is the reusable workflow body rather than the consumer caller. +- **Verification:** `npm test` passed 122/122. `node C:\GitHub\archon-setup\bin\onboard.mjs C:\GitHub\github-workflows-38-governance-baseline --audit --json` reported 31 present / 0 missing / 5 drifted, with startup readiness complete and remaining drift adjudicated above. `C:\Program Files\Git\bin\bash.exe .githooks/scripts/install-githooks.sh`, `test-owner-maintenance.sh`, `test-checkout-role.sh`, and `bash -n .githooks/commit-msg .githooks/pre-commit .githooks/scripts/*.sh` passed. `C:\Users\josep\go\bin\actionlint.exe` passed across `.github/workflows/*.yml` and `examples/*.yml`. +- **Propagation:** pending archon-setup snapshot refresh after merge. diff --git a/examples/minimal-ci.yml b/examples/minimal-ci.yml index 0068dd0..a60745e 100644 --- a/examples/minimal-ci.yml +++ b/examples/minimal-ci.yml @@ -35,4 +35,5 @@ jobs: runs-on: ubuntu-latest steps: - name: No-op gate - run: echo "minimal-ci: no build/test configured for this repo. ci-success passes by design." + run: | + echo "minimal-ci: no build/test configured for this repo. ci-success passes by design." diff --git a/package.json b/package.json index 61deb12..066bfee 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,11 @@ "agent:close-preflight": "node scripts/agent-close-preflight.mjs", "agent:pr-ready": "node scripts/agent-pr-ready.mjs", "pr:contract": "node scripts/pr-contract.mjs", - "test": "vitest run" + "test": "vitest run", + "agent:status": "node scripts/agent/status.mjs", + "agent:prune": "node scripts/agent/prune.mjs", + "agent:start-task": "node scripts/agent/start-task.mjs", + "agent:pr-body": "node scripts/agent/pr-body.mjs" }, "devDependencies": { "vitest": "^4.1.7" diff --git a/scripts/agent/lib.mjs b/scripts/agent/lib.mjs new file mode 100644 index 0000000..e3ebda2 --- /dev/null +++ b/scripts/agent/lib.mjs @@ -0,0 +1,240 @@ +// scripts/agent/lib.mjs +// Pure helpers for the agent lifecycle commands. No I/O — every function here is +// unit-tested in test/agent/lib.test.mjs. Command shims (start-task/status/prune) +// inject git/gh output and consume these. + +// Branch convention: agent/<tool>/<issue>-<slug> (AGENTS.md:15) +export function sanitizeSlug(value) { + const slug = String(value) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .split('-') + .filter(Boolean) + .slice(0, 6) // cap length so branch/worktree names stay manageable + .join('-'); + return slug || null; +} +export function buildBranchName(agent, issue, slug) { + if (!agent || !issue || !slug) { + throw new Error(`buildBranchName: all arguments required (got agent=${agent}, issue=${issue}, slug=${slug})`); + } + return `agent/${agent}/${issue}-${slug}`; +} +export function parseIssueFromBranch(branch) { + const match = /^agent\/[^/]+\/(\d+)-/.exec(String(branch)); + return match ? match[1] : null; +} + +const ISSUE_LINK_RE = /\b(?:Closes|Fixes|Refs)\s+#\d+\b/i; +const ISSUE_PLACEHOLDER_RES = [ + /^TODO:\s*(?:Closes|Fixes|Refs)\s+#(?:___|<[^>\r\n]+>)\s*$/im, + /^(?:Closes|Fixes|Refs)\s+#(?:___|<[^>\r\n]+>)?\s*$/im, +]; + +export function populatePrBodyTemplate(template, { issue }) { + const body = String(template ?? ''); + if (!issue || ISSUE_LINK_RE.test(body)) return ensureFinalNewline(body); + + const link = `Closes #${issue}`; + for (const placeholderRe of ISSUE_PLACEHOLDER_RES) { + if (placeholderRe.test(body)) { + return ensureFinalNewline(body.replace(placeholderRe, link)); + } + } + + const lines = body.split(/\r?\n/); + const headingIndex = lines.findIndex((line) => /^##\s+Linked Issue\s*$/i.test(line.trim())); + if (headingIndex !== -1) { + let insertIndex = headingIndex + 1; + while (insertIndex < lines.length && lines[insertIndex].trim() === '') insertIndex += 1; + + if (insertIndex >= lines.length || /^##\s+/.test(lines[insertIndex])) { + lines.splice(headingIndex + 1, 0, '', link); + } else if (/^(?:TODO\b|#(?:___)?$|<[^>]+>$)/i.test(lines[insertIndex].trim())) { + lines[insertIndex] = link; + } else { + lines.splice(insertIndex, 0, link); + } + + return ensureFinalNewline(lines.join('\n')); + } + + return ensureFinalNewline(`${body.trimEnd()}\n\n## Linked Issue\n\n${link}`); +} + +export function parseGitStatusPorcelain(raw) { + if (!raw) return []; + return raw + .split('\0') + .filter(Boolean) + .map((record) => ({ status: record.slice(0, 2), path: record.slice(3) })); +} +export function assertCheckoutIsSafe({ statusEntries, currentBranch, defaultBranch }) { + if (statusEntries.length > 0) { + const sample = statusEntries.slice(0, 3).map((e) => e.path).join(', '); + throw new Error(`Working tree is dirty. Commit or stash before starting a task. Dirty: ${sample}`); + } + if (!currentBranch || currentBranch !== defaultBranch) { + throw new Error(`start-task must run from the default branch (${defaultBranch}); current: ${currentBranch || '(detached HEAD)'}`); + } +} + +export function parseWorktreeList(porcelain) { + const list = []; + let current = null; + for (const line of String(porcelain).split('\n')) { + if (line.startsWith('worktree ')) current = { path: line.slice('worktree '.length).trim(), branch: null, head: null }; + // `HEAD <oid>` is the worktree's local tip; prune compares it to a merged PR's + // headRefOid so a lane with commits beyond what was merged is never deleted. + else if (line.startsWith('HEAD ')) { if (current) current.head = line.slice('HEAD '.length).trim(); } + else if (line.startsWith('branch ')) { + if (current) current.branch = line.slice('branch refs/heads/'.length).trim(); + } else if (line === '' && current) { list.push(current); current = null; } + } + if (current) list.push(current); + return list; +} + +// Prune safety contract (#27 AC): remove ONLY worktrees that are +// (a) not the primary checkout, (b) not the current checkout, +// (c) not the default branch, (d) on an explicitly retired agent/* branch, +// AND (e) clean (no uncommitted changes). +// Anything dirty is skipped and reported. Anything unretired is kept. +export function classifyPruneCandidates({ worktrees, primaryPath, currentPath, defaultBranch, mergedBranches, dirtyPaths }) { + const remove = [], skipDirty = [], keep = []; + for (const wt of worktrees) { + // Protected worktrees are never touched and are not tracked in keep/remove/skipDirty + const isProtected = wt.path === primaryPath || wt.path === currentPath || wt.branch === defaultBranch || !wt.branch?.startsWith('agent/'); + if (isProtected) continue; + if (dirtyPaths.has(wt.path)) { skipDirty.push(wt); continue; } + if (mergedBranches.has(wt.branch)) { remove.push(wt); continue; } + keep.push(wt); + } + return { remove, skipDirty, keep }; +} + +// GitHub PR merge signal for squash- and rebase-merges, which `git branch --merged` +// cannot see: those rewrite the lane's commits onto a NEW SHA on the default branch, +// so the lane is never an ancestor of it. CONSERVATIVE by construction — returns +// { merged: true } ONLY when ALL hold: +// - no OPEN PR exists for the branch (an open PR always wins → keep), AND +// - a MERGED PR exists whose base is the configured default branch, AND +// - that PR's recorded head SHA (headRefOid) equals the lane's local tip, +// proving the lane has no commits beyond what was merged. +// Every other shape (no PR, closed-unmerged, merged into a non-default base, local +// tip ahead of the merged head, or an unknown local tip) returns { merged: false } +// with a `reason` so the caller keeps the lane and can explain why. Never infers a +// merge from diffs/patch-ids — only from GitHub's own MERGED state. +export function classifyPrMergeSignal({ prs, defaultBranch, localTip }) { + if (!Array.isArray(prs) || prs.length === 0) return { merged: false, reason: 'no-pr' }; + if (prs.some((p) => p?.state === 'OPEN')) return { merged: false, reason: 'open-pr' }; + const mergedIntoDefault = prs.filter((p) => p?.state === 'MERGED' && p?.baseRefName === defaultBranch); + if (mergedIntoDefault.length === 0) { + const mergedElsewhere = prs.some((p) => p?.state === 'MERGED'); + return { merged: false, reason: mergedElsewhere ? 'merged-non-default-base' : 'closed-unmerged' }; + } + if (!localTip) return { merged: false, reason: 'tip-unknown' }; + const headMatch = mergedIntoDefault.some((p) => p?.headRefOid && p.headRefOid === localTip); + if (!headMatch) return { merged: false, reason: 'tip-ahead-of-merged' }; + return { merged: true, reason: 'github-pr' }; +} + +export function classifyPruneRetirement({ + worktrees, + ancestryMergedBranches = new Set(), + prsByBranch = new Map(), + defaultBranch, + ghUnavailable = false, +}) { + const retiredBranches = new Set(); + const keepReason = new Map(); + const retireReason = new Map(); + + for (const wt of worktrees) { + if (!wt.branch?.startsWith('agent/')) continue; + if (ghUnavailable) { + keepReason.set(wt.branch, ancestryMergedBranches.has(wt.branch) ? 'gh-unavailable-ancestry-only' : 'gh-unavailable'); + continue; + } + + const signal = classifyPrMergeSignal({ + prs: prsByBranch.get(wt.branch) || [], + defaultBranch, + localTip: wt.head, + }); + if (signal.merged) { + retiredBranches.add(wt.branch); + retireReason.set(wt.branch, signal.reason); + } else { + keepReason.set(wt.branch, signal.reason); + } + } + + return { retiredBranches, keepReason, retireReason }; +} + +export function inferNextAction({ onDefaultBranch, dirty, hasPr, ahead = 0 }) { + if (onDefaultBranch) return 'run `npm run agent:start-task -- <issue>` to begin a task'; + if (dirty) return 'commit your changes'; + if (!hasPr && ahead > 0) return 'push and open a PR (jma-git-pr-lifecycle)'; + if (hasPr) return 'address review / mark PR ready'; + return 'no action — branch is in sync'; +} +export function detectClaimsInstalled({ claimsFileExists }) { + return Boolean(claimsFileExists); +} +export function primaryRootFromCommonDir(commonDir) { + // `git rev-parse --path-format=absolute --git-common-dir` ends in /.git, but + // git can emit backslash separators on Windows; accept either so the result + // is the primary checkout root with no trailing separator. + return String(commonDir).replace(/[\\/]?\.git([\\/].*)?$/, ''); +} +export function checkStartupReadiness(baseline, { exists }) { + const required = Array.isArray(baseline?.required) ? baseline.required : []; + const expectedDirectories = Array.isArray(baseline?.expectedDirectories) ? baseline.expectedDirectories : []; + const all = [...required, ...expectedDirectories]; + const present = []; + const missing = []; + for (const relPath of all) { + if (exists(relPath)) present.push(relPath); + else missing.push(relPath); + } + return { status: missing.length ? 'incomplete' : 'complete', present, missing }; +} +export function formatStartupMap(baseline, { repoPath = '<repo>', archonSetupCommand = 'node <path-to-archon-setup>/bin/onboard.mjs', readiness = null } = {}) { + const legacy = Array.isArray(baseline?.legacy) ? baseline.legacy : []; + const lines = [ + 'Agent startup map:', + '- Plans: docs/plans/', + '- Agent process: docs/agent-process/', + '- Repo update log: docs/repo-update-log.md', + '- Check map: .agent/check-map.yml', + '- Coordination: .agent/coordination/README.md', + '- PR process: .github/PULL_REQUEST_TEMPLATE.md', + '- Agent scripts: scripts/agent/', + '- Doc sweep: scripts/doc-sweep/', + ]; + if (legacy.length) lines.push(`- Legacy plans: ${legacy.join(', ')} (history only)`); + if (readiness?.missing?.length) lines.push('', `Missing startup baseline paths: ${readiness.missing.join(', ')}`); + lines.push('', 'If these files are missing or unclear, stop searching and run:', `${archonSetupCommand} ${repoPath} --audit`); + return lines.join('\n'); +} +export function formatStatusReport(s) { + const prText = s.pr ? `#${s.pr.number} ${s.pr.state} ${s.pr.url}` : 'none'; + return [ + `Branch: ${s.branch}`, + `Default branch: ${s.defaultBranch}`, + `Upstream: ${s.upstream ?? 'none'}`, + `PR: ${prText}`, + `Issue: ${s.issue ? `#${s.issue}` : 'none'}`, + `Dirty: ${s.dirty ? `yes (${s.dirtyCount} path${s.dirtyCount === 1 ? '' : 's'})` : 'clean'}`, + `Worktree: ${s.worktreePath}`, + `Claims: ${s.claimsInstalled ? 'installed' : 'not installed'}`, + `Next: ${s.nextAction}`, + ].join('\n'); +} + +function ensureFinalNewline(value) { + return value.endsWith('\n') ? value : `${value}\n`; +} diff --git a/scripts/agent/pr-body.mjs b/scripts/agent/pr-body.mjs new file mode 100644 index 0000000..2cbc345 --- /dev/null +++ b/scripts/agent/pr-body.mjs @@ -0,0 +1,40 @@ +// scripts/agent/pr-body.mjs +// Print the committed PR template with the linked issue filled in, to STDOUT. +// No file is written — pipe it: `npm run agent:pr-body -- 58 | gh pr create --body-file -` +// or `gh pr edit <n> --body-file -`. Replaces the old worktree-local .pr-body.md, +// which dirtied the working tree and collided with clean-tree close/preflight gates. +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { parseIssueFromBranch, populatePrBodyTemplate } from './lib.mjs'; + +const checkoutRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: process.cwd(), encoding: 'utf8' }).trim(); + +const templatePath = path.join(checkoutRoot, '.github', 'PULL_REQUEST_TEMPLATE.md'); +if (!fs.existsSync(templatePath)) { + console.error('No .github/PULL_REQUEST_TEMPLATE.md in this repo; nothing to fill. Write the PR body by hand.'); + process.exit(1); +} + +const issue = resolveIssue(process.argv[2]); +const template = fs.readFileSync(templatePath, 'utf8'); +// populatePrBodyTemplate is a no-op fill when issue is null — emits the raw template. +process.stdout.write(populatePrBodyTemplate(template, { issue })); + +// Resolve the issue number from an explicit arg, the task metadata, then the branch name. +function resolveIssue(arg) { + if (arg && /^\d+$/.test(arg)) return arg; + + const taskFile = path.join(checkoutRoot, '.agent', 'current-task.json'); + if (fs.existsSync(taskFile)) { + try { + const issue = JSON.parse(fs.readFileSync(taskFile, 'utf8')).issue; + if (issue) return String(issue); + } catch { /* fall through to branch parsing */ } + } + + try { + const branch = execFileSync('git', ['branch', '--show-current'], { cwd: checkoutRoot, encoding: 'utf8' }).trim(); + return parseIssueFromBranch(branch); + } catch { return null; } +} diff --git a/scripts/agent/prune.mjs b/scripts/agent/prune.mjs new file mode 100644 index 0000000..e3bc5b2 --- /dev/null +++ b/scripts/agent/prune.mjs @@ -0,0 +1,122 @@ +// scripts/agent/prune.mjs +import { execFileSync } from 'node:child_process'; +import { rmSync, existsSync } from 'node:fs'; +import { parseWorktreeList, classifyPruneCandidates, classifyPruneRetirement, primaryRootFromCommonDir } from './lib.mjs'; + +const dryRun = process.argv.includes('--dry-run'); + +const primaryPath = primaryRootFromCommonDir(git(['rev-parse', '--path-format=absolute', '--git-common-dir'])); +const currentPath = git(['rev-parse', '--show-toplevel']); +const defaultBranch = ghOrNull(['repo', 'view', '--json', 'defaultBranchRef', '--jq', '.defaultBranchRef.name']) || 'main'; + +git(['worktree', 'prune']); // safe: only drops admin records for already-deleted dirs + +const worktrees = parseWorktreeList(git(['worktree', 'list', '--porcelain'])); + +// Ancestry is useful context, but not sufficient proof of retirement: a fresh +// no-diff task branch is also reachable from the default branch after it advances. +const ancestryMerged = new Set( + git(['branch', '--merged', defaultBranch, '--format=%(refname:short)']).split(/\r?\n/).map((b) => b.trim()).filter(Boolean), +); + +// GitHub PR state is the retirement signal for merge, squash, and rebase lanes. +// Graceful degradation: if `gh` is missing/unauthenticated/rate-limited/malformed, +// `prsByBranch` stays empty and agent lanes are kept — never delete on uncertainty. +// --limit 200 covers recent lanes (gh lists newest-first); stale lanes beyond that +// are simply kept, never wrongly removed. +const prListRaw = ghOrNull(['pr', 'list', '--state', 'all', '--limit', '200', + '--json', 'number,state,baseRefName,headRefName,headRefOid,url']); +const ghUnavailable = prListRaw === null; +const prsByBranch = new Map(); +if (!ghUnavailable) { + try { + for (const pr of JSON.parse(prListRaw)) { + if (!prsByBranch.has(pr.headRefName)) prsByBranch.set(pr.headRefName, []); + prsByBranch.get(pr.headRefName).push(pr); + } + } catch { /* malformed gh output — leave map empty; ancestry signal still applies */ } +} + +const { + retiredBranches: mergedBranches, + keepReason, + retireReason, +} = classifyPruneRetirement({ worktrees, ancestryMergedBranches: ancestryMerged, prsByBranch, defaultBranch, ghUnavailable }); +const dirtyPaths = new Set(worktrees.filter((wt) => isDirty(wt.path)).map((wt) => wt.path)); + +const { remove, skipDirty, keep } = classifyPruneCandidates({ + worktrees, primaryPath, currentPath, defaultBranch, mergedBranches, dirtyPaths, +}); + +const removeReason = (wt) => retireReason.get(wt.branch) || 'github-pr'; + +if (dryRun) { + console.log('agent:prune --dry-run (no changes will be made)\n'); + for (const wt of remove) console.log(`remove [${removeReason(wt)}] ${wt.path} [${wt.branch}]`); + for (const wt of skipDirty) console.log(`keep [dirty] ${wt.path} [${wt.branch}]`); + for (const wt of keep) console.log(`keep [${keepReason.get(wt.branch) || 'unmerged'}] ${wt.path} [${wt.branch}]`); + console.log(`\nwould remove ${remove.length}, keep-dirty ${skipDirty.length}, keep ${keep.length}`); + if (ghUnavailable) console.log('note: `gh` unavailable — agent lanes kept because merged-PR evidence cannot be checked.'); + process.exit(0); +} + +let removed = 0; +const failed = []; +for (const wt of remove) { + const reason = removeReason(wt); + try { + removeWorktree(wt.path); + // classifyPruneRetirement proved local tip == the merged PR's headRefOid, so + // the ref holds nothing beyond what landed, regardless of merge strategy. + try { git(['branch', '-D', wt.branch]); } catch { /* leave a ref we couldn't delete */ } + console.log(`removed: ${wt.path} [${wt.branch}] (${reason})`); + removed += 1; + } catch (err) { + // One un-removable lane must not abort the whole sweep — report and continue. + failed.push(wt); + console.error(`FAILED: ${wt.path} [${wt.branch}]: ${err.message}`); + } +} +for (const wt of skipDirty) console.log(`skipped (dirty, kept): ${wt.path} [${wt.branch}]`); +console.log(`\nremoved ${removed}, skipped-dirty ${skipDirty.length}, kept ${keep.length}, failed ${failed.length}`); +if (ghUnavailable) console.log('note: `gh` unavailable — agent lanes kept because merged-PR evidence cannot be checked.'); +if (failed.length) process.exitCode = 1; // surface partial failure, but only after the full sweep + +function git(a) { return execFileSync('git', a, { cwd: process.cwd(), encoding: 'utf8' }).trim(); } +function ghOrNull(a) { try { return execFileSync('gh', a, { cwd: process.cwd(), encoding: 'utf8' }).trim(); } catch { return null; } } +function isDirty(worktreePath) { + try { return execFileSync('git', ['status', '--porcelain'], { cwd: worktreePath, encoding: 'utf8' }).trim().length > 0; } + catch { return true; } // if we can't tell, treat as dirty and skip — never delete on uncertainty +} + +// `git worktree remove` (no --force) is the primary path and keeps the dirty-tree +// refusal as a safety net. But on Windows it can unregister the worktree and delete +// the `.git` link, then abort the recursive rmdir on ignored residue (node_modules/dist) +// with "Directory not empty". When that happens we finish the deletion ourselves — +// gated on a cleanliness check sampled BEFORE the destructive call, because a partial +// removal deletes `.git` and makes a post-failure dirtiness check unreliable. +function removeWorktree(worktreePath) { + const wasClean = !isDirty(worktreePath); // sample now; classification may be stale + try { + git(['worktree', 'remove', worktreePath]); + return; + } catch (removeErr) { + if (!wasClean) throw removeErr; // dirty since the scan — never force-delete real work + if (isLocked(worktreePath)) throw removeErr; // explicit human lock — respect it, never override + // maxRetries/retryDelay ride out transient Windows EBUSY/ENOTEMPTY locks — Node fs.rmSync docs. + if (existsSync(worktreePath)) rmSync(worktreePath, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + git(['worktree', 'prune']); // reconcile the admin record for the dir we hand-deleted + } +} +function isLocked(worktreePath) { + // A locked worktree is an explicit "do not touch" — the fallback must never override it. + // `git worktree list --porcelain` prints a bare `locked` line inside that worktree's stanza. + const target = normalizePath(worktreePath); + let inTarget = false; + for (const line of git(['worktree', 'list', '--porcelain']).split('\n')) { + if (line.startsWith('worktree ')) inTarget = normalizePath(line.slice('worktree '.length).trim()) === target; + else if (inTarget && (line === 'locked' || line.startsWith('locked '))) return true; + } + return false; +} +function normalizePath(p) { return p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(); } diff --git a/scripts/agent/start-task.mjs b/scripts/agent/start-task.mjs new file mode 100644 index 0000000..51465d1 --- /dev/null +++ b/scripts/agent/start-task.mjs @@ -0,0 +1,79 @@ +// scripts/agent/start-task.mjs +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { sanitizeSlug, buildBranchName, parseGitStatusPorcelain, assertCheckoutIsSafe } from './lib.mjs'; + +const DEFAULT_AGENT = 'codex'; +const [, , issueArg, ...rest] = process.argv; +const args = parseArgs(rest); + +if (!issueArg || !/^\d+$/.test(issueArg)) { + fail('Usage: npm run agent:start-task -- <issue-number> [--agent <name>] [--slug <slug>]'); +} + +// Bootstrap the checkout root directly (do not route through git() — it depends on this value). +const checkoutRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: process.cwd(), encoding: 'utf8' }).trim(); +const repoName = path.basename(checkoutRoot); +const agent = args.agent || DEFAULT_AGENT; + +const issue = JSON.parse(gh(['issue', 'view', issueArg, '--json', 'number,title,url,state'])); +if (issue.state !== 'OPEN') fail(`Issue #${issueArg} is not open (state: ${issue.state}).`); + +const defaultBranch = gh(['repo', 'view', '--json', 'defaultBranchRef', '--jq', '.defaultBranchRef.name']); + +try { + assertCheckoutIsSafe({ + statusEntries: parseGitStatusPorcelain(git(['status', '--porcelain=1', '-z'], { trim: false })), + currentBranch: git(['branch', '--show-current']), + defaultBranch, + }); +} catch (error) { fail(error.message); } + +git(['fetch', 'origin', defaultBranch]); + +const slug = sanitizeSlug(args.slug || issue.title) || fail('Could not derive a slug; pass --slug <value>.'); +const branchName = buildBranchName(agent, issueArg, slug); +const worktreePath = path.join(path.dirname(checkoutRoot), `${repoName}-${issueArg}-${slug}`); + +if (existingIssueBranches(issueArg).length) fail(`Issue #${issueArg} already has a branch.`); +if (branchExists(branchName)) fail(`Branch already exists: ${branchName}`); +if (fs.existsSync(worktreePath)) fail(`Worktree path already exists: ${worktreePath}`); + +git(['worktree', 'add', '-b', branchName, worktreePath, `origin/${defaultBranch}`]); + +// Initial task metadata (#27 AC). Runtime file, gitignored. Written into the NEW worktree. +const metadata = { + issue: issue.number, title: issue.title, url: issue.url, + branch: branchName, agent, defaultBranch, createdAt: new Date().toISOString(), +}; +fs.mkdirSync(path.join(worktreePath, '.agent'), { recursive: true }); +fs.writeFileSync(path.join(worktreePath, '.agent', 'current-task.json'), JSON.stringify(metadata, null, 2) + '\n'); + +console.log(`Ready to implement #${issue.number}: ${issue.title}`); +console.log(`Branch: ${branchName}`); +console.log(`Worktree: ${worktreePath}`); +console.log('\nNext steps:'); +console.log(` 1. cd "${worktreePath}"`); +console.log(' 2. npm run agent:status'); +console.log(` 3. npm run agent:pr-body -- ${issue.number} # filled PR body to stdout; pipe into gh pr create/edit --body-file -`); +console.log(' 4. open a draft PR from that body (jma-git-pr-lifecycle)'); + +// ---- I/O helpers ---- +function git(a, o = {}) { const out = execFileSync('git', a, { cwd: checkoutRoot, encoding: 'utf8' }); return o.trim === false ? out : out.trim(); } +function gh(a) { + try { return execFileSync('gh', a, { cwd: checkoutRoot, encoding: 'utf8' }).trim(); } + catch { return fail('`gh` is required for start-task (issue lookup + default branch). Install/authenticate gh, or create the worktree manually per AGENTS.md.'); } +} +function branchExists(b) { try { git(['show-ref', '--verify', '--quiet', `refs/heads/${b}`]); return true; } catch { return false; } } +function existingIssueBranches(issueNumber) { + return git(['for-each-ref', '--format=%(refname:short)', 'refs/heads/agent']) + .split(/\r?\n/).map((l) => l.trim()).filter(Boolean) + .filter((l) => new RegExp(`^agent/[^/]+/${issueNumber}-`).test(l)); +} +function parseArgs(argv) { + const out = {}; + for (let i = 0; i < argv.length; i += 1) if (argv[i].startsWith('--')) { out[argv[i].slice(2)] = argv[i + 1]; i += 1; } + return out; +} +function fail(m) { console.error(m); process.exit(1); } diff --git a/scripts/agent/status.mjs b/scripts/agent/status.mjs new file mode 100644 index 0000000..1a59f26 --- /dev/null +++ b/scripts/agent/status.mjs @@ -0,0 +1,49 @@ +// scripts/agent/status.mjs +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { parseIssueFromBranch, parseGitStatusPorcelain, detectClaimsInstalled, inferNextAction, formatStatusReport, checkStartupReadiness, formatStartupMap } from './lib.mjs'; + +const checkoutRoot = git(['rev-parse', '--show-toplevel']); + +const branch = git(['branch', '--show-current']) || '(detached)'; +const defaultBranch = ghOrNull(['repo', 'view', '--json', 'defaultBranchRef', '--jq', '.defaultBranchRef.name']) || 'main'; +const upstream = gitOrNull(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']); +const statusEntries = parseGitStatusPorcelain(git(['status', '--porcelain=1', '-z'], { trim: false })); +const ahead = upstream ? Number(gitOrNull(['rev-list', '--count', `${upstream}..HEAD`]) || 0) : 0; +const prRaw = ghOrNull(['pr', 'view', '--json', 'number,url,state']); +const pr = prRaw ? JSON.parse(prRaw) : null; +// Claims live under .agent/coordination/claims/ per the coordination contract +// (.agent/coordination/README.md), not at a top-level .agent/claims.json. +// Resolve them against the current worktree, not the primary checkout's root +// (--git-common-dir): claims are per-worktree state, and doc-sweep's loader +// reads them from the worktree being inspected the same way. +const claimsInstalled = detectClaimsInstalled({ claimsFileExists: fs.existsSync(path.join(checkoutRoot, '.agent', 'coordination', 'claims')) }); + +console.log(formatStatusReport({ + branch, defaultBranch, upstream, pr, issue: parseIssueFromBranch(branch), + dirty: statusEntries.length > 0, dirtyCount: statusEntries.length, + worktreePath: checkoutRoot, claimsInstalled, + nextAction: inferNextAction({ onDefaultBranch: branch === defaultBranch, dirty: statusEntries.length > 0, hasPr: Boolean(pr), ahead }), +})); +const startupBaseline = readStartupBaseline(checkoutRoot); +if (startupBaseline) { + const readiness = checkStartupReadiness(startupBaseline, { + exists: (relPath) => fs.existsSync(path.join(checkoutRoot, relPath)), + }); + console.log('\n' + formatStartupMap(startupBaseline, { + repoPath: checkoutRoot, + archonSetupCommand: 'node <path-to-archon-setup>/bin/onboard.mjs', + readiness, + })); +} +if (!ghAvailable()) console.log('\n(note: `gh` unavailable — PR/default-branch info degraded)'); + +function git(a, o = {}) { const out = execFileSync('git', a, { cwd: process.cwd(), encoding: 'utf8' }); return o.trim === false ? out : out.trim(); } +function gitOrNull(a) { try { return git(a); } catch { return null; } } +function ghOrNull(a) { try { return execFileSync('gh', a, { cwd: process.cwd(), encoding: 'utf8' }).trim(); } catch { return null; } } +function ghAvailable() { try { execFileSync('gh', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; } } +function readStartupBaseline(root) { + try { return JSON.parse(fs.readFileSync(path.join(root, '.agent', 'startup-baseline.json'), 'utf8')); } + catch { return null; } +} diff --git a/scripts/doc-sweep/git.mjs b/scripts/doc-sweep/git.mjs new file mode 100644 index 0000000..96836ff --- /dev/null +++ b/scripts/doc-sweep/git.mjs @@ -0,0 +1,274 @@ +// git.mjs — git I/O layer for doc-sweep (Tasks 1.4–1.5) +// Spec: docs/agent-process/doc-sweep.md §4.2 (enumeration D6/D9), §4.4/§4.5 (commit safety) +// All git output parsed NUL-safe via -z flags; buffer encoding prevents mojibake on paths. + +import { execFileSync } from 'node:child_process'; +import { lstatSync, statSync, openSync, closeSync, unlinkSync, mkdirSync, readFileSync, writeSync } from 'node:fs'; +import { hostname } from 'node:os'; +import { join } from 'node:path'; + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +/** + * Run git with -C <wt> and return raw Buffer output. + * Using { encoding: 'buffer' } is NUL-safe (spec D6): we split on \0, not newlines, + * so paths with spaces, Unicode, or newlines are preserved correctly. + */ +function git(wt, args, extraEnv) { + return execFileSync('git', ['-C', wt, ...args], { + encoding: 'buffer', + ...(extraEnv ? { env: { ...process.env, ...extraEnv } } : {}), + }); +} + +/** + * Split a NUL-delimited Buffer into non-empty UTF-8 strings. + * Spec §4.2 D6: always parse -z output this way; never split on newlines. + */ +function splitNul(buf) { + return buf.toString('utf8').split('\0').filter(Boolean); +} + +/** + * Returns true if the repo-relative path inside wt is a symlink or junction. + * Spec §4.2: skip symlinks/junctions — lstat only, never follow (M3). + */ +function isSymlink(wt, rel) { + try { + return lstatSync(join(wt, rel)).isSymbolicLink(); + } catch { + // If lstat fails (path gone between enumeration and check), treat as non-symlink; + // the TOCTOU re-stat in commitFileGuarded will catch any disappearing file later. + return false; + } +} + +// ─── §4.2 Enumeration ───────────────────────────────────────────────────────── + +/** + * enumerateCandidates(wt) → string[] + * + * Returns the union of two DISJOINT NUL-delimited git views (spec §4.2 D9): + * (a) untracked files: git ls-files --others --exclude-standard -z + * (b) staged-but-uncommitted ADDs: git diff --cached --name-only --diff-filter=A -z + * + * The two views are disjoint: a staged file does NOT appear in --others; both are required + * to capture the author's "git add = save my work" signal (adversarial C1). + * + * Per-entry filters applied before returning: + * - Skip entries ending in '/' (collapsed untracked directory / nested repo — spec H4). + * - Skip symlinks/junctions (lstat; never follow — spec M3). + * Duplicates are removed via Set (possible if the same path somehow appears in both views). + */ +export function enumerateCandidates(wt) { + const untracked = splitNul(git(wt, ['ls-files', '--others', '--exclude-standard', '-z'])); + const stagedAdds = splitNul(git(wt, ['diff', '--cached', '--name-only', '--diff-filter=A', '-z'])); + + // Union: Set dedup preserves order of first appearance + const union = new Set([...untracked, ...stagedAdds]); + + return [...union].filter((p) => { + if (p.endsWith('/')) return false; // collapsed dir / nested repo — spec §4.2 D9 + if (isSymlink(wt, p)) return false; // symlink — spec §4.2 M3 + return true; + }); +} + +/** + * enumerateIgnored(wt, roots) → string[] + * + * Returns ignored-but-present files under the allow-listed roots (spec §4.2 C2). + * These are SURFACE-ONLY — the orchestrator must never auto-commit them. + * Committing a gitignored file is a policy violation; the sweep only surfaces them. + * + * Default roots match the spec §4.2 allow-listed paths that are commonly gitignored + * (especially .html-artifacts/). + */ +export function enumerateIgnored(wt, roots = ['docs', '.changelog', '.html-artifacts']) { + const raw = splitNul(git(wt, ['ls-files', '--others', '--ignored', '--exclude-standard', '-z', '--', ...roots])); + // Filter out collapsed dir/ entries (same rule as candidates) + return raw.filter((p) => !p.endsWith('/')); +} + +// ─── §4.5 TOCTOU capture ───────────────────────────────────────────────────── + +/** + * statSig(wt, rel) → { mtimeMs: number, size: number } + * + * Captures mtime + size for TOCTOU re-stat (spec §4.5 C4). + * Call at classify time; re-stat immediately before git add; abort if changed. + * Uses statSync (follows symlinks) — but symlinks are filtered before this is called. + */ +export function statSig(wt, rel) { + const s = statSync(join(wt, rel)); + return { mtimeMs: s.mtimeMs, size: s.size }; +} + +// ─── §4.4 / §4.5 Commit safety ─────────────────────────────────────────────── + +/** + * commitFileGuarded(wt, rel, captured, message, scan) → { committed: boolean, reason?: string } + * + * Commits a single file only when ALL safety checks pass (spec §4.4/§4.5): + * + * 1. TOCTOU re-stat (C4): re-stat now; if mtimeMs or size differ from `captured` + * → return { committed: false, reason: 'toctou' }. Do NOT proceed. + * + * 2. Secret gate (M2): call scan(absPath). `scan` is dependency-injected so the + * orchestrator can wire a real gitleaks runner; tests inject a stub. + * scan returns true = clean, false = hit. + * → scan returns false: return { committed: false, reason: 'secret-scan' }. + * + * 3. Commit: git add -- <rel> (exactly one path, NEVER -A or '.') then git commit -m <message>. + * Hooks and signing are respected — no --no-verify (spec §4.5 L1). + * + * @param {string} wt Absolute path to the working tree root + * @param {string} rel Repo-relative POSIX path of the file to commit + * @param {{ mtimeMs: number, size: number }} captured Stat captured at classify time + * @param {string} message Commit message (must already contain the issue ref per the hook) + * @param {(absPath: string) => boolean} scan Secret scanner; true = clean + * @param {object} [opts] + * @param {boolean} [opts.allowMainCommit=false] Set ALLOW_MAIN_COMMIT=1 in the commit env — + * the repo's own AUDITED owner-maintenance override. Hooks still run (it is not --no-verify); + * the override is logged by the hook to .agent/bypass.log. Used only on the owner-gated + * primary-default lane for sweepable docs outside the repo's narrow owner-maintenance-safe set. + * + * On hook rejection (or any git error) the staged path is reset and { committed:false, + * reason:'hook-rejected' } is returned — never --no-verify (spec §4.5 L1). + */ +export function commitFileGuarded(wt, rel, captured, message, scan, { allowMainCommit = false } = {}) { + // Step 1 — TOCTOU re-stat (spec §4.5 C4) + const now = statSig(wt, rel); + if (now.mtimeMs !== captured.mtimeMs || now.size !== captured.size) { + return { committed: false, reason: 'toctou' }; + } + + // Step 2 — Deterministic secret gate (spec §4.4, M2) + const absPath = join(wt, rel); + if (!scan(absPath)) { + return { committed: false, reason: 'secret-scan' }; + } + + // Step 3 — Selective, file-by-file staging then commit (spec §4.5 H4 / L1). + // --only commits exactly the given path regardless of what else is in the index, + // preventing a clobber of another agent's pre-staged work (C1 fix). + const env = allowMainCommit ? { ALLOW_MAIN_COMMIT: '1' } : undefined; + try { + git(wt, ['add', '--', rel]); // exactly one path, never -A or '.' + git(wt, ['commit', '-m', message, '--only', '--', rel], env); // path-scoped; hooks/signing run + } catch { + // Hook rejection or git failure → restore a clean index, then leave + log (spec §4.5 L1). + try { git(wt, ['reset', '-q', '--', rel]); } catch { /* best-effort unstage */ } + return { committed: false, reason: 'hook-rejected' }; + } + + return { committed: true }; +} + +// ─── §4.5 Sweep lock (H5) ──────────────────────────────────────────────────── + +/** + * Lock file path — per-repo, under .agent/coordination/ so it sits beside claim files. + * Source: spec §4.5 H5 "acquire a per-repo lock … held across classify+commit". + */ +function lockPath(wt) { + return join(wt, '.agent', 'coordination', 'doc-sweep.lock'); +} + +/** + * Sweep-lock staleness TTL. A held lock older than this is presumed abandoned (the + * holding sweep crashed/was killed) and may be reclaimed — without this, a single + * crashed sweep would strand the O_EXCL lock forever and block all future sweeps. + * Source: owner decision 2026-06-04 — a sweep completes in seconds; 15 min is far + * longer than any legitimate run, so an older lock cannot belong to a live sweep. + */ +export const LOCK_TTL_MS = 15 * 60 * 1000; // 900 000 ms + +/** + * Read the acquire timestamp recorded in a lock file. + * Prefers the JSON `ts` field; falls back to the lock file's own mtime when the + * payload is missing/corrupt (a lock with no readable ts must still be able to expire). + * Returns null only if the file cannot be stat'd at all. + */ +function readLockTimestamp(path) { + try { + const ts = JSON.parse(readFileSync(path, 'utf8'))?.ts; + if (typeof ts === 'number' && Number.isFinite(ts)) return ts; + } catch { + // unparseable / missing payload — fall through to the mtime fallback + } + try { + return statSync(path).mtimeMs; + } catch { + return null; + } +} + +/** Create the lock file with O_EXCL and write the forensic payload. Throws EEXIST if held. */ +function createLockFile(path, now, pid) { + const fd = openSync(path, 'wx'); // O_WRONLY | O_CREAT | O_EXCL — atomic exclusive create + try { + writeSync(fd, JSON.stringify({ ts: now, pid, host: hostname() }) + '\n'); + } finally { + closeSync(fd); + } +} + +/** + * acquireLock(wt, opts?) → { acquired: boolean, handle?: { path, reclaimed? } } + * + * Acquires an exclusive per-repo sweep lock (spec §4.5 H5). + * + * The lock records { ts, pid, host }. A second concurrent call fails on O_EXCL and + * returns { acquired: false } — UNLESS the existing lock is older than ttlMs, in which + * case its holder is presumed dead and the lock is reclaimed (handle.reclaimed = true). + * This fixes the original no-expiry lock, where a crashed sweep stranded it forever. + * + * @param {string} wt Working-tree root. + * @param {object} [opts] + * @param {number} [opts.now=Date.now()] Current time (ms); injectable for tests. + * @param {number} [opts.ttlMs=LOCK_TTL_MS] Staleness threshold. + * @param {number} [opts.pid=process.pid] Recorded for forensics. + */ +export function acquireLock(wt, { now = Date.now(), ttlMs = LOCK_TTL_MS, pid = process.pid } = {}) { + const path = lockPath(wt); + // Ensure the coordination directory exists (may not exist in fresh repos) + mkdirSync(join(wt, '.agent', 'coordination'), { recursive: true }); + + try { + createLockFile(path, now, pid); + return { acquired: true, handle: { path } }; + } catch (err) { + if (err.code !== 'EEXIST') throw err; // unexpected error — re-throw + } + + // Lock already exists — reclaim only if the holder is stale (older than ttlMs). + const heldTs = readLockTimestamp(path); + if (heldTs != null && now - heldTs < ttlMs) { + return { acquired: false }; // a live (recent) holder — skip, do NOT queue + } + + // Stale (or unreadable + old) → reclaim atomically: remove, then re-create with O_EXCL. + try { + unlinkSync(path); + } catch { + // Another process may have just removed/reclaimed it — fall through to the create attempt. + } + try { + createLockFile(path, now, pid); + return { acquired: true, handle: { path, reclaimed: true } }; + } catch (err) { + if (err.code === 'EEXIST') return { acquired: false }; // lost the reclaim race — skip + throw err; + } +} + +/** + * releaseLock(handle) + * + * Removes the lock file acquired by acquireLock. + * Call exactly once per successful acquire; double-release throws (file already gone). + */ +export function releaseLock(handle) { + unlinkSync(handle.path); +} diff --git a/scripts/doc-sweep/lib.mjs b/scripts/doc-sweep/lib.mjs new file mode 100644 index 0000000..e01cdcd --- /dev/null +++ b/scripts/doc-sweep/lib.mjs @@ -0,0 +1,213 @@ +// lib.mjs — pure classification core for doc-sweep +// Spec: docs/agent-process/doc-sweep.md §4.1 (allow-list/exclude) and §4.3 (classifier + claims) +// No I/O, no git — just pure functions for use by the orchestrator. + +// ─── §4.1 Allow-list / hard-exclude ────────────────────────────────────────── +// Match case-insensitively (NTFS — spec §4.1 D10). Exclude wins ties. + +/** Allow-list roots — docs/**, .changelog/**, .html-artifacts/**, image assets */ +const ALLOW = [ + /^docs\//i, + /^\.changelog\//i, + /^\.html-artifacts\//i, + /\.(png|jpe?g|gif|webp|svg)$/i, +]; + +/** + * Carve-outs from the docs/** allow-list: + * docs/process/** and docs/architecture/** require review — spec §4.1. + */ +const ALLOW_EXCEPT = [/^docs\/process\//i, /^docs\/architecture\//i]; + +/** + * Hard-excludes — spec §4.1: code/CI/hooks, agent config, governance files, manifests. + * A path matching any of these is NEVER swept. + * + * I4: also exclude source/config files that can appear under docs/ in a Docusaurus site + * (e.g. docs/docusaurus.config.ts, docs/src/**, docs/static/**). Only .md and .mdx + * remain sweepable inside docs/. + */ +const EXCLUDE = [ + /^src\//i, + /^scripts\//i, + /^\.github\//i, + /^\.githooks\//i, + /^\.claude\//i, + /^\.codex\//i, + /^\.gemini\//i, + /^\.agent\/schema\//i, + /^(README|AGENTS|CLAUDE|GEMINI)\.md$/i, + // package*.json at any depth — spec §4.1 + /(^|\/)package[^/]*\.json$/i, + // I4: code/config extensions never swept regardless of directory (Docusaurus site in docs/) + // Source: spec §4.1 I4 — .json supersedes the package*.json rule but keep both for clarity. + /\.(js|jsx|ts|tsx|mjs|cjs|mts|cts|json)$/i, + // I4: Docusaurus source tree and static assets inside docs/ — never sweepable + /^docs\/src\//i, + /^docs\/static\//i, +]; + +/** + * Normalize a path from git output to repo-relative POSIX: + * backslashes → forward slashes, strip leading "./". + * Spec §4.1 D10: normalize before matching. + */ +export const norm = (p) => String(p).replace(/\\/g, '/').replace(/^\.\//, ''); + +/** + * Returns true iff relPath is a sweepable doc candidate per §4.1. + * Matching is case-insensitive (NTFS); exclude wins any tie (D10 H2). + */ +export function isSweepable(relPath) { + const p = norm(relPath); + // Hard-exclude wins over everything — spec "exclude wins" rule. + if (EXCLUDE.some((r) => r.test(p))) return false; + // Carve-outs within the docs/** allow-list also exclude. + if (ALLOW_EXCEPT.some((r) => r.test(p))) return false; + return ALLOW.some((r) => r.test(p)); +} + +// ─── §4.3 Staleness constant ────────────────────────────────────────────────── + +/** + * 12-hour staleness threshold for the primary-default-branch lane. + * Source: owner decision 2026-06-02 (spec §4.3). + * Rationale: long enough that a live-but-slow session never trips it; + * short enough to recover within a workday. Local-clock-relative. + */ +export const STALE_MS = 12 * 60 * 60 * 1000; // 43 200 000 ms + +// ─── §4.3 Checkout-state-aware classifier (D8/D10) ─────────────────────────── + +/** + * classify({ lane, mtimeMs, now, claimStatus }) → { verdict, reason } + * + * verdict ∈ 'eligible' | 'leave-log' | 'skip' + * + * lane: + * 'primary-default' — primary checkout on default branch (F19 ⇒ no concurrent agent expected) + * 'worktree' — a linked worktree (feature branch); positive death signal required (D8) + * 'detached' — detached HEAD; never eligible (D10 C3) + * + * claimStatus: 'active' | 'expired' | 'absent' + * Ignored for primary-default and detached lanes; mtime is the gate on primary-default. + * On worktrees, mtime is NEVER the eligibility gate (D8 adversarial hardening). + */ +export function classify({ lane, mtimeMs, now, claimStatus = 'absent' }) { + if (lane === 'detached') { + // Detached HEAD → never eligible (spec §4.3 D10, adversarial C3). + return { verdict: 'leave-log', reason: 'detached-head' }; + } + + if (lane === 'worktree') { + // Worktrees require a POSITIVE death signal (D8). + // mtime alone is necessary-not-sufficient; it never makes a worktree doc eligible. + if (claimStatus === 'active') return { verdict: 'skip', reason: 'active-claim' }; + if (claimStatus === 'expired') return { verdict: 'eligible', reason: 'expired-claim' }; + // absent / board-off → cannot prove dead → leave + log + surface + return { verdict: 'leave-log', reason: 'no-positive-death-signal' }; + } + + // primary-default: 12h freshness gate only (F19 ⇒ no concurrent agent expected) + if (now - mtimeMs < STALE_MS) return { verdict: 'leave-log', reason: 'fresh<12h' }; + return { verdict: 'eligible', reason: 'stale-on-default' }; +} + +// ─── §4.3 Claim coverage + status (D3b, fail-safe H3) ──────────────────────── + +/** + * coveringClaimStatus(claims, { repo, worktree, relPath, now }) + * → 'active' | 'expired' | 'absent' + * + * A claim covers a doc iff ALL of: + * - claim.repo === repo + * - claim.worktree OR claim.branch === worktree (either field accepted) + * - claim.paths glob covers relPath + * - for 'active': status === 'active' AND not past expiresAt + * + * Fail-safe (H3): if path-glob parsing throws for a claim that otherwise matches + * repo+worktree+active, treat it as covering+active to block (err toward over-blocking). + * + * active (unexpired) dominates; expired/expiresAt-past yields 'expired'; none → 'absent'. + */ +export function coveringClaimStatus(claims, { repo, worktree, relPath, now }) { + let best = 'absent'; + + for (const c of claims ?? []) { + // Filter: repo must match + if (c.repo !== repo) continue; + // Filter: branch or worktree field must match (spec D3b: "claim branch/worktree") + if (c.worktree !== worktree && c.branch !== worktree) continue; + + // Path-glob coverage check — with fail-safe (H3) + let covers; + try { + covers = pathCovers(c.paths ?? [], relPath); + } catch { + // Path-glob parse failure for a repo+worktree-matching active claim → fail safe: block. + covers = c.status === 'active'; + } + if (!covers) continue; + + // Determine whether the claim is expired + const pastExpiry = c.expiresAt && Date.parse(c.expiresAt) <= now; + const isActive = c.status === 'active' && !pastExpiry; + + if (isActive) return 'active'; // active dominates; short-circuit + best = 'expired'; + } + + return best; +} + +/** + * Returns true if any entry in `paths` covers `relPath`. + * Entries ending in '/' are treated as directory prefixes. + * Entries containing glob metacharacters ('[', '*', '?', '{') are parsed as glob patterns + * using a minimal glob-to-regex converter; invalid patterns cause a throw (H3 fail-safe). + * Plain entries use exact-match or prefix-match. + */ +function pathCovers(paths, relPath) { + return paths.some((pre) => { + if (/[*?{[]/.test(pre)) { + // Glob-like entry: convert to regex. Throws on invalid bracket expressions (H3). + const re = globToRegex(pre); + return re.test(relPath); + } + const prefix = pre.endsWith('/') ? pre : pre + '/'; + return relPath === pre || relPath.startsWith(prefix); + }); +} + +/** + * Minimal glob-to-regex converter. + * Supports: '*' (any chars except '/'), '**' (any chars incl '/'), '?' (one char), '[…]' bracket. + * Throws SyntaxError on an unclosed bracket expression — callers catch for H3 fail-safe. + */ +function globToRegex(glob) { + let re = '^'; + for (let i = 0; i < glob.length; i++) { + const c = glob[i]; + if (c === '*') { + if (glob[i + 1] === '*') { re += '.*'; i++; } else { re += '[^/]*'; } + } else if (c === '?') { + re += '[^/]'; + } else if (c === '[') { + const close = glob.indexOf(']', i + 1); + if (close === -1) throw new SyntaxError(`Unclosed bracket in glob: ${glob}`); + re += glob.slice(i, close + 1); + i = close; + } else if (c === '{' || c === '}') { + // Brace expansion ({a,b}) is unsupported. Throw rather than escape-to-literal: + // escaping would silently match only the literal "{a,b}" (fail OPEN — a claim meant + // to cover docs/a/** + docs/b/** would block nothing). Throwing lets the + // coveringClaimStatus fail-safe (H3) over-block an active claim instead. + // Source: spec §4.3 fail-safe + "brace-glob fail-closed" hardening follow-up. + throw new SyntaxError(`Unsupported brace glob (fail-closed): ${glob}`); + } else { + re += c.replace(/[.+^${}()|\\]/g, '\\$&'); + } + } + re += '$'; + return new RegExp(re); +} diff --git a/scripts/doc-sweep/sweep.mjs b/scripts/doc-sweep/sweep.mjs new file mode 100644 index 0000000..3794355 --- /dev/null +++ b/scripts/doc-sweep/sweep.mjs @@ -0,0 +1,481 @@ +// sweep.mjs — orchestrator for doc-sweep (Task 1.6) +// Spec: docs/agent-process/doc-sweep.md §4.2–§4.5, §7 acceptance fixture +// Wires lib.mjs + git.mjs into a single sweepRepo() function + CLI entry point. + +import { execFileSync } from 'node:child_process'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join, basename, dirname } from 'node:path'; + +import { isSweepable, classify, coveringClaimStatus, norm } from './lib.mjs'; +import { + enumerateCandidates, + enumerateIgnored, + statSig, + commitFileGuarded, + acquireLock, + releaseLock, +} from './git.mjs'; + +// ─── Commit message (lane-aware — spec §4.5 + commit-msg hook exemption) ─────── +// Source: spec §4.5 convention ("docs(sweep): recover orphaned docs from prior session") +// reconciled with archon's .githooks/commit-msg, which requires an issue ref UNLESS the +// subject is docs(owner):/chore(owner): (owner-maintenance lane) on owner-maintenance-safe +// paths. The hardcoded (#92) is replaced by per-lane selection so the runner is repo-agnostic. + +/** Normalize a caller-supplied or branch-derived issue ref to "#N", or null if none. */ +function deriveIssueRef(issueRef, branch) { + if (issueRef != null && String(issueRef).trim() !== '') { + return `#${String(issueRef).replace(/^#/, '').trim()}`; + } + // Agent branch convention: agent/<tool>/<issue>-<slug> → take the issue number. + const m = /^agent\/[^/]+\/(\d+)-/.exec(branch ?? ''); + return m ? `#${m[1]}` : null; +} + +/** + * buildSweepMessage({ lane, issueRef, branch }) → string + * + * Lane-aware sweep commit subject: + * - primary-default with NO issue ref → docs(owner): … (exempt from the issue-ref rule; + * no fabricated issue number — works for owner-maintenance-safe paths). + * - otherwise → docs(sweep): … (#N), carrying the caller's issueRef or one derived from the + * agent branch name. On the primary-default lane this pairs with the ALLOW_MAIN_COMMIT + * override to recover sweepable docs outside the narrow owner-maintenance-safe set. + */ +export function buildSweepMessage({ lane, issueRef, branch } = {}) { + const ref = deriveIssueRef(issueRef, branch); + if (lane === 'primary-default' && !ref) { + return 'docs(owner): recover orphaned docs from prior session'; + } + return `docs(sweep): recover orphaned docs from prior session${ref ? ` (${ref})` : ''}`; +} + +// ─── Default secret scanner ─────────────────────────────────────────────────── +/** + * defaultScan(absPath) → boolean + * + * Shells out to gitleaks for a deterministic secret gate (spec §4.4 M2). + * exit 0 = clean → true. Any hit → false. + * If gitleaks is not installed (ENOENT) → false (fail closed: no scanner ⇒ no auto-commit). + * Tests always inject a stub so this path is never hit in test runs. + */ +function defaultScan(absPath) { + try { + execFileSync('gitleaks', ['detect', '--no-git', '--source', absPath], { + stdio: 'ignore', + }); + return true; // exit 0 = clean + } catch (err) { + if (err.code === 'ENOENT') { + // gitleaks not installed — fail closed (spec §4.4: no scanner ⇒ no auto-commit) + process.stderr.write('[doc-sweep] gitleaks not found; failing closed (no auto-commit)\n'); + return false; + } + // Non-zero exit = scan found a hit + return false; + } +} + +// ─── Git helpers (used only in the orchestrator) ─────────────────────────────── + +/** Run git synchronously with buffer output; never throws unless stdio fails. */ +function gitBuf(wt, args) { + return execFileSync('git', ['-C', wt, ...args], { encoding: 'buffer' }); +} + +/** + * gitStr(wt, args) → string (trimmed) + * Returns empty string on non-zero exit (for commands like symbolic-ref -q HEAD). + * stderr is suppressed so git diagnostic noise (e.g. "fatal: origin/HEAD is not a + * symbolic ref") never leaks to the terminal — errors are caught, not printed (M1 fix). + */ +function gitStr(wt, args) { + try { + return execFileSync('git', ['-C', wt, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return ''; + } +} + +// ─── Checkout-state / lane detection (spec §4.3, D10, adversarial C3) ───────── + +/** + * detectLane(wt, defaultBranch) → 'primary-default' | 'worktree' | 'detached' + * + * Step 1: symbolic-ref -q HEAD — exits non-zero for detached HEAD (C3). + * - non-zero → 'detached' + * - zero: get short branch name + * Step 2: compare --git-dir and --git-common-dir to determine primary vs linked worktree. + * - equal ⇒ primary checkout; differ ⇒ linked worktree + * Step 3: lane assignment: + * - primary && branch === defaultBranch → 'primary-default' + * - primary && branch !== defaultBranch (F19 guard broken) → 'worktree' + * - linked worktree (non-primary) → 'worktree' + * - detached HEAD → 'detached' + */ +function detectLane(wt, defaultBranch) { + // Detached HEAD detection — symbolic-ref -q HEAD exits non-zero when detached (D10 C3). + // Do NOT use abbrev-ref (returns literal "HEAD" when detached — adversarial C3). + const symRef = gitStr(wt, ['symbolic-ref', '-q', 'HEAD']); + if (!symRef) return 'detached'; // non-zero exit → detached HEAD + + // Short branch name (strip refs/heads/ prefix) + const branch = gitStr(wt, ['symbolic-ref', '--short', 'HEAD']); + + // Primary vs linked worktree: --git-dir vs --git-common-dir + // Equal paths ⇒ primary checkout; differ ⇒ linked worktree (spec §4.3 D10). + const gitDir = gitStr(wt, ['rev-parse', '--git-dir']); + const commonDir = gitStr(wt, ['rev-parse', '--git-common-dir']); + const isPrimary = (gitDir === commonDir); + + if (!isPrimary) return 'worktree'; // linked worktree regardless of branch + + // Primary checkout + if (branch === defaultBranch) return 'primary-default'; + // Primary but on non-default branch → F19 guard broken → treat as worktree (spec §4.3) + return 'worktree'; +} + +/** + * detectDefaultBranch(wt) → string + * + * Detect via `git symbolic-ref --short refs/remotes/origin/HEAD` (strip 'origin/'). + * Falls back to 'main' if no remote configured (common in temp-repo tests). + * Source: spec §4.3 "if not passed, detect via symbolic-ref --short refs/remotes/origin/HEAD". + */ +function detectDefaultBranch(wt) { + const ref = gitStr(wt, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']); + if (ref) { + // ref is like "origin/main" → strip the "origin/" prefix + return ref.replace(/^origin\//, ''); + } + return 'main'; // fallback when no remote — spec §4.3 +} + +// ─── Repo name detection (I3) ──────────────────────────────────────────────── + +/** + * detectRepoName(wt) → string + * + * Returns the canonical repository name for claim matching (spec §4.3 D3b). + * + * For a linked worktree the working-tree FOLDER name (basename(wt)) is wrong — + * it reflects the worktree checkout directory, not the repo. Two strategies: + * + * 1. Remote origin URL: `git remote get-url origin` → strip trailing `.git`, + * take the last path segment. Works for any hosted repo. + * 2. Fallback (no remote / ENOENT): `git rev-parse --path-format=absolute + * --git-common-dir` yields the shared .git dir; its parent is the primary + * checkout folder whose basename IS the repo name. + * + * Source: spec §4.3 I3 fix directive. + */ +function detectRepoName(wt) { + // Strategy 1: derive from remote origin URL + const originUrl = gitStr(wt, ['remote', 'get-url', 'origin']); + if (originUrl) { + // Strip trailing .git, then take the last path segment. + // Works for https://github.com/owner/repo.git and git@github.com:owner/repo.git + const stripped = originUrl.replace(/\.git$/, ''); + const segment = stripped.split(/[/:]/).filter(Boolean).pop(); + if (segment) return segment; + } + + // Strategy 2: common-dir parent basename (linked worktree fallback) + const commonDir = gitStr(wt, ['rev-parse', '--path-format=absolute', '--git-common-dir']); + if (commonDir) { + // commonDir is the shared .git directory; its parent is the primary checkout. + return basename(dirname(commonDir)); + } + + // Last resort: folder name (original behaviour, correct for non-worktree cases) + return basename(wt); +} + +// ─── Default loadClaims ─────────────────────────────────────────────────────── + +/** + * defaultLoadClaims(wt) → claim[] + * + * Reads .agent/coordination/claims/*.json if the directory exists, else returns []. + * Parses each file as JSON; silently skips unparseable files (fail-safe: ambiguity + * never makes a doc eligible — spec §4.3 H3). + */ +function defaultLoadClaims(wt) { + const claimsDir = join(wt, '.agent', 'coordination', 'claims'); + let entries; + try { + entries = readdirSync(claimsDir); + } catch { + return []; // directory absent → no claims + } + const claims = []; + for (const entry of entries) { + if (!entry.endsWith('.json')) continue; + try { + const raw = readFileSync(join(claimsDir, entry), 'utf8'); + claims.push(JSON.parse(raw)); + } catch { + // Unparseable claim — skip; fail-safe is handled in coveringClaimStatus + } + } + return claims; +} + +// ─── Default open-PR check (destination tiering, H1) ─────────────────────────── + +/** + * defaultHasOpenPR(wt, branch) → boolean + * + * True iff an OPEN PR tracks `branch`. Used by the worktree-lane destination tier: + * the sweep commits a recovered doc only when a PR tracks the branch, so the commit is + * pushed/visible rather than re-stranded on a local-only branch (spec §4.5 H1). + * + * gh infers the repo from the working tree's origin remote. Any failure (no open PR, + * gh not installed, not authenticated) → false: conservative, never commit to a branch + * we cannot prove a PR tracks. + */ +function defaultHasOpenPR(wt, branch) { + try { + const out = execFileSync( + 'gh', ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'number'], + { cwd: wt, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, + ); + return JSON.parse(out).length > 0; + } catch { + return false; + } +} + +// ─── sweepRepo ──────────────────────────────────────────────────────────────── + +/** + * sweepRepo(wt, opts) → { eligible, leaveLog, skip, surfaceOnly } + * + * Main orchestrator. Implements spec §4.2–§4.5 in order. + * + * @param {string} wt Absolute path to the working tree root. + * @param {object} opts + * @param {number} opts.now Current timestamp (ms); use Date.now() in CLI. + * @param {boolean} [opts.apply=false] If true, acquires lock and commits eligible items. + * @param {string} [opts.defaultBranch] Default branch name; detected if omitted. + * @param {(abs: string) => boolean} [opts.scan] Secret scanner; defaults to gitleaks. + * @param {(wt: string) => object[]} [opts.loadClaims] Claims loader; defaults to reading .agent/coordination/claims/*.json. + * @param {boolean} [opts.owner=false] Owner gate (L2): authorizes commits on the primary-default lane. + * @param {(wt: string, branch: string) => boolean} [opts.hasOpenPR] Worktree-lane gate (H1); defaults to a gh query. + * @param {boolean} [opts.allowMainCommit=false] Pass the audited ALLOW_MAIN_COMMIT override on the primary-default lane. + * + * Returns buckets: + * eligible — docs ready to commit (or that were committed when apply=true) + * leaveLog — docs that need human review / cannot be proven dead + * skip — docs with an active live claim (worktree lane) + * surfaceOnly — gitignored allow-listed docs (surface for awareness, never commit) + * + * Each item: { path: string, lane: string, reason: string } + */ +export async function sweepRepo(wt, { + now, + apply = false, + defaultBranch, + scan, + loadClaims, + owner = false, + hasOpenPR, + allowMainCommit = false, + issueRef, +} = {}) { + // Resolve defaults + const resolvedDefaultBranch = defaultBranch ?? detectDefaultBranch(wt); + const resolvedScan = scan ?? defaultScan; + const resolvedLoadClaims = loadClaims ?? defaultLoadClaims; + const resolvedHasOpenPR = hasOpenPR ?? defaultHasOpenPR; + + // Repo name for claim matching — detect from remote URL or common-dir (spec §4.3 I3). + // basename(wt) is wrong for linked worktrees whose folder name differs from the repo name. + const repo = detectRepoName(wt); + + // Buckets — returned regardless of apply mode + const eligible = []; + const leaveLog = []; + const skip = []; + const surfaceOnly = []; + + // ── Step 1: Checkout-state / lane detection (spec §4.3, D10) ────────────── + const lane = detectLane(wt, resolvedDefaultBranch); + + // Hoist currentBranch — used in the claim worktree field for every candidate (M2 fix). + // Computing this once avoids a redundant git call per candidate in the loop below. + const currentBranch = gitStr(wt, ['symbolic-ref', '--short', 'HEAD']) || 'HEAD'; + + // ── Step 2a: Enumerate ignored allow-listed docs → surfaceOnly (spec §4.2 C2) ── + // Must be done BEFORE enumerateCandidates so the gitignored set is separate. + const ignoredPaths = enumerateIgnored(wt); + for (const p of ignoredPaths) { + const rel = norm(p); + if (isSweepable(rel)) { + surfaceOnly.push({ path: rel, lane, reason: 'gitignored' }); + } + } + + // Build a set of ignored paths so we can skip them in candidate enumeration + // (though they're disjoint by git's own logic, be defensive) + const ignoredSet = new Set(ignoredPaths.map(norm)); + + // ── Step 2b: Enumerate candidates (spec §4.2 D9) ────────────────────────── + const candidates = enumerateCandidates(wt); + + // Load claims once for the entire sweep (spec §4.3 D3b) + const claims = resolvedLoadClaims(wt); + + // ── Step 3–5: Per-candidate classification ──────────────────────────────── + for (const rawPath of candidates) { + const rel = norm(rawPath); + + // Skip gitignored paths (they go to surfaceOnly above, not candidates — but be safe) + if (ignoredSet.has(rel)) continue; + + // §4.1 allow-list / hard-exclude filter — non-sweepable → drop (not bucketed) + if (!isSweepable(rel)) continue; + + // Capture mtime+size at classify time (TOCTOU re-stat later at commit — spec §4.5 C4) + let captured; + try { + captured = statSig(wt, rel); + } catch { + // File disappeared between enumeration and stat — leave+log + leaveLog.push({ path: rel, lane, reason: 'stat-error' }); + continue; + } + + const { mtimeMs } = captured; + + // claimStatus needed only for worktree lane (spec §4.3) + let claimStatus = 'absent'; + if (lane === 'worktree') { + claimStatus = coveringClaimStatus(claims, { + repo, + worktree: currentBranch, + relPath: rel, + now, + }); + } + + // Classify + const { verdict, reason } = classify({ lane, mtimeMs, now, claimStatus }); + + if (verdict === 'eligible') { + eligible.push({ path: rel, lane, reason, captured }); + } else if (verdict === 'skip') { + skip.push({ path: rel, lane, reason }); + } else { + // leave-log + leaveLog.push({ path: rel, lane, reason }); + } + } + + // ── Step 6: Apply mode — acquire lock and commit eligible items ──────────── + if (apply && eligible.length > 0) { + const lockResult = acquireLock(wt); + if (!lockResult.acquired) { + // Lock held by another sweep — skip without queuing (spec §4.5 H5). + // Strip the internal TOCTOU metadata here too so this early return has + // the same item shape as the normal path below. + for (const item of eligible) delete item.captured; + process.stderr.write('[doc-sweep] Lock held by another sweep; skipping apply.\n'); + return { eligible, leaveLog, skip, surfaceOnly }; + } + + try { + // Process eligible items in place; failed/blocked ones move to leaveLog. + // Destination is tiered by lane (spec §4.5 H1/L2): + // primary-default → owner-maintenance lane, gated by an explicit owner assertion (L2) + // worktree → commit only when an open PR tracks the branch; else re-strands (H1) + const committed = []; + for (const item of eligible) { + let result; + if (item.lane === 'primary-default') { + if (!owner) { + leaveLog.push({ path: item.path, lane: item.lane, reason: 'owner-gate' }); + continue; + } + const message = buildSweepMessage({ lane: 'primary-default', issueRef, branch: currentBranch }); + result = commitFileGuarded(wt, item.path, item.captured, message, resolvedScan, { allowMainCommit }); + } else { + if (!resolvedHasOpenPR(wt, currentBranch)) { + leaveLog.push({ path: item.path, lane: item.lane, reason: 'no-open-pr' }); + continue; + } + const message = buildSweepMessage({ lane: 'worktree', issueRef, branch: currentBranch }); + result = commitFileGuarded(wt, item.path, item.captured, message, resolvedScan); + } + if (result.committed) { + committed.push(item); + } else { + // TOCTOU / secret-scan / hook-rejected → move to leaveLog + leaveLog.push({ path: item.path, lane: item.lane, reason: result.reason }); + } + } + // Replace eligible with only the committed ones + eligible.length = 0; + eligible.push(...committed); + } finally { + releaseLock(lockResult.handle); + } + } + + // Strip the internal `captured` field before returning — callers only need path/lane/reason + for (const item of eligible) { + delete item.captured; + } + + return { eligible, leaveLog, skip, surfaceOnly }; +} + +// ─── CLI entry point ────────────────────────────────────────────────────────── + +// Detect if run directly: import.meta.url matches process.argv[1] (cross-platform safe) +const isMain = process.argv[1] && + (new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname || + import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/'))); + +if (isMain) { + // Parse CLI args: --repo <path> [--apply] [--json] + const args = process.argv.slice(2); + const repoIdx = args.indexOf('--repo'); + if (repoIdx === -1 || !args[repoIdx + 1]) { + process.stderr.write('Usage: node sweep.mjs --repo <path> [--apply] [--json]\n'); + process.exit(1); + } + const wt = args[repoIdx + 1]; + const apply = args.includes('--apply'); + const json = args.includes('--json'); + const owner = args.includes('--owner'); + const allowMainCommit = args.includes('--allow-main-commit'); + const issueIdx = args.indexOf('--issue'); + const issueRef = issueIdx !== -1 ? args[issueIdx + 1] : undefined; + + sweepRepo(wt, { now: Date.now(), apply, owner, allowMainCommit, issueRef }).then((buckets) => { + if (json) { + process.stdout.write(JSON.stringify(buckets, null, 2) + '\n'); + } else { + const fmt = (label, items) => { + if (items.length === 0) return `${label}: (none)\n`; + return `${label} (${items.length}):\n` + + items.map((e) => ` ${e.path} [${e.reason}]`).join('\n') + '\n'; + }; + process.stdout.write( + `\n=== doc-sweep report for ${wt} ===\n` + + fmt('eligible', buckets.eligible) + + fmt('leaveLog', buckets.leaveLog) + + fmt('skip', buckets.skip) + + fmt('surfaceOnly', buckets.surfaceOnly) + '\n' + ); + } + }).catch((err) => { + process.stderr.write(`[doc-sweep] Error: ${err.message}\n`); + process.exit(1); + }); +}