diff --git a/.agents/policy-selector-install.json b/.agents/policy-selector-install.json new file mode 100644 index 00000000..d049096f --- /dev/null +++ b/.agents/policy-selector-install.json @@ -0,0 +1,23 @@ +{ + "install_relpath": ".agents/skills/repo-policy-selector", + "installed_bundle_release": { + "bundle_name": "repo-policy-selector", + "bundle_version": "0.1.22", + "policy_library": { + "catalog_path": "policy-library/catalog.yaml", + "content_sha256": "03d4cba5d1aaad7ca2805b847dd69083ebf4193b8812693ee7ea8d9079f56b42", + "relative_root": "policy-library", + "schema_path": "policy-library/SCHEMA.md" + }, + "release_ref": "v0.1.22", + "schema_version": 1, + "source_commit": "12a7f9fef466522e99be44d980c44a4ff056f540", + "source_ref": "12a7f9fef466522e99be44d980c44a4ff056f540", + "source_repo_root": "/home/ecochran76/workspace.local/agent-policies", + "source_tree_state": "clean-ref" + }, + "installed_policy_root": "/home/ecochran76/workspace.local/dev-browser-policy-adoption/.agents/skills/repo-policy-selector/policy-library", + "installed_selector_root": "/home/ecochran76/workspace.local/dev-browser-policy-adoption/.agents/skills/repo-policy-selector", + "selector_root": "/home/ecochran76/.agents/skills/repo-policy-selector", + "source_type": "local-path" +} diff --git a/.agents/skills/repo-policy-selector/SKILL.md b/.agents/skills/repo-policy-selector/SKILL.md new file mode 100644 index 00000000..a59cccff --- /dev/null +++ b/.agents/skills/repo-policy-selector/SKILL.md @@ -0,0 +1,97 @@ +--- +name: repo-policy-selector +description: choose and adapt the right reusable agent-policy modules for a target repository by inspecting repo signals such as AGENTS.md, docs/dev/policies, roadmap/runbook files, repo shape, and workflow complexity, then recommend a policy profile from this policy library and draft the local policy patch. +--- + +Select the right reusable policy bundle for a repository, then adapt it into repo-local guidance. + +## Workflow + +1. Confirm the selector has an installed policy library available locally. +2. Deterministically enumerate the installed policy library from `catalog.yaml` before making recommendations. +3. Inspect the target repo lightly: + - `AGENTS.md` if present + - `docs/dev/policies/` if present + - roadmap/runbook/progress files if present + - cluttered or legacy planning/note surfaces that may need migration into canonical locations + - repo shape and workflow complexity +4. Record a graph-memory discovery assessment: + - `use` as the repo default when Graphiti or another installed graph-memory + workflow is explicitly present + - `task-conditional` when the shared policy is selected but repo evidence + does not establish a concrete graph-memory workflow + - preserve the module's per-task `use` / `skip` / `unavailable` rubric +5. Run `scripts/select_policy.py` for a deterministic first-pass profile/module recommendation. +6. Extract the repo's current policy surfaces deterministically before drafting adoption changes. +7. If the repo shows cluttered or legacy plans, notes, or memories, treat migration as part of adoption rather than patching only the final steady-state policy. +8. Classify existing policy surfaces against the installed templates: + - `keep` + - `merge` + - `retire` +9. Run `scripts/audit_planning_contract.py` to check the applicable contract; + every starter profile adopts proportional bounded planning discipline. + Supply `--plans-dir`, `--roadmap-path`, or `--runbook-path` for documented + alternate authorities. `--active-only` is a steady-state gate and reports + unclassified legacy exclusions; it does not complete migration. It may + accept only exact findings named by a valid + `docs/dev/planning-audit-baseline.json`, while continuing to report matched + and unused baseline entries. Use `--force` only for pre-adoption assessment; + active baselines never apply to full or forced audits. +10. If the repo adopts `goal-execution-governance`, run `scripts/audit_planning_contract.py --goal-only` and require concrete local bounds, execute-by-default continuation, action-specific approval gates, local replan before escalation, at-most-one risk-triggered drift-discovery pass, closed-world verification when review occurs, and minimal material-checkpoint fields. +11. If the repo adopts `active-lane-coordination`, refresh remote-tracking refs through its normal fetch policy and run `scripts/audit_active_lanes.py` with an explicit default ref. Prefer `--catalog-only` when the catalog is the complete authorized population; use repeated exact `--branch` selectors for bounded unregistered-lane discovery and reserve `--branch-prefix` for explicit broader surveys. Treat missing, unequal, or contradictory custody as a fail-closed planning input, not as permission for the auditor to mutate Git. +12. Validate that the recommended profile and modules exist in the installed library bundle before drafting changes. +13. Read the referenced policy modules from this policy library before drafting changes. +14. Decide whether the repo needs: + - a starter profile with minimal edits + - a profile plus module overrides + - a missing-modules patch when the repo already partially or mostly matches the selected profile + - a migration-first adoption because plans, notes, or memories are cluttered + - a custom composition because no single profile fits cleanly +15. Draft the repo-local policy patch or recommendation, keeping the adopted policy in `docs/dev/policies/` and using `AGENTS.md` as the wire-in entrypoint. + +## Required references + +- Read [references/selection-workflow.md](references/selection-workflow.md) before doing non-trivial selection work. +- Read [references/policy-shapes.md](references/policy-shapes.md) before drafting repo-local policy files or the `AGENTS.md` wire-in. + +## Command recipes + +```bash +python scripts/manage_policy.py --repo-root /path/to/target-repo adopt --json +python scripts/manage_policy.py --repo-root /path/to/target-repo adopt --write-drafts +python scripts/manage_policy.py --repo-root /path/to/target-repo --policy-root /path/to/target-repo/.codex/skills/repo-policy-selector/policy-library check-for-updates --json +python scripts/manage_policy.py --repo-root /path/to/target-repo upgrade-check --profile skill-repo-maintainer --policy-git-root /path/to/agent-policies --baseline-ref --current-ref HEAD --json +python scripts/manage_policy.py --repo-root /path/to/target-repo upgrade-plan --profile skill-repo-maintainer --policy-git-root /path/to/agent-policies --baseline-ref --current-ref HEAD --json +python scripts/manage_policy.py --repo-root /path/to/agent-policies release-bundle --source-root /path/to/agent-policies --bundle-version --release-ref --source-ref --json +python scripts/manage_policy.py --repo-root /path/to/agent-policies release-notes --current-ref --previous-ref --write --json +python scripts/manage_policy.py --repo-root /path/to/agent-policies release-publish --tag --notes-file repo-policy-selector/releases/.md --latest --json +python scripts/manage_policy.py --repo-root /path/to/agent-policies release-cut --bundle-version --release-ref --previous-ref --publish --latest --json + +python scripts/manage_policy.py --repo-root /path/to/target-repo install-downstream --bundle-git-url https://github.com/CochranResearchGroup/agent-policies.git --bundle-ref --target-repo-root /path/to/target-repo --write-drafts --json + +python scripts/select_policy.py --repo-root /path/to/repo --policy-root /path/to/agent-policies +python scripts/select_policy.py --repo-root /path/to/target-repo --policy-root .. --json +python scripts/select_policy.py --repo-root /path/to/target-repo --policy-root .. --write-drafts +python scripts/check_policy_upgrades.py --repo-root /path/to/target-repo --profile skill-repo-maintainer --policy-git-root /path/to/agent-policies --baseline-ref --current-ref HEAD --json +python scripts/plan_policy_upgrade_actions.py --repo-root /path/to/target-repo --profile skill-repo-maintainer --policy-git-root /path/to/agent-policies --baseline-ref --current-ref HEAD --json +python scripts/audit_planning_contract.py --repo-root /path/to/repo --json +python scripts/audit_planning_contract.py --repo-root /path/to/repo --active-only --json +python scripts/audit_planning_contract.py --repo-root /path/to/repo --plans-dir doc/dev/plans --json +python scripts/audit_active_lanes.py --repo-root /path/to/repo --default-ref refs/remotes/origin/main --json +python scripts/audit_active_lanes.py --repo-root /path/to/repo --default-ref refs/remotes/origin/main --catalog-only --json +python scripts/audit_active_lanes.py --repo-root /path/to/repo --default-ref refs/remotes/origin/main --branch feature/lane-a --branch fix/lane-b --json +python scripts/audit_active_lanes.py --repo-root /path/to/repo --default-ref refs/heads/main --catalog-path docs/dev/active-lanes.yaml --plans-dir docs/dev/plans +``` + +## Guardrails + +- Do not overwrite repo-local nuance just to match a shared profile exactly. +- Prefer composing modules over inventing a monolithic new policy block. +- If the target repo has mature local rules, recommend a partial adoption instead of a full replacement. +- `--write-drafts` should only create missing planned policy files and rewrite `AGENTS.md`; it should refuse to overwrite an existing planned target file. +- This skill should remain self-contained when installed with its policy library. +- Installation, policy enumeration, and repo wiring should be handled deterministically. +- Downstream install should support a one-shot path that copies a pinned selector bundle into the target repo from either a reviewed git ref or a local bundle path and can draft the initial local policy set immediately. +- Released selector bundles should carry a deterministic `release-manifest.json` next to the bundled `policy-library/`. +- `audit_active_lanes.py` is read-only. It never fetches or performs Git, catalog, plan, worktree, branch, or remote mutations; a clean report never grants integration or cleanup authority. +- Treat the policy library as a source library, not the runtime source of truth for the target repo. diff --git a/.agents/skills/repo-policy-selector/agents/openai.yaml b/.agents/skills/repo-policy-selector/agents/openai.yaml new file mode 100644 index 00000000..ea613816 --- /dev/null +++ b/.agents/skills/repo-policy-selector/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Repo Policy Selector" + short_description: "Choose policy bundles for repos" + default_prompt: "Use $repo-policy-selector to inspect this repo and recommend the right shared policy profile and module mix." + +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/repo-policy-selector/policy-library/SCHEMA.md b/.agents/skills/repo-policy-selector/policy-library/SCHEMA.md new file mode 100644 index 00000000..f81828e9 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/SCHEMA.md @@ -0,0 +1,420 @@ +# Policy Schema + +This repo is intentionally small. The schema is lightweight so humans and scripts can both work with it. + +## Repository Objects + +There are three first-class objects: + +1. `module` +2. `profile` +3. `catalog` + +Repo-local `AGENTS.md` files are downstream entrypoints, not objects in this repo. +The adopted repo-local policy should live under `docs/dev/policies/` and be wired in from `AGENTS.md`. + +## Repo Purpose And Workflow Subtype + +Policy selection is purpose-aware. + +Each target repo should be classified by: + +1. `repo_purpose` +2. optional `workflow_subtype` +3. optional `execution_bias` + +Initial purpose families: + +- `product-engineering` +- `operations-platform` +- `website` +- `course-workspace` +- `library-cli` +- `seminal-workspace` +- `workspace-agent` +- `writing-project` + +Examples of `workflow_subtype` under `writing-project`: + +- `grant-proposal-writing` +- `grant-proposal-review` +- `journal-article-writing` +- `patent-application-writing` +- `technical-report-writing` + +Rules: +- purpose should describe the repo's operating model, not only its subject matter +- `website` is the canonical purpose; the established starter profile remains + `website-maintenance`, and selector helpers accept the legacy purpose name as + compatibility vocabulary +- subtype is optional and should refine the operating model when it changes policy needs materially +- execution bias is optional and should describe whether the repo prefers lower wall-clock time, lower coordination/token cost, or a balance of the two +- selector output should always include purpose; subtype is recommended when confidence is adequate +- selector output should include execution bias when repo policy or workflow signals make it clear +- selector output should include `policy-management` as the first adopted policy in selector-managed repos + +Execution-bias vocabulary: + +- `max-dev-speed` +- `balanced` +- `max-token-efficiency` + +## Planning Contract + +Every starter profile adopts bounded planning discipline. The amount of +ceremony remains proportional: trivial one-step work does not require a plan, +while substantive, multi-file, multi-step, risky, or resumable work does. + +For repos that adopt bounded planning discipline: + +- actionable plans should live under `docs/dev/plans/` unless the repo has a clearly documented alternate location +- plan filenames should use a deterministic serial-plus-date prefix, for example `0001-2026-04-09-slice-name.md` +- planning migration for active repos should include both: + - structural migration for canonical files, naming, and wiring + - semantic reconciliation against actual shipped state and recent history +- plan states should come from a small fixed vocabulary such as: + - `PLANNED` + - `OPEN` + - `CLOSED` + - `CANCELLED` +- plans in an active state such as `OPEN` should include a short `Current State` section +- if the repo uses `ROADMAP.md`, it should be the master plan and should change cautiously +- if the repo uses `RUNBOOK.md`, it should be a dated turn log of what happened, not a second roadmap +- if the repo uses roadmap lanes, lane ids should use one canonical naming pattern, for example `P## | ` +- if the repo uses roadmap lanes, lanes in an active state such as `OPEN` should include a short `Current State` note +- if the repo uses roadmap lanes, active lanes such as `OPEN` should normally have at least one actionable plan +- if the repo uses `ROADMAP.md` and `RUNBOOK.md`, plan wiring into them should be auditable by deterministic helpers +- deterministic audits should report whether the planning contract is + applicable from adopted policy rather than failing every repo that lacks the + strict roadmap/runbook profile +- repos with documented alternate authority paths should pass those paths to + the audit rather than copying artifacts solely to satisfy defaults +- active-only audits should report excluded closed or unclassified legacy plans; + they are steady-state gates, not evidence that historical migration is done +- an active-only audit may consume `docs/dev/planning-audit-baseline.json` with + `schema_version: 1`, a non-empty `rationale`, a non-empty + `review_condition`, and exact `accepted_findings`; matched and unused entries + must remain visible, and the baseline must not affect full or forced audits +- absence of a plans directory is not an active-only failure, but remains a + structural failure for full or forced audits + +## Notes And Memories Contract + +For repos that adopt notes/memory continuity discipline: + +- dated notes should live under `docs/dev/notes/` +- durable memories should live under `docs/dev/memories/` +- notes and memories should use the same deterministic serial-plus-date filename prefix as plans, for example `0001-2026-04-10-slice-name.md` +- deterministic helpers should be used to enumerate, audit, or manage notes and memories when the repo provides them +- policy adoption or upgrade feedback that should inform future shared policy work should be recorded as dated notes instead of left only in chat history +- one dated note may satisfy upgrade tracking, adoption feedback, and continuity capture when it records the decision and reusable lesson clearly +- when a repo also uses a durable memory system, notes and memories remain the place for richer human-readable continuity unless a more specific shared memory-usage module says otherwise + +## Active-Lane Coordination Contract + +Multi-track repositories may adopt a default-branch active-lane projection at +`docs/dev/active-lanes.yaml` or a documented equivalent path. The projection is +optional outside repositories with concurrent projects, branches, or worktrees. + +The catalog uses `schema_version: 1` and a `lanes` list. Every lane requires: + +```yaml +- id: P42 + objective: Carrier reconciliation + plan: docs/dev/plans/0042-YYYY-MM-DD-carrier-reconciliation.md + plan_ref: refs/heads/feature/p42-carrier-reconciliation + branch: feature/p42-carrier-reconciliation + target: main + plan_state: OPEN + custody_state: ACTIVE_WORKTREE + checkpoint: + remote_ref: refs/remotes/origin/feature/p42-carrier-reconciliation + integration: merge + dependencies: [] + overlaps: [] + updated_at: YYYY-MM-DD +``` + +Optional fields include `reconciled_overlaps`, `validation_status`, +`validation_ref`, `integration_receipt`, `archive_ref`, `archive_remote_ref`, +`blocker`, and `disposition`. Lists use inline YAML form in schema version 1 so +the bundled dependency-free auditor can parse them deterministically. + +Rules: + +- lane ids and branch ownership are unique +- plan states are `PLANNED`, `OPEN`, `BLOCKED`, `CLOSED`, or `CANCELLED` +- custody states are `ACTIVE_WORKTREE`, `PAUSED_REF`, `INTEGRATION_READY`, + `INTEGRATED`, `ARCHIVED`, or `DISCARD_APPROVED` +- referenced dependencies identify another catalog lane +- plan metadata at `plan_ref:plan` must agree with the catalog for active plans +- the catalog contains no absolute worktree paths, ephemeral agent ids, secrets, + tenant data, or private runtime details +- the default-branch catalog owns discovery and Git custody projection only; + roadmap, branch-local plan, runbook, review, and Git evidence retain their + separate authority +- auditing is read-only; fetching and every mutation remain caller-controlled +- catalog-only discovery audits registered lanes without enumerating unrelated + topic refs; exact repeated branch selectors bound unregistered-plan discovery +- prefix discovery is an explicit broader survey and may be inappropriate for + repositories with large historical branch namespaces +- audit output reports `local_remote_relation` as `missing`, `local_only`, + `remote_only`, `equal`, `local_ahead`, `remote_ahead`, or `diverged` +- `ACTIVE_WORKTREE` lanes fail closed with `local_ahead_of_remote`, + `remote_ahead_of_local`, or `local_remote_diverged` when both tips exist but + are unequal + +## Module Contract + +Modules live under `modules/*.md`. + +Each module should contain: + +1. YAML frontmatter +2. a short `## Policy` section +3. optional `## Adoption Notes` + +Required frontmatter fields: + +```yaml +id: git-worktree-hygiene +title: Git / Worktree Hygiene +summary: Keep branch scope narrow and treat overlap as reconciliation. +tags: + - git + - worktrees + - merge +``` + +Rules: +- `id` must be stable and kebab-case. +- `summary` should describe the reusable idea, not one repo's local wording. +- `tags` should support search and profile composition. +- module body text should be reusable across multiple repos. +- avoid repo-local paths, commands, and product nouns unless they are essential to the policy itself. + +## Profile Contract + +Profiles live under `profiles/*.yaml`. + +Required fields: + +```yaml +id: repo-product-engineering +summary: Full planning and branch-discipline profile for multi-lane product repos. +modules: + - planning-discipline + - roadmap-runbook-governance + - git-worktree-hygiene + - turn-closeout +overrides: + expects_runbook: true + expects_roadmap: true + parallel_execution_bias: true +``` + +Rules: +- profiles compose modules; they should not duplicate module prose +- every starter profile should include `planning-discipline`; stricter + `roadmap-runbook-governance` remains optional and purpose-specific +- `overrides` are hints for the selector/adopter, not a second prose policy layer +- profile ids should reflect repo archetypes, not one named repository +- profiles should declare their intended `repo_purpose` +- profiles may declare supported `workflow_subtypes` + +## Catalog Contract + +The catalog is a lightweight index: +- `modules[].id` +- `modules[].path` +- `modules[].tags` +- `profiles[].id` +- `profiles[].path` +- `profiles[].tags` + +It exists to support simple deterministic tools without requiring a richer database. + +For deployment and installation: +- the policy library should be installable alongside the selector as a self-contained bundle +- profile and module enumeration should come from deterministic library artifacts such as `catalog.yaml` +- adoption wiring into target repos should be deterministic: + - adopted policy files live under `docs/dev/policies/` + - each shared module identity has exactly one active adopted policy path, + regardless of the local ordinal filename + - `AGENTS.md` acts as the entrypoint that wires those files into the repo contract + - `AGENTS.md` should tell agents when to re-read relevant adopted policy files, especially at the start of non-trivial turns and when scope changes + - duplicate identities are reported with all claiming paths and block write + mode until explicit content and wire-in reconciliation chooses one path + +## Harvesting Contract + +Harvesting should classify policy into one of four outcomes: + +1. `reuse-existing-module` +2. `update-existing-module` +3. `propose-new-module` +4. `keep-repo-local` + +Preferred default: +- if a rule fits an existing concept, update or reuse that concept +- only propose a new module when the rule is genuinely reusable and conceptually distinct +- adoption feedback from downstream repos should be treated as a first-class harvest input when it identifies repeatable fit problems, missing modules, or over-prescriptive profiles +- multi-repo harvests should define an inventory and explicit exclusion classes +- availability, active adoption/wiring, and enacted behavior are separate + evidence stages and should not be collapsed into one keyword score +- audit incompatibility and excluded legacy artifacts should be reported as + limitations or migration debt, not silently counted as policy failure + +Harvesting should also respect purpose boundaries: +- do not mix `workspace-agent` memory/heartbeat rules into `product-engineering` profiles +- do not force heavyweight engineering roadmap governance onto lightweight local-ops or simple library repos +- treat `writing-project` policies as deliverable-oriented rather than codebase-architecture-oriented + +For `writing-project`, likely reusable policy themes include: +- environment-aware workspace continuity +- authoritative deliverable discipline +- runbook or handoff continuity for long document workflows +- document safety rules for DOCX/PDF/editing pipelines +- review and evidence-pack organization + +Those should remain separate from `product-engineering` modules unless a rule is clearly shared across both families. + +For `product-engineering`, likely reusable policy themes include: +- architecture or service-boundary guardrails +- codegraph-backed source discovery and impact analysis before non-trivial code edits or refactors +- documentation-change control when plans, semantics, or operator surfaces move +- validation and handoff discipline with explicit verification before commit or handoff +- preview artifact review when generated reports, local builds, review packets, or visual outputs need browser-based human inspection before approval +- subagent runtime provenance when delegated work produces code, policy, or validation evidence +- long-running goal execution with stable objectives, bounded work packets, + durable checkpoints, explicit state transitions, convergence tests, and stop + rules + +Those should stay distinct from `writing-project` modules even when both families use planning or closeout rules. + +For `website`, likely reusable policy themes include: +- environment and surface targeting across canonical, staging, local, and deprecated web surfaces +- governance for DB-backed state that cannot be represented faithfully in git +- live-drift reconciliation before release +- backup and recovery discipline as part of operational completeness +- visual release QA and reusable web-interface quality rules +- codegraph-backed source discovery when the website repo includes owned application code, themes, plugins, build systems, or deploy tooling +- preview artifact review when local builds, screenshots, PDFs, or generated review packets should be surfaced as one browser-review session + +Those should stay distinct from general `product-engineering` modules when the repo's main operating risk is live website change management rather than service architecture evolution. + +For `operations-platform`, likely reusable policy themes include: +- boundary discipline between reusable product code and private runtime state +- codegraph-backed source discovery and impact analysis for product/runtime code before changing shared operator workflows +- governance for when runtime state itself should become a managed artifact set +- tenant or environment isolation for local state, artifacts, and resources +- fieldwork productization after live customer or operator interventions +- extraction discipline for oversized command or orchestration trunks +- release and validation rules that account for both publishable software and private operational runtimes +- graph-backed memory usage when a durable memory service becomes part of normal operator workflow +- memory-service runtime governance when the repo builds or operates the installed durable memory service itself +- subagent lifecycle and tool-surface governance when live operator workflows depend on spawned agents +- preview artifact review when operator handoffs, approval packets, dry-run outputs, or generated local artifacts require human review before mutation + +Those should stay distinct from both `website` and general `product-engineering` modules when the repo's main operating risk is mixing product evolution with live tenant operations. + +For repos that build or operate subagent runtimes, likely reusable policy themes include: +- status and completion signals derived from runtime state instead of model claims alone +- run ids, session ids, transcript paths, announce payloads, and provenance for delegated work +- tool allow/deny policy by agent role and spawn depth +- concurrency, fan-out, nesting, timeout, and cascade-stop limits +- model, token, cost, archive, cleanup, and retention expectations + +Those should stay distinct from ordinary workflow delegation rules; most repos need `subagent-workflow-optimization`, while only runtime-oriented repos need `subagent-runtime-governance`. + +For repos that use `/goal` or other long-horizon autonomous execution, likely +reusable policy themes include: + +- a stable goal objective plus high-level milestone/campaign plan +- just-in-time bounded execution packets rather than premature low-level plans +- standing authority with an execute-by-default rule for obvious, in-scope, + low-risk actions +- approval stops limited to material departures or explicit gates that apply to + the exact contemplated action; vague gate labels and local counter exhaustion + are insufficient +- explicit states, dependencies, joins, bounded feedback cycles, and terminal outcomes +- automatic delegation decisions and calibrated fresh-context worker/auditor roles +- at most one risk-triggered broad drift-discovery pass followed, when used, by + primary adjudication and closed-world verification of accepted findings +- evidence-shaped review findings, explicit dispositions, and goal-level review + budgets that survive plan versions and successor packets +- material-boundary checkpoints with a cadence backstop, scoped drift guards, + and local replan before escalation + +Those belong in `goal-execution-governance`. Exact time, token, slice, command, +and runbook thresholds should remain repo-local. + +For repos that rely on installed durable memory systems, likely reusable policy themes include: +- when to read memory before re-asking the user +- active memory discovery before non-trivial planning, debugging, audit, adoption, upgrade, harvest, or handoff work +- repo-named memory groups and atlas/routing behavior when the right group is unclear +- what belongs in graph-backed memory versus notes and memories docs +- when to mirror compact source-cited policy, adoption, or harvest summaries into graph memory after repo-file artifacts exist +- duplicate-write and memory-spam avoidance +- cautious use of destructive maintenance tools +- verification of memory-derived claims against repo files, artifacts, commits, tests, or cited episodes + +Graph-backed memory usage is part of the default starter profiles because durable context retrieval has become a normal agent workflow surface. Repo-local policy should still name the actual memory group, discovery skill, privacy boundary, and write discipline for that repo. + +For repos that contain code and have an indexed codegraph available, likely reusable policy themes include: +- consulting codegraph before non-trivial code edits, architecture claims, trace analysis, or refactor planning +- using structural queries such as context, trace, callers, callees, impact, and indexed file listings before broad manual search loops +- treating codegraph output as discovery evidence that still requires source reads and tests +- treating fresh-worktree initialization as routine local derived-state maintenance when the repo already establishes codegraph as expected +- distinguishing a watched active checkout from explicit-path projects and fresh worktrees that may require an explicit sync +- checking status after edits and performing one explicit sync when the index is stale, pending, unwatched, or auto-sync is disabled +- keeping exact sibling checkout paths, MCP tool names, service repair, and project-specific index exclusions repo-local + +For repos that build or operate installed memory-service runtimes, likely reusable policy themes include: +- client/server provider boundaries, especially when agent clients do not own service backend credentials +- installed-release, runtime-config, service-manager, health, and listener verification before diagnosis +- durable async queue status, retry, and dead-letter visibility +- live read-after-write smoke checks after install, restart, migration, or backend changes +- keeping product code separate from private runtime state, credentials, queues, and databases + +Those should stay distinct from repo-local tool names, partition-key semantics, and deployment assumptions unless those behaviors clearly generalize. + +For repos that maintain executable code and regression suites, likely reusable +policy themes include: +- placing each invariant at the cheapest reliable test layer +- tiering focused, presubmit, comprehensive, and live or soak execution +- concrete repo-local wall-clock and compute budgets +- hermeticity, order independence, and duration-aware parallel execution +- guarded affected-test selection with an unknown-impact fallback and periodic + comprehensive drift detection +- preserving first-failure evidence across retries and using owned, expiring + quarantine for flaky tests +- recurring review of slow, redundant, obsolete, and low-yield tests with a + retained-risk mapping before consolidation or deletion + +Exact commands, CI jobs, numeric budgets, marker names, provider gates, and +risk inventories should remain repo-local. + +For `course-workspace`, likely reusable policy themes include: +- course identity, term, workspace, archive, and generated-artifact governance +- LMS CLI read-before-write discipline, live course target checks, and post-write validation +- cloud-drive placeholder and provider-native document handling for course workspaces +- student-data, assessment, answer-key, grading, and private-feedback safety +- validation and handoff rules that distinguish read-only inspection from live course mutation +- preview artifact review when course materials, PDFs, Office documents, generated packets, or galleries need browser review before sharing or LMS writes + +Those should stay distinct from `writing-project` modules when the repo's main operating risk is live course operation and student-data handling rather than producing a writing deliverable. + +## Repo-Local Override Rule + +The target repo's repo-local policy remains authoritative after adoption. +By default, that policy should live under `docs/dev/policies/`, with `AGENTS.md` acting as the wire-in entrypoint. + +That means: +- shared policy modules are a source library +- profiles are starter bundles +- repo-local policy files under `docs/dev/policies/` are the durable adopted layer +- `AGENTS.md` should keep repo-specific guidance and treat policy entry as one section, not the whole document +- `AGENTS.md` should act as a policy-loading contract, not just a one-time pointer +- repo-local command lists, paths, and domain-specific rules usually stay local +- selector and harvester outputs should draft or recommend changes, not silently replace local policy diff --git a/.agents/skills/repo-policy-selector/policy-library/catalog.yaml b/.agents/skills/repo-policy-selector/policy-library/catalog.yaml new file mode 100644 index 00000000..2096f748 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/catalog.yaml @@ -0,0 +1,171 @@ +modules: + - id: policy-management + path: modules/policy-management.md + tags: [policy, governance, installation, wiring] + - id: policy-upgrade-management + path: modules/policy-upgrade-management.md + tags: [policy, upgrades, releases, governance] + - id: policy-adoption-feedback-loop + path: modules/policy-adoption-feedback-loop.md + tags: [policy, feedback, upgrades, notes] + - id: notes-and-memories + path: modules/notes-and-memories.md + tags: [notes, memories, continuity, governance] + - id: graph-backed-memory-usage + path: modules/graph-backed-memory-usage.md + tags: [memory, graph, retrieval, continuity] + - id: codegraph-usage + path: modules/codegraph-usage.md + tags: [code, graph, analysis, refactor, index, worktree] + - id: memory-service-runtime-governance + path: modules/memory-service-runtime-governance.md + tags: [memory, runtime, mcp, operations] + - id: seminal-workspace-evolution + path: modules/seminal-workspace-evolution.md + tags: [policy, archetypes, feedback, evolution] + - id: planning-discipline + path: modules/planning-discipline.md + tags: [planning, slices, parallelism] + - id: goal-execution-governance + path: modules/goal-execution-governance.md + tags: [goals, agents, orchestration, antidrift, checkpoints] + - id: roadmap-runbook-governance + path: modules/roadmap-runbook-governance.md + tags: [governance, roadmap, runbook, antidrift] + - id: parallel-plan-design + path: modules/parallel-plan-design.md + tags: [planning, parallelism, agents, coordination] + - id: git-worktree-hygiene + path: modules/git-worktree-hygiene.md + tags: [git, branches, worktrees, merge] + - id: active-lane-coordination + path: modules/active-lane-coordination.md + tags: [git, planning, worktrees, coordination] + - id: commit-history-discipline + path: modules/commit-history-discipline.md + tags: [git, commits, history, review] + - id: branch-and-integration-strategy + path: modules/branch-and-integration-strategy.md + tags: [git, branches, integration, rebase] + - id: commit-and-push-cadence + path: modules/commit-and-push-cadence.md + tags: [git, commits, push, cadence] + - id: upstream-fork-maintenance + path: modules/upstream-fork-maintenance.md + tags: [git, fork, upstream, rebase] + - id: multi-agent-reconciliation + path: modules/multi-agent-reconciliation.md + tags: [agents, git, reconciliation, blame] + - id: turn-closeout + path: modules/turn-closeout.md + tags: [closeout, recommendations, handoff] + - id: policy-harvest-loop + path: modules/policy-harvest-loop.md + tags: [meta, harvesting, normalization] + - id: architecture-guardrails + path: modules/architecture-guardrails.md + tags: [architecture, boundaries, semantics, change-control] + - id: documentation-change-control + path: modules/documentation-change-control.md + tags: [docs, roadmap, runbook, semantics] + - id: validation-and-handoff + path: modules/validation-and-handoff.md + tags: [validation, tests, handoff, verification] + - id: code-testing-discipline + path: modules/code-testing-discipline.md + tags: [testing, regression, ci, performance, reliability] + - id: preview-artifact-review + path: modules/preview-artifact-review.md + tags: [artifacts, previews, review, approval] + - id: subagent-workflow-optimization + path: modules/subagent-workflow-optimization.md + tags: [agents, delegation, subagents, optimization] + - id: subagent-runtime-governance + path: modules/subagent-runtime-governance.md + tags: [agents, subagents, runtime, governance] + - id: versioning-and-release + path: modules/versioning-and-release.md + tags: [versioning, release, compatibility, changelog] + - id: course-workspace-governance + path: modules/course-workspace-governance.md + tags: [course, education, workspace, governance] + - id: lms-cli-governance + path: modules/lms-cli-governance.md + tags: [course, lms, canvas, cli] + - id: cloud-drive-course-governance + path: modules/cloud-drive-course-governance.md + tags: [course, drive, cloud, documents] + - id: student-data-and-assessment-safety + path: modules/student-data-and-assessment-safety.md + tags: [course, student-data, assessment, privacy] + - id: website-surface-targeting + path: modules/website-surface-targeting.md + tags: [website, environments, deployment, targeting] + - id: db-backed-state-governance + path: modules/db-backed-state-governance.md + tags: [website, database, cms, migrations] + - id: live-drift-reconciliation + path: modules/live-drift-reconciliation.md + tags: [website, drift, reconciliation, release] + - id: backup-and-recovery-operations + path: modules/backup-and-recovery-operations.md + tags: [website, backup, recovery, operations] + - id: visual-release-qa + path: modules/visual-release-qa.md + tags: [website, design, qa, release] + - id: web-interface-quality + path: modules/web-interface-quality.md + tags: [website, accessibility, interaction, performance] + - id: runtime-vs-product-boundary + path: modules/runtime-vs-product-boundary.md + tags: [runtime, product, boundary, state] + - id: runtime-state-governance + path: modules/runtime-state-governance.md + tags: [runtime, state, versioning, redaction] + - id: tenant-isolation-and-operator-state + path: modules/tenant-isolation-and-operator-state.md + tags: [tenant, runtime, isolation, operations] + - id: fieldwork-productization + path: modules/fieldwork-productization.md + tags: [fieldwork, productization, operations, migration] + - id: monolith-extraction-discipline + path: modules/monolith-extraction-discipline.md + tags: [architecture, modularity, refactor, debt] + - id: writing-environment-aware-workflow + path: modules/writing-environment-aware-workflow.md + tags: [writing, environment, continuity, handoff] + - id: writing-authoritative-deliverables + path: modules/writing-authoritative-deliverables.md + tags: [writing, deliverables, canonical, review] + - id: writing-document-edit-safety + path: modules/writing-document-edit-safety.md + tags: [writing, docx, citations, safety] + - id: writing-review-evidence-discipline + path: modules/writing-review-evidence-discipline.md + tags: [writing, review, evidence, analysis] + +profiles: + - id: repo-product-engineering + path: profiles/repo-product-engineering.yaml + tags: [product, engineering, roadmap, runbook, slices] + - id: standalone-library + path: profiles/standalone-library.yaml + tags: [library, cli, lightweight, git] + - id: seminal-workspace + path: profiles/seminal-workspace.yaml + tags: [seminal, formative, discovery, governance] + - id: writing-project + path: profiles/writing-project.yaml + tags: [writing, deliverable, proposal, manuscript] + - id: skill-repo-maintainer + path: profiles/skill-repo-maintainer.yaml + tags: [skills, policy, curation] + - id: website-maintenance + path: profiles/website-maintenance.yaml + tags: [website, operations, deploy, qa] + - id: operations-platform + path: profiles/operations-platform.yaml + tags: [operations, tenants, runtime, productization] + - id: course-workspace + path: profiles/course-workspace.yaml + tags: [course, education, lms, student-data] diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/active-lane-coordination.md b/.agents/skills/repo-policy-selector/policy-library/modules/active-lane-coordination.md new file mode 100644 index 00000000..f9d996b0 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/active-lane-coordination.md @@ -0,0 +1,32 @@ +--- +id: active-lane-coordination +title: Active Lane Coordination +summary: Keep a default-branch projection of concurrent off-main work while preserving branch-local execution plans and Git as custody evidence. +tags: + - git + - planning + - worktrees + - coordination +--- + +## Policy + +- Use this contract in repositories where several projects, agents, branches, or worktrees may remain active at once. Keep lighter repositories on proportional planning and Git policy without requiring a lane catalog. +- Keep a compact machine-readable active-lane catalog on the canonical default branch, normally `docs/dev/active-lanes.yaml`. A documented equivalent path is allowed. +- Treat the catalog as a discovery projection. A roadmap owns priority, a branch-local plan owns execution detail, a runbook owns chronological history, review tooling owns review state, and Git refs plus receipts prove custody and integration. +- Give each lane one stable id and one branch owner. Record its objective, plan path and source ref, branch, target, plan state, custody state, published checkpoint, remote ref, integration method, dependencies, overlaps, reconciliation date, and any blocker or disposition. +- Keep plan outcome state separate from Git custody state. Use a small plan vocabulary such as `PLANNED`, `OPEN`, `BLOCKED`, `CLOSED`, and `CANCELLED`, and a custody vocabulary such as `ACTIVE_WORKTREE`, `PAUSED_REF`, `INTEGRATION_READY`, `INTEGRATED`, `ARCHIVED`, and `DISCARD_APPROVED`. +- Keep detailed plans with their topic branches. Expose deterministic metadata for lane, state, branch, target, integration method, dependencies, overlaps, and base or checkpoint evidence so an auditor can read it from an explicit ref without checkout. +- Do not put absolute worktree paths, ephemeral agent identifiers, secrets, tenant data, or private runtime details in the shared catalog. Derive local worktree locations during reconciliation. +- Reconcile the catalog against current worktrees, bounded local and remote refs, branch-local plan metadata, checkpoint SHAs, target ancestry, receipts, dependencies, and overlap before planning, handoff, integration, or cleanup decisions. Prefer catalog-only discovery when the catalog is the complete authorized population; use exact repeated branch selectors for bounded unregistered-lane discovery. Prefix discovery is an explicit broader survey and should not be the default in repositories with large historical branch namespaces. +- For active worktree custody, classify equal, local-ahead, remote-ahead, and diverged local/remote tips explicitly. Local-ahead, remote-ahead, and diverged state fail closed until the lane owner reconciles and publishes the intended checkpoint. +- Fetching is a caller-controlled operation. A lane auditor must remain read-only and must not fetch, merge, rebase, push, delete refs, remove worktrees, edit plans, or infer authority from a clean report. +- Register normal work before parallel execution begins. An urgent lane may start first only when delay creates greater risk; register and publish its first recoverable checkpoint at the earliest safe boundary. +- Do not silently resolve catalog conflicts. Duplicate lane ids, two lanes claiming one branch, missing custody, stale checkpoints, active local/remote mismatch, plan/catalog drift, and unresolved overlaps fail closed until reconciled. +- Keep the catalog current through the repository's protected-default-branch workflow. A lane branch may propose its own registration, but it is not globally discoverable until that projection lands on the configured default ref. + +## Adoption Notes + +Adopt this module by default for multi-track product-engineering and operations-platform repositories. Add it to other profiles only when concurrent worktree, off-main planning, branch-registry, or multi-project signals justify the coordination cost. + +Repositories may provide a deterministic auditor such as `audit_active_lanes.py`. Its report is evidence for reconciliation, not permission to merge, publish, discard, or clean up. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/architecture-guardrails.md b/.agents/skills/repo-policy-selector/policy-library/modules/architecture-guardrails.md new file mode 100644 index 00000000..0af425ac --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/architecture-guardrails.md @@ -0,0 +1,25 @@ +--- +id: architecture-guardrails +title: Architecture Guardrails +summary: Keep changes aligned with the live architecture and avoid unplanned surface expansion. +tags: + - architecture + - boundaries + - semantics + - change-control +--- + +## Policy + +- Derive implementation boundaries from the live architecture and current service seams, not from aspirational or superseded layouts by default. +- Do not add new top-level workflows, endpoints, abstractions, or major aliases unless the governing plan or roadmap is updated in the same slice. +- Prefer tightening semantics and ownership boundaries over widening the surface area opportunistically. +- Keep provider-specific or deployment-specific heuristics at the narrowest layer that can own them cleanly. +- When a change would blur current architecture boundaries, stop and update the governing plan before proceeding. + +## Adoption Notes + +Use this module when the repo: +- has a service or architecture seam that must stay coherent +- has active refactors or staged migration work +- frequently risks structural drift through ad hoc feature additions diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/backup-and-recovery-operations.md b/.agents/skills/repo-policy-selector/policy-library/modules/backup-and-recovery-operations.md new file mode 100644 index 00000000..cf811f0f --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/backup-and-recovery-operations.md @@ -0,0 +1,30 @@ +--- +id: backup-and-recovery-operations +title: Backup And Recovery Operations +summary: Treat backup freshness, restore validation, and recovery procedures as part of operational completeness for live website repos. +tags: + - website + - backup + - recovery + - operations +--- + +## Policy + +- Define one durable recovery path for live website state and document it clearly. +- Be explicit about what the backup system is meant to recover, such as: + - databases + - uploaded assets + - runtime configuration + - owned code snapshots + - environment inventories +- Distinguish durable recovery artifacts from disposable local staging or pull directories. +- After meaningful live changes, refresh the backup cycle when the repo's recovery contract depends on capturing newly changed live state. +- Recovery procedures must describe both extraction and validation, not only archive creation. +- Restore workflows should identify the minimum artifacts that must exist for a recovery to be considered viable. +- If backup exclusions exist, document them explicitly so maintainers do not assume those surfaces are recoverable. +- Keep backup and restore commands deterministic enough that maintainers can run them without reconstructing missing context from chat history. + +## Adoption Notes + +Use this module when a repo manages live website or CMS state where recovery depends on a documented archive, backup, or snapshot workflow rather than source control alone. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/branch-and-integration-strategy.md b/.agents/skills/repo-policy-selector/policy-library/modules/branch-and-integration-strategy.md new file mode 100644 index 00000000..4d075e68 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/branch-and-integration-strategy.md @@ -0,0 +1,54 @@ +--- +id: branch-and-integration-strategy +title: Branch And Integration Strategy +summary: Choose one primary branch model, document how work lands, and be explicit about merge, rebase, and stabilization expectations. +tags: + - git + - branches + - integration + - rebase +--- + +## Policy + +- Document one primary integration model for the repo rather than switching casually between incompatible branch conventions. +- If a repo has both conservative maintenance work and more aggressive platform or architecture work, document how those tracks coexist instead of leaving branch choice to local habit. +- Be explicit about where normal work starts and where it lands: + - direct to `main` + - short-lived feature branches + - release or stabilization branches +- Prefer short-lived branches unless the repo has a documented reason for long-lived branch divergence. +- Prefer explicit track naming when different work classes coexist, for example maintenance-oriented branches versus architecture-oriented branches. +- State whether merge commits, rebased histories, or squash merges are preferred for shared history. +- State when rebasing is normal and when it is no longer appropriate because others may already depend on the branch. +- Once another lane, person, automation, or review surface depends on a published branch tip, do not rebase or otherwise rewrite it without explicit reconciliation and bounded lease protection. +- Treat branch protection, review gates, and release branching as part of the workflow contract rather than personal preference. +- Do not let local habit override the repo's documented integration model. +- When a repo supports parallel work, document whether reconciliation should happen by rebase, merge, or explicit integration branches. +- Keep branch lifecycle distinct from worktree lifecycle: a lane may remain active with a worktree, pause as a remotely preserved ref, become integration-ready, prove integration, remain temporarily cleanup-pending, archive, or receive explicit discard approval. +- Declare integration readiness only when the lane is clean, its tested checkpoint is published and matches the recorded SHA, dependencies and overlaps are reconciled, and the intended target and integration method are explicit. +- Prove merge integration by target ancestry. For squash or patch integration, preserve a durable receipt that identifies the source checkpoint and resulting target commit; do not infer integration from similar content or a closed pull request alone. +- Delete topic refs only after integration proof, verified archival, or exceptional discard approval. Routine branch cleanup must not use forced deletion to bypass missing evidence. +- Use disposable integration branches for cross-lane compatibility experiments. Do not make an exploratory integration branch a hidden source of truth for its component lanes. +- If current-behavior maintenance and future-architecture work can touch the same surface concurrently, document which class wins by default unless an approved migration slice says otherwise. + +## Adoption Notes + +Use this module when the repo has more than one contributor, review checkpoints, CI gates, or multiple valid ways work could land. + +Repo-type guidance: +- `product-engineering`: usually benefits from explicit rules for feature branches, protected branches, and stabilization before release +- `library-cli`: often benefits from a simple default branch plus tagged releases, but may still need clear rules for release branches when compatibility is sensitive +- `workspace-agent`: often benefits from short-lived branches and explicit rebase expectations because selector, prompt, and policy changes can drift quickly +- `writing-project`: may keep a lighter branch model, but collaborative review repos still benefit from an explicit default integration path + +Developer-preference guidance: +- trunk-based teams may prefer direct-to-main or very short-lived feature branches with fast validation +- review-heavy teams may prefer feature branches plus squash or rebase merges +- release-sensitive teams may require temporary stabilization branches before tags or deploys + +Multi-track repo guidance: +- repos that act as both a maintenance surface and a development platform usually need: + - one stable integration line + - short-lived feature branches or worktrees for parallel tracks + - explicit rules for when maintenance preserves current behavior and when migration work may intentionally replace it diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/cloud-drive-course-governance.md b/.agents/skills/repo-policy-selector/policy-library/modules/cloud-drive-course-governance.md new file mode 100644 index 00000000..b438a67e --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/cloud-drive-course-governance.md @@ -0,0 +1,27 @@ +--- +id: cloud-drive-course-governance +title: Cloud Drive Course Governance +summary: Govern course workspaces that rely on cloud-drive mounts, native cloud documents, connector identity, and placeholder files. +tags: + - course + - drive + - cloud + - documents +--- + +## Policy + +- Treat cloud-drive course folders as synchronized operational surfaces, not plain local filesystems. +- Keep the canonical cloud folder identity explicit when the course depends on a shared drive, folder id, or stable URL. +- Treat provider-native document placeholders, such as Google Drive for Desktop `.gsheet`, `.gdoc`, `.gslides`, and `.gform` files, as shortcuts or metadata stubs rather than reliable local document bodies. +- Use connector or API-aware tools when provider-native identity, sharing state, form responses, comments, or spreadsheet contents matter. +- Prefer stable cloud ids and configured URLs over parsing placeholder files. +- Do not assume local file mtimes, placeholder sizes, or synced shortcut contents fully represent provider-native document state. +- Do not duplicate, move, or rename native cloud documents through local filesystem operations unless the user explicitly asks for filesystem-level reorganization and the effect on cloud identity is understood. +- When a course workflow uses cloud spreadsheets or forms, prefer the course tool's configured integration or the stored provider URL over manual local placeholder reads. +- Before changing sharing, publication, or folder location, verify intended audience and course scope. +- Keep downloaded exports distinct from authoritative provider-native originals. + +## Adoption Notes + +Use this module when a course workspace lives in Google Drive, OneDrive, Box, Dropbox, or another cloud-sync surface and contains provider-native documents or LMS-linked cloud artifacts. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/code-testing-discipline.md b/.agents/skills/repo-policy-selector/policy-library/modules/code-testing-discipline.md new file mode 100644 index 00000000..28c3c04f --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/code-testing-discipline.md @@ -0,0 +1,47 @@ +--- +id: code-testing-discipline +title: Code Testing Discipline +summary: Protect important behavior with fast, trustworthy regression tests while governing suite latency, compute cost, duplication, and flakiness. +tags: + - testing + - regression + - ci + - performance + - reliability +--- + +## Policy + +- Treat tests as maintained product assets with both protective value and lifecycle cost. Test count, assertion count, and raw coverage percentage are not success metrics by themselves. +- Name the invariant or failure risk before adding a test. Place it at the cheapest layer that can prove it reliably: prefer a focused unit or contract test, use a narrow integration test for boundary behavior, and reserve end-to-end, live, soak, and exhaustive tests for risks that cheaper layers cannot establish. +- Before adding a regression test, inspect existing coverage for the invariant. Demonstrate that the new or changed test detects the defect before the fix when practical, then passes after the fix. Consolidate overlapping cases instead of accumulating historical duplicates. +- Put a regression test at a stable seam that exercises the real failure + pattern. If no such seam exists, do not add a shallow or implementation- + coupled proxy merely to claim coverage; record the unprotected risk and the + architecture or testability gap, then route remediation as a separate bounded + decision. +- Keep each test independent, deterministic, order-agnostic, and hermetic by default. Declare inputs, isolate writable state, use explicit readiness signals instead of arbitrary sleeps, and keep network, provider, browser, large-data, and live-system tests out of the default local lane unless their exact risk requires them. +- Define repo-local execution tiers and concrete wall-clock plus compute/resource budgets. At minimum distinguish focused development checks, blocking presubmit checks, periodic comprehensive regression, and opt-in live/soak/provider checks. A long comprehensive lane may remain valuable without blocking every change. +- Use affected-test selection or explicit changed-surface manifests for fast feedback only when the dependency mapping is trustworthy. Unknown impact must widen to a documented safe fallback, and a periodic comprehensive run must detect selection drift. Never describe a selected subset as the full suite. +- Measure suite economics over time: selection size, collection/startup cost, p50 and p95 wall time, total compute, peak constrained resources where material, slowest tests, flake rate, retry rate, and failure yield. Optimize repeated setup and collection costs before merely adding workers. +- Parallelize or shard only after tests are isolated and reproducible. Balance shards by observed duration when practical, retain exact shard identity in resumable receipts, and lower concurrency when contention increases failures or total resource cost. +- Treat retries as diagnostic or infrastructure-recovery evidence, not as erasure. Preserve the first failure, classify a pass-on-retry as flaky, and do not report the lane clean until policy-defined flake disposition is satisfied. Reconcile uncertain external effects before retrying any test that can mutate shared or live state. +- Quarantine a flaky test only with an owner, reason, issue or locator, quarantine date, expiry or service-level target, and replacement blocking coverage when the risk requires it. Repair, redesign, or remove quarantined tests promptly; quarantine is not permanent storage. +- Review expensive, redundant, obsolete, and low-yield tests on a recurring cadence. Every retained expensive test should protect a distinct named risk. Consolidation or deletion requires a retained-risk mapping and validation that the surviving suite still proves the intended contract. +- Use coverage to locate consequential gaps, not to chase a universal percentage. Prefer behavior, branch-risk, contract, and selectively applied mutation evidence over copy-pasted tests that only increase coverage. +- When a suite exceeds its local budget, profile before changing the gate. Prefer cheaper seams, shared-fixture optimization without weakened isolation, case consolidation, tier correction, trustworthy selection, caching on declared inputs, or duration-aware sharding. Raising a budget requires an explicit risk/economics decision and a follow-up date. +- Record exactly which tier, selection, environment, retries, shards, and exclusions ran. Validation claims must distinguish `focused`, `presubmit`, `comprehensive`, and `live_or_soak`, and must report any budget breach, flake, quarantine, or unexecuted risk. + +## Adoption Notes + +Each adopting repo should define a local test-suite contract with concrete values for: + +- `fast_feedback_target` +- `presubmit_blocking_budget` +- `presubmit_compute_budget` +- `comprehensive_lane_cadence` +- `unknown_impact_fallback` +- `flaky_test_disposition_sla` +- `retry_result_mode` + +Keep exact commands, marker names, CI job names, hardware assumptions, provider gates, and risk-specific test inventories repo-local. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/codegraph-usage.md b/.agents/skills/repo-policy-selector/policy-library/modules/codegraph-usage.md new file mode 100644 index 00000000..c9b8e4d4 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/codegraph-usage.md @@ -0,0 +1,39 @@ +--- +id: codegraph-usage +title: Codegraph Usage +summary: Use and keep current an expected codegraph before code exploration or edits so agents start from branch-accurate structural context instead of ad hoc text search alone. +tags: + - code + - graph + - analysis + - refactor + - index + - worktree +--- + +## Policy + +- When a repo has an available codegraph or indexed code-intelligence service, consult it before making non-trivial code changes, architecture claims, trace analysis, or refactor plans. +- Prefer codegraph context, trace, callers, callees, impact, or file-index tools for structural questions such as: + - where a symbol is defined + - what calls or depends on a function, class, route, or component + - how one behavior flows into another + - what a refactor is likely to affect + - which files make up an unfamiliar subsystem +- Use the repo's documented codegraph entrypoint when one exists, such as a sibling `../codegraph` checkout, local MCP tools, CLI wrapper, or indexed workspace service. +- Resolve the intended repository or worktree root and inspect current index status before relying on graph results. A sibling checkout's index is not proof that a fresh worktree or different branch is indexed. +- When a repo has already adopted, configured, or explicitly declared codegraph as an expected development surface, treat a missing index in a verified local worktree as routine derived-state maintenance: run the documented initialization workflow and verify the resulting index status. Do not require a fresh approval solely because the worktree is new. +- Do not assume automatic refresh applies to every project. The active checkout may have a live watcher while secondary projects, explicit-path queries, and fresh worktrees require explicit synchronization. +- When status or a staleness banner reports pending files, disabled auto-sync, an unwatched project, or a stale index, run the documented explicit sync once and re-check status. Do not wait repeatedly on a watcher that is absent or disabled. +- Treat the codegraph as a discovery and impact-analysis aid, not as proof that a change is correct. Verify behavior with source reads, targeted tests, type checks, linters, browser checks, or runtime smoke as appropriate. +- Prefer codegraph lookups over broad manual grep loops for symbol, flow, caller/callee, and architecture questions. Use text search or direct file reads to confirm details the index does not cover. +- After editing code, inspect the reported staleness or pending-sync state instead of guessing a delay. Use direct reads for specifically flagged files until synchronization is confirmed. +- Keep secrets, credentials, private logs, and unrelated runtime data out of indexed codegraph inputs or persisted analysis artifacts. +- Before initialization, confirm the target root and repo-local exclusions. Stop and ask when codegraph has not been established for the repo, the target or allowed input scope is ambiguous, repo policy reserves indexing for an operator, or initialization would create unexpected tracked-file changes. +- If initialization or one explicit sync still leaves codegraph unavailable or stale, proceed with ordinary repo inspection and report the exact failed status or staleness evidence in the handoff when it affects confidence. + +## Adoption Notes + +Use this module when a repo contains code that agents edit, review, trace, or refactor and an indexed codegraph is available or expected in the working environment. + +Keep exact commands, MCP tool names, sibling checkout paths, service repair, and project-specific index exclusions repo-local. The reusable contract is initialize an expected missing index, explicitly sync when automatic refresh is absent, and verify current status. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/commit-and-push-cadence.md b/.agents/skills/repo-policy-selector/policy-library/modules/commit-and-push-cadence.md new file mode 100644 index 00000000..c0df8cea --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/commit-and-push-cadence.md @@ -0,0 +1,49 @@ +--- +id: commit-and-push-cadence +title: Commit And Push Cadence +summary: Checkpoint work often enough for safety and collaboration, but not so noisily that history stops being useful. +tags: + - git + - commits + - push + - cadence +--- + +## Policy + +- Commit at meaningful slice boundaries rather than waiting until a large body of work becomes hard to reason about or recover. +- Make an explicit local checkpoint before risky refactors, rebases, or cleanup that could discard work. +- Push when remote backup, collaboration, review, CI, or cross-machine continuity materially matters. +- Push a recoverable checkpoint before worktree closure, handoff to another owner, machine or environment transition, destructive cleanup, or any pause long enough that local custody could become ambiguous. +- Do not delay pushing important shared work so long that teammates or automation reason from stale branch state. +- Do not push half-understood or misleading commits to shared branches just to create activity. +- If the repo allows work-in-progress commits, keep them on private or clearly scoped branches unless the shared workflow says otherwise. +- Match push cadence to branch type: + - private branch: checkpoint for backup and continuity as needed + - shared feature branch: push whenever collaborators or CI need the current state + - protected branch: push only through the repo's documented integration path +- Name the source and destination explicitly for consequential pushes, for example `git push origin HEAD:refs/heads/`, then verify that the intended remote-tracking ref resolves to the pushed SHA. A successful process exit without ref readback is not sufficient custody evidence. +- Inspect ahead/behind or expected remote-tip state before pushing. Do not overwrite unexpected remote work. +- Prohibit plain forced pushes. A private-branch rewrite may use an exact expected-value `--force-with-lease=:` only when the repository permits rewriting, no dependent lane relies on the old history, and the replacement ref is verified afterward. +- Be explicit about whether end-of-day or end-of-slice pushing is expected for backup and handoff. +- In multi-track repos, do not let unpublished local `main` become a hidden holding area for architectural work once other maintainers depend on `main` for routine maintenance or operational continuity. +- Require handoff clarity about branch intent when different work classes coexist, for example whether a branch is maintenance-safe, migration-only, or still experimental. + +## Adoption Notes + +Use this module when repos need a durable answer to "when should I commit?" and "when should I push?" across more than one maintainer or environment. + +Repo-type guidance: +- `product-engineering`: usually wants frequent local commits, timely shared-branch pushes, and explicit rules for when CI-ready state is required +- `library-cli`: often wants commits at coherent feature/fix boundaries and pushes aligned with review or release preparation +- `workspace-agent`: usually benefits from frequent private checkpoints because local experimentation and repo-local automation can move quickly +- `writing-project`: may prefer fewer but still meaningful commits around review checkpoints, major draft edits, and submission-affecting changes + +Developer-preference guidance: +- solo maintainers can tolerate lighter push cadence if local recovery is strong, but should still push before machine risk or context switching +- teams with active CI or review automation should push early enough for those systems to stay relevant +- repos that value clean shared history may allow messy local checkpoints but require cleanup before integration + +Multi-track repo guidance: +- when conservative maintenance and deeper platform work happen in parallel, maintenance-oriented branches should be pushed soon enough that operators are not forced to reason from stale assumptions +- architectural branches can tolerate more local iteration, but should be published once their state is coherent enough for another operator to interpret without private chat context diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/commit-history-discipline.md b/.agents/skills/repo-policy-selector/policy-library/modules/commit-history-discipline.md new file mode 100644 index 00000000..a425b81d --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/commit-history-discipline.md @@ -0,0 +1,39 @@ +--- +id: commit-history-discipline +title: Commit History Discipline +summary: Keep commits truthful, scoped, and reviewable, and make history communicate what changed and why. +tags: + - git + - commits + - history + - review +--- + +## Policy + +- Prefer commits that represent one coherent change or one tightly related slice of work. +- Do not mix unrelated fixes, refactors, and feature work in the same commit when they can be separated cleanly. +- Keep commit messages truthful about the actual change instead of describing aspirational intent. +- Write commit subjects that make sense in history on their own without relying on chat context. +- Include enough detail in the commit body when the reason, risk, migration effect, or operator impact would be unclear from the diff alone. +- Commit before risky history operations, broad rebases, or destructive cleanup so recoverable checkpoints exist. +- Also commit at material handoff, context-switch, branch-transfer, worktree-closure, and integration-preparation boundaries. A checkpoint is mandatory before an operation that could make owned work harder to recover. +- Review both staged and unstaged diffs before committing. A clean commit message cannot make a mixed or incomplete index truthful. +- Keep temporary work-in-progress commits on a clearly scoped private or feature branch. Before shared integration, preserve useful intermediate history or consolidate it according to the repository's documented merge model without hiding materially distinct changes. +- Do not create fake cleanliness by squashing materially different changes into one commit if that harms reviewability or future archaeology. +- Do not create noisy checkpoint spam on shared history when the repo expects a cleaner review-oriented log. +- Treat commit history as a durable engineering artifact, not just transport for the current turn. + +## Adoption Notes + +Use this module in repos where git history is expected to support review, release notes, rollback, or later debugging. + +Repo-type guidance: +- `product-engineering`: usually wants reviewable feature/fix commits with bodies for migration or operator impact when needed +- `library-cli`: often benefits from commit history that maps cleanly to release notes and compatibility changes +- `workspace-agent`: usually benefits from explicit commit subjects because downstream maintainers often inspect history to understand selector, skill, or policy changes +- `writing-project`: can keep lighter history, but major structure changes, evidence updates, and submission-affecting edits should still be described truthfully + +Developer-preference guidance: +- squash-heavy teams may allow messy local checkpoint commits before merge, but should still require a truthful final shared history +- repos that value archaeology may prefer preserving a few well-scoped intermediate commits instead of aggressively flattening everything diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/course-workspace-governance.md b/.agents/skills/repo-policy-selector/policy-library/modules/course-workspace-governance.md new file mode 100644 index 00000000..c0daa95c --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/course-workspace-governance.md @@ -0,0 +1,37 @@ +--- +id: course-workspace-governance +title: Course Workspace Governance +summary: Govern live course workspace identity, folder authority, archives, generated artifacts, and instructor workflow boundaries. +tags: + - course + - education + - workspace + - governance +--- + +## Policy + +- Treat a course workspace as a live operational surface, not as a generic document folder. +- Make the course identity explicit before substantive work: + - course name or number + - term or offering + - canonical workspace path + - live LMS course target when applicable + - primary cloud-storage folder or drive identity when applicable +- Keep course-local configuration in the course workspace when it is non-secret and needed for reproducible operations. +- Keep secrets, access tokens, OAuth credentials, and private operator credentials outside synced or broadly shared course folders. +- Preserve existing human-organized course folders unless the user explicitly approves a reorganization. +- Separate active course material from: + - generated artifacts + - downloaded exports + - staged submissions + - grading work products + - archives from prior terms +- Treat archives as historical reference surfaces by default; do not mutate archived course material unless the task explicitly concerns archived content. +- Before broad folder cleanup or reorganization, inventory affected paths and state the intended move plan. +- Do not create parallel nested course roots or alternate canonical folders unless the user explicitly requests a migration. +- Keep course-specific operating facts in local policy, notes, or memories rather than relying only on generic shared policy. + +## Adoption Notes + +Use this module for course folders or repos that function as active instructor workspaces, especially when they combine LMS configuration, course documents, student artifacts, generated exports, and cloud-drive material. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/db-backed-state-governance.md b/.agents/skills/repo-policy-selector/policy-library/modules/db-backed-state-governance.md new file mode 100644 index 00000000..cbbc1f61 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/db-backed-state-governance.md @@ -0,0 +1,35 @@ +--- +id: db-backed-state-governance +title: DB-Backed State Governance +summary: Distinguish file-backed artifacts from database-backed website state, keep recovery and reproducibility explicit, and avoid pretending git is the history of mutable CMS state. +tags: + - website + - database + - cms + - migrations +--- + +## Policy + +- Distinguish clearly between state that can live as files in git and state that exists only in a database or CMS runtime. +- Put file-backed assets in git when the repo owns them, such as custom code, migrations, deployment scripts, configuration templates, and durable operational docs. +- Do not try to force mutable CMS or database-backed state into git when the repo cannot represent it faithfully. +- For DB-backed changes, classify each change as one of: + - recoverable state + - repeatable configuration + - editorial or operational content +- Recoverable state must be covered by the repo's backup and recovery workflow. +- Repeatable configuration should be promoted into reproducible artifacts when possible, such as: + - migration scripts + - command sequences + - sanitized SQL patches + - export/import helpers + - documented operator procedures +- Keep migrations narrowly scoped, reviewable, and safe to rerun where possible. +- Separate local-only and live-targeting migrations when production execution risk differs materially. +- When a change cannot be represented safely as code, be explicit that backup/recovery rather than git is the authoritative recovery path. +- Do not let undocumented DB-backed admin changes become silent durable state when they should instead be captured as repeatable artifacts. + +## Adoption Notes + +Use this module when repos manage websites, CMS-backed applications, or other systems where important operational state lives outside normal source-controlled files. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/documentation-change-control.md b/.agents/skills/repo-policy-selector/policy-library/modules/documentation-change-control.md new file mode 100644 index 00000000..1495bc0a --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/documentation-change-control.md @@ -0,0 +1,25 @@ +--- +id: documentation-change-control +title: Documentation Change Control +summary: Update the governing docs in the same slice when plans, semantics, or operator behavior change. +tags: + - docs + - roadmap + - runbook + - semantics +--- + +## Policy + +- Keep the live planning and execution docs current in the same slice as the code or behavior change. +- If scope, semantics, service contracts, or operator workflows change, update the corresponding user-facing or governing docs before handoff. +- Preserve completed or superseded plans as durable history instead of deleting them outright. +- Do not rely on chat history as the authoritative explanation of why a change happened; record it in the repo docs. +- When a change affects a narrow contract document, update that contract doc in the same slice rather than deferring it to later cleanup. + +## Adoption Notes + +Use this module when the repo: +- has roadmap, runbook, journal, contract, or execution-plan docs that steer work +- needs docs to stay aligned with semantics or operator behavior +- benefits from explicit anti-drift rules for plan and doc maintenance diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/fieldwork-productization.md b/.agents/skills/repo-policy-selector/policy-library/modules/fieldwork-productization.md new file mode 100644 index 00000000..521b18f0 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/fieldwork-productization.md @@ -0,0 +1,38 @@ +--- +id: fieldwork-productization +title: Fieldwork Productization +summary: Start live customer or tenant interventions as bounded fieldwork, then explicitly classify what becomes product, what stays local, and what should be retired. +tags: + - fieldwork + - productization + - operations + - migration +--- + +## Policy + +- Treat reactive live work for one tenant, customer, or operator as fieldwork until it proves reusable. +- Start fieldwork on an explicit bounded branch, note, or equivalent execution surface whenever practical. +- Fieldwork notes should capture: + - tenant or operator goal + - systems touched + - expected write surfaces + - artifact locations + - starting branch or commit state + - the provisional roadmap lane or product area +- During fieldwork, allow pragmatic code changes when needed to solve the live problem, but keep evidence, endpoint notes, and idempotency notes close to the field note. +- Before merging fieldwork into normal product history, classify outcomes explicitly: + - keep as product + - refactor before keep + - archive as note only + - discard +- When fieldwork reveals a repeatable workflow, decide whether its durable home is: + - core product code + - operational runtime config + - a skill or playbook + - or a local operator note +- Do not let one tenant's urgent workflow silently define the long-term product architecture without an explicit productization pass. + +## Adoption Notes + +Use this module when a repo is developed partly through live tenant, customer, or operator interventions that may later become reusable product behavior. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/git-worktree-hygiene.md b/.agents/skills/repo-policy-selector/policy-library/modules/git-worktree-hygiene.md new file mode 100644 index 00000000..31e6bea3 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/git-worktree-hygiene.md @@ -0,0 +1,33 @@ +--- +id: git-worktree-hygiene +title: Git / Worktree Hygiene +summary: Keep branch scope narrow, check dirty state early, and treat overlapping work as reconciliation instead of a normal merge. +tags: + - git + - branches + - worktrees + - merge +--- + +## Policy + +- Start branch-sensitive work by checking `git status`. +- Inventory all registered worktrees with `git worktree list --porcelain` before creating, closing, pruning, or reassigning one; the current checkout alone is not the repository topology. +- Treat pre-existing dirty state as a real constraint. +- Keep one bounded branch or worktree scope per execution slice or roadmap lane, consistent with the repo's documented integration model. +- When parallel work is needed, prefer `git worktree` over a second full clone. +- Do not call work merge-ready while the intended changes are still uncommitted. +- Treat the worktree as a checkout, the branch or detached commit as local custody, and a verified remote or archive ref as shared custody. Removing a worktree does not preserve uncommitted changes and does not prove the commits remain discoverable. +- Before removing a worktree, require a clean status, a named branch or explicitly preserved detached commit, an exact checkpoint SHA, and verified durable custody on the intended remote ref or on matching local and remote archive refs. +- Normal closure uses `git worktree remove` without `--force`. Forced removal is exceptional recovery work: first inventory the exact path, preserve any recoverable diff and commit, establish a durable ref, record the reason, and verify the retained SHA. +- Do not delete an unmerged branch merely because its worktree is gone. Prove integration, archival, or explicit discard approval separately. +- If overlapping dirty work exists across branches or worktrees, open a reconciliation step rather than calling it a normal merge. +- Keep branch scope narrow and avoid mixing unrelated lanes unless the active slice requires it. + +## Adoption Notes + +Use this module in repos where multiple lanes, multiple worktrees, or parallel agents regularly overlap. + +This module governs local git cleanliness and overlap handling. Use `branch-and-integration-strategy` to choose whether the repo prefers direct-to-`main`, short-lived feature branches, or another integration model. + +Use `active-lane-coordination` when several off-main lanes need default-branch discovery and custody reconciliation. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/goal-execution-governance.md b/.agents/skills/repo-policy-selector/policy-library/modules/goal-execution-governance.md new file mode 100644 index 00000000..fe9a7b82 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/goal-execution-governance.md @@ -0,0 +1,157 @@ +--- +id: goal-execution-governance +title: Goal Execution Governance +summary: Keep long-running agent goals autonomous and convergent with standing in-scope authority, proportional checkpoints, bounded feedback loops, and action-specific stop rules. +tags: + - goals + - agents + - orchestration + - antidrift + - checkpoints +--- + +## Policy + +- Apply this policy when autonomous work is expected to span multiple bounded + slices, context windows, sessions, or human/runtime gates. +- Preserve the user-approved objective as the stable goal contract. Do not + silently narrow, expand, or rewrite it to match the work already completed. +- Treat that approved goal as standing authority for ordinary in-envelope + implementation, validation, repair, retest, worker replacement, integration, + and bounded successor packets. A packet hard stop ends that execution window; + it does not revoke the approved goal or create a new approval gate by itself. +- Default to action, not permission seeking. When the next useful action is + clearly implied by the objective, stays within the same target and mutation + class, and is low risk, readily reversible, or already covered by explicit + authorization and safeguards, take it without asking the user to approve the + step or first manufacturing another packet. Record the decision afterward + when it matters for continuity. +- Ask for new authorization only for a material departure: changing the + objective, acceptance criteria, or non-goals; adding a new system, tenant, or + private-data class; widening mutation scope; crossing into a destructive, + external, legal, financial, publication, release, or public action not already + clearly authorized; materially raising + cost or resource ceilings; weakening a safety control; or choosing among + materially different outcomes the user must decide. Preserve stricter gates + only when they are explicitly established by higher-priority instructions, + the user, or applicable repo/runtime policy and apply to the exact action + contemplated. A broad label such as `runtime`, `provider`, `review`, or + `live` is not an approval gate by itself. +- An approval stop must identify the exact proposed action, the applicable + authority or safety boundary, and why no safe in-envelope default can make + progress. Missing bookkeeping, a packet counter reaching zero, reviewer + novelty, or generalized uncertainty is not sufficient by itself. +- Allow the campaign plan to stay high-level and derive bounded execution + packets just in time under `planning-discipline`. +- Model execution as explicit states and transitions even when no graph + framework is used. At minimum distinguish ready, active, awaiting-review, + awaiting-gate, blocked, complete, failed, and cancelled states. +- Use `parallel-plan-design` to make dependencies, fan-out, joins, and retry + edges inspectable. Every feedback cycle that can repeat model calls, tool + calls, agent runs, mutations, or context growth must have one named + controller, a semantic exit condition, and a hard bound. +- Treat material replanning as a new plan version or bounded successor packet. + Preserve what changed and why instead of mutating execution history in place. +- At goal start and after a material change, establish one concise control + record: current authority, unmet acceptance criteria, owned worktree scope, + current evidence, ready and blocked work, checkpoint cadence, and exact + applicable gates. Add delegation detail only when relevant. Do not recreate + this inventory for every packet. +- Choose concrete bounds before starting: work-unit attempts, review/rework + cycles, consecutive hardening/no-progress checkpoints, and maximum time, + slices, or available runtime budget between durable checkpoints. If one + metric is unavailable, another observable bound must still cover the loop. + Repo-local defaults may supply these values; an individual packet need not + restate them, and missing packet metadata does not block a first safe attempt. + Bounds prevent runaway work; they are not consumable approval tokens. When a + local bound is reached, first reassess, split the unit, change tactics, or + continue a different safe ready unit under the same authority. Escalate only + when no meaningful safe action remains or an exact action-specific gate is + reached. +- Keep one primary orchestrator responsible for authority, the critical path, + work-unit selection, integration, progress classification, and the final + completion claim. +- Apply `subagent-workflow-optimization` when delegation offers a useful + authorized lane. Apply `validation-and-handoff` for proportionate independent + review and final outcome verification. Neither a worker nor a reviewer is an + approval authority unless an explicit external contract says otherwise. +- At every durable checkpoint, compare the current state with the prior + checkpoint and classify movement as: + - `outcome_progress`: current evidence advances an acceptance criterion + - `blocker_reduction`: a verified blocker or material risk was removed + - `hardening`: resilience improved without changing acceptance state + - `no_progress`: the goal state did not materially change + - `regression`: evidence, safety, or alignment worsened +- Checkpoint at material state transitions, before context handoff, before a + risky or gated mutation, at closeout, and at the configured cadence backstop. + Routine low-risk steps inside one coherent unit do not each require a durable + checkpoint or approval-like receipt. Record the state transition, current + acceptance state, progress classification, evidence, material blockers, and + next action or stop reason; add review or delegation detail only when those + events occurred. +- A failed closed-world verification of an accepted blocking finding transitions + the unit to split, reframe, block, or escalation; it does not silently reopen + an unbounded review cycle. +- Allow at most one broad fresh-context drift-discovery pass per approved goal, + and run it only when consequence, uncertainty, or observed drift makes + independent discovery useful. After the primary adjudicates candidate + findings, verification is closed-world against accepted findings plus + critical regressions introduced by remediation. Plan versions and successor + packets inherit the maximum rather than resetting it. +- Scope every drift guard to what its evidence actually affects. Stale evidence + blocks the associated current claim; an unsafe dirty overlap blocks mutation + of that overlap; an accepted blocking finding blocks the affected criterion + or integration; and an exhausted loop bound blocks repeating that same loop. + Continue unrelated safe work when it can still advance the approved goal. +- Stop autonomous execution only when no meaningful safe in-envelope action + remains, an exact applicable gate requires a user decision, or the objective + is complete, cancelled, or disproven. Repeated hardening or no-progress first + requires a local tactic change or bounded reframe; it does not automatically + require operator approval. +- Continue automatically whenever a useful in-scope action is available and no + exact applicable gate blocks it. A recent checkpoint may support that choice, + but creating another checkpoint is not a prerequisite for taking an obvious + low-risk next step. +- Completion requires current evidence for every acceptance criterion. Token + spend, elapsed time, test count, schema growth, documentation volume, and + completed slice count are not completion evidence by themselves. + +## Adoption Notes + +Use this module for repos that run `/goal`, unattended campaigns, multi-session +agent work, or other long-horizon autonomous execution. + +Before calling adoption complete, adopting repos must define concrete checkpoint +and drift thresholds plus the required checkpoint-record fields in repo-local +policy. When a deterministic planning/runbook audit exists, extend it to verify +goal-plan versioning, checkpoint identifiers, progress classification, and the +configured bounds. Keep exact token counters, time windows, command names, and +runbook schemas repo-local. + +Use a machine-checkable repo-local section such as: + +```text +## Local Goal Bounds +max_work_unit_attempts: +max_review_rework_cycles: +max_hardening_checkpoints: +checkpoint_interval: +authorization_gate: material_departure_or_explicit_action_gate_only +continuation_default: execute_obvious_in_scope_low_risk +bound_exhaustion_mode: local_replan_before_escalation +max_review_discovery_passes: 1 +review_verification_mode: closed_world_if_reviewed +checkpoint_mode: material_boundary_with_cadence_backstop +checkpoint_record_fields: state_transition, acceptance_state, progress_classification, evidence, material_blockers, next_action_or_stop_reason +``` + +The selector bundle's planning auditor supports `--goal-only` to verify this +contract without requiring roadmap/runbook governance. + +Recommended companion modules: + +- `planning-discipline` +- `parallel-plan-design` +- `subagent-workflow-optimization` +- `validation-and-handoff` +- `commit-and-push-cadence` diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/graph-backed-memory-usage.md b/.agents/skills/repo-policy-selector/policy-library/modules/graph-backed-memory-usage.md new file mode 100644 index 00000000..a5148363 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/graph-backed-memory-usage.md @@ -0,0 +1,64 @@ +--- +id: graph-backed-memory-usage +title: Graph-Backed Memory Usage +summary: Use graph-backed memory as durable retrievable context without turning it into a scratchpad or replacing notes and memories docs. +tags: + - memory + - graph + - retrieval + - continuity +--- + +## Policy + +- Treat graph-backed memory as durable retrievable context, not as a scratchpad for every turn. +- Use graph-backed memory for compact, stable cross-turn facts such as: + - user preferences + - project decisions + - durable entity relationships + - recurring operational context that later turns should retrieve quickly +- Do not store ephemeral material in graph-backed memory, including: + - temporary debugging notes + - one-off command output + - transient errors unless they represent a durable incident worth tracking + - raw reasoning traces + - secrets, tokens, passwords, or credential material +- Before re-asking the user for likely durable context, prefer a bounded graph-memory read. +- At the start of non-trivial work, make one lightweight discovery decision: + - `use` when prior decisions, user preferences, runtime history, cross-repo + context, or avoided repeated investigation could materially affect the work + - `skip` when the task is trivial or self-contained and supplied content or + current authoritative sources are sufficient + - `unavailable` when the memory system or required retrieval surface is not + healthy; continue from repo-native evidence and state the fallback only + when it materially limits confidence +- For non-trivial planning, debugging, architecture, audit, adoption, upgrade, + harvest, or handoff work, default to `use` when prior context may exist. Do + not make performative memory calls when the decision is `skip`. +- Keep discovery bounded to the narrowest relevant group and one or two focused + reads before widening. Discovery is read-first and does not authorize a + memory write. +- Query the repo-named memory group first when repo policy names one. +- When the right memory group is unclear, or when the task crosses repos, tenants, or domains, query a reviewed atlas or routing layer first and inspect retrieval, privacy, export, and audience policy before descending into source groups. +- Prefer compact, factual, retrieval-friendly writes over conversational filler or repeated paraphrases of the same fact. +- Avoid memory spam: + - do not write the same preference or project fact every turn + - prefer one good durable memory over many near-duplicate entries + - if a durable fact changed, record the new durable state rather than narrating every intermediate thought +- Use partitions, groups, namespaces, or equivalent separation mechanisms deliberately so unrelated projects, tenants, or domains do not bleed into one another. +- Treat destructive memory-maintenance tools as explicit cleanup or repair operations, not casual day-to-day commands. +- Verify the memory system's availability or health before debugging against it or assuming it is available during normal work. +- Treat memory-derived claims as advisory until verified against repo files, artifacts, commits, tests, or cited episodes. +- Keep richer narrative rationale, long-form handoff, and human-readable change history in repo notes or memories; use graph-backed memory for compact retrieval-oriented facts and relationships. + +## Adoption Notes + +Use this module when a repo regularly works with an installed graph-backed memory system and agents need explicit discipline for when to read, write, partition, or clean up memory. + +This module complements: +- `notes-and-memories`, which governs durable repo notes and long-form continuity artifacts +- `runtime-state-governance`, when the memory system is part of a user-scoped or operator-scoped runtime surface + +Keep product-specific tool names, partition-key semantics, and runtime assumptions repo-local unless they clearly generalize across multiple graph-memory systems. + +Repo-local policy should name the primary memory group when one exists and should identify the memory-discovery skill, tool, or command agents are expected to use. For Graphiti, prefer the `graphiti-discovery` skill and record the repo's primary `group_id`; if the repo does not use Graphiti, preserve the same `use` / `skip` / `unavailable` decision contract with its actual memory system. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/live-drift-reconciliation.md b/.agents/skills/repo-policy-selector/policy-library/modules/live-drift-reconciliation.md new file mode 100644 index 00000000..291f68f3 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/live-drift-reconciliation.md @@ -0,0 +1,29 @@ +--- +id: live-drift-reconciliation +title: Live Drift Reconciliation +summary: Detect and reconcile changes made outside the repo workflow before release so live drift becomes an explicit merge decision instead of an accidental overwrite. +tags: + - website + - drift + - reconciliation + - release +--- + +## Policy + +- Before a meaningful release to a live website or CMS-managed surface, check whether live state has drifted from the repo and local review environment. +- Treat live drift as a real reconciliation surface, not as background noise. +- Review file-backed drift first, because tracked code differences can be overwritten silently by deploy. +- Review database-backed or admin-side drift next, such as content, menus, assets, settings, or other runtime-managed changes. +- For each meaningful drift item, make an explicit decision to: + - accept the live change into repo-managed state + - overwrite it intentionally with the next release + - promote it into a reproducible migration or operational artifact + - defer release until the conflict is resolved +- If both local work and live changes touch the same page, flow, asset set, setting, or configuration surface, resolve that conflict explicitly before deploy. +- Record drift reports or reconciliation notes durably enough that future maintainers can understand why a release did or did not overwrite live state. +- Do not assume the live site still matches the last pulled mirror or the last reviewed backup state. + +## Adoption Notes + +Use this module when website or CMS changes can be made outside normal repo flow, especially through admin interfaces, vendor dashboards, or other live operational surfaces. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/lms-cli-governance.md b/.agents/skills/repo-policy-selector/policy-library/modules/lms-cli-governance.md new file mode 100644 index 00000000..3f98a94c --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/lms-cli-governance.md @@ -0,0 +1,39 @@ +--- +id: lms-cli-governance +title: LMS CLI Governance +summary: Keep LMS-backed command-line operations scoped, read-before-write, and validated when CLI actions can affect live courses. +tags: + - course + - lms + - canvas + - cli +--- + +## Policy + +- Treat LMS CLI actions as live course operations when they target a production course. +- Confirm the active course, environment, and folder-scoped configuration before any live write. +- Prefer read-only inspection before mutation, for example: + - list + - show + - export + - doctor + - planner + - dry-run import +- Use explicit apply or write flags only after inspecting the planned change or after the user clearly requests the live write. +- Prefer the course workspace's configured default course over ad hoc repeated course ids, unless the task is verifying or overriding scope. +- Treat spreadsheet import/export, ObjectView-style sync, bulk assignment updates, messaging, file publication, page edits, module edits, quiz edits, and grade-related commands as live course operations. +- Keep local LMS exports, audit outputs, and temporary machine-readable products in a documented generated-artifact area unless a repo-local policy names a more specific location. +- After changing LMS content or settings, validate with a matching read-only command or export. +- Before applying a live LMS write, confirm: + - active course target + - target environment + - input artifact source + - intended audience + - whether student data, grades, feedback, or answer keys could be exposed or modified + +## Adoption Notes + +Use this module when a repo or folder drives Canvas, Moodle, Blackboard, Google Classroom, or another LMS through a CLI or automation layer. + +Canvas CLI is a concrete instance of this pattern, but the policy is intentionally LMS-generic. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/memory-service-runtime-governance.md b/.agents/skills/repo-policy-selector/policy-library/modules/memory-service-runtime-governance.md new file mode 100644 index 00000000..0d378d43 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/memory-service-runtime-governance.md @@ -0,0 +1,45 @@ +--- +id: memory-service-runtime-governance +title: Memory Service Runtime Governance +summary: Govern installed memory-service runtimes with clear client/server boundaries, health checks, queue visibility, and live smoke verification. +tags: + - memory + - runtime + - mcp + - operations +--- + +## Policy + +- Treat an installed memory service as an operational runtime, not just a library import or agent-side feature. +- Keep the client/server boundary explicit: + - agent clients invoke the service + - the service owns its model, embedding, storage, and queue backends + - client configuration should not be assumed to supply the service's provider credentials or runtime state +- Keep reusable code, scripts, docs, schemas, and redacted examples in the repo; keep live service config, credentials, queues, databases, caches, and operator state in the runtime home. +- Verify the authoritative runtime state before diagnosing behavior, including: + - installed release or manifest identity + - active process or service manager state + - current runtime config source + - health endpoint or equivalent readiness check + - bound listener or transport endpoint +- When the service accepts asynchronous memory work, expose durable job status rather than relying on fire-and-forget behavior. +- Use a small, explicit queue vocabulary such as pending, running, succeeded, failed, retrying, and dead-lettered. +- Make dead-letter inspection and recovery deliberate operations, with separate list, requeue, and drop paths when the runtime supports them. +- After install, restart, migration, or backend changes, run a live read-after-write smoke that proves the service can accept memory input, process it, and retrieve the resulting record. +- Wait for readiness before smoke checks so startup races are not confused with broken releases. +- Check for stale processes or port/listener conflicts before assuming the installer, service manager, or new release is faulty. +- Treat destructive memory or queue maintenance as explicit repair or cleanup work, not normal exploration. +- When docs and runtime factory/config code disagree about supported providers or backends, verify against the executable runtime source or a live health/smoke check before giving setup guidance. +- Keep provider names, runtime paths, service names, port numbers, database choices, and exact tool names repo-local unless they clearly generalize across multiple memory-service runtimes. + +## Adoption Notes + +Use this module when a repo builds, installs, or operates a durable memory service, especially when agents reach it through MCP or a similar service boundary. + +This module complements: +- `graph-backed-memory-usage`, which governs what agents should read and write as durable graph memory +- `runtime-vs-product-boundary`, which separates reusable product code from private runtime state +- `runtime-state-governance`, which decides when runtime artifacts themselves need backup, migration, or versioned tracking + +For repos that only consume a memory service through normal agent workflow and do not operate the service runtime, prefer `graph-backed-memory-usage`. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/monolith-extraction-discipline.md b/.agents/skills/repo-policy-selector/policy-library/modules/monolith-extraction-discipline.md new file mode 100644 index 00000000..bb35f6eb --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/monolith-extraction-discipline.md @@ -0,0 +1,29 @@ +--- +id: monolith-extraction-discipline +title: Monolith Extraction Discipline +summary: Treat oversized command trunks, service files, or workflow modules as liabilities that require explicit extraction boundaries and staged decomposition. +tags: + - architecture + - modularity + - refactor + - debt +--- + +## Policy + +- When one file or module becomes the default landing zone for new behavior, treat that as a design smell rather than normal growth. +- Define extraction seams before adding more unrelated behavior into an oversized trunk file. +- Prefer moving stable responsibilities into focused modules such as: + - command groups + - service layers + - workflow packages + - runtime or tenant helpers + - rendering or output helpers +- Keep extraction work incremental and behavior-preserving; do not require a single all-or-nothing rewrite before improvement can start. +- Record the intended long-term boundaries in a bounded plan before a large decomposition effort. +- When live operations pressure encourages short-term additions to the monolith, record the debt explicitly and schedule the extraction slice rather than treating the temporary shortcut as a permanent structure. +- Use tests and explicit validation to hold behavior steady during extraction. + +## Adoption Notes + +Use this module when a repo has one oversized command file, orchestrator, or service module that is accumulating unrelated product and operational responsibilities. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/multi-agent-reconciliation.md b/.agents/skills/repo-policy-selector/policy-library/modules/multi-agent-reconciliation.md new file mode 100644 index 00000000..1b081f73 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/multi-agent-reconciliation.md @@ -0,0 +1,35 @@ +--- +id: multi-agent-reconciliation +title: Multi-Agent Reconciliation +summary: Reconcile overlapping agent work explicitly, preserve ownership signals, and use history to resolve conflicts instead of silently overwriting edits. +tags: + - agents + - git + - reconciliation + - blame +--- + +## Policy + +- Treat overlapping agent changes as reconciliation work, not as normal silent merge cleanup. +- Prefer disjoint write scopes before parallel execution, and record ownership when multiple agents are active. +- Reconcile against the default-branch active-lane projection before assigning a new branch or worktree when the repository adopts that contract. Record dependencies and expected overlap before concurrent edits begin. +- When integrating conflicting edits, inspect history directly rather than assuming the most recent edit is correct. +- Use commit history, branch context, and `git blame` or equivalent file-history inspection when authorship and intent need to be reconstructed. +- Preserve useful ownership signals such as named commits, clear branch purpose, or explicit closeout notes when they help later reconciliation. +- Preserve subagent run ids, session keys, transcript paths, or equivalent provenance when integrating delegated work. +- When delegated output changes code, policy, or durable docs, cite the delegated source in closeout, commit context, or the relevant plan/handoff note. +- If delegated outputs conflict, inspect logs or transcripts before deciding which result to keep. +- Do not treat summarized announce messages as sufficient reconciliation evidence for high-risk changes. +- Do not rewrite another agent's work without first understanding the intended change surface. +- If a collision reveals weak lane boundaries, update the plan or policy so the same overlap is less likely next time. +- Do not treat an agent session ending as Git closure. The responsible owner must leave a clean published checkpoint and an explicit custody or integration disposition before its worktree can be removed safely. + +## Adoption Notes + +Use this module when repos regularly use multiple agents or contributors on adjacent surfaces and need explicit conflict-resolution discipline. + +Execution-bias guidance: +- `max-dev-speed`: tolerate more concurrent work, but require stronger reconciliation rules and clearer ownership tracking +- `balanced`: prefer disjoint writes first and use reconciliation discipline when overlap still occurs +- `max-token-efficiency`: minimize overlap up front because post-hoc reconciliation is expensive in both time and context tokens diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/notes-and-memories.md b/.agents/skills/repo-policy-selector/policy-library/modules/notes-and-memories.md new file mode 100644 index 00000000..fb76a9b0 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/notes-and-memories.md @@ -0,0 +1,30 @@ +--- +id: notes-and-memories +title: Notes And Memories +summary: Keep durable notes and memories under docs/dev with deterministic naming and use deterministic helpers to manage them. +tags: + - notes + - memories + - continuity + - governance +--- + +## Policy + +- Routinely look for opportunities to record durable notes under `docs/dev/notes/` when a slice leaves behind findings, migration lessons, semantic mismatches, or operational lessons worth preserving. +- Routinely look for opportunities to record durable memories under `docs/dev/memories/` when a repo accumulates stable context, conventions, or recurring facts that future sessions should not have to rediscover. +- Prefer notes for dated observations tied to a specific slice or event. +- Prefer memories for stable context that should persist across many slices. +- Use the same deterministic serial-plus-date filename prefix for notes and memories that the repo uses for plans, for example `0001-YYYY-MM-DD-slug.md`. +- Keep notes and memories discoverable and auditable through deterministic helpers rather than relying on chat history or ad hoc filenames. +- When a repo adopts this policy, check existing `docs/dev/notes/` and `docs/dev/memories/` before starting work and record new entries when the current slice produces reusable context. +- Do not create multiple overlapping notes for one event when one well-scoped dated note already captures the decision, evidence, and reusable lesson. +- When a repo also uses a durable memory system, keep the boundary explicit: use notes and memories for richer human-readable continuity, and use the memory system for compact retrieval-oriented facts and relationships. +- When a repo has an explicit graph-memory group, consider mirroring compact source-cited summaries after durable notes or memories are created so future agents can discover the context without rereading every file. +- Do not treat graph-memory writes as a substitute for repo-file continuity. Write the durable note, memory, plan, release note, or artifact first, then mirror only the stable retrieval-oriented facts. + +## Adoption Notes + +Use this module when a repo benefits from durable continuity beyond plans alone, especially for migrations, policy evolution, operational knowledge, or recurring maintainer context. + +Use `graph-backed-memory-usage` as a companion module when the repo relies on an installed graph-backed memory system during normal work. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/parallel-plan-design.md b/.agents/skills/repo-policy-selector/policy-library/modules/parallel-plan-design.md new file mode 100644 index 00000000..1a07fdd3 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/parallel-plan-design.md @@ -0,0 +1,44 @@ +--- +id: parallel-plan-design +title: Parallel Plan Design +summary: Design plans so parallelizable work is explicit, low-conflict lanes are separated from critical-path blockers, and ownership stays clear. +tags: + - planning + - parallelism + - agents + - coordination +--- + +## Policy + +- Give each parallel lane a clear owner, bounded scope, and expected write surface. +- Keep the critical path visible so parallel work does not hide the real blocker. +- Prefer plan slices that minimize cross-lane file overlap and reconciliation cost. +- Call out integration points explicitly when multiple lanes must converge before completion. +- Express non-trivial execution as inspectable work units and dependency edges, + including fan-out, join, review, retry, and terminal transitions. A table or + plan section is sufficient; a graph framework is not required. +- Do not open parallel lanes just because tools allow delegation; open them only when the work can move independently. +- If a lane becomes coordination-heavy, collapse it back into the critical path or redefine the lane boundary. +- Declare the intended active-agent concurrency before spawning many subagents or parallel workers. +- Cap active subagents per plan lane unless the repo explicitly optimizes for `max-dev-speed` and has strong reconciliation rules. +- Avoid nested subagents by default. +- Use nested or orchestrator subagents only when the plan names the parent orchestration role, child scopes, result-flow path, and synthesis responsibility. +- Treat high fan-out as a plan smell unless the subtasks are independent, low-conflict, and cheap to verify. +- Put a semantic exit condition and a hard bound on every review, retry, repair, + or agent-handoff edge that can cycle back to prior work. +- Reaching a local loop bound ends or reframes that loop; it does not create a + user-approval gate by itself. Continue another safe in-scope route when one is + available, and escalate only when no meaningful route remains or an exact + action-specific boundary requires a user decision. +- When a work unit cannot be bounded or has too many coupled write surfaces, + return it for split/reframe before spawning workers. + +## Adoption Notes + +Use this module when repos regularly use subagents, parallel contributors, or multiple active implementation lanes. + +Execution-bias guidance: +- `max-dev-speed`: open more parallel lanes when ownership and write surfaces are clear enough to keep wall-clock time down +- `balanced`: parallelize bounded sidecar work but keep urgent blockers and tightly coupled work on the critical path +- `max-token-efficiency`: keep fewer active lanes, prefer larger local ownership, and avoid parallel decomposition that duplicates context or creates heavy reconciliation work diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/planning-discipline.md b/.agents/skills/repo-policy-selector/policy-library/modules/planning-discipline.md new file mode 100644 index 00000000..e5b10d3b --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/planning-discipline.md @@ -0,0 +1,94 @@ +--- +id: planning-discipline +title: Planning Discipline +summary: Keep stable high-level outcome plans while deriving bounded execution packets, explicit definitions of done, and parallel workstreams as work becomes ready. +tags: + - planning + - slices + - parallelism + - states +--- + +## Policy + +- Adopt bounded planning discipline in every repo, with ceremony proportional + to the work. Trivial one-step tasks do not need a plan artifact; substantive, + multi-file, multi-step, risky, or resumable work does. +- Use bounded plan artifacts under `docs/dev/plans/` or an equivalent plans directory, not ad hoc note files scattered through the repo. +- Plan filenames should use a deterministic serial-plus-date prefix such as `0001-YYYY-MM-DD-plan-slug.md`. +- If the repo uses a canonical long-range plan such as `ROADMAP.md`, treat it as the source of truth for priority. +- If the repo uses a canonical live execution log such as `RUNBOOK.md`, treat it as the source of truth for what happened turn by turn. +- When `RUNBOOK.md` is present, maintain it as a dated turn log with deterministic headings such as `Turn N | YYYY-MM-DD`. +- Treat planning migration for active repos as two phases: + - structural migration to establish canonical files, naming, and wiring + - semantic reconciliation to align plan text and lane status with the actual shipped state +- Configure deterministic planning audits to the repo's documented authority + paths. Do not assume `docs/dev/plans`, `ROADMAP.md`, or `RUNBOOK.md` when the + repo has an explicit equivalent such as `doc/dev/plans`. +- Each plan should carry an explicit deterministic state from a small fixed vocabulary, for example: + - `PLANNED` + - `OPEN` + - `CLOSED` + - `CANCELLED` +- Multi-track repositories may also use `BLOCKED`. Keep this outcome state separate from Git custody such as active worktree, paused ref, integration-ready, integrated, archived, or discard-approved. +- For any plan in an active state such as `OPEN`, require a short `Current State` section that says what already exists and what still remains. +- Use bounded plan artifacts with explicit scope, non-goals, acceptance criteria, and definition of done. +- A plan organizes execution; it does not grant, consume, or renew authority. + Once the user approves a goal, routine in-scope plan revisions, packets, and + successors proceed under that standing authority. Do not ask for approval + merely because the next step was not enumerated in advance. +- Keep plan altitude proportional to its horizon. A campaign or `/goal` plan may + remain high-level when it preserves the objective, milestones, dependencies, + gates, and outcome evidence; derive detailed implementation packets just in + time instead of pretending every future step is knowable up front. +- Separate the stable objective and milestone plan from mutable execution state. + Record material replanning as an explicit revision or successor plan rather + than silently rewriting the goal to fit current progress. +- Keep goal-level control state outside any one plan version. Successor plans, + packet retries, and reviewer replacement inherit standing authority, accepted + finding ledgers, review-discovery counts, and no-progress history; they do not + reset those controls merely by changing a filename or version number. +- When one reasonable next step is clearly implied and low risk, choose it and + keep moving. Ask the user to choose only when alternatives would materially + change the outcome, scope, cost, or safety envelope. +- Give each active execution packet one bounded outcome, owner, expected write + surface, required inputs, validation evidence, and terminal condition. +- When active work lives off the default branch, keep execution detail in the branch-local plan and publish only a compact active-lane projection to the default branch. Plan closure does not by itself authorize branch deletion or worktree removal. +- When a task is large enough to plan, explicitly separate: + - parallelizable low-conflict tracks + - critical-path serialized work +- Keep one critical-path owner visible even when subagents or parallel workers are used. +- Do not let one plan artifact accumulate endless follow-on polish; close it or open a new bounded slice. +- Reconcile plan state promptly when implementation lands, a successor + supersedes the plan, or a gate blocks integration. Stale `OPEN` labels are + continuity debt even when the implementation itself is sound; repair the + record, but do not treat the label alone as a new approval gate. +- Do not equate plan activity with progress. Require current evidence that a + slice advances an acceptance criterion or removes a verified blocker. +- Classify plan-only refinement, reviewer novelty, extra documentation, and + speculative hardening as hardening rather than outcome progress unless they + demonstrably remove a verified blocker or advance an acceptance criterion. +- If the repo adopts roadmap/runbook governance, keep plan wiring and plan state aligned with those canonical files. +- When the planning contract changes in a way that affects validation, update the deterministic audit helper in the same slice. +- Separate steady-state enforcement from legacy migration. A current/active + audit may ignore closed or unclassified historical artifacts only when the + report names what it excluded and the repo retains a bounded migration or + baseline decision for that debt. +- An active-only audit may accept exact repo-local findings from + `docs/dev/planning-audit-baseline.json` when the file records a rationale, + review condition, and exact finding strings. Keep accepted and unused + baseline entries visible in the report; do not apply the baseline to full or + forced audits, and do not let one accepted finding suppress a new one. +- Absence of a plans directory is not itself an active-scope defect. Continue + to require the configured directory during full or forced structural audits. + +## Adoption Notes + +Use this module as a baseline in every starter profile. A lightweight repo may +use short bounded plans only for substantive work; baseline adoption does not +imply that every turn needs a plan. + +Use `roadmap-runbook-governance` as the stricter companion module when the repo keeps canonical `ROADMAP.md` and `RUNBOOK.md` authority. + +Use `goal-execution-governance` when the plan will drive autonomous work across +multiple slices, sessions, context windows, or gates. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/policy-adoption-feedback-loop.md b/.agents/skills/repo-policy-selector/policy-library/modules/policy-adoption-feedback-loop.md new file mode 100644 index 00000000..5dd22ea2 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/policy-adoption-feedback-loop.md @@ -0,0 +1,56 @@ +--- +id: policy-adoption-feedback-loop +title: Policy Adoption Feedback Loop +summary: Record what worked, what did not, and what should change upstream after policy adoption or upgrade so reusable lessons do not disappear into chat history. +tags: + - policy + - feedback + - upgrades + - notes +--- + +## Policy + +- After first policy adoption, the first substantive execution under that + policy, a major policy upgrade, or meaningful policy friction, record a dated + feedback artifact in the adopting repo. +- The feedback artifact should identify at least: + - installed policy bundle version or immutable ref, or an explicit statement + that provenance is unknown and must be repaired + - selected profile + - modules adopted + - modules deferred, retired, or overridden locally + - what worked cleanly + - what created friction or ambiguity + - what should remain repo-local + - what may warrant an upstream module, profile, or selector change +- Distinguish installation, active wiring, and enacted behavior. Cite the + `AGENTS.md` entrypoint for wiring and a current plan, runbook entry, closeout, + audit receipt, or runtime readback for behavioral evidence. +- If no substantive execution has exercised the policy yet, record `not yet + evidenced` rather than calling adoption successful or ineffective. +- Prefer storing dated adoption feedback in the repo's normal durable continuity surface, such as: + - `docs/dev/notes/` + - `docs/dev/memories/` + - bounded plans plus matching runbook entries + - another documented local equivalent +- Do not leave important adoption lessons only in chat history, commit messages, or oral maintainer knowledge. +- When feedback appears reusable across repos, route it into the shared policy repo through a deterministic harvest path rather than treating it as one repo's private observation. +- If the repo uses a pinned installed selector bundle, tie feedback to that pinned version so later maintainers can interpret it correctly. +- When a repo adopts local overrides instead of the exact starter profile, record why; those reasons are often the best signal for future shared policy refinement. +- When a repo upgrades policy, compare the new experience to prior adoption notes so repeated friction becomes visible over time. +- Record stale local-policy prose, invalid local facts, and audit-contract + incompatibilities as adoption defects even when the underlying work outcome + was successful. +- When a repo has an explicit graph-memory group, mirror compact source-cited adoption feedback into that group after the dated feedback artifact exists, especially when it identifies reusable friction, missing modules, profile-fit issues, or selector behavior changes. +- Keep graph-memory feedback entries small and source-anchored. They should point future agents to the dated artifact or release note, not replace it. +- A single dated artifact may satisfy this module, `policy-upgrade-management`, and `notes-and-memories` when it captures both the upgrade or adoption decision and the resulting feedback clearly. + +## Adoption Notes + +Use this module when repos adopt shared policy from an external source library and want a durable loop between downstream adoption experience and upstream policy improvement. + +This module complements `notes-and-memories` and `policy-harvest-loop`: +- `notes-and-memories` defines where continuity artifacts live +- `policy-harvest-loop` governs how a policy repo normalizes reusable rules +- `policy-adoption-feedback-loop` governs how adopting repos capture feedback that can later be harvested diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/policy-harvest-loop.md b/.agents/skills/repo-policy-selector/policy-library/modules/policy-harvest-loop.md new file mode 100644 index 00000000..a6dbad17 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/policy-harvest-loop.md @@ -0,0 +1,60 @@ +--- +id: policy-harvest-loop +title: Policy Harvest Loop +summary: Capture reusable policy from successful sessions and normalize it back into reusable modules rather than leaving it trapped in one repo. +tags: + - policy + - harvesting + - normalization +--- + +## Policy + +- When a repo develops a strong local policy, decide whether it is: + - repo-local only + - reusable enough for the shared policy repo +- Prefer normalizing reusable rules into small modules rather than copying giant `AGENTS.md` sections wholesale. +- Preserve the original repo-specific wording only when the local context is essential. +- Before adding prose policy, choose the cheapest reliable enforcement surface: + a deterministic check or linter, a review standard, a precise routing + pointer, repo-local policy, or shared policy. Prefer automation for an + objective failure that tooling can detect, and reserve always-loaded steering + text for behavior that actually requires agent judgment. +- Treat repeated navigation cost, tool inefficiency, stale pointers, no-op + instructions, and unavailable evidence as harvest candidates alongside + behavioral rules. The durable improvement may be better information access + or tooling rather than more policy text. +- Harvest from: + - repo `AGENTS.md` + - repeated session behavior + - runbook or antidrift patterns + - branch and merge discipline that proved useful in practice + - dated adoption feedback, notes, memories, and release notes + - compact graph-memory facts when they are source-cited and verified against repo artifacts +- For a multi-repo harvest, define the checkout inventory and exclusions before + comparing repos. Classify worktrees, aliases, backups, smoke clones, public + exports, and the policy source repo separately so they do not inflate + downstream adoption or effectiveness counts. +- Keep three evidence stages distinct: + - `available`: the policy or behavior appears in a repo artifact + - `adopted`: the active entrypoint wires it into the repo contract + - `evidenced`: a current plan, runbook, closeout, or runtime receipt shows it + affecting execution +- Do not call a policy effective from file presence or keyword counts alone. + Review direct source paths for strong, partial, and weak cases, including + counterexamples where tests passed but a gate correctly stopped integration. +- When deterministic audits are used as fleet evidence, record applicability, + configured paths, audit scope, excluded legacy/unclassified artifacts, and + false-positive limitations. A large problem count from an incompatible + contract is migration evidence, not proof that execution quality is poor. +- When a repo has an explicit graph-memory group, query it before starting substantial harvest work so prior policy decisions and repeated friction are not rediscovered from scratch. +- After a harvest changes shared modules, profiles, selector behavior, or schema, mirror a compact source-cited summary into the policy memory group when the repo's Graphiti write workflow is available and safe. +- Do not harvest directly from unsourced memory facts. Treat graph memory as discovery and routing evidence until verified against repo files, artifacts, commits, or cited episodes. + +## Adoption Notes + +Use this module in policy repos and skill repos that curate reusable agent behavior. + +Fleet-level harvests should produce a reproducible inventory/scorecard plus a +dated synthesis note. Keep exact repo scores and checkout paths in the audit +artifact rather than in this reusable module. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/policy-management.md b/.agents/skills/repo-policy-selector/policy-library/modules/policy-management.md new file mode 100644 index 00000000..4d4863de --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/policy-management.md @@ -0,0 +1,41 @@ +--- +id: policy-management +title: Policy Management +summary: Install policy first, keep adopted repo-local policy under docs/dev/policies, and wire it in deterministically from AGENTS.md. +tags: + - policy + - governance + - installation + - wiring +--- + +## Policy + +- When a repo adopts shared policy, install the policy library before running selection or adoption workflows. +- Enumerate available profiles, modules, and catalog metadata deterministically from the installed policy library rather than relying on chat history or sibling checkout layout. +- Keep the adopted repo-local policy under `docs/dev/policies/`. +- Keep exactly one active repo-local policy file per shared module identity. + Treat the module id, not the ordinal filename, as the stable identity; a new + serial must not turn an upgrade into a second active copy. +- Keep `AGENTS.md` as the entrypoint that wires the adopted repo-local policy into the repo contract. +- Treat `AGENTS.md` as a policy-loading contract, not just a static pointer. +- Treat repo-local policy as one section of `AGENTS.md`, not the whole file. +- Keep repo-specific commands, environment prerequisites, and operating constraints in `AGENTS.md` or adjacent local docs even after shared policy is installed. +- Keep `AGENTS.md` thin relative to the full durable policy body; do not turn it into the full policy dump if the repo can keep policy files under `docs/dev/policies/`. +- Make each policy pointer name both the target and the condition that should + trigger reading it. Required policy behind a vague or stale pointer is not + reliably wired. +- Keep each rule in one authoritative location. Use `AGENTS.md` for routing and + repo-specific constraints, and use linked policy files for the durable body; + do not duplicate the same rule across both surfaces for emphasis. +- Re-read the relevant adopted policy files at the start of any non-trivial turn. +- Re-read the relevant adopted policy files when task scope changes mid-session. +- Treat policy installation, policy enumeration, and `AGENTS.md` wiring as deterministic setup work rather than ad hoc prose copying. +- Validate policy identity and wire-in uniqueness deterministically. Duplicate + identities must name every conflicting path and fail closed until a + maintainer reconciles them; tooling must not silently choose a winner. +- When the repo uses an installable selector bundle, ensure the selector ships with the policy library it depends on. + +## Adoption Notes + +Use this module as the first adopted policy when a repo is managed through the shared policy selector workflow. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/policy-upgrade-management.md b/.agents/skills/repo-policy-selector/policy-library/modules/policy-upgrade-management.md new file mode 100644 index 00000000..3f840f36 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/policy-upgrade-management.md @@ -0,0 +1,57 @@ +--- +id: policy-upgrade-management +title: Policy Upgrade Management +summary: Check for shared policy updates deliberately, evaluate upgrade impact, and adopt, retire, or defer repo-local policy changes explicitly. +tags: + - policy + - upgrades + - releases + - governance +--- + +## Policy + +- Treat shared policy upgrades as intentional maintenance work, not accidental drift from copying files ad hoc. +- Check for policy-library updates through a deterministic source of truth, such as: + - tagged releases + - upstream commits + - a pinned selector bundle version + - a checked-out local policy repo or workspace path + - a known GitHub repository and branch or release channel +- Record what version, tag, commit, bundle ref, or local policy source the repo last reviewed or adopted when that information materially affects reproducibility. +- When upstream policy changes appear, decide explicitly whether to: + - adopt a new module + - upgrade an already adopted module + - retire a no-longer-useful local policy + - defer the change for a documented reason +- Review profile changes separately from module changes; a profile upgrade should not silently force a repo into every newly suggested module. +- When a local repo has customized policy, prefer merge review over blind overwrite. +- Retire superseded local policy files explicitly when a shared replacement makes them unnecessary. +- Resolve upgrades by module identity before allocating a new ordinal filename. + Replace or merge the existing adopted path when one identity exists; when + several paths claim the identity, stop and require explicit reconciliation. +- An upgrade is incomplete while `AGENTS.md` wires both a superseded and current + generation. Remove the retired pointer in the same transaction and verify + that exactly one retained path remains. +- Never infer the winner between divergent duplicates from filename recency, + modification time, or list order. Compare content and local overrides, retain + the intended semantics, and record the retirement decision. +- Scope upgrades against the repo's retained module set first; a broader profile recommendation should not automatically become the new local baseline when fit review says otherwise. +- When the policy library publishes release notes, changelog entries, or comparable upgrade summaries, use them to scope the upgrade review before patching local policy. +- If the repo follows upstream commits directly instead of releases, define how often to check and what level of change justifies adoption. +- Keep policy upgrade decisions durable in repo docs, plans, runbooks, or notes when the rationale would otherwise be lost. +- One dated policy adoption or upgrade artifact may serve as the canonical durable record for: + - the upgrade decision + - adoption feedback + - reusable continuity notes + when it records the version reviewed, decision taken, rationale, and notable fit or friction. + +## Adoption Notes + +Use this module when the repo depends on an external or shared policy library and needs a durable contract for staying current without adopting every upstream change blindly. + +Repo-type guidance: +- `product-engineering`: usually wants deliberate upgrade review because planning, release, and operator policies can have cross-cutting effects +- `library-cli`: often benefits from checking policy upgrades near release or dependency-maintenance cycles +- `workspace-agent`: often benefits from reviewing upstream policy or selector updates regularly because skill and orchestration behavior can drift quickly +- `writing-project`: usually wants lighter upgrade cadence tied to major workflow or deliverable shifts rather than constant policy churn diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/preview-artifact-review.md b/.agents/skills/repo-policy-selector/policy-library/modules/preview-artifact-review.md new file mode 100644 index 00000000..e5e62ef8 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/preview-artifact-review.md @@ -0,0 +1,31 @@ +--- +id: preview-artifact-review +title: Preview Artifact Review +summary: Publish important review artifacts through an available preview service so human review and approval happen in a browser instead of through scattered local paths. +tags: + - artifacts + - previews + - review + - approval +--- + +## Policy + +- When a turn produces artifacts that materially need human inspection, publish them through the available preview or approval service before asking the user to review them. +- Treat preview publishing as especially important for: + - rendered documents + - HTML or website builds + - PDFs, Office documents, images, and galleries + - reports, review packets, benchmark outputs, or generated summaries + - any artifact family where one browser URL is clearer than a list of local file paths +- Use the preview skill or service only when it is available in the current environment. If it is unavailable, fall back to clear local paths and say that preview publishing was not available. +- Group related outputs into one preview session and return the session URL rather than sending many artifact links. +- If the next action depends on human approval, publish the preview, state the decision needed, and stop before making the gated change. +- Before continuing approval-sensitive work, read the preview feedback through the service when available; if feedback is absent or ambiguous, ask the user directly instead of assuming approval. +- Do not publish secrets, raw credentials, private tokens, or sensitive raw logs to preview sessions. +- Do not persist raw share-link tokens in repo docs, memory systems, logs, or committed artifacts. +- Keep preview publishing focused on human-review value. Do not create preview sessions for trivial outputs that are already clear in the terminal or final response. + +## Adoption Notes + +Use this module when a repo regularly creates local artifacts that should be inspected or approved by a human, especially when browser rendering, visual layout, generated packets, or multi-file output families matter. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/roadmap-runbook-governance.md b/.agents/skills/repo-policy-selector/policy-library/modules/roadmap-runbook-governance.md new file mode 100644 index 00000000..b82a0fcc --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/roadmap-runbook-governance.md @@ -0,0 +1,39 @@ +--- +id: roadmap-runbook-governance +title: Roadmap / Runbook Governance +summary: Prevent plan drift by assigning clear authority to roadmap, runbook, and progress files. +tags: + - governance + - roadmap + - runbook + - antidrift + - auditability +--- + +## Policy + +- Keep the roles separate: + - roadmap: master plan, priority map, and lane catalog + - runbook: dated turn log of what happened + - progress ledger: completed-history record +- When an active-lane catalog is adopted, treat it as the current Git-custody and discovery projection, not as a second roadmap. The roadmap continues to own priority; branch-local plans own execution detail; the runbook owns chronology; Git evidence owns custody and integration proof. +- Treat `ROADMAP.md` as the master plan and revise it cautiously. +- Do not materially reorder, rename, or reprioritize roadmap lanes unless the user explicitly asks for that change, or unless a narrow correction is required to unblock already-requested work. +- Use one canonical top-level roadmap item naming convention, for example `P## | `, rather than mixing free-form phases, lanes, and milestones. +- If duplicated status text drifts, the roadmap and runbook win over stale summaries elsewhere. +- For any roadmap lane in an active state such as `OPEN`, include a short `Current State` note that says what already exists and what still remains. +- New plan artifacts must live under `docs/dev/plans/` and be wired into both the roadmap and the runbook before they are treated as active. +- For any roadmap lane in an active state such as `OPEN`, require at least one actionable plan unless the lane is being closed in the same slice. +- If a feature does not fit an existing lane, update the roadmap first and make the priority decision explicit. +- Plan wiring and plan-state semantics should be auditable by deterministic helpers rather than relying on chat history or inference. +- After a planning-contract migration for an active repo, record one dated review note or runbook entry that captures the migration, semantic mismatches found, and refinements required. +- Treat progress/history files as retrospective, not planning authority. +- When append-only plans or runbook history become too large for fast authority + recovery, maintain a compact current-head projection of active lanes, latest + checkpoint evidence, blockers, and next actions. The projection may be + generated, but it must link back to the canonical ledger and must not replace + or rewrite history. + +## Adoption Notes + +Use this module when the repo already carries multiple planning files or has suffered planning drift. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/runtime-state-governance.md b/.agents/skills/repo-policy-selector/policy-library/modules/runtime-state-governance.md new file mode 100644 index 00000000..d5fac12d --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/runtime-state-governance.md @@ -0,0 +1,39 @@ +--- +id: runtime-state-governance +title: Runtime State Governance +summary: Govern when user-scoped runtime state should itself become a managed artifact set, and separate tracked durable state from ephemeral or secret runtime material. +tags: + - runtime + - state + - versioning + - redaction +--- + +## Policy + +- Treat user-scoped runtime state as a separate surface from the product repo. +- Do not version all runtime output by default; classify runtime material first: + - durable authoritative state + - durable but derived state + - ephemeral caches and previews + - secrets and sensitive logs +- Version runtime state only when it is: + - expensive to reconstruct + - intentionally curated over time + - needed across machines or operators + - important for audit, rollback, or continuity +- Keep secrets, raw credentials, and highly sensitive logs out of versioned runtime state even when other runtime artifacts are tracked. +- Prefer a dedicated tracked runtime-state root or repo over mixing tracked and untracked concerns indiscriminately across one large runtime home. +- Use explicit ignore rules so caches, temporary artifacts, bulk exports, and machine-local scratch data do not pollute the tracked runtime state history. +- Prefer structured JSON, YAML, or similarly inspectable files for tracked runtime state over opaque blobs when practical. +- Be explicit about which runtime artifacts are authoritative versus rebuildable. +- When runtime state schema changes, migrate it deliberately and record the change rather than silently rewriting old state in incompatible ways. +- If the runtime state is version controlled, use clear commit hygiene, pruning rules, and archival rules so the state history remains understandable and recoverable. + +## Adoption Notes + +Use this module when a repo's user-scoped runtime home contains durable tenant, operator, or environment state that may itself need version control, backup, migration, or cross-machine continuity. + +This module complements: +- `runtime-vs-product-boundary`, which keeps runtime state out of the product repo +- `tenant-isolation-and-operator-state`, which keeps runtime state isolated per tenant or operator environment diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/runtime-vs-product-boundary.md b/.agents/skills/repo-policy-selector/policy-library/modules/runtime-vs-product-boundary.md new file mode 100644 index 00000000..9a66d3c4 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/runtime-vs-product-boundary.md @@ -0,0 +1,26 @@ +--- +id: runtime-vs-product-boundary +title: Runtime Vs Product Boundary +summary: Keep reusable product code and templates in the repo while pushing live user, tenant, and operator state into a user-scoped runtime home. +tags: + - runtime + - product + - boundary + - state +--- + +## Policy + +- Keep the repo publishable without private runtime state. +- Store reusable code, schemas, templates, docs, redacted fixtures, and deterministic examples in the repo. +- Store live user data, tenant data, secrets, caches, artifacts, action logs, and operator memories in a user-scoped runtime home outside the repo. +- Treat repo-local config files as templates or examples unless they are intentionally non-sensitive defaults. +- Resolve private runtime state through stable selectors such as profile ids, tenant ids, resource ids, or artifact ids rather than by hardcoding private local paths into product code. +- Do not let personal testing data, ad hoc exports, or one tenant's artifacts become the canonical product interface by accident. +- A good boundary test is: deleting the runtime home should still leave a coherent public repo. + +## Adoption Notes + +Use this module when a repo is both: +- a reusable software project +- and a tool used against live private operator or tenant state during development diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/seminal-workspace-evolution.md b/.agents/skills/repo-policy-selector/policy-library/modules/seminal-workspace-evolution.md new file mode 100644 index 00000000..89182c57 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/seminal-workspace-evolution.md @@ -0,0 +1,43 @@ +--- +id: seminal-workspace-evolution +title: Seminal Workspace Evolution +summary: When a repo does not fit existing policy families well, treat it as a formative workspace, capture fit and friction deliberately, and use that evidence to shape a future repo type. +tags: + - policy + - archetypes + - feedback + - evolution + - continuity +--- + +## Policy + +- If a repo does not fit an existing shared policy family cleanly, treat its selected profile as provisional rather than final. +- Record why the current repo does not fit the existing families well enough, including: + - which candidate profiles were considered + - what fit cleanly + - what felt over-prescriptive + - what important workflow or artifact surfaces remain uncovered +- After meaningful turns, record dated fit notes that capture: + - what policy worked well + - what required local override or reinterpretation + - what was missing from the shared library + - whether the repo is converging toward a stable new archetype +- Promote repeated stable conclusions into `docs/dev/memories/`; keep turn- or slice-specific refinement notes in `docs/dev/notes/`. +- When the same missing pattern appears repeatedly, propose: + - a new reusable module + - a refined selector rule + - a new profile + - or a new `repo_purpose` + rather than letting the workspace remain an indefinite one-off. +- Keep the current repo-local policy explicit while the archetype is still forming; do not pretend the repo is well served by an existing family just because a weak nearest match exists. +- When the workspace is serving as the first strong example of a new repo type, treat those fit notes as first-class harvest input for the shared policy repo. + +## Adoption Notes + +Use this module when a repo is a formative or seminal workspace whose operating model is not yet covered well by the existing shared policy families. + +This module complements: +- `policy-adoption-feedback-loop`, which captures adoption and upgrade feedback +- `notes-and-memories`, which defines where formative notes and stable conclusions live +- `policy-harvest-loop`, which governs how those conclusions become reusable shared policy diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/student-data-and-assessment-safety.md b/.agents/skills/repo-policy-selector/policy-library/modules/student-data-and-assessment-safety.md new file mode 100644 index 00000000..1b234350 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/student-data-and-assessment-safety.md @@ -0,0 +1,27 @@ +--- +id: student-data-and-assessment-safety +title: Student Data And Assessment Safety +summary: Protect student-identifiable data, grades, submissions, reflections, feedback, answer keys, and assessment artifacts in course workspaces. +tags: + - course + - student-data + - assessment + - privacy +--- + +## Policy + +- Treat course workspaces as student-data-sensitive by default when they contain rosters, submissions, grades, reflections, evaluations, feedback, attendance, or assessment artifacts. +- Minimize student-identifiable details in summaries, notes, and handoffs. +- Prefer aggregate counts, status summaries, anonymized references, or folder-level descriptions unless named student detail is necessary for the task. +- Do not disclose student names, grades, submissions, exam scans, rubric contents tied to named students, reflections, evaluations, or private feedback unless the user explicitly asks and the disclosure is necessary. +- Keep answer keys, exam solutions, rubrics, and grading artifacts separated from student-facing upload material. +- Do not upload, copy, move, or share graded material, answer keys, private feedback, or student-identifiable records into public or broadly shared locations without explicit approval. +- Keep secrets, LMS tokens, OAuth credentials, answer keys, and private assessment material out of synced public-facing or student-accessible folders. +- Treat historical archives as potentially containing prior student records; do not treat them as public reference material. +- Before sending LMS messages, announcements, sharing changes, published files, gradebook changes, or form-response exports, verify intended audience and course scope. +- Record validation and residual risk without copying unnecessary student-identifiable data into the closeout. + +## Adoption Notes + +Use this module for live course operations, LMS-backed classroom management, grading workflows, seminar/reflection workflows, or any folder that contains student work or assessment material. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/subagent-runtime-governance.md b/.agents/skills/repo-policy-selector/policy-library/modules/subagent-runtime-governance.md new file mode 100644 index 00000000..9bc7b5b7 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/subagent-runtime-governance.md @@ -0,0 +1,50 @@ +--- +id: subagent-runtime-governance +title: Subagent Runtime Governance +summary: Govern spawned subagent lifecycle, status, tool access, nesting, transcript retention, and concurrency when a repo builds or operates subagent runtimes. +tags: + - agents + - subagents + - runtime + - governance +--- + +## Policy + +- Treat subagent runtimes as operational systems with lifecycle, provenance, tool-surface, and cost controls. +- Define the authoritative status vocabulary for subagent runs, including success, failure, timeout, cancellation, and unknown outcomes. +- Require completion signals to come from runtime status, logs, or transcripts rather than model-written claims alone. +- Preserve enough run metadata for later audit or reconciliation, such as: + - run id + - session id or session key + - parent run id + - transcript or log path + - start and finish timestamps + - runtime status + - token, model, and cost metadata when available +- Define the expected announce or completion payload shape, including status, result, notes, and retrieval path for deeper inspection. +- Make subagent tool access explicit. +- Deny session-management, system, destructive, credential, and live-operation tools by default unless the subagent role requires them. +- When nested subagents are allowed, define: + - maximum spawn depth + - maximum children per parent + - global concurrency cap + - which depth may orchestrate children + - how results flow back to the primary agent + - how cancellation cascades through children +- Prefer shallow nesting. Treat depth beyond one orchestrator layer as exceptional unless the repo exists to operate agent runtimes. +- Require timeouts or watchdog expectations for long-running subagent work. +- Treat transcript cleanup, archive, or deletion as a retention decision rather than incidental cleanup. +- Make cost and model defaults explicit for spawned work so low-risk sidecar work does not silently consume high-cost reasoning. +- Document known runtime limitations, such as best-effort announce delivery, process restarts, shared gateway resources, or missing context injection. +- Keep runtime-specific command names, config syntax, and deployment assumptions repo-local unless they generalize across multiple subagent runtimes. + +## Adoption Notes + +Use this module when the repo builds, configures, or operates subagent infrastructure, not merely when it occasionally delegates work. + +For ordinary repos that only use subagents as a workflow technique, prefer: +- `subagent-workflow-optimization` +- `parallel-plan-design` +- `multi-agent-reconciliation` +- `validation-and-handoff` diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/subagent-workflow-optimization.md b/.agents/skills/repo-policy-selector/policy-library/modules/subagent-workflow-optimization.md new file mode 100644 index 00000000..b0d4121d --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/subagent-workflow-optimization.md @@ -0,0 +1,80 @@ +--- +id: subagent-workflow-optimization +title: Subagent Workflow Optimization +summary: Make an explicit delegation decision for non-trivial work, automatically use subagents for valuable bounded lanes, and keep the primary agent on orchestration and integration. +tags: + - agents + - delegation + - subagents + - optimization +--- + +## Policy + +- Delegate only concrete, bounded subtasks that materially advance the active slice. +- At the start of non-trivial work and after material replanning, consider + whether delegation would create a genuinely useful independent lane. This is + an execution choice, not a user-approval event. +- When subagent tooling and capacity are available, spawn without additional + user prompting if at least one useful bounded lane exists, such as: + - independent discovery or evidence collection off the immediate critical path + - implementation with a disjoint write surface + - context-heavy work that benefits from an isolated context window + - independent validation, audit, or adversarial review +- Record a non-delegation reason only when a plan expected a worker or the lack + of delegation materially affects timing, independence, or evidence. Do not + create a `not_spawned` receipt for every routine packet. +- When delegation occurs, leave a durable receipt for consequential work: + record the bounded lane, available agent/run/session handle, terminal status, + evidence returned, and the primary agent's reconciliation decision. +- Keep urgent blocking work local when the next action depends directly on the answer. +- Give delegated work explicit ownership, expected output, and write scope. +- Prefer subagents for independent sidecar work, verification, or implementation slices with disjoint write sets. +- Do not spawn parallel work that duplicates context loading or repeats the same exploration without a clear benefit. +- Reuse prior agent context when the task is a continuation of the same bounded thread. +- Prefer fresh context when independence is part of the value: neutral review, + adversarial audit, a newly split work unit after drift, or a handoff intended + to shed accumulated context and assumptions. +- For a fresh reviewer, provide a frozen review packet: objective, acceptance + criteria, non-goals, target identity or commit, applicable gates, review mode, + and—during remediation verification—the accepted finding ledger. Ask for + evidence-shaped candidate findings and explicitly permit a no-finding result. +- A reviewer detects drift; it does not own scope, finding disposition, goal + authority, or operator approval. The primary agent reconciles the result and + may reject, backlog, or seek evidence for a candidate that does not satisfy + the frozen contract. +- Do not turn reviewer completion, reviewer agreement, or a second reviewer + opinion into a prerequisite for obvious low-risk progress unless an explicit + acceptance or safety contract requires that review. +- Use broad fresh context for the initial drift scan. Use closed-world prompts + for later verification and carry the same finding identifiers across worker + replacement, plan revisions, and successor packets so review discovery does + not restart accidentally. +- Keep final integration responsibility with the primary agent even when subagents perform part of the work. +- Be explicit about whether the repo optimizes for wall-clock speed, token efficiency, or a balance of the two. +- Treat spawned subagents as asynchronous runtime artifacts, not just informal delegation. +- Record the subagent run id, session id, transcript path, or equivalent handle when the runtime provides one. +- Do not assume delegated work completed until an announce payload, status check, log read, or transcript inspection confirms completion. +- A plan that merely names a subagent role is design evidence, not proof that a + worker ran. Effectiveness claims require a runtime handle or an explicit + unavailable-runtime receipt plus the resulting integration decision. +- For critical or high-risk delegated work, inspect the transcript or logs instead of relying only on a summarized announce. +- Prefer subagent closeout that includes status, result, notes, and available runtime, token, or cost metadata. +- Set explicit timeout expectations for long-running, slow-tool, or uncertain delegated work. +- Give each subagent a stop condition and require it to return partial evidence + rather than self-extending into adjacent work when the bound is reached. +- Use lower-cost or lower-reasoning models for bounded sidecar work only when the quality risk is low; keep synthesis, architecture, and final integration on an appropriately capable model. +- Treat subagent cleanup and transcript retention as deliberate choices when later evidence or reconciliation may matter. + +## Adoption Notes + +Use this module when repos actively rely on delegation or subagent orchestration rather than single-agent execution. + +Execution-bias guidance: +- `max-dev-speed`: delegate earlier, parallelize more independent work, and accept some coordination overhead to reduce wall-clock time +- `balanced`: delegate bounded sidecar work and verification, but keep tightly coupled or critical-path work local +- `max-token-efficiency`: delegate only when the subtask is clearly independent and the expected gain exceeds the added context and reconciliation cost +- `max-token-efficiency` still requires the explicit delegation decision; it + changes the threshold for spawning, not whether delegation is considered + +Use `subagent-runtime-governance` as a companion module when the repo builds, configures, or operates the subagent runtime itself. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/tenant-isolation-and-operator-state.md b/.agents/skills/repo-policy-selector/policy-library/modules/tenant-isolation-and-operator-state.md new file mode 100644 index 00000000..51ea20f0 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/tenant-isolation-and-operator-state.md @@ -0,0 +1,25 @@ +--- +id: tenant-isolation-and-operator-state +title: Tenant Isolation And Operator State +summary: Keep one runtime profile per tenant or operator environment, with isolated state, resources, artifacts, and logs. +tags: + - tenant + - runtime + - isolation + - operations +--- + +## Policy + +- Treat one runtime profile as one tenant or one operator environment. +- Keep state, resources, memories, artifacts, and action history isolated per profile. +- Bind any persistent local state to the exact runtime target it belongs to, such as profile name, tenant label, base URL, database, or equivalent identity. +- Refuse silent reuse of one tenant's local state against another tenant or environment. +- Keep tenant-specific secrets, mailbox bindings, connector identities, and deploy-time resources outside the repo in the runtime home. +- Prefer explicit readiness checks before tenant-specific write workflows. +- Record durable tenant facts in tenant-scoped runtime memories rather than repo docs when those facts are private, environment-specific, or operationally sensitive. +- Keep cross-tenant product behavior in product code and shared docs; keep tenant-specific operational facts in isolated runtime state. + +## Adoption Notes + +Use this module when a repo manages more than one live customer, tenant, environment, or operator-facing runtime target. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/turn-closeout.md b/.agents/skills/repo-policy-selector/policy-library/modules/turn-closeout.md new file mode 100644 index 00000000..870063f9 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/turn-closeout.md @@ -0,0 +1,22 @@ +--- +id: turn-closeout +title: Turn Closeout +summary: Default to a best recommendation at end of turn, with a small number of alternate closeout modes. +tags: + - closeout + - recommendation + - handoff +--- + +## Policy + +- End-of-turn closeout should default to a best recommendation. +- Alternate closeout modes should be explicit and limited, for example: + - plan or audit + - next slice details + - pause and review roadmap alignment +- Do not end with vague “what do you want next?” language when a best recommendation is available. + +## Adoption Notes + +Use this module when the repo values consistent turn endings and explicit next-step guidance. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/upstream-fork-maintenance.md b/.agents/skills/repo-policy-selector/policy-library/modules/upstream-fork-maintenance.md new file mode 100644 index 00000000..0bb00ff6 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/upstream-fork-maintenance.md @@ -0,0 +1,54 @@ +--- +id: upstream-fork-maintenance +title: Upstream Fork Maintenance +summary: Isolate private work from upstream sync, rebase private branches deliberately, and rewrite history only where ownership and safety are clear. +tags: + - git + - fork + - upstream + - rebase +--- + +## Policy + +- Use a distinct upstream remote when the repo carries private or local features on top of a non-owned active upstream. +- Keep private feature work isolated from the branch used to mirror or track upstream state. +- Rebase private branches onto fresh upstream state when the goal is to keep a small, understandable delta over an active upstream. +- Prefer force-push only on branches that are explicitly private, unshared, or documented as rebase-managed. +- Do not rewrite shared branch history casually when other collaborators, CI systems, or deployments may already depend on it. +- Keep one branch or tag that records the last known clean upstream sync point before heavy private divergence. +- Before rewriting a downstream carry, preserve its exact prior tip and freeze + the downstream semantic invariants that must survive, such as excluded + features, promotion membership, authority boundaries, runtime behavior, or + publication scope. +- Record conflict-prone patches, local carry patches, or intentionally retained divergences somewhere durable when they are likely to recur across rebases. +- Resolve conflicts from each side's intent and primary sources. A favor option + or conflict-free operation is only a textual result; it is not proof that the + downstream semantics survived. +- After the operation, verify the frozen downstream invariants independently of + syntax, manifest, and test-runner checks. When semantic verification fails, + rebuild from the preserved tip and old/new upstream inputs or abort and + restart from the recovery point. +- Do not make merge or rebase completion mandatory. Abort or restart is the + safe disposition when intent is unavailable, the recovery point is + uncertain, or the proposed resolution cannot be validated without inventing + behavior. +- Keep source presence, promoted or enabled membership, local installation, + release publication, and remote publication as separate proof boundaries. +- Be explicit about whether downstream release tags are cut from rebased private branches, merge-based integration branches, or snapshots after upstream sync. +- If a private feature is becoming long-lived and hard to rebase, reconsider whether it should remain a fork-local patch set or become a maintained downstream branch line. + +## Adoption Notes + +Use this module when the repo is a fork or downstream derivative of an actively changing upstream that the maintainers do not control. + +Repo-type guidance: +- `product-engineering`: useful for internal product forks of vendor or open-source systems where private deployable behavior rides on top of active upstream updates +- `library-cli`: useful for downstream maintained forks that publish their own releases while selectively ingesting upstream fixes +- `workspace-agent`: useful for private skill, prompt, or policy forks built on public upstream agent tooling +- `writing-project`: rarely needed unless the repo is effectively maintaining a downstream derivative of another canonical source tree + +Developer-preference guidance: +- rebase-oriented downstreams usually want small private deltas and frequent upstream sync +- audit-heavy downstreams may prefer merge-based integration branches that preserve explicit upstream incorporation points +- force-push is reasonable on truly private maintenance branches, but not as a default on shared collaboration branches diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/validation-and-handoff.md b/.agents/skills/repo-policy-selector/policy-library/modules/validation-and-handoff.md new file mode 100644 index 00000000..8598d22c --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/validation-and-handoff.md @@ -0,0 +1,74 @@ +--- +id: validation-and-handoff +title: Validation And Handoff +summary: Run relevant verification before commit or handoff and leave concise evidence-backed closeout notes. +tags: + - validation + - tests + - handoff + - verification +--- + +## Policy + +- Run the relevant validation for the touched surface before commit, handoff, or merge preparation. +- Prefer targeted verification that matches the changed area, and widen to broader suites when the impact is user-visible or cross-cutting. +- Include concrete pass/fail evidence in the handoff or closeout note. +- For off-main work, bind validation to the exact checkpoint SHA and report branch, remote custody, worktree status, target, integration method, and remaining cleanup or archival disposition. +- Keep handoff notes concise, explicit about remaining risk, and clear about the next recommended action. +- When live or manual smoke matters for the changed surface, record whether it was run and what it proved. +- Prefer validation receipts that bind the result to a durable commit, artifact, + installed version, endpoint response, or other current-state identifier. + Temporary paths alone are not durable handoff evidence; preserve or publish + the necessary artifact in a repo-approved location, or record why the proof + is intentionally ephemeral and how it can be reproduced. +- Distinguish validation run by the primary agent from validation reported by a subagent or delegated worker. +- If validation was delegated, record whether the primary agent independently verified the result or accepted the delegated evidence as-is. +- For failed, timed-out, incomplete, or unknown subagent statuses, state what was trusted, what was ignored, and what remains unverified. +- Use an independent evaluator when fresh judgment materially reduces risk or + uncertainty, or when an explicit acceptance contract requires it. Duration or + plan count alone does not make independent review mandatory for routine, + low-risk work. +- Treat evaluator output as candidate evidence, not an automatic veto. The + primary agent owns adjudication and records each candidate as `blocking`, + `nonblocking_backlog`, `rejected`, or `needs_evidence` against the frozen + objective, acceptance criteria, non-goals, and applicable safety controls. +- A reviewer is not an approver. Work may continue on unaffected in-scope units + while candidates are adjudicated, and only an accepted blocking finding may + block the action or criterion it actually affects. +- Require each candidate finding to state the criterion, evidence, consequence, + reproducer, confidence, and suggested disposition. A useful independent + review may return no findings; novelty and finding count are not quality + metrics. +- When both conformance and objective correctness matter, report them as + separate review axes: one for repository standards and one for the frozen + specification or acceptance contract. Do not let a pass on one axis mask a + failure on the other, and do not let the separation bypass primary-agent + evidence review and disposition. +- Separate review modes. Use at most one broad fresh-context `drift_discovery` + pass when observed drift, consequence, or uncertainty justifies it. After + adjudication, use `closed_world` remediation + verification limited to accepted blocking findings and critical regressions + introduced by their fixes. Do not reopen broad discovery merely because a + new evaluator performs final verification. +- Bound review and rework at the goal level, not only per plan version. Prefer + one consolidated candidate set and one bounded remediation pass; if accepted + blocking findings still fail verification, split, reframe, or block the unit + instead of continuing an open-ended evaluator/optimizer loop. Record + nonblocking concerns in backlog without silently expanding the active plan. +- A review or rework bound ending triggers primary-agent disposition, local + reframe, or a scoped block. It does not consume goal authority or require user + approval when another safe in-scope action remains. +- Validate the resulting outcome and current external state, not only the + transcript, diff shape, test count, or agent's narrative of progress. +- Distinguish `validated`, `integration-ready`, `integrated`, and `cleanup-complete`; none implies the next. Verify target ancestry or a squash/patch receipt before claiming integration, and verify retained refs before removing a worktree or branch. +- Treat fail-closed gates as successful policy execution when they prevent an + unsafe or disproven change from integrating. Report the blocked outcome and + evidence instead of grading effectiveness only by shipped changes. + +## Adoption Notes + +Use this module when the repo: +- has multiple test or smoke surfaces with different scopes +- expects evidence-backed closeout notes +- needs clear verification and residual-risk communication before review or release diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/versioning-and-release.md b/.agents/skills/repo-policy-selector/policy-library/modules/versioning-and-release.md new file mode 100644 index 00000000..96c7065c --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/versioning-and-release.md @@ -0,0 +1,43 @@ +--- +id: versioning-and-release +title: Versioning And Release +summary: Use one explicit versioning and release strategy, tie versions to immutable cuts, and record compatibility and rollout intent clearly. +tags: + - versioning + - release + - compatibility + - changelog +--- + +## Policy + +- Document one primary versioning scheme for the repo instead of mixing incompatible schemes opportunistically. +- Choose a versioning scheme that matches the consumer contract: + - use semantic versioning when downstream users depend on compatibility signals between released artifacts + - use date-based or milestone-based versioning when the repo primarily ships dated deliverables, internal deployment cuts, or review snapshots rather than reusable APIs +- Treat a version or release tag as an immutable cut that points to a reviewable repo state. +- Version and release consumer-visible changes, not every internal commit by default. +- Record what changed, who it affects, and any required migration, rollout, or operator action for each release. +- Keep the release process deterministic enough that two maintainers would cut the same release from the same validated state. +- Be explicit about release gating: + - what validation is required before a release cut + - whether release notes are required + - whether tags, packages, deploys, or deliverable bundles are the canonical release artifact +- Do not imply stronger compatibility guarantees than the repo can actually honor. +- If the repo supports multiple artifact types, document which artifact is authoritative for versioning and which are derived outputs. + +## Adoption Notes + +Use this module when the repo ships named versions, release tags, package builds, deployable cuts, or formal deliverable revisions. + +Repo-type guidance: +- `product-engineering`: usually version consumer-facing API, app, or deployable changes; release notes should emphasize user-visible behavior, operator steps, and migration risk +- `library-cli`: usually prefer semantic versioning because external consumers often depend on compatibility signals from tags or packages +- `workspace-agent`: version installable skills, plugins, or policy bundles when downstream repos consume them as artifacts; if the repo is mostly internal, lighter tag-based releases may be enough +- `writing-project`: often prefer revision, milestone, or date-based release cuts tied to submission or review checkpoints rather than semantic versioning + +Developer-preference guidance: +- manual maintainers may prefer explicit human-reviewed release notes and manual tagging +- automation-heavy teams may prefer deterministic changelog generation, release scripts, and automated tagging once validation passes +- trunk-based repos may cut releases directly from `main`, while branch-heavy repos may require a documented release branch or stabilization step +- concise teams may keep short release summaries, while externally consumed repos usually need more explicit compatibility and upgrade notes diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/visual-release-qa.md b/.agents/skills/repo-policy-selector/policy-library/modules/visual-release-qa.md new file mode 100644 index 00000000..81884f45 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/visual-release-qa.md @@ -0,0 +1,29 @@ +--- +id: visual-release-qa +title: Visual Release QA +summary: Treat meaningful UI and UX changes as requiring visual review, targeted browser validation, and explicit release evidence rather than code deploy success alone. +tags: + - website + - design + - qa + - release +--- + +## Policy + +- Do not treat a passing code deploy as sufficient evidence for design-facing or UX-facing website changes. +- For meaningful visual changes, validate the exact changed page, flow, or component rather than checking only a homepage or generic smoke route. +- Run visual review on the intended review surface before release. +- Capture durable review evidence when the change is visually meaningful, such as: + - screenshots + - focused crops + - concise review notes + - targeted QA artifacts +- Include basic functional confirmation for the primary call to action or interactive flow affected by the change. +- When automated browser or performance checks exist, use them as supporting evidence rather than a substitute for visual review. +- After release, verify the live surface that users actually see, not only the local or staging surface. +- If the release contract includes recovery or backup refresh after live changes, treat that as part of release completeness rather than optional cleanup. + +## Adoption Notes + +Use this module when repos ship website changes where layout, styling, copy presentation, or interaction quality matters materially and cannot be validated by code-level tests alone. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/web-interface-quality.md b/.agents/skills/repo-policy-selector/policy-library/modules/web-interface-quality.md new file mode 100644 index 00000000..a317712f --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/web-interface-quality.md @@ -0,0 +1,29 @@ +--- +id: web-interface-quality +title: Web Interface Quality +summary: Set reusable quality expectations for website interaction, accessibility, responsive behavior, states, and performance without hard-coding one brand's visual style. +tags: + - website + - accessibility + - interaction + - performance +--- + +## Policy + +- Keyboard and focus behavior should work on every meaningful interactive path. +- Prefer native semantics before custom interaction patterns, and ensure visible focus indicators remain intact. +- Design and verify responsive behavior across small, typical, and wide screens rather than assuming one viewport is representative. +- Treat empty, error, sparse, dense, and loading states as part of the interface contract, not as afterthoughts. +- Forms should keep labels, validation feedback, and submission behavior clear and accessible. +- Do not rely on color alone to communicate status, errors, or success. +- Use motion only when it clarifies cause and effect or adds deliberate value, and provide a reduced-motion path. +- Avoid unnecessary layout shift, missing image dimensions, or interaction jank that degrades the live experience. +- Prefer inline guidance and recoverable flows over dead ends or opaque failures. +- Validate meaningful interface changes with a combination of browser review, accessibility awareness, and basic performance checks appropriate to the surface. + +## Adoption Notes + +Use this module when repos own public-facing website or web-application interfaces and need a reusable baseline for interaction quality. + +This module should stay framework-agnostic and avoid encoding one company's brand-specific design language, typography, or copywriting style as universal policy. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/website-surface-targeting.md b/.agents/skills/repo-policy-selector/policy-library/modules/website-surface-targeting.md new file mode 100644 index 00000000..fe98686a --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/website-surface-targeting.md @@ -0,0 +1,30 @@ +--- +id: website-surface-targeting +title: Website Surface Targeting +summary: Identify the exact website surface a change affects before editing, validating, or deploying, especially when repos manage multiple live, local, staging, or deprecated surfaces. +tags: + - website + - environments + - deployment + - targeting +--- + +## Policy + +- Before changing code, content artifacts, migrations, or validation targets, state which website surface the work is meant to affect. +- Treat multiple surfaces as distinct until proven otherwise, for example: + - canonical public site + - staging or preview site + - local review mirror + - deprecated legacy surface + - secondary or nested site +- Do not assume a change to one tracked code path affects the canonical public experience. +- Verify which runtime, hostname, path root, or deploy target a proposed change actually reaches before editing or releasing. +- Keep the canonical public surface explicit in repo docs so validation and release checks do not drift to the wrong environment. +- Mark deprecated or non-production surfaces explicitly and treat them as opt-in targets, not default release paths. +- When a repo maintains both legacy and current surfaces, document the boundary between them and keep routine workflows pointed at the current surface by default. +- Validation should target the same surface the slice intends to affect; do not treat a passing check on one surface as sufficient evidence for another. + +## Adoption Notes + +Use this module when a repo manages website work across more than one meaningful surface, especially when local mirrors, preview hosts, nested sites, legacy domains, or deprecated installations coexist. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/writing-authoritative-deliverables.md b/.agents/skills/repo-policy-selector/policy-library/modules/writing-authoritative-deliverables.md new file mode 100644 index 00000000..5f36520d --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/writing-authoritative-deliverables.md @@ -0,0 +1,22 @@ +--- +id: writing-authoritative-deliverables +title: Writing Workspace Authoritative Deliverables +summary: Preserve one current authoritative file per deliverable and make canonical outputs explicit during drafting and review. +tags: + - writing + - deliverables + - canonical + - review +--- + +## Policy + +- Maintain one current authoritative file per deliverable. +- When multiple related outputs exist, explicitly name the canonical client-facing or reviewer-facing artifact. +- Move stale or superseded drafts into an archive or staging area instead of leaving ambiguous near-duplicates in the active workspace. +- In handoffs and runbooks, refer to the canonical artifact directly rather than forcing a future session to infer it. +- Treat authoritative-file status as workflow state that must be preserved across turns. + +## Adoption Notes + +Use this module for proposal, manuscript, review, memo, and patent-analysis workspaces where duplicate draft sprawl is a recurring risk. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/writing-document-edit-safety.md b/.agents/skills/repo-policy-selector/policy-library/modules/writing-document-edit-safety.md new file mode 100644 index 00000000..67057003 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/writing-document-edit-safety.md @@ -0,0 +1,26 @@ +--- +id: writing-document-edit-safety +title: Writing Workspace Document Edit Safety +summary: Protect fragile document structures and prefer safe editing patterns for DOCX, citation fields, tracked changes, and review artifacts. +tags: + - writing + - docx + - citations + - safety +--- + +## Policy + +- Treat complex office documents as fragile structured artifacts, not plain text blobs. +- Prefer safe editing methods that preserve: + - live citation fields + - tracked changes state + - embedded drawings or anchors + - formatting that carries workflow meaning +- If a tradeoff remains, preserve semantic document structures first and defer cosmetic cleanup to a later safe pass. +- When generating review snapshots such as PDFs, verify they correspond to the intended source document version. +- Document risky editing seams so future sessions do not rediscover them by accident. + +## Adoption Notes + +Use this module for writing-project repos that operate heavily on DOCX, PDF, citation-managed manuscripts, or similar structured deliverables. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/writing-environment-aware-workflow.md b/.agents/skills/repo-policy-selector/policy-library/modules/writing-environment-aware-workflow.md new file mode 100644 index 00000000..c6aeec06 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/writing-environment-aware-workflow.md @@ -0,0 +1,22 @@ +--- +id: writing-environment-aware-workflow +title: Writing Workspace Environment-Aware Workflow +summary: Keep one authoritative writing workspace across local and transient environments, and make continuity explicit when moving between them. +tags: + - writing + - environment + - continuity + - handoff +--- + +## Policy + +- At the start of a turn, determine whether the agent is operating in the authoritative local workspace or a transient mirrored/uploaded environment. +- If work happens in a transient environment, require an explicit handoff artifact or replacement workspace bundle before turn end. +- Keep one authoritative working copy per deliverable path. Avoid competing drafts across environments unless the split is explicit and temporary. +- Record enough continuity information to resume without reconstructing document state from chat history alone. +- Treat environment changes as workflow-relevant context, not background noise. + +## Adoption Notes + +Use this module for writing-project repos where agents regularly move between local disks, mirrored folders, online REPL sessions, or packaged review bundles. diff --git a/.agents/skills/repo-policy-selector/policy-library/modules/writing-review-evidence-discipline.md b/.agents/skills/repo-policy-selector/policy-library/modules/writing-review-evidence-discipline.md new file mode 100644 index 00000000..45b730f1 --- /dev/null +++ b/.agents/skills/repo-policy-selector/policy-library/modules/writing-review-evidence-discipline.md @@ -0,0 +1,28 @@ +--- +id: writing-review-evidence-discipline +title: Writing Workspace Review And Evidence Discipline +summary: Keep review context, evidence depth, comparison artifacts, and analysis outputs organized and explicitly tied to the active deliverable. +tags: + - writing + - review + - evidence + - analysis +--- + +## Policy + +- Make the active review or analysis scope explicit. +- Keep companion evidence artifacts organized and discoverable, such as: + - review context + - rubrics + - side memos + - literature or award comparisons + - closest-art or prior-art notes + - FTO or claim-strategy support materials +- Identify canonical entry points for evidence and analysis so future sessions do not need to rediscover them. +- Distinguish missing inputs, open issues, and next actions clearly. +- Treat evidence depth and comparison scope as part of the deliverable contract, not incidental notes. + +## Adoption Notes + +Use this module for proposal review, literature-backed writing, patentability analysis, and other writing-project workflows where evidence organization materially changes output quality. diff --git a/.agents/skills/repo-policy-selector/references/dev-plan.md b/.agents/skills/repo-policy-selector/references/dev-plan.md new file mode 100644 index 00000000..c25dd1bc --- /dev/null +++ b/.agents/skills/repo-policy-selector/references/dev-plan.md @@ -0,0 +1,111 @@ +# Development Plan + +This is the developer handoff home for the repo-policy skill family. + +## Current state + +The repo-policy tooling now has three generally reusable pieces plus one repo-local maintainer tool: + +1. purpose-aware policy library in `agent-policies` +2. deterministic selector in `repo-policy-selector/scripts/select_policy.py` +3. deterministic planning audit helper in `repo-policy-selector/scripts/audit_planning_contract.py` +4. repo-scoped maintainer harvester in `.codex/skills/repo-policy-harvester/scripts/harvest_policy.py` + +## Current supported policy families + +- `product-engineering` +- `writing-project` +- `workspace-agent` +- `library-cli` + +The strongest implemented families today are: +- `product-engineering` +- `writing-project` + +## Current shared modules worth preserving + +### Product-engineering + +- `planning-discipline` +- `roadmap-runbook-governance` +- `architecture-guardrails` +- `documentation-change-control` +- `git-worktree-hygiene` +- `turn-closeout` +- `validation-and-handoff` + +### Writing-project + +- `planning-discipline` +- `turn-closeout` +- `writing-environment-aware-workflow` +- `writing-authoritative-deliverables` +- `writing-document-edit-safety` +- `writing-review-evidence-discipline` + +## Important recent work + +Recent commits from the original `agent-skills` repo for this family: + +- `c4eb94c` Add repo policy selector and harvester skills +- `a3c3dac` Make repo policy selector purpose-aware +- `de7e5a6` Calibrate selector for review and analysis workspaces +- `927d09e` Make policy harvester purpose-aware for writing projects +- `064198f` Reuse shared writing-project policy modules in harvester +- `e8c5267` Recommend full writing-project policy modules +- `50b4ee1` Add product-engineering policy harvesting +- `6a769a4` Refine engineering policy detection for shared seams +- `3d01cbb` Add deterministic planning contract audit + +## Concrete adoption examples + +### Product-engineering + +- `/home/ecochran76/workspace.local/litscout/AGENTS.md` +- `/home/ecochran76/workspace.local/google-messages-cli/AGENTS.md` + +### Writing-project + +- `/mnt/h/My Drive/Project Management/Proposals/2026-02-26 USDA AFRI SAS Strengthening Agricultural Systems/AGENTS.md` +- `/mnt/h/My Drive/Project Management/Proposals/2026-05-19 NSF TTP/AGENT.MD` + +## Planning-contract position + +The current planning standard is now explicit: + +- `ROADMAP.md` is the master plan +- `RUNBOOK.md` is the dated turn log +- actionable plans live under `docs/dev/plans/` +- roadmap headings should use `P## | ` +- runbook headings should use `Turn N | YYYY-MM-DD` +- plan filenames should use `0001-YYYY-MM-DD-plan-slug.md` +- plan states should be deterministic: + - `PLANNED` + - `OPEN` + - `CLOSED` + - `CANCELLED` + +Use: + +```bash +python scripts/audit_planning_contract.py --repo-root /path/to/repo --json +``` + +to audit that contract. + +The auditor now derives applicability from adopted planning policies. Use +`--force` for a pre-adoption assessment, path flags for documented alternate +authorities, and `--active-only` for a steady-state gate that explicitly reports +unclassified legacy exclusions. + +## Next best work + +1. Use the selector and audit helper on more real repos before adding more abstraction. +2. Add new policy families only when at least two real repos show the same reusable pattern. +3. Prefer improving adoption docs and heuristics over adding more modules prematurely. + +## Avoid + +- mixing workspace-agent memory rules into normal engineering repos +- forcing the strict planning contract onto a mature repo without a bounded migration +- adding broad new policy families without concrete adoption examples diff --git a/.agents/skills/repo-policy-selector/references/policy-shapes.md b/.agents/skills/repo-policy-selector/references/policy-shapes.md new file mode 100644 index 00000000..d124b37e --- /dev/null +++ b/.agents/skills/repo-policy-selector/references/policy-shapes.md @@ -0,0 +1,34 @@ +# Policy Shapes + +Prefer three adoption shapes. + +## 1. Starter profile + +Use when the repo has weak or inconsistent policy and the shared profile fits well. + +## 2. Profile plus overrides + +Use when the repo clearly matches a profile, but has local needs such as: +- stronger roadmap governance +- stricter git/worktree handling +- repo-specific command or data rules +- a mixed maintenance-plus-platform operating model that needs branch-policy refinement without creating a whole new generic profile + +## 3. Custom composition + +Use when the repo has strong local policy already or spans multiple operating modes. + +Examples that often need custom composition or a profile plus overrides: +- repos that are both a conservative maintenance surface and a forward-looking development platform +- repos where one maintainer preserves the current production scheme while another is building migration architecture + +## Drafting guidance + +- Keep shared policy wording concise. +- Preserve repo-local commands, paths, and naming. +- Avoid copying policy-repo metadata or YAML frontmatter into repo-local policy files. +- Keep `AGENTS.md` thin when possible and use it to wire in policy that lives under `docs/dev/policies/`. +- Preserve purpose-specific local rules: + - codebase build/test/release rules for engineering repos + - memory/heartbeat/social conduct for workspace-agent repos + - deliverable and evidence organization rules for writing-project repos diff --git a/.agents/skills/repo-policy-selector/references/selection-workflow.md b/.agents/skills/repo-policy-selector/references/selection-workflow.md new file mode 100644 index 00000000..22768870 --- /dev/null +++ b/.agents/skills/repo-policy-selector/references/selection-workflow.md @@ -0,0 +1,238 @@ +# Selection Workflow + +## Goal + +Recommend the best starter policy profile and module composition for a target repo. + +Selection is purpose-aware. Do not choose a profile until you have an explicit or inferred repo purpose. + +Bounded planning discipline is a universal starter-profile baseline. Keep its +ceremony proportional to the work, and do not infer that every repo needs a +`ROADMAP.md` or `RUNBOOK.md`. + +Installation, policy enumeration, and downstream repo wiring should be deterministic. + +## Installation first + +Before selection in another repo: +- install the selector together with its policy library +- prefer a one-shot install path that places the pinned selector bundle under `.codex/skills/repo-policy-selector/` in the target repo and records the chosen git ref or local source +- prefer an installed selector bundle with a `release-manifest.json` so downstream repos can pin a reviewed bundle version +- confirm the installed bundle can enumerate: + - profiles + - modules + - catalog metadata + - by reading `catalog.yaml`, not by relying on hard-coded assumptions alone + +## Inspect first + +Read the target repo's: +- `AGENTS.md` +- `docs/dev/policies/` +- roadmap / runbook / progress files if present +- `docs/dev/plans/`, `docs/dev/notes/`, and `docs/dev/memories/` when present +- obvious repo-shape signals such as `package.json`, `pyproject.toml`, `tests/`, `docs/dev/` + +Extract existing policy surfaces before recommending adoption changes. +That extraction should inventory current policy-bearing files and classify them against the installed templates as: +- `keep` +- `merge` +- `retire` + +Derive adopted identity from the module id encoded by the local policy filename, +not from the ordinal prefix. Report every canonical path when more than one file +claims an identity, use recommendation mode `identity-reconciliation-required`, +and refuse write mode until a maintainer preserves the intended local semantics +in one retained file and removes superseded wire-in entries. + +When `AGENTS.md` already contains substantive local guidance, infer repo-local policy sections and classify them as: +- `keep` +- `merge` +- `review-conflict` + +Do not assume every section of an existing `AGENTS.md` should be replaced by the shared policy wire-in. +Treat `AGENTS.md` as a policy-loading contract, not just a static pointer: +- tell agents to re-read relevant policy files at the start of non-trivial turns +- tell agents to re-read relevant policy files when scope changes +- prefer explicit re-read triggers over assuming the initial file read remains sufficient for a long session + +## Look for these signals + +- roadmap/runbook discipline +- bounded plan discipline, including lightweight plans for substantive work in + repos without roadmap/runbook authorities +- cluttered or legacy planning surfaces that need migration into canonical files +- cluttered or legacy notes/memories that need migration into canonical directories +- multiple active lanes +- parallel work or worktree usage +- multi-agent or delegated execution +- subagent runtime operation, including spawn depth, session ids, transcript paths, announce payloads, tool policy, and concurrency limits +- explicit closeout policy +- evidence of policy drift or anti-drift corrections +- whether the repo is a product repo, simple library, or skill/prompt/policy repo +- whether the repo is fundamentally a writing-project workspace with deliverable-driven organization +- whether the repo is fundamentally an operations-platform workspace with tenant-scoped runtime state, live operator workflows, and fieldwork that later becomes product +- whether the repo is fundamentally a course-workspace with LMS-backed live course operations, cloud-drive course materials, and student-data or assessment risk +- whether the repo is fundamentally a website repository with live-surface targeting, DB-backed state, drift reconciliation, or visual release QA +- whether the repo is fundamentally a formative seminal workspace whose operating model does not fit the existing families cleanly yet +- whether the repo uses an installed durable graph-memory system and needs explicit read/write/cleanup discipline in addition to notes and memories +- whether the repo has an indexed codegraph or `../codegraph` workflow that agents should consult before source-code edits or architecture analysis +- whether the repo produces local artifacts, reports, review packets, rendered documents, or local builds that should be surfaced through a preview or approval service for human review + +For course workspaces, prioritize operational signals over generic document-folder shape: +- LMS config such as `canvas-cli.yml` +- live course ids or environment names +- assignment, submission, grading, quiz, module, roster, announcement, exam, seminar, or lecture folders +- student-data, FERPA, reflection, evaluation, response, grade, rubric, answer-key, or private-feedback language +- cloud-drive placeholders such as `.gsheet`, `.gform`, `.gdoc`, or `.gslides` +- Google Drive, OneDrive, or similar provider-native folder ids and connector workflows + +For graph-backed memory usage, prioritize signals such as: +- installed graph-memory tools or MCP usage +- graph-backed durable memory language +- explicit memory-discovery skill, atlas, routing, or group-id guidance +- explicit read-before-re-ask memory guidance +- duplicate-write or memory-spam concerns +- destructive memory-maintenance tools that require explicit caution + +Graph-backed memory usage is part of the starter policy set by default. Repo-local adoption still needs to specify the actual memory group, discovery skill or command, privacy boundary, and write/cleanup expectations. + +The selector must also return a `memory_discovery` assessment. Use +`repo_default: use` when repo signals establish Graphiti or another graph-backed +memory workflow, and `repo_default: task-conditional` otherwise. This is a +repo-level routing result, not a command to query memory on every task. The +adopted policy supplies the per-task `use` / `skip` / `unavailable` decision. + +For codegraph usage, prioritize signals such as: +- `../codegraph`, codegraph MCP tools, codegraph CLI wrappers, or indexed workspace service language +- source-code exploration, architecture tracing, refactor planning, impact analysis, callers/callees, or symbol graph language +- instructions that agents should consult codegraph before code edits rather than relying only on broad text search + +For subagent runtime governance, prioritize signals such as: +- subagent run ids, session ids, session keys, transcript paths, logs, or announce payloads +- non-blocking spawn, timeout, cancellation, cascade-stop, archive, or cleanup behavior +- maximum spawn depth, nested subagents, child limits, or global concurrency caps +- tool allow/deny policy for spawned agents +- token, model, cost, or runtime stats on spawned work + +For long-running goal execution, prioritize explicit signals such as: + +- `/goal`, goal mode, goal-compatible, or goal-execution policy language +- multi-session or long-running autonomous execution +- goal checkpoints, acceptance-progress classification, convergence guards, or + repeated-hardening stop rules +- high-level campaign plans that derive bounded execution packets over time +- standing authority, execute-by-default continuation, action-specific approval + gates, local replan before escalation, or complaints about repeated + in-envelope approval stops and gate mazes +- fresh-context drift review, accepted finding ledgers, primary adjudication, or + closed-world remediation verification + +Recommend `goal-execution-governance` and +`subagent-workflow-optimization`, `parallel-plan-design`, and +`validation-and-handoff` when these signals are present. Except for the +explicit `/goal` command, require both a long-goal marker and agentic, +autonomous, multi-session, subagent, context-window, or checkpoint context. Do +not infer long-horizon autonomy from ordinary product-goal language alone. + +For preview artifact review, prioritize signals such as: +- an available previews or browser-review skill/service +- generated local artifacts that are hard to inspect from terminal output alone +- review packets, approval packets, dry-run artifacts, reports, rendered docs, PDFs, Office documents, screenshots, galleries, or local HTML builds +- explicit approval workflow language that requires human feedback before a mutation, release, send, upload, or publish step + +## Purpose classification + +Pick the repo purpose first: + +- `product-engineering` +- `operations-platform` +- `website` +- `course-workspace` +- `library-cli` +- `seminal-workspace` +- `workspace-agent` +- `writing-project` + +Then add a workflow subtype when it materially changes policy needs, for example: +- `grant-proposal-writing` +- `grant-proposal-review` +- `journal-article-writing` +- `patent-application-writing` + +Then add execution bias when it materially changes coordination policy: +- `max-dev-speed` +- `balanced` +- `max-token-efficiency` + +## Selection output + +Return: +- inferred `repo_purpose` +- inferred `workflow_subtype` when applicable +- inferred `execution_bias` when applicable +- recommended profile +- recommended modules +- memory-discovery assessment, including whether the shared policy is selected, + whether repo-level graph-memory signals exist, and the repo default +- recommendation mode such as `full-profile`, `patch-missing`, + `already-aligned`, or `identity-reconciliation-required` +- next modules to add when the repo already partially matches the selected profile +- deterministic install-plan entries with target local policy paths and rendered draft content +- an `AGENTS.md` wire-in patch for the planned policy set +- update-discovery reports that compare the installed pinned bundle against the latest published GitHub release without changing the installed policy source +- upgrade reports that compare an adopting repo's current policy coverage against a newer policy-library ref when a baseline tag or commit is available +- deterministic upgrade action plans that classify modules as install, upgrade-review, or retire-review +- installed bundle release metadata and, when available, baseline/current bundle release metadata resolved from upstream refs +- retirement cleanup patches that show which local policy files would be removed and how `AGENTS.md` would be rewired if retirement is accepted +- adoption mode such as `clean-adoption` or `migration-first` +- migration targets when cluttered planning or notes/memories are detected +- extracted existing policy surfaces +- inferred repo-local policy findings from existing `AGENTS.md` sections +- per-surface migration actions such as `keep`, `merge`, or `retire` +- extracted plan, note, and memory migration surfaces +- validation problems if recommended profiles/modules are missing from the installed library +- duplicate adopted-policy identities with every conflicting canonical path +- strong signals observed +- gaps between current local policy and selected shared policy +- whether to patch `docs/dev/policies/` and the `AGENTS.md` wire-in now or only produce a recommendation + +When `goal-execution-governance` is adopted, run: + +```bash +python scripts/audit_planning_contract.py --repo-root /path/to/repo --goal-only --json +``` + +Do not call goal-policy adoption complete until concrete local bounds, +execute-by-default continuation, action-specific approval gates, local replan +before escalation, at-most-one risk-triggered goal review, closed-world +verification when review occurs, and the minimal material-checkpoint contract +pass this audit. Treat packet bounds as convergence controls rather than +expiring goal approval. + +The normal planning audit is applicability-aware. It enforces +`planning-discipline` only when that policy is adopted, and only requires +roadmap/runbook wiring when `roadmap-runbook-governance` is adopted. For a +pre-adoption migration assessment, pass `--force`. For documented non-default +locations, pass `--plans-dir`, `--roadmap-path`, and/or `--runbook-path`. + +`--active-only` evaluates known `PLANNED` and `OPEN` plans and reports closed or +unclassified legacy exclusions separately. It does not fail merely because no +plans directory exists. A repo may retain an exact-match +`docs/dev/planning-audit-baseline.json` with schema version 1, a rationale, +review condition, and accepted finding strings; the report keeps matched and +unused entries visible, and unlisted findings remain blocking. The baseline is +ignored by full and forced audits. Use active-only as a current-state gate, not +as proof that historical schema migration is complete. + +Prefer the higher-level `scripts/manage_policy.py` entrypoint when the caller wants one command family for: +- adoption planning +- draft writing +- update checks against the latest published GitHub release +- upgrade checks +- upgrade action planning +- release-bundle preparation for the installed selector artifact +- deterministic release-note generation for tagged selector bundles +- GitHub release publication using checked-in release-note markdown +- end-to-end selector release cutting from clean repo state +- one-shot downstream installation with optional draft-writing diff --git a/.agents/skills/repo-policy-selector/release-manifest.json b/.agents/skills/repo-policy-selector/release-manifest.json new file mode 100644 index 00000000..ae34c0b6 --- /dev/null +++ b/.agents/skills/repo-policy-selector/release-manifest.json @@ -0,0 +1,16 @@ +{ + "bundle_name": "repo-policy-selector", + "bundle_version": "0.1.22", + "policy_library": { + "catalog_path": "policy-library/catalog.yaml", + "content_sha256": "03d4cba5d1aaad7ca2805b847dd69083ebf4193b8812693ee7ea8d9079f56b42", + "relative_root": "policy-library", + "schema_path": "policy-library/SCHEMA.md" + }, + "release_ref": "v0.1.22", + "schema_version": 1, + "source_commit": "12a7f9fef466522e99be44d980c44a4ff056f540", + "source_ref": "12a7f9fef466522e99be44d980c44a4ff056f540", + "source_repo_root": "/home/ecochran76/workspace.local/agent-policies", + "source_tree_state": "clean-ref" +} diff --git a/.agents/skills/repo-policy-selector/releases/v0.1.10.md b/.agents/skills/repo-policy-selector/releases/v0.1.10.md new file mode 100644 index 00000000..cd308aa0 --- /dev/null +++ b/.agents/skills/repo-policy-selector/releases/v0.1.10.md @@ -0,0 +1,67 @@ +# Selector Release v0.1.10 + +- Bundle version: `0.1.10` +- Source commit: `a7bc9ec1ff60ae61eab71b01c5d574528453a39a` +- Source ref: `a7bc9ec1ff60ae61eab71b01c5d574528453a39a` +- Previous release: `v0.1.9` + +## Summary + +- `scripts`: 1 changed +- `modules`: 16 changed +- `profiles`: 6 changed +- `docs`: 11 changed + +## Commits + +- `a7bc9ec` Add course workspace policy family +- `0a3d6e3` policy(selector): broaden upgrade and feedback artifact guidance +- `b290593` Refine branch policy for multi-track repos + +## Changed Paths + +### Scripts + +- `repo-policy-selector/scripts/select_policy.py` + +### Modules + +- `modules/branch-and-integration-strategy.md` +- `modules/cloud-drive-course-governance.md` +- `modules/commit-and-push-cadence.md` +- `modules/course-workspace-governance.md` +- `modules/lms-cli-governance.md` +- `modules/policy-adoption-feedback-loop.md` +- `modules/policy-upgrade-management.md` +- `modules/student-data-and-assessment-safety.md` +- `repo-policy-selector/policy-library/modules/branch-and-integration-strategy.md` +- `repo-policy-selector/policy-library/modules/cloud-drive-course-governance.md` +- `repo-policy-selector/policy-library/modules/commit-and-push-cadence.md` +- `repo-policy-selector/policy-library/modules/course-workspace-governance.md` +- `repo-policy-selector/policy-library/modules/lms-cli-governance.md` +- `repo-policy-selector/policy-library/modules/policy-adoption-feedback-loop.md` +- `repo-policy-selector/policy-library/modules/policy-upgrade-management.md` +- `repo-policy-selector/policy-library/modules/student-data-and-assessment-safety.md` + +### Profiles + +- `profiles/course-workspace.yaml` +- `profiles/repo-product-engineering.yaml` +- `profiles/website-maintenance.yaml` +- `repo-policy-selector/policy-library/profiles/course-workspace.yaml` +- `repo-policy-selector/policy-library/profiles/repo-product-engineering.yaml` +- `repo-policy-selector/policy-library/profiles/website-maintenance.yaml` + +### Docs + +- `ADOPTION.md` +- `README.md` +- `SCHEMA.md` +- `catalog.yaml` +- `docs/dev/notes/0003-2026-04-15-course-repo-policy-family-need.md` +- `docs/dev/policies/0011-branch-and-integration-strategy.md` +- `docs/dev/policies/0012-commit-and-push-cadence.md` +- `repo-policy-selector/policy-library/SCHEMA.md` +- `repo-policy-selector/policy-library/catalog.yaml` +- `repo-policy-selector/references/policy-shapes.md` +- `repo-policy-selector/references/selection-workflow.md` diff --git a/.agents/skills/repo-policy-selector/releases/v0.1.11.md b/.agents/skills/repo-policy-selector/releases/v0.1.11.md new file mode 100644 index 00000000..74930c91 --- /dev/null +++ b/.agents/skills/repo-policy-selector/releases/v0.1.11.md @@ -0,0 +1,46 @@ +# Selector Release v0.1.11 + +- Bundle version: `0.1.11` +- Source commit: `32878763cf903dfb6181e0dfa46807b0d665173d` +- Source ref: `32878763cf903dfb6181e0dfa46807b0d665173d` +- Previous release: `v0.1.10` + +## Summary + +- `scripts`: 1 changed +- `modules`: 4 changed +- `docs`: 8 changed +- `other`: 1 changed + +## Commits + +- `3287876` Add selector regression tests +- `88433f8` Integrate graph-backed memory policy + +## Changed Paths + +### Scripts + +- `repo-policy-selector/scripts/select_policy.py` + +### Modules + +- `modules/graph-backed-memory-usage.md` +- `modules/notes-and-memories.md` +- `repo-policy-selector/policy-library/modules/graph-backed-memory-usage.md` +- `repo-policy-selector/policy-library/modules/notes-and-memories.md` + +### Docs + +- `ADOPTION.md` +- `README.md` +- `SCHEMA.md` +- `catalog.yaml` +- `docs/dev/notes/0001-2026-04-18-graph-backed-memory-usage-candidate.md` +- `repo-policy-selector/policy-library/SCHEMA.md` +- `repo-policy-selector/policy-library/catalog.yaml` +- `repo-policy-selector/references/selection-workflow.md` + +### Other + +- `repo-policy-selector/tests/test_select_policy.py` diff --git a/.agents/skills/repo-policy-selector/releases/v0.1.12.md b/.agents/skills/repo-policy-selector/releases/v0.1.12.md new file mode 100644 index 00000000..9ac503ed --- /dev/null +++ b/.agents/skills/repo-policy-selector/releases/v0.1.12.md @@ -0,0 +1,50 @@ +# Selector Release v0.1.12 + +- Bundle version: `0.1.12` +- Source commit: `abfb3014e98dd5527dc788a4687ad11f583d7cfa` +- Source ref: `abfb3014e98dd5527dc788a4687ad11f583d7cfa` +- Previous release: `v0.1.11` + +## Summary + +- `scripts`: 1 changed +- `modules`: 10 changed +- `docs`: 7 changed +- `other`: 1 changed + +## Commits + +- `abfb301` Add subagent runtime governance policy + +## Changed Paths + +### Scripts + +- `repo-policy-selector/scripts/select_policy.py` + +### Modules + +- `modules/multi-agent-reconciliation.md` +- `modules/parallel-plan-design.md` +- `modules/subagent-runtime-governance.md` +- `modules/subagent-workflow-optimization.md` +- `modules/validation-and-handoff.md` +- `repo-policy-selector/policy-library/modules/multi-agent-reconciliation.md` +- `repo-policy-selector/policy-library/modules/parallel-plan-design.md` +- `repo-policy-selector/policy-library/modules/subagent-runtime-governance.md` +- `repo-policy-selector/policy-library/modules/subagent-workflow-optimization.md` +- `repo-policy-selector/policy-library/modules/validation-and-handoff.md` + +### Docs + +- `ADOPTION.md` +- `README.md` +- `SCHEMA.md` +- `catalog.yaml` +- `repo-policy-selector/policy-library/SCHEMA.md` +- `repo-policy-selector/policy-library/catalog.yaml` +- `repo-policy-selector/references/selection-workflow.md` + +### Other + +- `repo-policy-selector/tests/test_select_policy.py` diff --git a/.agents/skills/repo-policy-selector/releases/v0.1.13.md b/.agents/skills/repo-policy-selector/releases/v0.1.13.md new file mode 100644 index 00000000..d460f407 --- /dev/null +++ b/.agents/skills/repo-policy-selector/releases/v0.1.13.md @@ -0,0 +1,20 @@ +# Selector Release v0.1.13 + +- Bundle version: `0.1.13` +- Source commit: `b38c90694e15562819d28a4338c5d148dc5171fd` +- Source ref: `b38c90694e15562819d28a4338c5d148dc5171fd` +- Previous release: `v0.1.12` + +## Summary + +- `scripts`: 1 changed + +## Commits + +- `b38c906` Handle gitfile repos in selector + +## Changed Paths + +### Scripts + +- `repo-policy-selector/scripts/select_policy.py` diff --git a/.agents/skills/repo-policy-selector/releases/v0.1.14.md b/.agents/skills/repo-policy-selector/releases/v0.1.14.md new file mode 100644 index 00000000..9f497479 --- /dev/null +++ b/.agents/skills/repo-policy-selector/releases/v0.1.14.md @@ -0,0 +1,115 @@ +# Selector Release v0.1.14 + +- Bundle version: `0.1.14` +- Source commit: `b3fdcccf8a36a3eef596a1a35b2084c8c51ff254` +- Source ref: `b3fdcccf8a36a3eef596a1a35b2084c8c51ff254` +- Previous release: `v0.1.13` + +## Summary + +- `scripts`: 2 changed +- `modules`: 26 changed +- `profiles`: 16 changed +- `docs`: 23 changed +- `other`: 7 changed + +## Commits + +- `b3fdccc` Complete resumable fleet rollout safety +- `8a178ad` Harden fleet rollout transactions +- `574a339` Add transactional policy fleet rollout +- `ba640d8` feat: audit sibling policy effectiveness +- `e96946f` feat: govern long-running agent goals +- `c0d7cc5` feat: default graph memory and codegraph policies +- `c367160` feat: add memory and preview policy modules + +## Changed Paths + +### Scripts + +- `repo-policy-selector/scripts/audit_planning_contract.py` +- `repo-policy-selector/scripts/select_policy.py` + +### Modules + +- `modules/codegraph-usage.md` +- `modules/goal-execution-governance.md` +- `modules/graph-backed-memory-usage.md` +- `modules/memory-service-runtime-governance.md` +- `modules/notes-and-memories.md` +- `modules/parallel-plan-design.md` +- `modules/planning-discipline.md` +- `modules/policy-adoption-feedback-loop.md` +- `modules/policy-harvest-loop.md` +- `modules/preview-artifact-review.md` +- `modules/roadmap-runbook-governance.md` +- `modules/subagent-workflow-optimization.md` +- `modules/validation-and-handoff.md` +- `repo-policy-selector/policy-library/modules/codegraph-usage.md` +- `repo-policy-selector/policy-library/modules/goal-execution-governance.md` +- `repo-policy-selector/policy-library/modules/graph-backed-memory-usage.md` +- `repo-policy-selector/policy-library/modules/memory-service-runtime-governance.md` +- `repo-policy-selector/policy-library/modules/notes-and-memories.md` +- `repo-policy-selector/policy-library/modules/parallel-plan-design.md` +- `repo-policy-selector/policy-library/modules/planning-discipline.md` +- `repo-policy-selector/policy-library/modules/policy-adoption-feedback-loop.md` +- `repo-policy-selector/policy-library/modules/policy-harvest-loop.md` +- `repo-policy-selector/policy-library/modules/preview-artifact-review.md` +- `repo-policy-selector/policy-library/modules/roadmap-runbook-governance.md` +- `repo-policy-selector/policy-library/modules/subagent-workflow-optimization.md` +- `repo-policy-selector/policy-library/modules/validation-and-handoff.md` + +### Profiles + +- `profiles/course-workspace.yaml` +- `profiles/operations-platform.yaml` +- `profiles/repo-product-engineering.yaml` +- `profiles/seminal-workspace.yaml` +- `profiles/skill-repo-maintainer.yaml` +- `profiles/standalone-library.yaml` +- `profiles/website-maintenance.yaml` +- `profiles/writing-project.yaml` +- `repo-policy-selector/policy-library/profiles/course-workspace.yaml` +- `repo-policy-selector/policy-library/profiles/operations-platform.yaml` +- `repo-policy-selector/policy-library/profiles/repo-product-engineering.yaml` +- `repo-policy-selector/policy-library/profiles/seminal-workspace.yaml` +- `repo-policy-selector/policy-library/profiles/skill-repo-maintainer.yaml` +- `repo-policy-selector/policy-library/profiles/standalone-library.yaml` +- `repo-policy-selector/policy-library/profiles/website-maintenance.yaml` +- `repo-policy-selector/policy-library/profiles/writing-project.yaml` + +### Docs + +- `.codex/skills/repo-policy-harvester/SKILL.md` +- `.codex/skills/repo-policy-harvester/references/harvest-workflow.md` +- `ADOPTION.md` +- `AGENTS.md` +- `README.md` +- `SCHEMA.md` +- `catalog.yaml` +- `docs/dev/notes/0004-2026-04-26-graphiti-atlas-discovery-handoff.md` +- `docs/dev/notes/0005-2026-07-19-agent-loop-flow-control-policy-review.md` +- `docs/dev/notes/0006-2026-07-20-sibling-runbook-policy-effectiveness-audit.md` +- `docs/dev/plans/0002-2026-07-19-agent-planning-subagent-goal-policy-refresh.md` +- `docs/dev/plans/0003-2026-07-20-sibling-runbook-policy-effectiveness-audit.md` +- `docs/dev/plans/0004-2026-07-20-selector-v0-1-14-mass-rollout.md` +- `docs/dev/policies/0002-policy-harvest-loop.md` +- `docs/dev/policies/0005-notes-and-memories.md` +- `docs/dev/policies/0009-parallel-plan-design.md` +- `docs/dev/policies/0013-validation-and-handoff.md` +- `docs/dev/policies/0014-goal-execution-governance.md` +- `repo-policy-selector/SKILL.md` +- `repo-policy-selector/policy-library/SCHEMA.md` +- `repo-policy-selector/policy-library/catalog.yaml` +- `repo-policy-selector/references/dev-plan.md` +- `repo-policy-selector/references/selection-workflow.md` + +### Other + +- `.codex/skills/repo-policy-harvester/scripts/audit_policy_fleet.py` +- `.codex/skills/repo-policy-harvester/scripts/rollout_policy_fleet.py` +- `.codex/skills/repo-policy-harvester/tests/test_audit_policy_fleet.py` +- `.codex/skills/repo-policy-harvester/tests/test_rollout_policy_fleet.py` +- `.gitignore` +- `repo-policy-selector/tests/test_audit_planning_contract.py` +- `repo-policy-selector/tests/test_select_policy.py` diff --git a/.agents/skills/repo-policy-selector/releases/v0.1.15.md b/.agents/skills/repo-policy-selector/releases/v0.1.15.md new file mode 100644 index 00000000..465d6b8c --- /dev/null +++ b/.agents/skills/repo-policy-selector/releases/v0.1.15.md @@ -0,0 +1,84 @@ +# Selector Release v0.1.15 + +- Bundle version: `0.1.15` +- Source commit: `082a4bc9be8119b059dc8efcc5a85c2cb694b9da` +- Source ref: `082a4bc9be8119b059dc8efcc5a85c2cb694b9da` +- Previous release: `v0.1.14` + +## Summary + +- `scripts`: 4 changed +- `modules`: 8 changed +- `profiles`: 6 changed +- `docs`: 22 changed +- `other`: 6 changed + +## Commits + +- `082a4bc` Harden fleet rollout push preflight +- `bc3282d` Refine goal authority and review convergence +- `96354e7` Make planning baseline universal +- `da35e49` Close selector v0.1.14 fleet rollout + +## Changed Paths + +### Scripts + +- `repo-policy-selector/scripts/audit_planning_contract.py` +- `repo-policy-selector/scripts/install_selector_bundle.py` +- `repo-policy-selector/scripts/manage_policy.py` +- `repo-policy-selector/scripts/select_policy.py` + +### Modules + +- `modules/goal-execution-governance.md` +- `modules/planning-discipline.md` +- `modules/subagent-workflow-optimization.md` +- `modules/validation-and-handoff.md` +- `repo-policy-selector/policy-library/modules/goal-execution-governance.md` +- `repo-policy-selector/policy-library/modules/planning-discipline.md` +- `repo-policy-selector/policy-library/modules/subagent-workflow-optimization.md` +- `repo-policy-selector/policy-library/modules/validation-and-handoff.md` + +### Profiles + +- `profiles/course-workspace.yaml` +- `profiles/seminal-workspace.yaml` +- `profiles/standalone-library.yaml` +- `repo-policy-selector/policy-library/profiles/course-workspace.yaml` +- `repo-policy-selector/policy-library/profiles/seminal-workspace.yaml` +- `repo-policy-selector/policy-library/profiles/standalone-library.yaml` + +### Docs + +- `AGENTS.md` +- `README.md` +- `SCHEMA.md` +- `docs/dev/notes/0007-2026-07-20-selector-v0-1-14-mass-rollout.md` +- `docs/dev/notes/0008-2026-07-20-universal-planning-selector-feedback.md` +- `docs/dev/notes/0009-2026-08-05-goal-approval-and-review-loop-feedback.md` +- `docs/dev/plans/0001-2026-04-11-website-maintenance-policy-family-plan.md` +- `docs/dev/plans/0004-2026-07-20-selector-v0-1-14-mass-rollout.md` +- `docs/dev/plans/0005-2026-07-20-universal-planning-and-local-policy-completion.md` +- `docs/dev/plans/0006-2026-08-05-goal-standing-authority-and-review-convergence.md` +- `docs/dev/plans/0007-2026-08-06-selector-v0-1-15-release-and-fleet-rollout.md` +- `docs/dev/policies/0013-validation-and-handoff.md` +- `docs/dev/policies/0014-goal-execution-governance.md` +- `docs/dev/policies/0015-planning-discipline.md` +- `docs/dev/policies/0016-graph-backed-memory-usage.md` +- `docs/dev/policies/0017-codegraph-usage.md` +- `docs/dev/policies/0018-multi-agent-reconciliation.md` +- `docs/dev/policies/0019-subagent-workflow-optimization.md` +- `docs/dev/policies/0020-policy-adoption-feedback-loop.md` +- `repo-policy-selector/SKILL.md` +- `repo-policy-selector/policy-library/SCHEMA.md` +- `repo-policy-selector/references/selection-workflow.md` + +### Other + +- `.codex/skills/repo-policy-harvester/scripts/harvest_policy.py` +- `.codex/skills/repo-policy-harvester/scripts/rollout_policy_fleet.py` +- `.codex/skills/repo-policy-harvester/tests/test_harvest_policy.py` +- `.codex/skills/repo-policy-harvester/tests/test_rollout_policy_fleet.py` +- `repo-policy-selector/tests/test_audit_planning_contract.py` +- `repo-policy-selector/tests/test_select_policy.py` diff --git a/.agents/skills/repo-policy-selector/releases/v0.1.16.md b/.agents/skills/repo-policy-selector/releases/v0.1.16.md new file mode 100644 index 00000000..0aef840f --- /dev/null +++ b/.agents/skills/repo-policy-selector/releases/v0.1.16.md @@ -0,0 +1,69 @@ +# Selector Release v0.1.16 + +- Bundle version: `0.1.16` +- Source commit: `2a47b614de7518d0025c98d358cd5d3a0fbc4baf` +- Source ref: `2a47b614de7518d0025c98d358cd5d3a0fbc4baf` +- Previous release: `v0.1.15` + +## Summary + +- `scripts`: 2 changed +- `modules`: 4 changed +- `profiles`: 2 changed +- `docs`: 18 changed +- `other`: 2 changed + +## Commits + +- `2a47b61` Make CodeGraph index maintenance proactive +- `f320287` Add canonical website repo purpose +- `41fcfb2` Record operator-restored IM baseline +- `c2af97d` Stabilize concurrent receipts disposition +- `481c2db` Record planning audit remediation gates +- `1fc718b` Make active planning audits proportionate +- `e6ab7a4` Close selector v0.1.15 fleet rollout + +## Changed Paths + +### Scripts + +- `repo-policy-selector/scripts/audit_planning_contract.py` +- `repo-policy-selector/scripts/select_policy.py` + +### Modules + +- `modules/codegraph-usage.md` +- `modules/planning-discipline.md` +- `repo-policy-selector/policy-library/modules/codegraph-usage.md` +- `repo-policy-selector/policy-library/modules/planning-discipline.md` + +### Profiles + +- `profiles/website-maintenance.yaml` +- `repo-policy-selector/policy-library/profiles/website-maintenance.yaml` + +### Docs + +- `ADOPTION.md` +- `AGENTS.md` +- `README.md` +- `SCHEMA.md` +- `catalog.yaml` +- `docs/dev/notes/0010-2026-08-06-selector-v0-1-15-release-and-fleet-rollout.md` +- `docs/dev/notes/0011-2026-08-06-planning-audit-baseline-remediation.md` +- `docs/dev/notes/0012-2026-08-11-codegraph-reindexing-friction.md` +- `docs/dev/plans/0007-2026-08-06-selector-v0-1-15-release-and-fleet-rollout.md` +- `docs/dev/plans/0008-2026-08-06-planning-audit-baseline-remediation.md` +- `docs/dev/plans/0009-2026-08-06-website-repo-purpose.md` +- `docs/dev/plans/0010-2026-08-11-codegraph-index-refresh.md` +- `docs/dev/plans/0011-2026-08-11-selector-v0-1-16-release-and-rollout.md` +- `docs/dev/policies/0017-codegraph-usage.md` +- `repo-policy-selector/SKILL.md` +- `repo-policy-selector/policy-library/SCHEMA.md` +- `repo-policy-selector/policy-library/catalog.yaml` +- `repo-policy-selector/references/selection-workflow.md` + +### Other + +- `repo-policy-selector/tests/test_audit_planning_contract.py` +- `repo-policy-selector/tests/test_select_policy.py` diff --git a/.agents/skills/repo-policy-selector/releases/v0.1.17.md b/.agents/skills/repo-policy-selector/releases/v0.1.17.md new file mode 100644 index 00000000..1498fab1 --- /dev/null +++ b/.agents/skills/repo-policy-selector/releases/v0.1.17.md @@ -0,0 +1,62 @@ +# Selector Release v0.1.17 + +- Bundle version: `0.1.17` +- Source commit: `dfe80ca0e849527378c8b58e2a2e740923c91890` +- Source ref: `dfe80ca0e849527378c8b58e2a2e740923c91890` +- Previous release: `v0.1.16` + +## Summary + +- `scripts`: 1 changed +- `modules`: 10 changed +- `docs`: 14 changed +- `other`: 5 changed + +## Commits + +- `dfe80ca` Make goal execution autonomous by default +- `cb64927` Close selector v0.1.16 fleet rollout + +## Changed Paths + +### Scripts + +- `repo-policy-selector/scripts/audit_planning_contract.py` + +### Modules + +- `modules/goal-execution-governance.md` +- `modules/parallel-plan-design.md` +- `modules/planning-discipline.md` +- `modules/subagent-workflow-optimization.md` +- `modules/validation-and-handoff.md` +- `repo-policy-selector/policy-library/modules/goal-execution-governance.md` +- `repo-policy-selector/policy-library/modules/parallel-plan-design.md` +- `repo-policy-selector/policy-library/modules/planning-discipline.md` +- `repo-policy-selector/policy-library/modules/subagent-workflow-optimization.md` +- `repo-policy-selector/policy-library/modules/validation-and-handoff.md` + +### Docs + +- `SCHEMA.md` +- `docs/dev/notes/0013-2026-08-11-selector-v0-1-16-release-and-fleet-rollout.md` +- `docs/dev/notes/0014-2026-08-12-goal-autonomy-without-approval-ceremony.md` +- `docs/dev/plans/0011-2026-08-11-selector-v0-1-16-release-and-rollout.md` +- `docs/dev/plans/0012-2026-08-12-goal-autonomy-without-approval-ceremony.md` +- `docs/dev/plans/0013-2026-08-12-selector-v0-1-17-release-and-fleet-rollout.md` +- `docs/dev/policies/0009-parallel-plan-design.md` +- `docs/dev/policies/0013-validation-and-handoff.md` +- `docs/dev/policies/0014-goal-execution-governance.md` +- `docs/dev/policies/0015-planning-discipline.md` +- `docs/dev/policies/0019-subagent-workflow-optimization.md` +- `repo-policy-selector/SKILL.md` +- `repo-policy-selector/policy-library/SCHEMA.md` +- `repo-policy-selector/references/selection-workflow.md` + +### Other + +- `.codex/skills/repo-policy-harvester/scripts/harvest_policy.py` +- `.codex/skills/repo-policy-harvester/scripts/rollout_policy_fleet.py` +- `.codex/skills/repo-policy-harvester/tests/test_harvest_policy.py` +- `.codex/skills/repo-policy-harvester/tests/test_rollout_policy_fleet.py` +- `repo-policy-selector/tests/test_audit_planning_contract.py` diff --git a/.agents/skills/repo-policy-selector/releases/v0.1.18.md b/.agents/skills/repo-policy-selector/releases/v0.1.18.md new file mode 100644 index 00000000..d6818072 --- /dev/null +++ b/.agents/skills/repo-policy-selector/releases/v0.1.18.md @@ -0,0 +1,91 @@ +# Selector Release v0.1.18 + +- Bundle version: `0.1.18` +- Source commit: `475a852c0b6932e7feb9d45931c7505e4c1f2443` +- Source ref: `475a852c0b6932e7feb9d45931c7505e4c1f2443` +- Previous release: `v0.1.17` + +## Summary + +- `scripts`: 2 changed +- `modules`: 18 changed +- `profiles`: 4 changed +- `docs`: 21 changed +- `other`: 2 changed + +## Commits + +- `475a852` plan: open selector v0.1.18 release and Odollo pilot +- `e9ca199` docs: close git lifecycle coordination plan +- `de852fc` fix: bound active lane ref discovery +- `8f75bca` docs: record active lane enactment and release handoff +- `562225c` feat: reconcile active lanes in selector tooling +- `25c4621` policy: govern active lane custody and closure +- `446d07f` feat: complete active lane custody audit +- `222a372` Add active lane custody audit foundation +- `9bdd6a8` Plan Git lifecycle coordination policy +- `410f7ca` Close selector v0.1.17 fleet rollout + +## Changed Paths + +### Scripts + +- `repo-policy-selector/scripts/audit_active_lanes.py` +- `repo-policy-selector/scripts/select_policy.py` + +### Modules + +- `modules/active-lane-coordination.md` +- `modules/branch-and-integration-strategy.md` +- `modules/commit-and-push-cadence.md` +- `modules/commit-history-discipline.md` +- `modules/git-worktree-hygiene.md` +- `modules/multi-agent-reconciliation.md` +- `modules/planning-discipline.md` +- `modules/roadmap-runbook-governance.md` +- `modules/validation-and-handoff.md` +- `repo-policy-selector/policy-library/modules/active-lane-coordination.md` +- `repo-policy-selector/policy-library/modules/branch-and-integration-strategy.md` +- `repo-policy-selector/policy-library/modules/commit-and-push-cadence.md` +- `repo-policy-selector/policy-library/modules/commit-history-discipline.md` +- `repo-policy-selector/policy-library/modules/git-worktree-hygiene.md` +- `repo-policy-selector/policy-library/modules/multi-agent-reconciliation.md` +- `repo-policy-selector/policy-library/modules/planning-discipline.md` +- `repo-policy-selector/policy-library/modules/roadmap-runbook-governance.md` +- `repo-policy-selector/policy-library/modules/validation-and-handoff.md` + +### Profiles + +- `profiles/operations-platform.yaml` +- `profiles/repo-product-engineering.yaml` +- `repo-policy-selector/policy-library/profiles/operations-platform.yaml` +- `repo-policy-selector/policy-library/profiles/repo-product-engineering.yaml` + +### Docs + +- `ADOPTION.md` +- `AGENTS.md` +- `README.md` +- `SCHEMA.md` +- `catalog.yaml` +- `docs/dev/notes/0015-2026-08-12-selector-v0-1-17-release-and-fleet-rollout.md` +- `docs/dev/notes/0016-2026-08-20-active-lane-coordination-first-enactment.md` +- `docs/dev/plans/0013-2026-08-12-selector-v0-1-17-release-and-fleet-rollout.md` +- `docs/dev/plans/0014-2026-08-20-git-worktree-lifecycle-and-active-lane-coordination.md` +- `docs/dev/plans/0015-2026-08-20-selector-v0-1-18-release-and-active-lane-rollout.md` +- `docs/dev/policies/0004-git-worktree-hygiene.md` +- `docs/dev/policies/0010-commit-history-discipline.md` +- `docs/dev/policies/0011-branch-and-integration-strategy.md` +- `docs/dev/policies/0012-commit-and-push-cadence.md` +- `docs/dev/policies/0013-validation-and-handoff.md` +- `docs/dev/policies/0015-planning-discipline.md` +- `docs/dev/policies/0018-multi-agent-reconciliation.md` +- `docs/dev/policies/0021-active-lane-coordination.md` +- `repo-policy-selector/SKILL.md` +- `repo-policy-selector/policy-library/SCHEMA.md` +- `repo-policy-selector/policy-library/catalog.yaml` + +### Other + +- `repo-policy-selector/tests/test_audit_active_lanes.py` +- `repo-policy-selector/tests/test_select_policy.py` diff --git a/.agents/skills/repo-policy-selector/releases/v0.1.19.md b/.agents/skills/repo-policy-selector/releases/v0.1.19.md new file mode 100644 index 00000000..82517c37 --- /dev/null +++ b/.agents/skills/repo-policy-selector/releases/v0.1.19.md @@ -0,0 +1,50 @@ +# Selector Release v0.1.19 + +- Bundle version: `0.1.19` +- Source commit: `6f4b97615d889de01890a1227f176a452933d5a8` +- Source ref: `6f4b97615d889de01890a1227f176a452933d5a8` +- Previous release: `v0.1.18` + +## Summary + +- `scripts`: 1 changed +- `modules`: 2 changed +- `docs`: 10 changed +- `other`: 1 changed + +## Commits + +- `6f4b976` plan: open selector v0.1.19 fleet rollout +- `09e7784` docs: close active-lane auditor remediation +- `cf0c8d1` feat: bound active-lane audit discovery +- `4637096` docs: record post-pilot P0206 activity +- `957e8b1` docs: close v0.1.18 Odollo shadow rollout +- `8b743cb` plan: freeze v0.1.18 Odollo pilot boundary + +## Changed Paths + +### Scripts + +- `repo-policy-selector/scripts/audit_active_lanes.py` + +### Modules + +- `modules/active-lane-coordination.md` +- `repo-policy-selector/policy-library/modules/active-lane-coordination.md` + +### Docs + +- `README.md` +- `SCHEMA.md` +- `docs/dev/notes/0017-2026-08-21-selector-v0-1-18-release-and-odollo-shadow-pilot.md` +- `docs/dev/notes/0018-2026-08-21-active-lane-auditor-scale-and-divergence.md` +- `docs/dev/plans/0015-2026-08-20-selector-v0-1-18-release-and-active-lane-rollout.md` +- `docs/dev/plans/0016-2026-08-21-active-lane-auditor-scale-and-divergence.md` +- `docs/dev/plans/0017-2026-08-21-selector-v0-1-19-release-and-fleet-rollout.md` +- `docs/dev/policies/0021-active-lane-coordination.md` +- `repo-policy-selector/SKILL.md` +- `repo-policy-selector/policy-library/SCHEMA.md` + +### Other + +- `repo-policy-selector/tests/test_audit_active_lanes.py` diff --git a/.agents/skills/repo-policy-selector/releases/v0.1.20.md b/.agents/skills/repo-policy-selector/releases/v0.1.20.md new file mode 100644 index 00000000..4d07599d --- /dev/null +++ b/.agents/skills/repo-policy-selector/releases/v0.1.20.md @@ -0,0 +1,66 @@ +# Selector Release v0.1.20 + +- Bundle version: `0.1.20` +- Source commit: `6a67ab044e1110d18f5f12d8b5b9a9a6fc3a6265` +- Source ref: `6a67ab0` +- Previous release: `v0.1.19` + +## Summary + +- `release`: 1 changed +- `modules`: 2 changed +- `profiles`: 12 changed +- `docs`: 12 changed +- `other`: 1 changed + +## Commits + +- `a4f562c` chore: stamp selector bundle v0.1.20 +- `6a67ab0` feat: add code testing discipline +- `a280429` docs: record Canvas selector recovery +- `5e0f71f` docs: close selector v0.1.19 fleet rollout + +## Changed Paths + +### Release + +- `repo-policy-selector/release-manifest.json` + +### Modules + +- `modules/code-testing-discipline.md` +- `repo-policy-selector/policy-library/modules/code-testing-discipline.md` + +### Profiles + +- `profiles/operations-platform.yaml` +- `profiles/repo-product-engineering.yaml` +- `profiles/seminal-workspace.yaml` +- `profiles/skill-repo-maintainer.yaml` +- `profiles/standalone-library.yaml` +- `profiles/website-maintenance.yaml` +- `repo-policy-selector/policy-library/profiles/operations-platform.yaml` +- `repo-policy-selector/policy-library/profiles/repo-product-engineering.yaml` +- `repo-policy-selector/policy-library/profiles/seminal-workspace.yaml` +- `repo-policy-selector/policy-library/profiles/skill-repo-maintainer.yaml` +- `repo-policy-selector/policy-library/profiles/standalone-library.yaml` +- `repo-policy-selector/policy-library/profiles/website-maintenance.yaml` + +### Docs + +- `ADOPTION.md` +- `AGENTS.md` +- `README.md` +- `SCHEMA.md` +- `catalog.yaml` +- `docs/dev/notes/0019-2026-08-21-selector-v0-1-19-release-and-fleet-rollout.md` +- `docs/dev/notes/0020-2026-08-25-code-test-suite-policy-research.md` +- `docs/dev/plans/0017-2026-08-21-selector-v0-1-19-release-and-fleet-rollout.md` +- `docs/dev/plans/0018-2026-08-25-code-testing-discipline-policy.md` +- `docs/dev/policies/0022-code-testing-discipline.md` +- `repo-policy-selector/policy-library/SCHEMA.md` +- `repo-policy-selector/policy-library/catalog.yaml` + +### Other + +- `repo-policy-selector/tests/test_select_policy.py` diff --git a/.agents/skills/repo-policy-selector/releases/v0.1.21.md b/.agents/skills/repo-policy-selector/releases/v0.1.21.md new file mode 100644 index 00000000..bf91171c --- /dev/null +++ b/.agents/skills/repo-policy-selector/releases/v0.1.21.md @@ -0,0 +1,73 @@ +# Selector Release v0.1.21 + +- Bundle version: `0.1.21` +- Source commit: `afaa0542e8f1ad056eb6aebbe4b83b59088dc55b` +- Source ref: `afaa0542e8f1ad056eb6aebbe4b83b59088dc55b` +- Previous release: `v0.1.20` + +## Summary + +- `scripts`: 2 changed +- `release`: 1 changed +- `modules`: 12 changed +- `docs`: 15 changed +- `other`: 1 changed + +## Commits + +- `60050d8` chore: stamp selector bundle v0.1.21 +- `ce1e2c5` plan: open selector v0.1.21 deployment +- `afaa054` docs: close Mapocock policy harvest +- `c375eee` policy: enforce identity and semantic reconciliation +- `12bd16d` plan: open Mapocock policy harvest +- `929df87` docs: close selector v0.1.20 fleet rollout +- `624df01` plan: open selector v0.1.20 fleet rollout +- `8b9a900` docs: close code testing policy plan + +## Changed Paths + +### Scripts + +- `repo-policy-selector/scripts/manage_policy.py` +- `repo-policy-selector/scripts/select_policy.py` + +### Release + +- `repo-policy-selector/release-manifest.json` + +### Modules + +- `modules/code-testing-discipline.md` +- `modules/policy-harvest-loop.md` +- `modules/policy-management.md` +- `modules/policy-upgrade-management.md` +- `modules/upstream-fork-maintenance.md` +- `modules/validation-and-handoff.md` +- `repo-policy-selector/policy-library/modules/code-testing-discipline.md` +- `repo-policy-selector/policy-library/modules/policy-harvest-loop.md` +- `repo-policy-selector/policy-library/modules/policy-management.md` +- `repo-policy-selector/policy-library/modules/policy-upgrade-management.md` +- `repo-policy-selector/policy-library/modules/upstream-fork-maintenance.md` +- `repo-policy-selector/policy-library/modules/validation-and-handoff.md` + +### Docs + +- `SCHEMA.md` +- `docs/dev/notes/0020-2026-08-25-code-test-suite-policy-research.md` +- `docs/dev/notes/0021-2026-08-25-selector-v0-1-20-release-and-fleet-rollout.md` +- `docs/dev/notes/0022-2026-08-25-mapocock-policy-harvest-and-enforcement.md` +- `docs/dev/plans/0018-2026-08-25-code-testing-discipline-policy.md` +- `docs/dev/plans/0019-2026-08-25-selector-v0-1-20-release-and-fleet-rollout.md` +- `docs/dev/plans/0020-2026-08-25-mapocock-policy-harvest-and-enforcement.md` +- `docs/dev/plans/0021-2026-08-26-selector-v0-1-21-release-and-deployment.md` +- `docs/dev/policies/0001-reusable-policy-library.md` +- `docs/dev/policies/0002-policy-harvest-loop.md` +- `docs/dev/policies/0007-policy-upgrade-management.md` +- `docs/dev/policies/0013-validation-and-handoff.md` +- `docs/dev/policies/0022-code-testing-discipline.md` +- `repo-policy-selector/policy-library/SCHEMA.md` +- `repo-policy-selector/references/selection-workflow.md` + +### Other + +- `repo-policy-selector/tests/test_select_policy.py` diff --git a/.agents/skills/repo-policy-selector/releases/v0.1.22.md b/.agents/skills/repo-policy-selector/releases/v0.1.22.md new file mode 100644 index 00000000..60e2cd73 --- /dev/null +++ b/.agents/skills/repo-policy-selector/releases/v0.1.22.md @@ -0,0 +1,51 @@ +# Selector Release v0.1.22 + +- Bundle version: `0.1.22` +- Source commit: `12a7f9fef466522e99be44d980c44a4ff056f540` +- Source ref: `12a7f9fef466522e99be44d980c44a4ff056f540` +- Previous release: `v0.1.21` + +## Summary + +- `scripts`: 2 changed +- `release`: 1 changed +- `modules`: 2 changed +- `docs`: 7 changed +- `other`: 1 changed + +## Commits + +- `82dc161` chore: stamp selector bundle v0.1.22 +- `7ebfc19` plan selector v0.1.22 release and rollout +- `12a7f9f` make Graphiti discovery decisions explicit +- `bf341c6` docs: close selector v0.1.21 deployment + +## Changed Paths + +### Scripts + +- `repo-policy-selector/scripts/manage_policy.py` +- `repo-policy-selector/scripts/select_policy.py` + +### Release + +- `repo-policy-selector/release-manifest.json` + +### Modules + +- `modules/graph-backed-memory-usage.md` +- `repo-policy-selector/policy-library/modules/graph-backed-memory-usage.md` + +### Docs + +- `docs/dev/notes/0023-2026-08-26-selector-v0-1-21-release-and-deployment.md` +- `docs/dev/plans/0021-2026-08-26-selector-v0-1-21-release-and-deployment.md` +- `docs/dev/plans/0022-2026-08-29-graphiti-discovery-default-policy.md` +- `docs/dev/plans/0023-2026-08-29-selector-v0-1-22-release-and-fleet-rollout.md` +- `docs/dev/policies/0016-graph-backed-memory-usage.md` +- `repo-policy-selector/SKILL.md` +- `repo-policy-selector/references/selection-workflow.md` + +### Other + +- `repo-policy-selector/tests/test_select_policy.py` diff --git a/.agents/skills/repo-policy-selector/releases/v0.1.3.md b/.agents/skills/repo-policy-selector/releases/v0.1.3.md new file mode 100644 index 00000000..dafaf70f --- /dev/null +++ b/.agents/skills/repo-policy-selector/releases/v0.1.3.md @@ -0,0 +1,25 @@ +# Selector Release v0.1.3 + +- Bundle version: `0.1.3` +- Source commit: `30f4eaa64ca8389a8a6d57582327295e1cb401fe` +- Source ref: `30f4eaa64ca8389a8a6d57582327295e1cb401fe` +- Previous release: `v0.1.2` + +## Summary + +- `scripts`: 1 changed +- `release`: 1 changed + +## Commits + +- `28da28f` Normalize selector release source refs + +## Changed Paths + +### Scripts + +- `repo-policy-selector/scripts/release_selector_bundle.py` + +### Release + +- `repo-policy-selector/release-manifest.json` diff --git a/.agents/skills/repo-policy-selector/releases/v0.1.4.md b/.agents/skills/repo-policy-selector/releases/v0.1.4.md new file mode 100644 index 00000000..5387fd12 --- /dev/null +++ b/.agents/skills/repo-policy-selector/releases/v0.1.4.md @@ -0,0 +1,37 @@ +# Selector Release v0.1.4 + +- Bundle version: `0.1.4` +- Source commit: `9a97c13e53ab65a016c5711c14dda428af028beb` +- Source ref: `9a97c13e53ab65a016c5711c14dda428af028beb` +- Previous release: `v0.1.3` + +## Summary + +- `scripts`: 4 changed +- `release`: 1 changed +- `docs`: 3 changed + +## Commits + +- `9a97c13` Add end-to-end selector release cut helper +- `d7abe29` Add GitHub release publish helper +- `1355cdd` Add selector release note generation + +## Changed Paths + +### Scripts + +- `repo-policy-selector/scripts/generate_release_notes.py` +- `repo-policy-selector/scripts/manage_policy.py` +- `repo-policy-selector/scripts/publish_github_release.py` +- `repo-policy-selector/scripts/release_cut.py` + +### Release + +- `repo-policy-selector/releases/v0.1.3.md` + +### Docs + +- `README.md` +- `repo-policy-selector/SKILL.md` +- `repo-policy-selector/references/selection-workflow.md` diff --git a/.agents/skills/repo-policy-selector/releases/v0.1.5.md b/.agents/skills/repo-policy-selector/releases/v0.1.5.md new file mode 100644 index 00000000..47e8cfca --- /dev/null +++ b/.agents/skills/repo-policy-selector/releases/v0.1.5.md @@ -0,0 +1,74 @@ +# Selector Release v0.1.5 + +- Bundle version: `0.1.5` +- Source commit: `0df358687930b1f283031181af1e0e129c495be6` +- Source ref: `0df358687930b1f283031181af1e0e129c495be6` +- Previous release: `v0.1.4` + +## Summary + +- `scripts`: 12 changed +- `modules`: 12 changed +- `profiles`: 2 changed +- `docs`: 15 changed + +## Commits + +- `0df3586` Add selector update check command +- `838ea25` Add website maintenance policy family +- `4c9b9d3` Suppress selector bytecode cache noise + +## Changed Paths + +### Scripts + +- `repo-policy-selector/scripts/audit_planning_contract.py` +- `repo-policy-selector/scripts/check_for_updates.py` +- `repo-policy-selector/scripts/check_policy_upgrades.py` +- `repo-policy-selector/scripts/generate_release_notes.py` +- `repo-policy-selector/scripts/install_selector_bundle.py` +- `repo-policy-selector/scripts/manage_policy.py` +- `repo-policy-selector/scripts/plan_policy_upgrade_actions.py` +- `repo-policy-selector/scripts/publish_github_release.py` +- `repo-policy-selector/scripts/release_cut.py` +- `repo-policy-selector/scripts/release_selector_bundle.py` +- `repo-policy-selector/scripts/select_policy.py` +- `repo-policy-selector/scripts/sync_policy_library.py` + +### Modules + +- `modules/backup-and-recovery-operations.md` +- `modules/db-backed-state-governance.md` +- `modules/live-drift-reconciliation.md` +- `modules/visual-release-qa.md` +- `modules/web-interface-quality.md` +- `modules/website-surface-targeting.md` +- `repo-policy-selector/policy-library/modules/backup-and-recovery-operations.md` +- `repo-policy-selector/policy-library/modules/db-backed-state-governance.md` +- `repo-policy-selector/policy-library/modules/live-drift-reconciliation.md` +- `repo-policy-selector/policy-library/modules/visual-release-qa.md` +- `repo-policy-selector/policy-library/modules/web-interface-quality.md` +- `repo-policy-selector/policy-library/modules/website-surface-targeting.md` + +### Profiles + +- `profiles/website-maintenance.yaml` +- `repo-policy-selector/policy-library/profiles/website-maintenance.yaml` + +### Docs + +- `ADOPTION.md` +- `AGENTS.md` +- `README.md` +- `SCHEMA.md` +- `catalog.yaml` +- `docs/dev/plans/0001-2026-04-11-website-maintenance-policy-family-plan.md` +- `docs/dev/policies/0009-parallel-plan-design.md` +- `docs/dev/policies/0010-commit-history-discipline.md` +- `docs/dev/policies/0011-branch-and-integration-strategy.md` +- `docs/dev/policies/0012-commit-and-push-cadence.md` +- `docs/dev/policies/0013-validation-and-handoff.md` +- `repo-policy-selector/SKILL.md` +- `repo-policy-selector/policy-library/SCHEMA.md` +- `repo-policy-selector/policy-library/catalog.yaml` +- `repo-policy-selector/references/selection-workflow.md` diff --git a/.agents/skills/repo-policy-selector/releases/v0.1.6.md b/.agents/skills/repo-policy-selector/releases/v0.1.6.md new file mode 100644 index 00000000..6163057b --- /dev/null +++ b/.agents/skills/repo-policy-selector/releases/v0.1.6.md @@ -0,0 +1,45 @@ +# Selector Release v0.1.6 + +- Bundle version: `0.1.6` +- Source commit: `bf2ef14bf0ec74d5b18da996189c52c289cce58f` +- Source ref: `bf2ef14bf0ec74d5b18da996189c52c289cce58f` +- Previous release: `v0.1.5` + +## Summary + +- `modules`: 2 changed +- `profiles`: 10 changed +- `docs`: 6 changed + +## Commits + +- `bf2ef14` Add policy adoption feedback loop module + +## Changed Paths + +### Modules + +- `modules/policy-adoption-feedback-loop.md` +- `repo-policy-selector/policy-library/modules/policy-adoption-feedback-loop.md` + +### Profiles + +- `profiles/repo-product-engineering.yaml` +- `profiles/skill-repo-maintainer.yaml` +- `profiles/standalone-library.yaml` +- `profiles/website-maintenance.yaml` +- `profiles/writing-project.yaml` +- `repo-policy-selector/policy-library/profiles/repo-product-engineering.yaml` +- `repo-policy-selector/policy-library/profiles/skill-repo-maintainer.yaml` +- `repo-policy-selector/policy-library/profiles/standalone-library.yaml` +- `repo-policy-selector/policy-library/profiles/website-maintenance.yaml` +- `repo-policy-selector/policy-library/profiles/writing-project.yaml` + +### Docs + +- `ADOPTION.md` +- `README.md` +- `SCHEMA.md` +- `catalog.yaml` +- `repo-policy-selector/policy-library/SCHEMA.md` +- `repo-policy-selector/policy-library/catalog.yaml` diff --git a/.agents/skills/repo-policy-selector/releases/v0.1.7.md b/.agents/skills/repo-policy-selector/releases/v0.1.7.md new file mode 100644 index 00000000..91ecaf57 --- /dev/null +++ b/.agents/skills/repo-policy-selector/releases/v0.1.7.md @@ -0,0 +1,20 @@ +# Selector Release v0.1.7 + +- Bundle version: `0.1.7` +- Source commit: `fbf21262c5d7bad23dbe86eb84f0195f5f1247a1` +- Source ref: `fbf21262c5d7bad23dbe86eb84f0195f5f1247a1` +- Previous release: `v0.1.6` + +## Summary + +- `scripts`: 1 changed + +## Commits + +- `fbf2126` Tighten selector semantic policy matching + +## Changed Paths + +### Scripts + +- `repo-policy-selector/scripts/select_policy.py` diff --git a/.agents/skills/repo-policy-selector/releases/v0.1.8.md b/.agents/skills/repo-policy-selector/releases/v0.1.8.md new file mode 100644 index 00000000..4fae23bc --- /dev/null +++ b/.agents/skills/repo-policy-selector/releases/v0.1.8.md @@ -0,0 +1,77 @@ +# Selector Release v0.1.8 + +- Bundle version: `0.1.8` +- Source commit: `dd3ed514ca1c71b35f044a46f12048eb8fef6e08` +- Source ref: `dd3ed514ca1c71b35f044a46f12048eb8fef6e08` +- Previous release: `v0.1.7` + +## Summary + +- `scripts`: 2 changed +- `modules`: 26 changed +- `profiles`: 4 changed +- `docs`: 7 changed + +## Commits + +- `dd3ed51` Infer repo-local policy from AGENTS sections +- `2088e9d` Preserve repo guidance in generated AGENTS files +- `f404610` Add runtime state governance policy +- `98568b6` Add operations platform policy family +- `f92c7cf` Prevent cross-credit from adoption note references +- `86f5150` Add seminal workspace policy profile +- `75881b7` Detect duplicate planning authorities during adoption +- `b7da5b2` Tighten policy module boundaries + +## Changed Paths + +### Scripts + +- `repo-policy-selector/scripts/manage_policy.py` +- `repo-policy-selector/scripts/select_policy.py` + +### Modules + +- `modules/fieldwork-productization.md` +- `modules/git-worktree-hygiene.md` +- `modules/monolith-extraction-discipline.md` +- `modules/notes-and-memories.md` +- `modules/parallel-plan-design.md` +- `modules/planning-discipline.md` +- `modules/policy-adoption-feedback-loop.md` +- `modules/policy-management.md` +- `modules/policy-upgrade-management.md` +- `modules/runtime-state-governance.md` +- `modules/runtime-vs-product-boundary.md` +- `modules/seminal-workspace-evolution.md` +- `modules/tenant-isolation-and-operator-state.md` +- `repo-policy-selector/policy-library/modules/fieldwork-productization.md` +- `repo-policy-selector/policy-library/modules/git-worktree-hygiene.md` +- `repo-policy-selector/policy-library/modules/monolith-extraction-discipline.md` +- `repo-policy-selector/policy-library/modules/notes-and-memories.md` +- `repo-policy-selector/policy-library/modules/parallel-plan-design.md` +- `repo-policy-selector/policy-library/modules/planning-discipline.md` +- `repo-policy-selector/policy-library/modules/policy-adoption-feedback-loop.md` +- `repo-policy-selector/policy-library/modules/policy-management.md` +- `repo-policy-selector/policy-library/modules/policy-upgrade-management.md` +- `repo-policy-selector/policy-library/modules/runtime-state-governance.md` +- `repo-policy-selector/policy-library/modules/runtime-vs-product-boundary.md` +- `repo-policy-selector/policy-library/modules/seminal-workspace-evolution.md` +- `repo-policy-selector/policy-library/modules/tenant-isolation-and-operator-state.md` + +### Profiles + +- `profiles/operations-platform.yaml` +- `profiles/seminal-workspace.yaml` +- `repo-policy-selector/policy-library/profiles/operations-platform.yaml` +- `repo-policy-selector/policy-library/profiles/seminal-workspace.yaml` + +### Docs + +- `ADOPTION.md` +- `README.md` +- `SCHEMA.md` +- `catalog.yaml` +- `repo-policy-selector/policy-library/SCHEMA.md` +- `repo-policy-selector/policy-library/catalog.yaml` +- `repo-policy-selector/references/selection-workflow.md` diff --git a/.agents/skills/repo-policy-selector/releases/v0.1.9.md b/.agents/skills/repo-policy-selector/releases/v0.1.9.md new file mode 100644 index 00000000..7e113765 --- /dev/null +++ b/.agents/skills/repo-policy-selector/releases/v0.1.9.md @@ -0,0 +1,44 @@ +# Selector Release v0.1.9 + +- Bundle version: `0.1.9` +- Source commit: `4bc00edee01b586bfd2c6842074d685b46ed385b` +- Source ref: `4bc00edee01b586bfd2c6842074d685b46ed385b` +- Previous release: `v0.1.8` + +## Summary + +- `scripts`: 1 changed +- `modules`: 2 changed +- `docs`: 9 changed +- `other`: 1 changed + +## Commits + +- `4bc00ed` Add policy reload contract and note harvesting + +## Changed Paths + +### Scripts + +- `repo-policy-selector/scripts/select_policy.py` + +### Modules + +- `modules/policy-management.md` +- `repo-policy-selector/policy-library/modules/policy-management.md` + +### Docs + +- `.codex/skills/repo-policy-harvester/SKILL.md` +- `.codex/skills/repo-policy-harvester/references/harvest-workflow.md` +- `ADOPTION.md` +- `AGENTS.md` +- `README.md` +- `SCHEMA.md` +- `docs/dev/notes/0002-2026-04-13-agents-policy-reload-gap.md` +- `repo-policy-selector/policy-library/SCHEMA.md` +- `repo-policy-selector/references/selection-workflow.md` + +### Other + +- `.codex/skills/repo-policy-harvester/scripts/harvest_policy.py` diff --git a/.agents/skills/repo-policy-selector/scripts/audit_active_lanes.py b/.agents/skills/repo-policy-selector/scripts/audit_active_lanes.py new file mode 100644 index 00000000..e20e7219 --- /dev/null +++ b/.agents/skills/repo-policy-selector/scripts/audit_active_lanes.py @@ -0,0 +1,672 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path +from typing import Any + + +sys.dont_write_bytecode = True + + +class CatalogSyntaxError(ValueError): + pass + + +def git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + environment = os.environ.copy() + environment["GIT_OPTIONAL_LOCKS"] = "0" + return subprocess.run( + ["git", "-C", str(repo), *args], + check=check, + capture_output=True, + text=True, + env=environment, + ) + + +def parse_scalar(value: str) -> Any: + value = value.strip() + if value == "[]": + return [] + if value.startswith("[") and value.endswith("]"): + return [item.strip() for item in value[1:-1].split(",") if item.strip()] + if value.isdigit(): + return int(value) + return value + + +def parse_catalog(text: str) -> dict[str, Any]: + catalog: dict[str, Any] = {"lanes": []} + current: dict[str, Any] | None = None + in_lanes = False + for line_number, raw_line in enumerate(text.splitlines(), start=1): + stripped = raw_line.strip() + if not stripped or stripped.startswith("#"): + continue + if not raw_line.startswith(" ") and stripped == "lanes:": + in_lanes = True + continue + if not raw_line.startswith(" ") and ":" in stripped: + key, value = stripped.split(":", 1) + if key.strip() in catalog and key.strip() != "lanes": + raise CatalogSyntaxError(f"invalid catalog syntax at line {line_number}: duplicate key {key.strip()}") + catalog[key.strip()] = parse_scalar(value) + in_lanes = False + continue + if in_lanes and raw_line.startswith(" - "): + if ":" not in stripped[2:]: + raise CatalogSyntaxError( + f"invalid catalog syntax at line {line_number}: expected key: value" + ) + current = {} + catalog["lanes"].append(current) + key, value = stripped[2:].split(":", 1) + current[key.strip()] = parse_scalar(value) + continue + if in_lanes and current is not None and raw_line.startswith(" ") and ":" in stripped: + key, value = stripped.split(":", 1) + if key.strip() in current: + raise CatalogSyntaxError( + f"invalid catalog syntax at line {line_number}: duplicate key {key.strip()}" + ) + current[key.strip()] = parse_scalar(value) + continue + raise CatalogSyntaxError( + f"invalid catalog syntax at line {line_number}: expected key: value" + ) + return catalog + + +def worktree_inventory(repo: Path) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + current: dict[str, Any] = {} + for line in git(repo, "worktree", "list", "--porcelain").stdout.splitlines() + [""]: + if not line: + if current: + records.append(current) + current = {} + continue + key, _, value = line.partition(" ") + if key in {"bare", "detached", "locked", "prunable"} and not value: + current[key] = True + else: + current[key] = value + for record in records: + path = record.get("worktree") + if not isinstance(path, str): + record["status"] = [] + continue + status = git(Path(path), "status", "--porcelain=v1", "--untracked-files=all", check=False) + record["status"] = status.stdout.splitlines() if status.returncode == 0 else [""] + return records + + +def ref_tip(repo: Path, ref: str) -> str | None: + result = git(repo, "rev-parse", "--verify", ref, check=False) + return result.stdout.strip() if result.returncode == 0 else None + + +def read_ref_file(repo: Path, ref: str, path: str) -> str | None: + result = git(repo, "show", f"{ref}:{path}", check=False) + return result.stdout if result.returncode == 0 else None + + +def plan_metadata(text: str) -> dict[str, str]: + metadata: dict[str, str] = {} + for key in ("state", "lane", "branch", "target", "integration"): + match = re.search(rf"(?im)^{key}\s*:\s*(.+?)\s*$", text) + if match: + metadata[key] = match.group(1).strip() + return metadata + + +DEFAULT_BRANCH_PREFIXES = ( + "feature/", + "feat/", + "fix/", + "field/", + "plan/", + "plan", + "codex/", + "integration/", +) + + +def branch_ref_inventory( + repo: Path, *, remote: str, branch_prefixes: tuple[str, ...] +) -> dict[str, dict[str, str]]: + result: dict[str, dict[str, str]] = {} + output = git( + repo, + "for-each-ref", + "--format=%(refname)", + "refs/heads", + f"refs/remotes/{remote}", + ).stdout + for ref in output.splitlines(): + if ref.endswith("/HEAD"): + continue + if ref.startswith("refs/heads/"): + branch = ref.removeprefix("refs/heads/") + if not branch.startswith(branch_prefixes): + continue + result.setdefault(branch, {})["local_ref"] = ref + elif ref.startswith(f"refs/remotes/{remote}/"): + branch = ref.removeprefix(f"refs/remotes/{remote}/") + if not branch.startswith(branch_prefixes): + continue + result.setdefault(branch, {})["remote_ref"] = ref + return result + + +def exact_branch_ref_inventory( + repo: Path, *, remote: str, branches: tuple[str, ...] +) -> dict[str, dict[str, str]]: + result: dict[str, dict[str, str]] = {} + for branch in branches: + local_ref = f"refs/heads/{branch}" + remote_ref = f"refs/remotes/{remote}/{branch}" + if ref_tip(repo, local_ref): + result.setdefault(branch, {})["local_ref"] = local_ref + if ref_tip(repo, remote_ref): + result.setdefault(branch, {})["remote_ref"] = remote_ref + return result + + +def discover_open_plans(repo: Path, ref: str, plans_dir: str) -> list[dict[str, str]]: + listing = git(repo, "ls-tree", "-r", "--name-only", ref, "--", plans_dir, check=False) + if listing.returncode != 0: + return [] + plans: list[dict[str, str]] = [] + for path in listing.stdout.splitlines(): + if not path.endswith(".md"): + continue + body = read_ref_file(repo, ref, path) + if body is None: + continue + metadata = plan_metadata(body) + if metadata.get("state") not in {"PLANNED", "OPEN", "BLOCKED"}: + continue + if not metadata.get("lane") or not metadata.get("branch"): + continue + metadata["plan"] = path + metadata["plan_ref"] = ref + plans.append(metadata) + return plans + + +def shared_refs_containing(repo: Path, commit: str) -> list[str]: + result = git( + repo, + "for-each-ref", + f"--contains={commit}", + "--format=%(refname)", + "refs/heads", + "refs/tags", + "refs/remotes", + check=False, + ) + return result.stdout.splitlines() if result.returncode == 0 else [] + + +def is_ancestor(repo: Path, ancestor: str | None, descendant: str | None) -> bool: + if not ancestor or not descendant: + return False + return git(repo, "merge-base", "--is-ancestor", ancestor, descendant, check=False).returncode == 0 + + +def local_remote_relation(repo: Path, local_tip: str | None, remote_tip: str | None) -> str: + if not local_tip and not remote_tip: + return "missing" + if local_tip and not remote_tip: + return "local_only" + if remote_tip and not local_tip: + return "remote_only" + if local_tip == remote_tip: + return "equal" + if is_ancestor(repo, remote_tip, local_tip): + return "local_ahead" + if is_ancestor(repo, local_tip, remote_tip): + return "remote_ahead" + return "diverged" + + +def audit_repo( + repo: Path, + *, + default_ref: str, + catalog_path: str = "docs/dev/active-lanes.yaml", + plans_dir: str = "docs/dev/plans", + remote: str = "origin", + branch_prefixes: tuple[str, ...] = DEFAULT_BRANCH_PREFIXES, + catalog_only: bool = False, + selected_branches: tuple[str, ...] = (), +) -> dict[str, Any]: + problems: list[str] = [] + catalog_text = read_ref_file(repo, default_ref, catalog_path) + if catalog_text is None: + return { + "schema_version": 1, + "repo_root": str(repo), + "default_ref": default_ref, + "catalog_path": catalog_path, + "lanes": [], + "problems": [f"catalog not found at {default_ref}:{catalog_path}"], + "ok": False, + } + + try: + catalog = parse_catalog(catalog_text) + except CatalogSyntaxError as error: + return { + "schema_version": 1, + "repo_root": str(repo), + "default_ref": default_ref, + "catalog_path": catalog_path, + "lanes": [], + "problems": [str(error)], + "ok": False, + } + if catalog.get("schema_version") != 1: + problems.append("catalog schema_version must be 1") + lanes_value = catalog.get("lanes") + if not isinstance(lanes_value, list): + lanes_value = [] + problems.append("catalog lanes must be a list") + lane_ids = [str(lane.get("id", "")) for lane in lanes_value if isinstance(lane, dict)] + for lane_id in sorted(set(lane_ids)): + if lane_id and lane_ids.count(lane_id) > 1: + problems.append(f"duplicate lane id: {lane_id}") + branch_names = [ + str(lane.get("branch", "")) for lane in lanes_value if isinstance(lane, dict) + ] + for branch in sorted(set(branch_names)): + if branch and branch_names.count(branch) > 1: + problems.append(f"branch claimed by multiple lanes: {branch}") + + required_fields = ( + "id", + "objective", + "plan", + "plan_ref", + "branch", + "target", + "plan_state", + "custody_state", + "checkpoint", + "remote_ref", + "integration", + "dependencies", + "overlaps", + "updated_at", + ) + known_lane_ids = {lane_id for lane_id in lane_ids if lane_id} + allowed_plan_states = {"PLANNED", "OPEN", "BLOCKED", "CLOSED", "CANCELLED"} + allowed_custody_states = { + "ACTIVE_WORKTREE", + "PAUSED_REF", + "INTEGRATION_READY", + "INTEGRATED", + "ARCHIVED", + "DISCARD_APPROVED", + } + for lane in lanes_value: + if not isinstance(lane, dict): + problems.append("catalog lane entries must be mappings") + continue + lane_id = str(lane.get("id", "")) + for field in required_fields: + if field not in lane or lane[field] == "": + problems.append(f"{lane_id}: missing required catalog field: {field}") + dependencies = lane.get("dependencies", []) + if not isinstance(dependencies, list): + problems.append(f"{lane_id}: dependencies must be a list") + else: + for dependency in dependencies: + if dependency not in known_lane_ids: + problems.append(f"{lane_id}: unknown dependency lane: {dependency}") + if lane.get("plan_state") not in allowed_plan_states: + problems.append(f"{lane_id}: invalid plan_state: {lane.get('plan_state')}") + if lane.get("custody_state") not in allowed_custody_states: + problems.append(f"{lane_id}: invalid custody_state: {lane.get('custody_state')}") + for field in ("overlaps", "reconciled_overlaps"): + if field in lane and not isinstance(lane[field], list): + problems.append(f"{lane_id}: {field} must be a list") + + worktrees = worktree_inventory(repo) + if catalog_only: + branch_refs = {} + elif selected_branches: + branch_refs = exact_branch_ref_inventory( + repo, remote=remote, branches=selected_branches + ) + else: + branch_refs = branch_ref_inventory( + repo, remote=remote, branch_prefixes=branch_prefixes + ) + lanes: list[dict[str, Any]] = [] + for catalog_lane in lanes_value: + if not isinstance(catalog_lane, dict): + continue + lane_id = str(catalog_lane.get("id", "")) + branch = str(catalog_lane.get("branch", "")) + local_ref = f"refs/heads/{branch}" if branch else "" + local_tip = ref_tip(repo, local_ref) if local_ref else None + remote_ref = str(catalog_lane.get("remote_ref", "")) + remote_tip = ref_tip(repo, remote_ref) if remote_ref else None + archive_ref = str(catalog_lane.get("archive_ref", "")) + archive_remote_ref = str(catalog_lane.get("archive_remote_ref", "")) + archive_local_tip = ref_tip(repo, archive_ref) if archive_ref else None + archive_remote_tip = ref_tip(repo, archive_remote_ref) if archive_remote_ref else None + lane_worktrees = [ + item + for item in worktrees + if local_ref and item.get("branch") == local_ref + ] + findings: list[str] = [] + custody_state = catalog_lane.get("custody_state") + if custody_state in {"ACTIVE_WORKTREE", "PAUSED_REF", "INTEGRATION_READY"} and not ( + local_tip or remote_tip + ): + findings.append("registered_but_missing") + problems.append(f"{lane_id}: registered branch has no local or remote ref") + + plan_state = str(catalog_lane.get("plan_state", "")) + if plan_state in {"PLANNED", "OPEN", "BLOCKED"}: + plan_ref = str(catalog_lane.get("plan_ref", "")) + plan_path = str(catalog_lane.get("plan", "")) + plan_body = read_ref_file(repo, plan_ref, plan_path) if plan_ref and plan_path else None + if plan_body is None: + findings.append("plan_catalog_drift") + problems.append(f"{lane_id}: registered plan is not readable at plan_ref") + else: + metadata = plan_metadata(plan_body) + comparisons = ( + ("state", "plan_state"), + ("lane", "id"), + ("branch", "branch"), + ("target", "target"), + ("integration", "integration"), + ) + for plan_key, catalog_key in comparisons: + plan_value = metadata.get(plan_key) + catalog_value = str(catalog_lane.get(catalog_key, "")) + if plan_value != catalog_value: + if "plan_catalog_drift" not in findings: + findings.append("plan_catalog_drift") + problems.append( + f"{lane_id}: plan {plan_key} {plan_value or ''} " + f"does not match catalog {plan_key} {catalog_value or ''}" + ) + if ( + local_tip + and lane_worktrees + and catalog_lane.get("plan_state") == "OPEN" + and custody_state == "ACTIVE_WORKTREE" + ): + findings.append("registered_active") + if local_tip and remote_ref and remote_tip is None: + findings.append("local_only") + problems.append(f"{lane_id}: local branch has no configured remote custody") + if any(item.get("status") for item in lane_worktrees): + findings.append("dirty_uncheckpointed") + problems.append(f"{lane_id}: assigned worktree has uncommitted state") + checkpoint = str(catalog_lane.get("checkpoint", "")) + ref_relation = local_remote_relation(repo, local_tip, remote_tip) + if checkpoint and local_tip and checkpoint != local_tip: + findings.append("stale_checkpoint") + problems.append(f"{lane_id}: catalog checkpoint does not match the local branch tip") + if custody_state == "ACTIVE_WORKTREE" and ref_relation == "local_ahead": + findings.append("local_ahead_of_remote") + problems.append(f"{lane_id}: active local checkpoint is ahead of remote custody") + if custody_state == "ACTIVE_WORKTREE" and ref_relation == "remote_ahead": + findings.append("remote_ahead_of_local") + problems.append(f"{lane_id}: active remote custody is ahead of the local checkpoint") + if custody_state == "ACTIVE_WORKTREE" and ref_relation == "diverged": + findings.append("local_remote_diverged") + problems.append(f"{lane_id}: active local and remote custody have diverged") + if custody_state == "ACTIVE_WORKTREE" and not lane_worktrees: + if "registered_but_missing" not in findings: + findings.append("registered_but_missing") + problems.append(f"{lane_id}: ACTIVE_WORKTREE lane has no assigned worktree") + if ( + custody_state == "PAUSED_REF" + and local_tip + and remote_tip + and not lane_worktrees + ): + findings.append("paused_ref") + if ( + custody_state == "ARCHIVED" + and archive_local_tip + and archive_local_tip == archive_remote_tip == checkpoint + and not lane_worktrees + ): + findings.append("archived_ref") + if custody_state == "ARCHIVED" and "archived_ref" not in findings: + problems.append(f"{lane_id}: ARCHIVED state lacks matching local and remote archive refs") + if catalog_lane.get("plan_state") == "CLOSED" and custody_state == "ACTIVE_WORKTREE": + findings.append("closed_plan_live_branch") + problems.append(f"{lane_id}: CLOSED plan still has active worktree custody") + overlaps = catalog_lane.get("overlaps", []) + reconciled_overlaps = catalog_lane.get("reconciled_overlaps", []) + if not isinstance(overlaps, list): + overlaps = [] + if not isinstance(reconciled_overlaps, list): + reconciled_overlaps = [] + unreconciled_overlaps = [item for item in overlaps if item not in reconciled_overlaps] + if unreconciled_overlaps: + findings.append("overlap_unreconciled") + for overlap in unreconciled_overlaps: + problems.append(f"{lane_id}: declared overlap lacks disposition: {overlap}") + target = str(catalog_lane.get("target", "")) + target_ref = target if target.startswith("refs/") else f"refs/heads/{target}" + target_tip = ref_tip(repo, target_ref) if target else None + integrated_into_target = is_ancestor(repo, local_tip, target_tip) + integration_receipt = str(catalog_lane.get("integration_receipt", "")) + receipt_verified = bool( + integration_receipt + and ref_tip(repo, integration_receipt) + and is_ancestor(repo, integration_receipt, target_tip) + ) + readiness_evidenced = ( + custody_state == "INTEGRATION_READY" + and local_tip + and local_tip == remote_tip == checkpoint + and catalog_lane.get("validation_status") == "passed" + and catalog_lane.get("validation_ref") == checkpoint + and not any(item.get("status") for item in lane_worktrees) + and not unreconciled_overlaps + and not integrated_into_target + ) + if readiness_evidenced: + findings.append("integration_ready") + if custody_state == "INTEGRATION_READY" and not readiness_evidenced: + findings.append("integration_ambiguous") + problems.append(f"{lane_id}: INTEGRATION_READY state lacks complete readiness evidence") + if custody_state == "INTEGRATED" and not (integrated_into_target or receipt_verified): + findings.append("integration_ambiguous") + problems.append( + f"{lane_id}: INTEGRATED state lacks ancestry or a verified integration receipt" + ) + if ( + custody_state == "INTEGRATED" + and (integrated_into_target or receipt_verified) + and (local_tip or remote_tip or lane_worktrees) + ): + findings.append("integrated_cleanup_pending") + lane_report = dict(catalog_lane) + lane_report.update( + { + "id": lane_id, + "local_tip": local_tip, + "remote_tip": remote_tip, + "local_remote_relation": ref_relation, + "worktrees": lane_worktrees, + "findings": findings, + "unreconciled_overlaps": unreconciled_overlaps, + "integrated_into_target": integrated_into_target, + "integration_receipt_verified": receipt_verified, + "archive_local_tip": archive_local_tip, + "archive_remote_tip": archive_remote_tip, + } + ) + lanes.append(lane_report) + + registered_branches = {str(item.get("branch", "")) for item in lanes} + default_branch = default_ref.rsplit("/", 1)[-1] + for branch, refs in sorted(branch_refs.items()): + if branch in registered_branches or branch == default_branch: + continue + source_ref = refs.get("local_ref") or refs.get("remote_ref") + if not source_ref: + continue + for metadata in discover_open_plans(repo, source_ref, plans_dir): + if metadata.get("branch") != branch: + continue + lane_id = metadata["lane"] + local_ref = refs.get("local_ref") + remote_ref = refs.get("remote_ref") + local_tip = ref_tip(repo, local_ref) if local_ref else None + remote_tip = ref_tip(repo, remote_ref) if remote_ref else None + lane_worktrees = [ + item for item in worktrees if local_ref and item.get("branch") == local_ref + ] + lanes.append( + { + "id": lane_id, + "objective": "", + "plan": metadata["plan"], + "plan_ref": source_ref, + "branch": branch, + "target": metadata.get("target", ""), + "plan_state": metadata["state"], + "custody_state": "ACTIVE_WORKTREE" if lane_worktrees else "PAUSED_REF", + "checkpoint": local_tip or remote_tip, + "remote_ref": remote_ref, + "integration": metadata.get("integration", ""), + "dependencies": [], + "overlaps": [], + "updated_at": "", + "local_tip": local_tip, + "remote_tip": remote_tip, + "worktrees": lane_worktrees, + "findings": ["unregistered_active"], + } + ) + problems.append(f"{lane_id}: active branch is absent from the default-ref catalog") + + reported_lane_ids = {str(item.get("id", "")) for item in lanes} + for worktree in (() if catalog_only or selected_branches else worktrees): + if not worktree.get("detached"): + continue + commit = str(worktree.get("HEAD", "")) + if not commit or shared_refs_containing(repo, commit): + continue + for metadata in discover_open_plans(repo, commit, plans_dir): + lane_id = metadata["lane"] + if lane_id in reported_lane_ids: + continue + lanes.append( + { + "id": lane_id, + "objective": "", + "plan": metadata["plan"], + "plan_ref": commit, + "branch": metadata["branch"], + "target": metadata.get("target", ""), + "plan_state": metadata["state"], + "custody_state": "ACTIVE_WORKTREE", + "checkpoint": commit, + "remote_ref": None, + "integration": metadata.get("integration", ""), + "dependencies": [], + "overlaps": [], + "updated_at": "", + "local_tip": commit, + "remote_tip": None, + "worktrees": [worktree], + "findings": ["orphaned_detached"], + } + ) + reported_lane_ids.add(lane_id) + problems.append(f"{lane_id}: detached worktree commit is not reachable from a shared ref") + + return { + "schema_version": 1, + "repo_root": str(repo), + "default_ref": default_ref, + "catalog_path": catalog_path, + "remote": remote, + "branch_prefixes": ( + [] if catalog_only or selected_branches else list(branch_prefixes) + ), + "discovery_mode": ( + "catalog-only" if catalog_only + else "exact-branches" if selected_branches + else "prefixes" + ), + "selected_branches": list(selected_branches), + "lanes": lanes, + "problems": problems, + "ok": not problems, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False) + parser.add_argument("--repo-root", required=True) + parser.add_argument("--default-ref", required=True) + parser.add_argument("--catalog-path", default="docs/dev/active-lanes.yaml") + parser.add_argument("--plans-dir", default="docs/dev/plans") + parser.add_argument("--remote", default="origin") + discovery_group = parser.add_mutually_exclusive_group() + discovery_group.add_argument( + "--catalog-only", + action="store_true", + help="audit only lanes registered in the default-ref catalog", + ) + discovery_group.add_argument( + "--branch", + action="append", + dest="selected_branches", + help="exact topic branch to inspect for unregistered plans; repeat as needed", + ) + discovery_group.add_argument( + "--branch-prefix", + action="append", + dest="branch_prefixes", + help="topic branch prefix to include; repeat to configure multiple prefixes", + ) + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + report = audit_repo( + Path(args.repo_root).resolve(), + default_ref=args.default_ref, + catalog_path=args.catalog_path, + plans_dir=args.plans_dir, + remote=args.remote, + branch_prefixes=tuple(args.branch_prefixes or DEFAULT_BRANCH_PREFIXES), + catalog_only=args.catalog_only, + selected_branches=tuple(args.selected_branches or ()), + ) + if args.json: + print(json.dumps(report, indent=2, sort_keys=True)) + else: + print(f"ok: {report['ok']}") + for problem in report["problems"]: + print(f"- {problem}") + for lane in report["lanes"]: + print(f"{lane['id']}: {', '.join(lane['findings']) or 'unclassified'}") + return 0 if report["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/repo-policy-selector/scripts/audit_planning_contract.py b/.agents/skills/repo-policy-selector/scripts/audit_planning_contract.py new file mode 100644 index 00000000..a1283955 --- /dev/null +++ b/.agents/skills/repo-policy-selector/scripts/audit_planning_contract.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import sys + +sys.dont_write_bytecode = True + +import argparse +import json +import re +from pathlib import Path + + +ROADMAP_HEADING_RE = re.compile(r"^##\s+P\d{2}\s+\|\s+.+$") +ROADMAP_LANE_HEADING_PREFIX_RE = re.compile(r"^##\s+P\d+") +RUNBOOK_TURN_RE = re.compile(r"^##\s+Turn\s+\d+\s+\|\s+\d{4}-\d{2}-\d{2}$") +RUNBOOK_TURN_HEADING_PREFIX_RE = re.compile(r"^##\s+Turn\b", re.IGNORECASE) +PLAN_FILE_RE = re.compile(r"^\d{4}-\d{4}-\d{2}-\d{2}-[a-z0-9-]+\.md$") +PLAN_STATE_RE = re.compile(r"(?im)^(?:state|status)\s*:\s*(PLANNED|OPEN|CLOSED|CANCELLED)\s*$") +ROADMAP_LANE_RE = re.compile(r"(?im)^(?:roadmap|lane|phase)\s*:\s*(P\d{2})\b") +CURRENT_STATE_RE = re.compile(r"(?im)^##\s+Current State\s*$|^(?:current state)\s*:", re.MULTILINE) +GOAL_BOUND_PATTERNS = { + "max_work_unit_attempts": re.compile(r"(?im)^max_work_unit_attempts\s*:\s*[1-9]\d*\s*$"), + "max_review_rework_cycles": re.compile(r"(?im)^max_review_rework_cycles\s*:\s*[1-9]\d*\s*$"), + "max_hardening_checkpoints": re.compile(r"(?im)^max_hardening_checkpoints\s*:\s*[1-9]\d*\s*$"), + "checkpoint_interval": re.compile( + r"(?im)^checkpoint_interval\s*:\s*[1-9]\d*\s+.*(?:minute|hour|slice|token|turn|context)" + ), +} +GOAL_CONTROL_PATTERNS = { + "authorization_gate": re.compile( + r"(?im)^authorization_gate\s*:\s*material_departure_or_explicit_action_gate_only\s*$" + ), + "continuation_default": re.compile( + r"(?im)^continuation_default\s*:\s*execute_obvious_in_scope_low_risk\s*$" + ), + "bound_exhaustion_mode": re.compile( + r"(?im)^bound_exhaustion_mode\s*:\s*local_replan_before_escalation\s*$" + ), + "max_review_discovery_passes": re.compile( + r"(?im)^max_review_discovery_passes\s*:\s*1\s*$" + ), + "review_verification_mode": re.compile( + r"(?im)^review_verification_mode\s*:\s*closed_world_if_reviewed\s*$" + ), + "checkpoint_mode": re.compile( + r"(?im)^checkpoint_mode\s*:\s*material_boundary_with_cadence_backstop\s*$" + ), +} +GOAL_CHECKPOINT_FIELD_TOKENS = { + "state_transition", + "acceptance_state", + "progress_classification", + "evidence", + "material_blockers", + "next_action_or_stop_reason", +} + + +def read_text(path: Path) -> str: + try: + return path.read_text(encoding="utf-8") + except FileNotFoundError: + return "" + + +def split_roadmap_sections(roadmap_text: str) -> dict[str, str]: + sections: dict[str, str] = {} + current_lane: str | None = None + current_lines: list[str] = [] + for line in roadmap_text.splitlines(): + if line.startswith("## "): + if current_lane is not None: + sections[current_lane] = "\n".join(current_lines).strip() + current_lines = [line] + match = re.match(r"^##\s+(P\d{2})\s+\|", line) + current_lane = match.group(1) if match else None + elif current_lane is not None: + current_lines.append(line) + if current_lane is not None: + sections[current_lane] = "\n".join(current_lines).strip() + return sections + + +def audit_goal_execution_contract(root: Path) -> dict: + policy_dir = root / "docs" / "dev" / "policies" + policy_paths = sorted(policy_dir.glob("*goal-execution-governance.md")) if policy_dir.exists() else [] + problems: list[str] = [] + policies: list[dict[str, object]] = [] + + for policy_path in policy_paths: + text = read_text(policy_path) + missing_bounds = [name for name, pattern in GOAL_BOUND_PATTERNS.items() if not pattern.search(text)] + missing_controls = [name for name, pattern in GOAL_CONTROL_PATTERNS.items() if not pattern.search(text)] + fields_match = re.search(r"(?im)^checkpoint_record_fields\s*:\s*(.+)$", text) + fields = { + field.strip() + for field in fields_match.group(1).split(",") + if field.strip() + } if fields_match else set() + missing_fields = sorted(GOAL_CHECKPOINT_FIELD_TOKENS - fields) + if "## Local Goal Bounds" not in text: + problems.append(f"goal policy missing Local Goal Bounds section: {policy_path.name}") + for name in missing_bounds: + problems.append(f"goal policy missing concrete bound {name}: {policy_path.name}") + for name in missing_controls: + problems.append(f"goal policy missing or invalid control {name}: {policy_path.name}") + for name in missing_fields: + problems.append(f"goal policy missing checkpoint field {name}: {policy_path.name}") + policies.append( + { + "path": str(policy_path), + "missing_bounds": missing_bounds, + "missing_controls": missing_controls, + "missing_checkpoint_fields": missing_fields, + } + ) + + return { + "repo_root": str(root), + "applicable": bool(policy_paths), + "policies": policies, + "ok": not problems, + "problems": problems, + } + + +def planning_contracts(root: Path) -> tuple[dict[str, bool], dict[str, bool]]: + policy_dir = root / "docs" / "dev" / "policies" + available = { + "planning_discipline": bool(list(policy_dir.glob("*planning-discipline.md"))) if policy_dir.exists() else False, + "roadmap_runbook_governance": bool(list(policy_dir.glob("*roadmap-runbook-governance.md"))) if policy_dir.exists() else False, + } + agents_text = read_text(root / "AGENTS.md") or read_text(root / "AGENT.MD") + policy_wired = bool( + re.search(r"docs/dev/policies|docs/dev/agent-policies", agents_text, re.IGNORECASE) + and re.search(r"\b(?:read|follow|policy entry|policy loading)\b", agents_text, re.IGNORECASE) + ) + adopted = {name: bool(present and policy_wired) for name, present in available.items()} + return available, adopted + + +def resolve_repo_path(root: Path, value: str | Path | None, default: str) -> Path: + path = Path(value) if value is not None else Path(default) + return path if path.is_absolute() else root / path + + +def active_baseline_findings(path: Path) -> tuple[list[str], list[str]]: + try: + baseline = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return [], [f"invalid planning audit baseline: {exc}"] + if not isinstance(baseline, dict): + return [], ["invalid planning audit baseline: root must be an object"] + if baseline.get("schema_version") != 1: + return [], ["invalid planning audit baseline: schema_version must be 1"] + for field in ("rationale", "review_condition"): + value = baseline.get(field) + if not isinstance(value, str) or not value.strip(): + return [], [f"invalid planning audit baseline: {field} must be a non-empty string"] + accepted = baseline.get("accepted_findings") + if ( + not isinstance(accepted, list) + or not accepted + or not all(isinstance(item, str) and item for item in accepted) + ): + return [], ["invalid planning audit baseline: accepted_findings must be a non-empty string list"] + return list(dict.fromkeys(accepted)), [] + + +def audit_repo( + root: Path, + *, + roadmap_path: str | Path | None = None, + runbook_path: str | Path | None = None, + plans_dir_path: str | Path | None = None, + active_only: bool = False, + force: bool = False, +) -> dict: + roadmap = resolve_repo_path(root, roadmap_path, "ROADMAP.md") + runbook = resolve_repo_path(root, runbook_path, "RUNBOOK.md") + plans_dir = resolve_repo_path(root, plans_dir_path, "docs/dev/plans") + available_contracts, contracts = planning_contracts(root) + planning_applicable = contracts["planning_discipline"] or contracts["roadmap_runbook_governance"] + roadmap_applicable = contracts["roadmap_runbook_governance"] or force + + problems: list[str] = [] + report: dict[str, object] = { + "repo_root": str(root), + "applicable": planning_applicable or force, + "available_contracts": available_contracts, + "adopted_contracts": contracts, + "audit_scope": "active" if active_only else "all", + "roadmap_path": str(roadmap), + "runbook_path": str(runbook), + "plans_dir": str(plans_dir), + "plans": [], + "excluded_closed_plans": [], + "excluded_unclassified_plans": [], + } + + if not planning_applicable and not force: + goal_contract = audit_goal_execution_contract(root) + goal_problems = goal_contract["problems"] + assert isinstance(goal_problems, list) + report["ok"] = bool(goal_contract["ok"]) + report["problems"] = list(goal_problems) + report["goal_execution_contract"] = goal_contract + return report + + roadmap_text = read_text(roadmap) + runbook_text = read_text(runbook) + + if roadmap_applicable and not roadmap_text: + problems.append("missing ROADMAP.md") + if roadmap_applicable and not runbook_text: + problems.append("missing RUNBOOK.md") + if not plans_dir.exists() and not active_only: + problems.append(f"missing plans directory: {plans_dir}") + + roadmap_headings = [ + line for line in roadmap_text.splitlines() if ROADMAP_LANE_HEADING_PREFIX_RE.match(line) + ] + bad_headings = [line for line in roadmap_headings if not ROADMAP_HEADING_RE.match(line)] + if roadmap_applicable and roadmap_text and bad_headings: + problems.append("ROADMAP.md has top-level headings that do not match '## P## | Title'") + report["roadmap_headings"] = roadmap_headings + roadmap_sections = split_roadmap_sections(roadmap_text) + open_roadmap_lanes = [ + lane_id + for lane_id, section in roadmap_sections.items() + if re.search(r"(?im)^(?:state|status)\s*:\s*OPEN\s*$", section) + ] + report["open_roadmap_lanes"] = open_roadmap_lanes + for lane_id in open_roadmap_lanes if roadmap_applicable else []: + section = roadmap_sections[lane_id] + if not CURRENT_STATE_RE.search(section): + problems.append(f"OPEN roadmap lane missing Current State note: {lane_id}") + + runbook_turns = [ + line for line in runbook_text.splitlines() if RUNBOOK_TURN_HEADING_PREFIX_RE.match(line) + ] + bad_turns = [line for line in runbook_turns if not RUNBOOK_TURN_RE.match(line)] + if roadmap_applicable and runbook_text and bad_turns: + problems.append("RUNBOOK.md has headings that do not match '## Turn N | YYYY-MM-DD'") + report["runbook_turns"] = runbook_turns + + if plans_dir.exists(): + for plan_path in sorted(plans_dir.glob("*.md")): + entry = { + "file": plan_path.name, + "path": str(plan_path), + "filename_ok": bool(PLAN_FILE_RE.match(plan_path.name)), + "state": None, + "state_ok": False, + "lane_id": None, + "lane_ok": False, + "current_state_ok": False, + "wired_in_roadmap": False, + "wired_in_runbook": False, + } + text = read_text(plan_path) + state_match = PLAN_STATE_RE.search(text) + lane_match = ROADMAP_LANE_RE.search(text) + if active_only and not state_match: + excluded = report["excluded_unclassified_plans"] + assert isinstance(excluded, list) + excluded.append(plan_path.name) + continue + if active_only and state_match and state_match.group(1) not in {"PLANNED", "OPEN"}: + excluded = report["excluded_closed_plans"] + assert isinstance(excluded, list) + excluded.append(plan_path.name) + continue + if state_match: + entry["state"] = state_match.group(1) + entry["state_ok"] = True + if lane_match: + entry["lane_id"] = lane_match.group(1) + entry["lane_ok"] = True + entry["current_state_ok"] = bool(CURRENT_STATE_RE.search(text)) + entry["wired_in_roadmap"] = plan_path.name in roadmap_text + entry["wired_in_runbook"] = plan_path.name in runbook_text + if not entry["filename_ok"]: + problems.append(f"plan filename does not match deterministic pattern: {plan_path.name}") + if not entry["state_ok"]: + problems.append(f"plan missing deterministic state: {plan_path.name}") + if roadmap_applicable and not entry["lane_ok"]: + problems.append(f"plan missing roadmap lane id: {plan_path.name}") + if entry["state"] == "OPEN" and not entry["current_state_ok"]: + problems.append(f"OPEN plan missing Current State section: {plan_path.name}") + if roadmap_applicable and not entry["wired_in_roadmap"]: + problems.append(f"plan not wired in ROADMAP.md: {plan_path.name}") + if roadmap_applicable and not entry["wired_in_runbook"]: + problems.append(f"plan not wired in RUNBOOK.md: {plan_path.name}") + cast_list = report["plans"] + assert isinstance(cast_list, list) + cast_list.append(entry) + plans = report["plans"] + assert isinstance(plans, list) + actionable_states = {"PLANNED", "OPEN"} + for lane_id in open_roadmap_lanes if roadmap_applicable else []: + if not any( + plan.get("lane_id") == lane_id and plan.get("state") in actionable_states + for plan in plans + if isinstance(plan, dict) + ): + problems.append(f"OPEN roadmap lane missing actionable plan coverage: {lane_id}") + + baseline_path = root / "docs/dev/planning-audit-baseline.json" + accepted_baseline_findings: list[str] = [] + unused_baseline_findings: list[str] = [] + if active_only and baseline_path.exists(): + accepted, baseline_problems = active_baseline_findings(baseline_path) + if baseline_problems: + problems.extend(baseline_problems) + else: + accepted_baseline_findings = [problem for problem in problems if problem in accepted] + unused_baseline_findings = [item for item in accepted if item not in problems] + problems[:] = [problem for problem in problems if problem not in accepted] + report["planning_audit_baseline_path"] = str(baseline_path) + report["accepted_baseline_findings"] = accepted_baseline_findings + report["unused_baseline_findings"] = unused_baseline_findings + report["ok"] = not problems + report["problems"] = problems + goal_contract = audit_goal_execution_contract(root) + report["goal_execution_contract"] = goal_contract + if not goal_contract["ok"]: + goal_problems = goal_contract["problems"] + assert isinstance(goal_problems, list) + problems.extend(goal_problems) + report["ok"] = False + return report + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repo-root", required=True) + parser.add_argument("--json", action="store_true") + parser.add_argument("--goal-only", action="store_true") + parser.add_argument("--active-only", action="store_true") + parser.add_argument("--force", action="store_true") + parser.add_argument("--roadmap-path") + parser.add_argument("--runbook-path") + parser.add_argument("--plans-dir") + args = parser.parse_args() + + root = Path(args.repo_root).resolve() + report = audit_goal_execution_contract(root) if args.goal_only else audit_repo( + root, + roadmap_path=args.roadmap_path, + runbook_path=args.runbook_path, + plans_dir_path=args.plans_dir, + active_only=args.active_only, + force=args.force, + ) + if args.json: + print(json.dumps(report, indent=2, sort_keys=True)) + else: + print(f"ok: {report['ok']}") + if report["problems"]: + print("problems:") + for problem in report["problems"]: + print(f"- {problem}") + return 0 if report["ok"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/repo-policy-selector/scripts/check_for_updates.py b/.agents/skills/repo-policy-selector/scripts/check_for_updates.py new file mode 100644 index 00000000..1f7533cb --- /dev/null +++ b/.agents/skills/repo-policy-selector/scripts/check_for_updates.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import sys + +sys.dont_write_bytecode = True + +import argparse +import json +import re +import subprocess +from pathlib import Path +from typing import Any + +from select_policy import enumerate_policy_library, read_json + +DEFAULT_REPO = "CochranResearchGroup/agent-policies" +INSTALL_RECORD_RELPATH = ".codex/policy-selector-install.json" + + +def run_gh(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run(["gh", *args], check=True, capture_output=True, text=True) + + +def read_install_record(repo_root: Path) -> dict[str, Any]: + return read_json(repo_root / INSTALL_RECORD_RELPATH) + + +def parse_version_tuple(value: str) -> tuple[int, ...]: + match = re.search(r"(\d+)\.(\d+)\.(\d+)", value) + if not match: + return () + return tuple(int(part) for part in match.groups()) + + +def normalized_installed_ref(installed_release: dict[str, Any], install_record: dict[str, Any]) -> str: + for key in ("release_ref", "bundle_ref", "bundle_version"): + value = installed_release.get(key) or install_record.get(key) + if isinstance(value, str) and value: + return value + source = install_record.get("install_source", {}) if isinstance(install_record.get("install_source"), dict) else {} + for key in ("bundle_ref",): + value = source.get(key) + if isinstance(value, str) and value: + return value + return "" + + +def github_releases(repo: str, limit: int) -> tuple[list[dict[str, Any]], str]: + try: + result = run_gh( + "release", + "list", + "--repo", + repo, + "--limit", + str(limit), + "--json", + "tagName,name,isLatest,isDraft,isPrerelease,publishedAt,createdAt", + ) + except subprocess.CalledProcessError as exc: + stderr = (exc.stderr or exc.stdout or str(exc)).strip() + return [], stderr + data = json.loads(result.stdout) + return (data if isinstance(data, list) else []), "" + + +def choose_latest_release(releases: list[dict[str, Any]], include_prereleases: bool) -> dict[str, Any]: + filtered = [ + rel for rel in releases + if not rel.get("isDraft") and (include_prereleases or not rel.get("isPrerelease")) + ] + return filtered[0] if filtered else {} + + +def releases_since_installed(releases: list[dict[str, Any]], installed_ref: str, include_prereleases: bool) -> list[dict[str, Any]]: + filtered = [ + rel for rel in releases + if not rel.get("isDraft") and (include_prereleases or not rel.get("isPrerelease")) + ] + if not installed_ref: + return filtered + out: list[dict[str, Any]] = [] + for rel in filtered: + if rel.get("tagName") == installed_ref: + break + out.append(rel) + return out + + +def check_for_updates( + *, + repo_root: Path, + policy_root: Path | None, + github_repo: str, + limit: int, + include_prereleases: bool, +) -> dict[str, Any]: + installed_library = enumerate_policy_library(policy_root) + install_record = read_install_record(repo_root) + installed_release = installed_library.get("release_manifest", {}) + installed_ref = normalized_installed_ref(installed_release, install_record) + installed_version = installed_release.get("bundle_version", "") if isinstance(installed_release, dict) else "" + releases, query_error = github_releases(github_repo, limit) + latest = choose_latest_release(releases, include_prereleases) + latest_tag = latest.get("tagName", "") if isinstance(latest, dict) else "" + latest_version = "" + if isinstance(latest, dict): + latest_version = latest_tag.removeprefix("v") if latest_tag.startswith("v") else latest_tag + + installed_tuple = parse_version_tuple(installed_version or installed_ref) + latest_tuple = parse_version_tuple(latest_version or latest_tag) + update_available = False + if latest_tag and installed_ref: + update_available = latest_tag != installed_ref + if installed_tuple and latest_tuple: + update_available = latest_tuple > installed_tuple + elif latest_tag: + update_available = True + + newer_releases = releases_since_installed(releases, installed_ref, include_prereleases) + if query_error: + recommended_action = "unable to query GitHub releases" + elif update_available: + recommended_action = f"review upgrade to {latest_tag}" + elif latest_tag: + recommended_action = "stay pinned" + else: + recommended_action = "unable to determine latest release" + + return { + "repo_root": str(repo_root), + "policy_root": installed_library.get("policy_root", ""), + "github_repo": github_repo, + "installed_bundle_release": installed_release, + "install_record": install_record, + "installed_ref": installed_ref, + "installed_version": installed_version, + "latest_release": latest, + "release_query_error": query_error, + "latest_tag": latest_tag, + "latest_version": latest_version, + "update_available": update_available, + "newer_releases": newer_releases, + "recommended_action": recommended_action, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repo-root", required=True) + parser.add_argument("--policy-root") + parser.add_argument("--github-repo", default=DEFAULT_REPO) + parser.add_argument("--limit", type=int, default=10) + parser.add_argument("--include-prereleases", action="store_true") + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + report = check_for_updates( + repo_root=Path(args.repo_root).resolve(), + policy_root=Path(args.policy_root).resolve() if args.policy_root else None, + github_repo=args.github_repo, + limit=args.limit, + include_prereleases=args.include_prereleases, + ) + if args.json: + print(json.dumps(report, indent=2, sort_keys=True)) + else: + print(f"repo_root: {report['repo_root']}") + print(f"github_repo: {report['github_repo']}") + print(f"installed_ref: {report['installed_ref'] or '-'}") + print(f"installed_version: {report['installed_version'] or '-'}") + print(f"latest_tag: {report['latest_tag'] or '-'}") + print(f"latest_version: {report['latest_version'] or '-'}") + print(f"update_available: {report['update_available']}") + print(f"recommended_action: {report['recommended_action']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/repo-policy-selector/scripts/check_policy_upgrades.py b/.agents/skills/repo-policy-selector/scripts/check_policy_upgrades.py new file mode 100644 index 00000000..dc2bf85b --- /dev/null +++ b/.agents/skills/repo-policy-selector/scripts/check_policy_upgrades.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import sys + +sys.dont_write_bytecode = True + +import argparse +import json +import subprocess +from pathlib import Path +from typing import Any + +from select_policy import ( + adopted_policy_id, + enumerate_policy_library, + extract_existing_policy_surfaces, + parse_catalog, + parse_profile, + policy_adoption_coverage, + read_text, +) + + +def git_show(repo_root: Path, ref: str, path: str) -> str: + try: + result = subprocess.run( + ["git", "-C", str(repo_root), "show", f"{ref}:{path}"], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError: + return "" + return result.stdout + + +def parse_json_text(text: str) -> dict[str, Any]: + if not text: + return {} + try: + data = json.loads(text) + except json.JSONDecodeError: + return {} + return data if isinstance(data, dict) else {} + + +def parse_release_manifest_at_ref(policy_repo_root: Path, ref: str) -> dict[str, Any]: + return parse_json_text(git_show(policy_repo_root, ref, "repo-policy-selector/release-manifest.json")) + + +def parse_catalog_at_ref(policy_repo_root: Path, ref: str) -> dict[str, list[dict[str, Any]]]: + text = git_show(policy_repo_root, ref, "catalog.yaml") + if not text: + return {"modules": [], "profiles": []} + temp = policy_repo_root / ".codex-tmp-catalog.yaml" + temp.write_text(text, encoding="utf-8") + try: + return parse_catalog(temp) + finally: + temp.unlink(missing_ok=True) + + +def parse_profile_at_ref(policy_repo_root: Path, ref: str, rel_path: str) -> dict[str, Any]: + text = git_show(policy_repo_root, ref, rel_path) + if not text: + return {} + temp = policy_repo_root / ".codex-tmp-profile.yaml" + temp.write_text(text, encoding="utf-8") + try: + return parse_profile(temp) + finally: + temp.unlink(missing_ok=True) + + +def catalog_map(items: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + return {item["id"]: item for item in items if "id" in item} + + +def changed_paths_between_refs(policy_repo_root: Path, baseline_ref: str, current_ref: str) -> list[str]: + try: + result = subprocess.run( + ["git", "-C", str(policy_repo_root), "diff", "--name-only", f"{baseline_ref}..{current_ref}"], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError: + return [] + return [line.strip() for line in result.stdout.splitlines() if line.strip()] + + +def current_profile_modules(profile_id: str, installed_library: dict[str, Any]) -> list[str]: + profile = installed_library.get("parsed_profiles", {}).get(profile_id, {}) + modules = profile.get("modules", []) + return list(modules) if isinstance(modules, list) else [] + + +def current_adopted_module_ids(repo_root: Path, installed_library: dict[str, Any], recommended_modules: list[str]) -> list[str]: + surfaces = extract_existing_policy_surfaces(repo_root) + coverage = policy_adoption_coverage(surfaces, recommended_modules, installed_library) + return coverage["already_adopted_modules"] + + +def upgrade_report( + repo_root: Path, + installed_library: dict[str, Any], + profile_id: str, + baseline_ref: str | None, + current_ref: str, + policy_repo_root: Path | None, +) -> dict[str, Any]: + recommended_modules = current_profile_modules(profile_id, installed_library) + adopted_modules = current_adopted_module_ids(repo_root, installed_library, recommended_modules) + installed_release_manifest = installed_library.get("release_manifest", {}) + report: dict[str, Any] = { + "selected_profile": profile_id, + "current_profile_modules": recommended_modules, + "already_adopted_modules": adopted_modules, + "newly_available_modules": [module_id for module_id in recommended_modules if module_id not in adopted_modules], + "changed_adopted_modules": [], + "retirement_review_modules": [], + "baseline_ref": baseline_ref, + "current_ref": current_ref, + "installed_bundle_release": installed_release_manifest, + "baseline_bundle_release": {}, + "current_bundle_release": installed_release_manifest, + } + if not baseline_ref or policy_repo_root is None: + return report + + baseline_catalog = parse_catalog_at_ref(policy_repo_root, baseline_ref) + baseline_profiles = catalog_map(baseline_catalog["profiles"]) + baseline_profile_entry = baseline_profiles.get(profile_id) + baseline_modules: list[str] = [] + if baseline_profile_entry and "path" in baseline_profile_entry: + baseline_profile = parse_profile_at_ref(policy_repo_root, baseline_ref, baseline_profile_entry["path"]) + modules = baseline_profile.get("modules", []) + if isinstance(modules, list): + baseline_modules = list(modules) + + changed_paths = changed_paths_between_refs(policy_repo_root, baseline_ref, current_ref) + changed_module_ids = { + Path(path).stem + for path in changed_paths + if path.startswith("modules/") and path.endswith(".md") + } + current_set = set(recommended_modules) + baseline_set = set(baseline_modules) + report["newly_available_modules"] = sorted(current_set - baseline_set) + report["retirement_review_modules"] = sorted(baseline_set - current_set) + report["changed_adopted_modules"] = sorted(changed_module_ids & set(adopted_modules)) + report["changed_paths"] = changed_paths + report["baseline_bundle_release"] = parse_release_manifest_at_ref(policy_repo_root, baseline_ref) + current_bundle_release = parse_release_manifest_at_ref(policy_repo_root, current_ref) + report["current_bundle_release"] = current_bundle_release or installed_release_manifest + return report + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repo-root", required=True) + parser.add_argument("--policy-root", required=False) + parser.add_argument("--profile", required=True) + parser.add_argument("--baseline-ref") + parser.add_argument("--current-ref", default="HEAD") + parser.add_argument("--policy-git-root") + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + repo_root = Path(args.repo_root).resolve() + policy_root = Path(args.policy_root).resolve() if args.policy_root else None + policy_git_root = Path(args.policy_git_root).resolve() if args.policy_git_root else None + installed_library = enumerate_policy_library(policy_root) + report = upgrade_report( + repo_root=repo_root, + installed_library=installed_library, + profile_id=args.profile, + baseline_ref=args.baseline_ref, + current_ref=args.current_ref, + policy_repo_root=policy_git_root, + ) + if args.json: + print(json.dumps(report, indent=2, sort_keys=True)) + else: + print(f"selected_profile: {report['selected_profile']}") + print(f"baseline_ref: {report['baseline_ref'] or '-'}") + print(f"current_ref: {report['current_ref']}") + installed_version = report.get("installed_bundle_release", {}).get("bundle_version", "-") + baseline_version = report.get("baseline_bundle_release", {}).get("bundle_version", "-") + current_version = report.get("current_bundle_release", {}).get("bundle_version", installed_version or "-") + print(f"installed_bundle_version: {installed_version}") + print(f"baseline_bundle_version: {baseline_version}") + print(f"current_bundle_version: {current_version}") + print(f"already_adopted_modules: {', '.join(report['already_adopted_modules']) or '-'}") + print(f"newly_available_modules: {', '.join(report['newly_available_modules']) or '-'}") + print(f"changed_adopted_modules: {', '.join(report['changed_adopted_modules']) or '-'}") + print(f"retirement_review_modules: {', '.join(report['retirement_review_modules']) or '-'}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/repo-policy-selector/scripts/generate_release_notes.py b/.agents/skills/repo-policy-selector/scripts/generate_release_notes.py new file mode 100644 index 00000000..4d60785c --- /dev/null +++ b/.agents/skills/repo-policy-selector/scripts/generate_release_notes.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import sys + +sys.dont_write_bytecode = True + +import argparse +import json +import subprocess +from pathlib import Path +from typing import Any + + +MANIFEST_PATH = "repo-policy-selector/release-manifest.json" +RELEASES_DIR = "repo-policy-selector/releases" + + +def git_text(repo_root: Path, *args: str) -> str: + try: + result = subprocess.run( + ["git", "-C", str(repo_root), *args], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError: + return "" + return result.stdout.strip() + + +def git_lines(repo_root: Path, *args: str) -> list[str]: + text = git_text(repo_root, *args) + return [line for line in text.splitlines() if line.strip()] + + +def parse_json_text(text: str) -> dict[str, Any]: + if not text: + return {} + try: + data = json.loads(text) + except json.JSONDecodeError: + return {} + return data if isinstance(data, dict) else {} + + +def manifest_for_ref(repo_root: Path, ref: str) -> dict[str, Any]: + if ref == "HEAD": + path = repo_root / MANIFEST_PATH + if path.exists(): + return parse_json_text(path.read_text(encoding="utf-8")) + return parse_json_text(git_text(repo_root, "show", f"{ref}:{MANIFEST_PATH}")) + + +def previous_tag(repo_root: Path, current_ref: str) -> str: + return git_text(repo_root, "describe", "--tags", "--abbrev=0", f"{current_ref}^") + + +def commit_summaries(repo_root: Path, previous_ref: str, current_ref: str) -> list[dict[str, str]]: + lines = git_lines(repo_root, "log", "--format=%H%x09%s", f"{previous_ref}..{current_ref}") + out: list[dict[str, str]] = [] + for line in lines: + sha, _, subject = line.partition("\t") + out.append({"sha": sha, "subject": subject}) + return out + + +def changed_paths(repo_root: Path, previous_ref: str, current_ref: str) -> list[str]: + return git_lines(repo_root, "diff", "--name-only", f"{previous_ref}..{current_ref}") + + +def categorize_paths(paths: list[str]) -> dict[str, list[str]]: + categories: dict[str, list[str]] = { + "modules": [], + "profiles": [], + "scripts": [], + "docs": [], + "release": [], + "other": [], + } + for path in paths: + if path.startswith("modules/") or path.startswith("repo-policy-selector/policy-library/modules/"): + categories["modules"].append(path) + elif path.startswith("profiles/") or path.startswith("repo-policy-selector/policy-library/profiles/"): + categories["profiles"].append(path) + elif path.startswith("repo-policy-selector/scripts/"): + categories["scripts"].append(path) + elif path == MANIFEST_PATH or path.startswith("repo-policy-selector/releases/"): + categories["release"].append(path) + elif path.endswith(".md") or path.endswith(".yaml"): + categories["docs"].append(path) + else: + categories["other"].append(path) + return categories + + +def markdown_notes(report: dict[str, Any]) -> str: + manifest = report.get("current_manifest", {}) + lines = [ + f"# Selector Release {report['release_ref']}", + "", + f"- Bundle version: `{manifest.get('bundle_version', '-')}`", + f"- Source commit: `{manifest.get('source_commit', '-')}`", + f"- Source ref: `{manifest.get('source_ref', '-') or '-'}`", + f"- Previous release: `{report['previous_ref']}`", + "", + "## Summary", + "", + ] + categories = report.get("changed_categories", {}) + for key in ["scripts", "release", "modules", "profiles", "docs", "other"]: + items = categories.get(key, []) + if items: + lines.append(f"- `{key}`: {len(items)} changed") + lines.extend(["", "## Commits", ""]) + for item in report.get("commits", []): + lines.append(f"- `{item['sha'][:7]}` {item['subject']}") + lines.append("") + lines.append("## Changed Paths") + lines.append("") + for key in ["scripts", "release", "modules", "profiles", "docs", "other"]: + items = categories.get(key, []) + if not items: + continue + lines.append(f"### {key.title()}") + lines.append("") + for path in items: + lines.append(f"- `{path}`") + lines.append("") + return "\n".join(lines).rstrip() + "\n" + + +def release_notes_report(repo_root: Path, current_ref: str, previous_ref: str | None) -> dict[str, Any]: + current_manifest = manifest_for_ref(repo_root, current_ref) + release_ref = current_manifest.get("release_ref") or current_ref + resolved_previous = previous_ref or previous_tag(repo_root, current_ref) + paths = changed_paths(repo_root, resolved_previous, current_ref) + report = { + "release_ref": release_ref, + "current_ref": current_ref, + "previous_ref": resolved_previous, + "current_manifest": current_manifest, + "changed_paths": paths, + "changed_categories": categorize_paths(paths), + "commits": commit_summaries(repo_root, resolved_previous, current_ref), + } + report["markdown"] = markdown_notes(report) + return report + + +def write_release_notes(repo_root: Path, report: dict[str, Any]) -> Path: + releases_dir = repo_root / RELEASES_DIR + releases_dir.mkdir(parents=True, exist_ok=True) + output_path = releases_dir / f"{report['release_ref']}.md" + output_path.write_text(report["markdown"], encoding="utf-8") + return output_path + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repo-root", required=True) + parser.add_argument("--current-ref", default="HEAD") + parser.add_argument("--previous-ref") + parser.add_argument("--write", action="store_true") + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + repo_root = Path(args.repo_root).resolve() + report = release_notes_report(repo_root, args.current_ref, args.previous_ref) + if args.write: + report["written_path"] = str(write_release_notes(repo_root, report)) + if args.json: + print(json.dumps(report, indent=2, sort_keys=True)) + else: + print(report["markdown"], end="") + if args.write: + print(f"written_path: {report['written_path']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/repo-policy-selector/scripts/install_selector_bundle.py b/.agents/skills/repo-policy-selector/scripts/install_selector_bundle.py new file mode 100644 index 00000000..3efb70cb --- /dev/null +++ b/.agents/skills/repo-policy-selector/scripts/install_selector_bundle.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys + +sys.dont_write_bytecode = True + +import tempfile +from pathlib import Path +from typing import Any + +INSTALL_RECORD_RELPATH = ".codex/policy-selector-install.json" + + +def copy_selector_bundle( + selector_root: Path, + target_repo_root: Path, + install_relpath: str, + force: bool, +) -> Path: + install_root = target_repo_root / install_relpath + if install_root.exists(): + if not force: + raise FileExistsError(f"install target already exists: {install_root}") + shutil.rmtree(install_root) + shutil.copytree( + selector_root, + install_root, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store"), + ) + return install_root + + +def clone_selector_source(bundle_git_url: str, bundle_ref: str, selector_subdir: str) -> tuple[tempfile.TemporaryDirectory[str], Path]: + temp_dir = tempfile.TemporaryDirectory(prefix="repo-policy-selector-") + clone_root = Path(temp_dir.name) / "source" + subprocess.run( + ["git", "clone", "--depth", "1", "--branch", bundle_ref, bundle_git_url, str(clone_root)], + check=True, + capture_output=True, + text=True, + ) + selector_root = clone_root / selector_subdir + if not selector_root.exists(): + raise FileNotFoundError(f"selector subdir not found in cloned repo: {selector_root}") + return temp_dir, selector_root + + +def run_installed_adopt( + install_root: Path, + target_repo_root: Path, + write_drafts: bool, +) -> dict[str, Any]: + cmd = [ + sys.executable, + str(install_root / "scripts" / "manage_policy.py"), + "--repo-root", + str(target_repo_root), + "--policy-root", + str(install_root / "policy-library"), + "adopt", + "--json", + ] + if write_drafts: + cmd.insert(-1, "--write-drafts") + result = subprocess.run(cmd, check=True, capture_output=True, text=True) + payload = json.loads(result.stdout) + return payload if isinstance(payload, dict) else {} + + +def write_install_record(target_repo_root: Path, record: dict[str, Any], install_record_relpath: str = INSTALL_RECORD_RELPATH) -> Path: + record_path = target_repo_root / install_record_relpath + record_path.parent.mkdir(parents=True, exist_ok=True) + record_path.write_text(json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return record_path + + +def install_selector_bundle( + *, + selector_root: Path | None, + bundle_git_url: str | None, + bundle_ref: str | None, + selector_subdir: str, + target_repo_root: Path, + install_relpath: str, + install_record_relpath: str = INSTALL_RECORD_RELPATH, + force: bool, + write_drafts: bool, +) -> dict[str, Any]: + temp_dir: tempfile.TemporaryDirectory[str] | None = None + source_descriptor: dict[str, Any] + resolved_selector_root: Path + if selector_root is not None: + resolved_selector_root = selector_root + source_descriptor = { + "source_type": "local-path", + "selector_root": str(selector_root), + } + else: + if not bundle_git_url or not bundle_ref: + raise ValueError("either selector_root or bundle_git_url + bundle_ref is required") + temp_dir, resolved_selector_root = clone_selector_source(bundle_git_url, bundle_ref, selector_subdir) + source_descriptor = { + "source_type": "git-ref", + "bundle_git_url": bundle_git_url, + "bundle_ref": bundle_ref, + "selector_subdir": selector_subdir, + } + + try: + install_root = copy_selector_bundle(resolved_selector_root, target_repo_root, install_relpath, force) + manifest_path = install_root / "release-manifest.json" + manifest: dict[str, Any] = {} + if manifest_path.exists(): + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + adopt_report = run_installed_adopt(install_root, target_repo_root, write_drafts) + install_record = { + **source_descriptor, + "install_relpath": install_relpath, + "installed_selector_root": str(install_root), + "installed_policy_root": str(install_root / "policy-library"), + "installed_bundle_release": manifest, + } + install_record_path = write_install_record(target_repo_root, install_record, install_record_relpath) + return { + "target_repo_root": str(target_repo_root), + "installed_selector_root": str(install_root), + "installed_policy_root": str(install_root / "policy-library"), + "install_relpath": install_relpath, + "installed_bundle_release": manifest, + "install_record_path": str(install_record_path), + "install_source": source_descriptor, + "write_drafts": write_drafts, + "adopt_report": adopt_report, + } + finally: + if temp_dir is not None: + temp_dir.cleanup() + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--selector-root") + parser.add_argument("--bundle-git-url") + parser.add_argument("--bundle-ref") + parser.add_argument("--selector-subdir", default="repo-policy-selector") + parser.add_argument("--target-repo-root", required=True) + parser.add_argument("--install-relpath", default=".codex/skills/repo-policy-selector") + parser.add_argument("--install-record-relpath", default=INSTALL_RECORD_RELPATH) + parser.add_argument("--force", action="store_true") + parser.add_argument("--write-drafts", action="store_true") + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + selector_root = Path(args.selector_root).resolve() if args.selector_root else None + report = install_selector_bundle( + selector_root=selector_root, + bundle_git_url=args.bundle_git_url, + bundle_ref=args.bundle_ref, + selector_subdir=args.selector_subdir, + target_repo_root=Path(args.target_repo_root).resolve(), + install_relpath=args.install_relpath, + install_record_relpath=args.install_record_relpath, + force=args.force, + write_drafts=args.write_drafts, + ) + if args.json: + print(json.dumps(report, indent=2, sort_keys=True)) + else: + print(f"target_repo_root: {report['target_repo_root']}") + print(f"installed_selector_root: {report['installed_selector_root']}") + print(f"installed_policy_root: {report['installed_policy_root']}") + print(f"install_relpath: {report['install_relpath']}") + print(f"install_record_path: {report['install_record_path']}") + bundle_version = report["installed_bundle_release"].get("bundle_version", "-") + print(f"installed_bundle_version: {bundle_version}") + print(f"write_drafts: {report['write_drafts']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/repo-policy-selector/scripts/manage_policy.py b/.agents/skills/repo-policy-selector/scripts/manage_policy.py new file mode 100644 index 00000000..4b2d9b9e --- /dev/null +++ b/.agents/skills/repo-policy-selector/scripts/manage_policy.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import sys + +sys.dont_write_bytecode = True + +import argparse +import json +from pathlib import Path +from typing import Any + +from check_for_updates import check_for_updates +from check_policy_upgrades import upgrade_report +from generate_release_notes import release_notes_report, write_release_notes +from install_selector_bundle import install_selector_bundle +from publish_github_release import publish_release +from release_cut import release_cut +from plan_policy_upgrade_actions import build_upgrade_action_plan +from release_selector_bundle import release_bundle +from select_policy import ( + build_install_plan, + choose_adoption_mode, + choose_profile, + detect_signals, + enumerate_policy_library, + extract_existing_migration_surfaces, + extract_existing_policy_surfaces, + infer_repo_local_policy_findings, + memory_discovery_assessment, + policy_adoption_coverage, + policy_identity_problems, + profile_expectation_gaps, + recommendation_mode, + require_unique_policy_identities, + render_agents_wirein, + summarize_migration_surface_actions, + summarize_policy_surface_actions, + validate_recommendations, + write_drafts, +) + + +def run_adopt(repo_root: Path, installed_library: dict[str, Any], write_drafts_flag: bool) -> dict[str, Any]: + signals = detect_signals(repo_root) + existing_migration_surfaces = extract_existing_migration_surfaces(repo_root) + existing_policy_surfaces = extract_existing_policy_surfaces(repo_root) + purpose, subtype, execution_bias, profile, modules, reasons = choose_profile(signals, installed_library) + memory_discovery = memory_discovery_assessment(signals, modules) + repo_local_policy_findings = infer_repo_local_policy_findings(repo_root, modules, profile, signals) + coverage = policy_adoption_coverage(existing_policy_surfaces, modules, installed_library) + expectation_gaps = profile_expectation_gaps(profile, signals, installed_library) + adoption_mode, migration_reasons, migration_targets = choose_adoption_mode(signals, expectation_gaps, coverage) + validation_problems = validate_recommendations(profile, modules, installed_library) + validation_problems.extend(policy_identity_problems(coverage["duplicate_policy_ids"])) + rec_mode = recommendation_mode(coverage) + next_modules = ( + [] + if rec_mode == "identity-reconciliation-required" + else coverage["missing_recommended_modules"] + if rec_mode == "patch-missing" + else modules + ) + install_plan = build_install_plan(repo_root, next_modules, coverage, installed_library) + agents_patch = render_agents_wirein(repo_root, install_plan, existing_policy_surfaces, purpose) + written_paths: list[str] = [] + if write_drafts_flag: + require_unique_policy_identities(coverage) + written_paths = write_drafts(repo_root, install_plan, agents_patch) + return { + "mode": "adopt", + "repo_root": str(repo_root), + "policy_root": installed_library["policy_root"], + "installed_bundle_release": installed_library.get("release_manifest", {}), + "repo_purpose": purpose, + "workflow_subtype": subtype, + "execution_bias": execution_bias, + "recommended_profile": profile, + "recommended_modules": modules, + "memory_discovery": memory_discovery, + "recommendation_mode": rec_mode, + "next_modules": next_modules, + "install_plan": install_plan, + "agents_wirein_patch": agents_patch, + "written_paths": written_paths, + "adoption_mode": adoption_mode, + "migration_reasons": migration_reasons, + "migration_targets": migration_targets, + "profile_expectation_gaps": expectation_gaps, + "policy_adoption_coverage": coverage, + "existing_policy_surfaces": existing_policy_surfaces, + "repo_local_policy_findings": repo_local_policy_findings, + "policy_surface_actions": summarize_policy_surface_actions(existing_policy_surfaces), + "existing_migration_surfaces": existing_migration_surfaces, + "migration_surface_actions": summarize_migration_surface_actions(existing_migration_surfaces), + "validation_problems": validation_problems, + "reasons": reasons, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repo-root", required=True) + parser.add_argument("--policy-root") + + subparsers = parser.add_subparsers(dest="mode", required=True) + + adopt_parser = subparsers.add_parser("adopt") + adopt_parser.add_argument("--write-drafts", action="store_true") + adopt_parser.add_argument("--json", action="store_true") + + upgrade_check_parser = subparsers.add_parser("upgrade-check") + upgrade_check_parser.add_argument("--profile", required=True) + upgrade_check_parser.add_argument("--baseline-ref") + upgrade_check_parser.add_argument("--current-ref", default="HEAD") + upgrade_check_parser.add_argument("--policy-git-root") + upgrade_check_parser.add_argument("--json", action="store_true") + + upgrade_plan_parser = subparsers.add_parser("upgrade-plan") + upgrade_plan_parser.add_argument("--profile", required=True) + upgrade_plan_parser.add_argument("--baseline-ref") + upgrade_plan_parser.add_argument("--current-ref", default="HEAD") + upgrade_plan_parser.add_argument("--policy-git-root") + upgrade_plan_parser.add_argument("--json", action="store_true") + + release_parser = subparsers.add_parser("release-bundle") + release_parser.add_argument("--source-root", required=True) + release_parser.add_argument("--selector-root") + release_parser.add_argument("--bundle-version", required=True) + release_parser.add_argument("--release-ref") + release_parser.add_argument("--source-ref") + release_parser.add_argument("--json", action="store_true") + + install_parser = subparsers.add_parser("install-downstream") + install_parser.add_argument("--selector-root") + install_parser.add_argument("--bundle-git-url") + install_parser.add_argument("--bundle-ref") + install_parser.add_argument("--selector-subdir", default="repo-policy-selector") + install_parser.add_argument("--target-repo-root", required=True) + install_parser.add_argument("--install-relpath", default=".codex/skills/repo-policy-selector") + install_parser.add_argument("--install-record-relpath", default=".codex/policy-selector-install.json") + install_parser.add_argument("--force", action="store_true") + install_parser.add_argument("--write-drafts", action="store_true") + install_parser.add_argument("--json", action="store_true") + + updates_parser = subparsers.add_parser("check-for-updates") + updates_parser.add_argument("--github-repo", default="CochranResearchGroup/agent-policies") + updates_parser.add_argument("--limit", type=int, default=10) + updates_parser.add_argument("--include-prereleases", action="store_true") + updates_parser.add_argument("--json", action="store_true") + + notes_parser = subparsers.add_parser("release-notes") + notes_parser.add_argument("--current-ref", default="HEAD") + notes_parser.add_argument("--previous-ref") + notes_parser.add_argument("--write", action="store_true") + notes_parser.add_argument("--json", action="store_true") + + publish_parser = subparsers.add_parser("release-publish") + publish_parser.add_argument("--tag", required=True) + publish_parser.add_argument("--title") + publish_parser.add_argument("--notes-file", required=True) + publish_parser.add_argument("--latest", action="store_true") + publish_parser.add_argument("--prerelease", action="store_true") + publish_parser.add_argument("--json", action="store_true") + + cut_parser = subparsers.add_parser("release-cut") + cut_parser.add_argument("--selector-root") + cut_parser.add_argument("--bundle-version", required=True) + cut_parser.add_argument("--release-ref", required=True) + cut_parser.add_argument("--previous-ref") + cut_parser.add_argument("--publish", action="store_true") + cut_parser.add_argument("--latest", action="store_true") + cut_parser.add_argument("--prerelease", action="store_true") + cut_parser.add_argument("--json", action="store_true") + + args = parser.parse_args() + + repo_root = Path(args.repo_root).resolve() + policy_root = Path(args.policy_root).resolve() if args.policy_root else None + installed_library = enumerate_policy_library(policy_root) + + if args.mode == "adopt": + out = run_adopt(repo_root, installed_library, args.write_drafts) + elif args.mode == "upgrade-check": + policy_git_root = Path(args.policy_git_root).resolve() if args.policy_git_root else None + out = upgrade_report( + repo_root=repo_root, + installed_library=installed_library, + profile_id=args.profile, + baseline_ref=args.baseline_ref, + current_ref=args.current_ref, + policy_repo_root=policy_git_root, + ) + out["mode"] = "upgrade-check" + elif args.mode == "release-bundle": + selector_root = Path(args.selector_root).resolve() if args.selector_root else Path(__file__).resolve().parents[1] + out = release_bundle( + source_root=Path(args.source_root).resolve(), + selector_root=selector_root, + bundle_version=args.bundle_version, + release_ref=args.release_ref, + source_ref=args.source_ref, + ) + out["mode"] = "release-bundle" + elif args.mode == "release-notes": + out = release_notes_report(repo_root, args.current_ref, args.previous_ref) + if args.write: + out["written_path"] = str(write_release_notes(repo_root, out)) + out["mode"] = "release-notes" + elif args.mode == "release-publish": + out = publish_release( + repo_root=repo_root, + tag=args.tag, + title=args.title, + notes_file=Path(args.notes_file).resolve(), + latest=args.latest, + prerelease=args.prerelease, + ) + out["mode"] = "release-publish" + elif args.mode == "release-cut": + selector_root = Path(args.selector_root).resolve() if args.selector_root else repo_root / "repo-policy-selector" + out = release_cut( + repo_root=repo_root, + selector_root=selector_root, + bundle_version=args.bundle_version, + release_ref=args.release_ref, + previous_ref=args.previous_ref, + publish=args.publish, + latest=args.latest, + prerelease=args.prerelease, + ) + out["mode"] = "release-cut" + elif args.mode == "install-downstream": + selector_root = Path(args.selector_root).resolve() if args.selector_root else None + out = install_selector_bundle( + selector_root=selector_root, + bundle_git_url=args.bundle_git_url, + bundle_ref=args.bundle_ref, + selector_subdir=args.selector_subdir, + target_repo_root=Path(args.target_repo_root).resolve(), + install_relpath=args.install_relpath, + install_record_relpath=args.install_record_relpath, + force=args.force, + write_drafts=args.write_drafts, + ) + out["mode"] = "install-downstream" + elif args.mode == "check-for-updates": + out = check_for_updates( + repo_root=repo_root, + policy_root=policy_root, + github_repo=args.github_repo, + limit=args.limit, + include_prereleases=args.include_prereleases, + ) + out["mode"] = "check-for-updates" + else: + policy_git_root = Path(args.policy_git_root).resolve() if args.policy_git_root else None + out = build_upgrade_action_plan( + repo_root=repo_root, + installed_library=installed_library, + profile_id=args.profile, + baseline_ref=args.baseline_ref, + current_ref=args.current_ref, + policy_repo_root=policy_git_root, + ) + out["mode"] = "upgrade-plan" + + if getattr(args, "json", False): + print(json.dumps(out, indent=2, sort_keys=True)) + else: + print(f"mode: {out['mode']}") + for key in sorted(k for k in out.keys() if k != "mode"): + value = out[key] + if isinstance(value, (dict, list)): + print(f"{key}: {json.dumps(value, indent=2, sort_keys=True)}") + else: + print(f"{key}: {value}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/repo-policy-selector/scripts/plan_policy_upgrade_actions.py b/.agents/skills/repo-policy-selector/scripts/plan_policy_upgrade_actions.py new file mode 100644 index 00000000..5947a14d --- /dev/null +++ b/.agents/skills/repo-policy-selector/scripts/plan_policy_upgrade_actions.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import sys + +sys.dont_write_bytecode = True + +import argparse +import json +from pathlib import Path +from typing import Any + +from check_policy_upgrades import upgrade_report +from select_policy import ( + adopted_policy_id, + build_install_plan, + enumerate_policy_library, + extract_existing_policy_surfaces, + policy_adoption_coverage, + render_agents_wirein, +) + + +def canonical_policy_map(existing_policy_surfaces: list[dict[str, Any]]) -> dict[str, list[str]]: + mapping: dict[str, list[str]] = {} + for item in existing_policy_surfaces: + if item["source_type"] != "canonical-policy": + continue + path = item["path"] + key = adopted_policy_id(Path(path)) + mapping.setdefault(key, []).append(path) + return mapping + + +def current_review_paths(existing_policy_surfaces: list[dict[str, Any]], module_ids: list[str]) -> dict[str, list[str]]: + coverage = canonical_policy_map(existing_policy_surfaces) + return {module_id: coverage.get(module_id, []) for module_id in module_ids} + + +def build_retirement_plan( + repo_root: Path, + existing_policy_surfaces: list[dict[str, Any]], + retirement_review_modules: list[str], +) -> dict[str, Any]: + review_paths = current_review_paths(existing_policy_surfaces, retirement_review_modules) + retire_paths = sorted({path for paths in review_paths.values() for path in paths}) + keep_surfaces = [ + item + for item in existing_policy_surfaces + if item["path"] not in retire_paths + ] + agents_patch = render_agents_wirein(repo_root, [], keep_surfaces) + return { + "retire_review_modules": retirement_review_modules, + "retire_paths": retire_paths, + "agents_wirein_patch": agents_patch, + } + + +def build_upgrade_action_plan( + repo_root: Path, + installed_library: dict[str, Any], + profile_id: str, + baseline_ref: str | None, + current_ref: str, + policy_repo_root: Path | None, +) -> dict[str, Any]: + existing_policy_surfaces = extract_existing_policy_surfaces(repo_root) + current_modules = installed_library.get("parsed_profiles", {}).get(profile_id, {}).get("modules", []) + if not isinstance(current_modules, list): + current_modules = [] + coverage = policy_adoption_coverage(existing_policy_surfaces, current_modules, installed_library) + report = upgrade_report( + repo_root=repo_root, + installed_library=installed_library, + profile_id=profile_id, + baseline_ref=baseline_ref, + current_ref=current_ref, + policy_repo_root=policy_repo_root, + ) + install_plan = build_install_plan(repo_root, report["newly_available_modules"], coverage, installed_library) + changed_paths = current_review_paths(existing_policy_surfaces, report["changed_adopted_modules"]) + retirement_paths = current_review_paths(existing_policy_surfaces, report["retirement_review_modules"]) + retirement_plan = build_retirement_plan(repo_root, existing_policy_surfaces, report["retirement_review_modules"]) + + actions: list[dict[str, Any]] = [] + for item in install_plan: + actions.append( + { + "action": "install-new", + "module_id": item["module_id"], + "target_policy_path": item["target_policy_path"], + "source_module_path": item["source_module_path"], + } + ) + for module_id, paths in changed_paths.items(): + actions.append( + { + "action": "upgrade-review", + "module_id": module_id, + "local_policy_paths": paths, + } + ) + for module_id, paths in retirement_paths.items(): + actions.append( + { + "action": "retire-review", + "module_id": module_id, + "local_policy_paths": paths, + } + ) + return { + "selected_profile": profile_id, + "baseline_ref": baseline_ref, + "current_ref": current_ref, + "upgrade_report": report, + "retirement_plan": retirement_plan, + "action_plan": actions, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repo-root", required=True) + parser.add_argument("--policy-root", required=False) + parser.add_argument("--profile", required=True) + parser.add_argument("--baseline-ref") + parser.add_argument("--current-ref", default="HEAD") + parser.add_argument("--policy-git-root") + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + repo_root = Path(args.repo_root).resolve() + policy_root = Path(args.policy_root).resolve() if args.policy_root else None + policy_git_root = Path(args.policy_git_root).resolve() if args.policy_git_root else None + installed_library = enumerate_policy_library(policy_root) + plan = build_upgrade_action_plan( + repo_root=repo_root, + installed_library=installed_library, + profile_id=args.profile, + baseline_ref=args.baseline_ref, + current_ref=args.current_ref, + policy_repo_root=policy_git_root, + ) + if args.json: + print(json.dumps(plan, indent=2, sort_keys=True)) + else: + print(f"selected_profile: {plan['selected_profile']}") + print(f"baseline_ref: {plan['baseline_ref'] or '-'}") + print(f"current_ref: {plan['current_ref']}") + print("action_plan:") + for item in plan["action_plan"]: + if item["action"] == "install-new": + print(f"- install-new {item['module_id']} -> {item['target_policy_path']}") + else: + paths = ", ".join(item.get("local_policy_paths", [])) or "-" + print(f"- {item['action']} {item['module_id']} ({paths})") + retirement_plan = plan["retirement_plan"] + if retirement_plan["retire_paths"]: + print("retirement_plan:") + for path in retirement_plan["retire_paths"]: + print(f"- remove {path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/repo-policy-selector/scripts/publish_github_release.py b/.agents/skills/repo-policy-selector/scripts/publish_github_release.py new file mode 100644 index 00000000..c9dd6331 --- /dev/null +++ b/.agents/skills/repo-policy-selector/scripts/publish_github_release.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import sys + +sys.dont_write_bytecode = True + +import argparse +import json +import subprocess +from pathlib import Path +from typing import Any + + +def run_gh(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run(["gh", *args], check=True, capture_output=True, text=True) + + +def release_exists(tag: str) -> bool: + result = subprocess.run(["gh", "release", "view", tag], capture_output=True, text=True) + return result.returncode == 0 + + +def publish_release( + *, + repo_root: Path, + tag: str, + title: str | None, + notes_file: Path, + latest: bool, + prerelease: bool, +) -> dict[str, Any]: + release_title = title or f"repo-policy-selector {tag}" + rel_path = notes_file.resolve().relative_to(repo_root.resolve()) + if release_exists(tag): + cmd = [ + "release", + "edit", + tag, + "--title", + release_title, + "--notes-file", + str(notes_file), + ] + if latest: + cmd.append("--latest") + if prerelease: + cmd.append("--prerelease") + run_gh(*cmd) + action = "edited" + else: + cmd = [ + "release", + "create", + tag, + "--title", + release_title, + "--notes-file", + str(notes_file), + ] + if latest: + cmd.append("--latest") + if prerelease: + cmd.append("--prerelease") + run_gh(*cmd) + action = "created" + view = run_gh("release", "view", tag, "--json", "url,name,tagName,isDraft,isPrerelease,isImmutable,publishedAt") + data = json.loads(view.stdout) + return { + "action": action, + "tag": tag, + "title": release_title, + "notes_file": str(notes_file), + "notes_repo_path": str(rel_path), + "url": data.get("url", ""), + "is_draft": data.get("isDraft", False), + "is_prerelease": data.get("isPrerelease", False), + "is_immutable": data.get("isImmutable", False), + "published_at": data.get("publishedAt", ""), + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repo-root", required=True) + parser.add_argument("--tag", required=True) + parser.add_argument("--title") + parser.add_argument("--notes-file", required=True) + parser.add_argument("--latest", action="store_true") + parser.add_argument("--prerelease", action="store_true") + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + report = publish_release( + repo_root=Path(args.repo_root).resolve(), + tag=args.tag, + title=args.title, + notes_file=Path(args.notes_file).resolve(), + latest=args.latest, + prerelease=args.prerelease, + ) + if args.json: + print(json.dumps(report, indent=2, sort_keys=True)) + else: + for key, value in report.items(): + print(f"{key}: {value}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/repo-policy-selector/scripts/release_cut.py b/.agents/skills/repo-policy-selector/scripts/release_cut.py new file mode 100644 index 00000000..925469d7 --- /dev/null +++ b/.agents/skills/repo-policy-selector/scripts/release_cut.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import sys + +sys.dont_write_bytecode = True + +import argparse +import json +import subprocess +from pathlib import Path +from typing import Any + +from generate_release_notes import release_notes_report, write_release_notes +from publish_github_release import publish_release +from release_selector_bundle import git_is_clean, git_value, release_bundle + + +def run_git(repo_root: Path, *args: str) -> str: + result = subprocess.run( + ["git", "-C", str(repo_root), *args], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def tag_exists(repo_root: Path, tag: str) -> bool: + result = subprocess.run(["git", "-C", str(repo_root), "rev-parse", "-q", "--verify", f"refs/tags/{tag}"], capture_output=True, text=True) + return result.returncode == 0 + + +def release_cut( + *, + repo_root: Path, + selector_root: Path, + bundle_version: str, + release_ref: str, + previous_ref: str | None, + publish: bool, + latest: bool, + prerelease: bool, +) -> dict[str, Any]: + if not git_is_clean(repo_root): + raise RuntimeError("release-cut requires a clean starting worktree") + if tag_exists(repo_root, release_ref): + raise RuntimeError(f"tag already exists: {release_ref}") + + source_commit = git_value(repo_root, "rev-parse", "HEAD") or "UNRELEASED" + bundle_report = release_bundle( + source_root=repo_root, + selector_root=selector_root, + bundle_version=bundle_version, + release_ref=release_ref, + source_ref=source_commit, + ) + notes_report = release_notes_report(repo_root, "HEAD", previous_ref) + notes_path = write_release_notes(repo_root, notes_report) + + run_git(repo_root, "add", str(selector_root / "release-manifest.json"), str(notes_path)) + commit_message = f"Release selector bundle {release_ref}" + run_git(repo_root, "commit", "-m", commit_message) + release_commit = git_value(repo_root, "rev-parse", "HEAD") + run_git(repo_root, "tag", "-a", release_ref, "-m", f"repo-policy-selector {release_ref}") + run_git(repo_root, "push", "origin", "main") + run_git(repo_root, "push", "origin", release_ref) + + publish_report: dict[str, Any] | None = None + if publish: + publish_report = publish_release( + repo_root=repo_root, + tag=release_ref, + title=f"repo-policy-selector {release_ref}", + notes_file=notes_path, + latest=latest, + prerelease=prerelease, + ) + + return { + "bundle_report": bundle_report, + "notes_path": str(notes_path), + "release_commit": release_commit, + "release_ref": release_ref, + "bundle_version": bundle_version, + "source_commit": source_commit, + "publish_report": publish_report, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repo-root", required=True) + parser.add_argument("--selector-root") + parser.add_argument("--bundle-version", required=True) + parser.add_argument("--release-ref", required=True) + parser.add_argument("--previous-ref") + parser.add_argument("--publish", action="store_true") + parser.add_argument("--latest", action="store_true") + parser.add_argument("--prerelease", action="store_true") + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + repo_root = Path(args.repo_root).resolve() + selector_root = Path(args.selector_root).resolve() if args.selector_root else repo_root / "repo-policy-selector" + report = release_cut( + repo_root=repo_root, + selector_root=selector_root, + bundle_version=args.bundle_version, + release_ref=args.release_ref, + previous_ref=args.previous_ref, + publish=args.publish, + latest=args.latest, + prerelease=args.prerelease, + ) + if args.json: + print(json.dumps(report, indent=2, sort_keys=True)) + else: + print(json.dumps(report, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/repo-policy-selector/scripts/release_selector_bundle.py b/.agents/skills/repo-policy-selector/scripts/release_selector_bundle.py new file mode 100644 index 00000000..f5ba37fc --- /dev/null +++ b/.agents/skills/repo-policy-selector/scripts/release_selector_bundle.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import sys + +sys.dont_write_bytecode = True + +import argparse +import hashlib +import json +import subprocess +import tempfile +from pathlib import Path +from typing import Any + +from select_policy import enumerate_policy_library +from sync_policy_library import sync_policy_library + + +def read_text(path: Path) -> str: + try: + return path.read_text(encoding="utf-8") + except FileNotFoundError: + return "" + + +def git_value(repo_root: Path, *args: str) -> str: + try: + result = subprocess.run( + ["git", "-C", str(repo_root), *args], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError: + return "" + return result.stdout.strip() + + +def git_is_clean(repo_root: Path) -> bool: + status = git_value(repo_root, "status", "--porcelain") + return not status + + + + +def materialize_source_ref(source_root: Path, source_ref: str) -> tuple[tempfile.TemporaryDirectory[str], Path, str]: + temp_dir = tempfile.TemporaryDirectory(prefix="selector-release-") + snapshot_root = Path(temp_dir.name) / "snapshot" + snapshot_root.mkdir(parents=True, exist_ok=True) + archive_cmd = f"git -C {shlex_quote(str(source_root))} archive {shlex_quote(source_ref)} | tar -x -C {shlex_quote(str(snapshot_root))}" + subprocess.run(["bash", "-lc", archive_cmd], check=True, capture_output=True, text=True) + source_commit = git_value(source_root, "rev-parse", source_ref) or "UNRELEASED" + return temp_dir, snapshot_root, source_commit + + +def shlex_quote(value: str) -> str: + return "'" + value.replace("'", "'\''") + "'" + +def compute_tree_digest(path: Path) -> str: + digest = hashlib.sha256() + for file_path in sorted(p for p in path.rglob("*") if p.is_file()): + digest.update(str(file_path.relative_to(path)).encode("utf-8")) + digest.update(b"\0") + digest.update(file_path.read_bytes()) + digest.update(b"\0") + return digest.hexdigest() + + +def write_release_manifest( + *, + selector_root: Path, + policy_library_root: Path, + bundle_version: str, + release_ref: str, + source_root: Path, + source_commit: str, + source_tree_state: str, + source_ref: str | None, +) -> dict[str, Any]: + manifest = { + "schema_version": 1, + "bundle_name": "repo-policy-selector", + "bundle_version": bundle_version, + "release_ref": release_ref, + "source_repo_root": str(source_root), + "source_commit": source_commit or "UNRELEASED", + "source_tree_state": source_tree_state, + "source_ref": source_ref or "", + "policy_library": { + "relative_root": "policy-library", + "catalog_path": "policy-library/catalog.yaml", + "schema_path": "policy-library/SCHEMA.md", + "content_sha256": compute_tree_digest(policy_library_root), + }, + } + manifest_path = selector_root / "release-manifest.json" + manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return manifest + + +def release_bundle( + *, + source_root: Path, + selector_root: Path, + bundle_version: str, + release_ref: str | None, + source_ref: str | None, +) -> dict[str, Any]: + output_root = selector_root / "policy-library" + temp_dir: tempfile.TemporaryDirectory[str] | None = None + effective_source_root = source_root + effective_source_ref = source_ref + if source_ref: + temp_dir, effective_source_root, source_commit = materialize_source_ref(source_root, source_ref) + source_tree_state = "clean-ref" + else: + if not git_is_clean(source_root): + raise RuntimeError("release-bundle without --source-ref requires a clean source tree") + source_commit = git_value(source_root, "rev-parse", "HEAD") or "UNRELEASED" + source_tree_state = "clean" + sync_policy_library(effective_source_root, output_root) + try: + manifest = write_release_manifest( + selector_root=selector_root, + policy_library_root=output_root, + bundle_version=bundle_version, + release_ref=release_ref or bundle_version, + source_root=source_root, + source_commit=source_commit, + source_tree_state=source_tree_state, + source_ref=effective_source_ref, + ) + installed_library = enumerate_policy_library(output_root) + finally: + if temp_dir is not None: + temp_dir.cleanup() + validation_problems: list[str] = [] + if not installed_library.get("catalog_found"): + validation_problems.append("missing catalog.yaml in bundled policy library") + if not installed_library.get("module_ids"): + validation_problems.append("bundled policy library has no enumerated modules") + if not installed_library.get("profile_ids"): + validation_problems.append("bundled policy library has no enumerated profiles") + return { + "selector_root": str(selector_root), + "policy_library_root": str(output_root), + "manifest_path": str(selector_root / "release-manifest.json"), + "bundle_version": manifest["bundle_version"], + "release_ref": manifest["release_ref"], + "source_commit": manifest["source_commit"], + "source_tree_state": manifest["source_tree_state"], + "source_ref": manifest.get("source_ref", ""), + "module_count": len(installed_library.get("module_ids", [])), + "profile_count": len(installed_library.get("profile_ids", [])), + "validation_problems": validation_problems, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--source-root", required=True) + parser.add_argument("--selector-root") + parser.add_argument("--bundle-version", required=True) + parser.add_argument("--release-ref") + parser.add_argument("--source-ref") + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + source_root = Path(args.source_root).resolve() + selector_root = ( + Path(args.selector_root).resolve() + if args.selector_root + else Path(__file__).resolve().parents[1] + ) + report = release_bundle( + source_root=source_root, + selector_root=selector_root, + bundle_version=args.bundle_version, + release_ref=args.release_ref, + source_ref=args.source_ref, + ) + if args.json: + print(json.dumps(report, indent=2, sort_keys=True)) + else: + print(f"selector_root: {report['selector_root']}") + print(f"policy_library_root: {report['policy_library_root']}") + print(f"manifest_path: {report['manifest_path']}") + print(f"bundle_version: {report['bundle_version']}") + print(f"release_ref: {report['release_ref']}") + print(f"source_commit: {report['source_commit']}") + print(f"source_tree_state: {report['source_tree_state']}") + print(f"source_ref: {report['source_ref'] or '-'}") + print(f"module_count: {report['module_count']}") + print(f"profile_count: {report['profile_count']}") + print(f"validation_problems: {', '.join(report['validation_problems']) or '-'}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/repo-policy-selector/scripts/select_policy.py b/.agents/skills/repo-policy-selector/scripts/select_policy.py new file mode 100755 index 00000000..b6b68856 --- /dev/null +++ b/.agents/skills/repo-policy-selector/scripts/select_policy.py @@ -0,0 +1,2425 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import sys + +sys.dont_write_bytecode = True + +import argparse +import json +import re +from pathlib import Path +from typing import Any + + +def read_text(path: Path) -> str: + try: + return path.read_text(encoding="utf-8") + except FileNotFoundError: + return "" + + +def read_json(path: Path) -> dict[str, Any]: + text = read_text(path) + if not text: + return {} + try: + data = json.loads(text) + except json.JSONDecodeError: + return {} + return data if isinstance(data, dict) else {} + + +def parse_catalog(catalog_path: Path) -> dict[str, list[dict[str, Any]]]: + text = read_text(catalog_path) + result: dict[str, list[dict[str, Any]]] = {"modules": [], "profiles": []} + section: str | None = None + current: dict[str, Any] | None = None + for raw_line in text.splitlines(): + line = raw_line.rstrip() + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if stripped.endswith(":") and stripped[:-1] in {"modules", "profiles"}: + section = stripped[:-1] + current = None + continue + if section is None: + continue + if stripped.startswith("- "): + current = {} + result[section].append(current) + payload = stripped[2:] + if ":" in payload: + key, value = payload.split(":", 1) + current[key.strip()] = value.strip() + continue + if current is None or ":" not in stripped: + continue + key, value = stripped.split(":", 1) + key = key.strip() + value = value.strip() + if value.startswith("[") and value.endswith("]"): + items = [item.strip() for item in value[1:-1].split(",") if item.strip()] + current[key] = items + else: + current[key] = value + return result + + +def parse_profile(profile_path: Path) -> dict[str, Any]: + text = read_text(profile_path) + result: dict[str, Any] = {} + section: str | None = None + current_list_key: str | None = None + current_dict_key: str | None = None + for raw_line in text.splitlines(): + line = raw_line.rstrip() + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if not line.startswith(" ") and stripped.endswith(":"): + section = stripped[:-1] + current_list_key = None + current_dict_key = section if section == "overrides" else None + if section == "overrides": + result[section] = {} + continue + if section in {"modules", "workflow_subtypes"} and stripped.startswith("- "): + result.setdefault(section, []).append(stripped[2:].strip()) + continue + if section == "overrides" and ":" in stripped: + key, value = stripped.split(":", 1) + result["overrides"][key.strip()] = value.strip() + continue + if section is None and ":" in stripped: + key, value = stripped.split(":", 1) + result[key.strip()] = value.strip() + return result + + +def parse_module(module_path: Path) -> dict[str, Any]: + text = read_text(module_path) + result: dict[str, Any] = {"body": text} + if text.startswith("---\n"): + _, _, remainder = text.partition("---\n") + frontmatter, _, body = remainder.partition("\n---\n") + if body: + result["body"] = body + for raw_line in frontmatter.splitlines(): + line = raw_line.rstrip() + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if stripped.startswith("- "): + continue + if ":" not in stripped: + continue + key, value = stripped.split(":", 1) + key = key.strip() + value = value.strip() + if value.startswith("[") and value.endswith("]"): + result[key] = [item.strip() for item in value[1:-1].split(",") if item.strip()] + else: + result[key] = value + return result + + +def enumerate_policy_library(policy_root: Path | None) -> dict[str, Any]: + base_dir = policy_root or Path(__file__).resolve().parents[1] / "policy-library" + catalog_path = base_dir / "catalog.yaml" + manifest_path = base_dir.parent / "release-manifest.json" + catalog = parse_catalog(catalog_path) + module_ids = [item["id"] for item in catalog["modules"] if "id" in item] + profile_ids = [item["id"] for item in catalog["profiles"] if "id" in item] + parsed_modules: dict[str, dict[str, Any]] = {} + for item in catalog["modules"]: + module_id = item.get("id") + module_path = item.get("path") + if not module_id or not module_path: + continue + parsed_modules[module_id] = parse_module(base_dir / module_path) + parsed_profiles: dict[str, dict[str, Any]] = {} + for item in catalog["profiles"]: + profile_id = item.get("id") + profile_path = item.get("path") + if not profile_id or not profile_path: + continue + parsed_profiles[profile_id] = parse_profile(base_dir / profile_path) + return { + "policy_root": str(base_dir), + "catalog_path": str(catalog_path), + "release_manifest_path": str(manifest_path), + "release_manifest": read_json(manifest_path), + "catalog_found": catalog_path.exists(), + "modules": catalog["modules"], + "profiles": catalog["profiles"], + "parsed_modules": parsed_modules, + "parsed_profiles": parsed_profiles, + "module_ids": module_ids, + "profile_ids": profile_ids, + } + + +def read_policy_text(repo_root: Path) -> str: + parts: list[str] = [] + agents_text = read_text(repo_root / "AGENTS.md") or read_text(repo_root / "AGENT.MD") + if agents_text: + parts.append(agents_text) + + policy_dir = repo_root / "docs" / "dev" / "policies" + if policy_dir.exists(): + for path in sorted(policy_dir.rglob("*.md")): + if path.is_file(): + parts.append(read_text(path)) + return "\n".join(part for part in parts if part) + + +def adopted_policy_id(path: Path) -> str: + stem = path.stem + match = re.match(r"^\d{4}(?:-\d{4}-\d{2}-\d{2})?-(.+)$", stem) + if match: + return match.group(1) + return stem + + +def duplicate_adopted_policy_ids(existing_policy_surfaces: list[dict]) -> dict[str, list[str]]: + paths_by_id: dict[str, list[str]] = {} + for item in existing_policy_surfaces: + if item.get("source_type") != "canonical-policy": + continue + path = Path(item["path"]) + paths_by_id.setdefault(adopted_policy_id(path), []).append(str(path)) + return { + module_id: sorted(paths) + for module_id, paths in sorted(paths_by_id.items()) + if len(paths) > 1 + } + + +def policy_identity_problems(duplicates: dict[str, list[str]]) -> list[str]: + return [ + f"duplicate adopted policy identity {module_id}: {', '.join(paths)}" + for module_id, paths in sorted(duplicates.items()) + ] + + +def require_unique_policy_identities(coverage: dict[str, Any]) -> None: + duplicates = coverage.get("duplicate_policy_ids", {}) + if duplicates: + raise ValueError("; ".join(policy_identity_problems(duplicates))) + + +def next_policy_serial(repo_root: Path) -> int: + policy_dir = repo_root / "docs" / "dev" / "policies" + max_serial = 0 + if policy_dir.exists(): + for path in policy_dir.glob("*.md"): + match = re.match(r"^(\d{4})-", path.name) + if match: + max_serial = max(max_serial, int(match.group(1))) + return max_serial + 1 + + +def tokenize(text: str) -> set[str]: + def normalize(token: str) -> str: + for prefix, replacement in [ + ("install", "install"), + ("wire", "wire"), + ("enumerat", "enumerat"), + ("adopt", "adopt"), + ("reconcil", "reconcil"), + ("document", "document"), + ("version", "version"), + ("release", "release"), + ("commit", "commit"), + ("branch", "branch"), + ("plan", "plan"), + ("goal", "goal"), + ("parallel", "parallel"), + ("memory", "memory"), + ("note", "note"), + ("harvest", "harvest"), + ("validat", "validat"), + ("architect", "architect"), + ("delegat", "delegat"), + ("agent", "agent"), + ("preview", "preview"), + ("artifact", "artifact"), + ("approv", "approv"), + ("codegraph", "codegraph"), + ("refactor", "refactor"), + ]: + if token.startswith(prefix): + return replacement + return token + + return { + normalize(token) + for token in re.findall(r"[a-z0-9]+", text.lower()) + if len(token) >= 4 and token not in { + "this", + "that", + "with", + "when", + "from", + "into", + "under", + "use", + "used", + "using", + "policy", + "policies", + "repo", + "repos", + "docs", + "dev", + "shared", + "local", + "module", + "modules", + "profile", + "profiles", + "should", + "keep", + "work", + "workflow", + } + } + + +def module_signature(module_id: str, module: dict[str, Any]) -> set[str]: + parts = [module_id.replace("-", " ")] + for key in ("title", "summary"): + value = module.get(key) + if isinstance(value, str): + parts.append(value) + tags = module.get("tags", []) + if isinstance(tags, list): + parts.extend(tag.replace("-", " ") for tag in tags if isinstance(tag, str)) + return tokenize("\n".join(parts)) + + +def module_anchor_tokens(module_id: str, module: dict[str, Any]) -> set[str]: + parts = [module_id.replace("-", " ")] + title = module.get("title", "") + if isinstance(title, str): + parts.append(title) + tags = module.get("tags", []) + if isinstance(tags, list): + parts.extend(tag.replace("-", " ") for tag in tags if isinstance(tag, str)) + return tokenize("\n".join(parts)) + + +def semantic_policy_text(text: str) -> str: + body = text + if body.startswith("---\n"): + _, _, remainder = body.partition("---\n") + _, _, after_frontmatter = remainder.partition("\n---\n") + if after_frontmatter: + body = after_frontmatter + policy_only = re.split(r"(?im)^##\s+adoption notes\s*$", body, maxsplit=1)[0] + return policy_only.strip() or body + + +def semantic_module_matches( + existing_policy_surfaces: list[dict], + installed_library: dict[str, Any], +) -> dict[str, list[str]]: + canonical_policy_paths = [ + Path(item["path"]) + for item in existing_policy_surfaces + if item["source_type"] == "canonical-policy" + ] + local_texts = {str(path): read_text(path) for path in canonical_policy_paths} + local_policy_texts = { + path: semantic_policy_text(text) for path, text in local_texts.items() + } + local_headings = { + path: next( + ( + line.lstrip("#").strip().lower() + for line in text.splitlines() + if line.startswith("#") + ), + "", + ) + for path, text in local_policy_texts.items() + } + local_tokens = {path: tokenize(text) for path, text in local_policy_texts.items()} + explicit_match_paths: dict[str, set[str]] = {path: set() for path in local_texts} + for module_id, module in installed_library.get("parsed_modules", {}).items(): + title = module.get("title", "") + for path_str, text in local_policy_texts.items(): + lower_text = text.lower() + heading = local_headings[path_str] + if module_id == "notes-and-memories": + if ( + (module_id in heading or (isinstance(title, str) and title and title.lower() in heading)) + and "docs/dev/notes" in lower_text + and "docs/dev/memories" in lower_text + and any( + phrase in lower_text + for phrase in [ + "prefer notes", + "prefer memories", + "dated notes", + "durable memories", + "dated observations", + "persist across many slices", + ] + ) + ): + explicit_match_paths[path_str].add(module_id) + continue + if module_id in heading or (isinstance(title, str) and title and title.lower() in heading): + explicit_match_paths[path_str].add(module_id) + matches: dict[str, list[str]] = {} + for module_id, module in installed_library.get("parsed_modules", {}).items(): + signature = module_signature(module_id, module) + anchors = module_anchor_tokens(module_id, module) + if not signature: + continue + scored_matches: list[tuple[int, str]] = [] + for path_str, text in local_policy_texts.items(): + lower_text = text.lower() + heading = local_headings[path_str] + title = module.get("title", "") + if ( + module_id == "notes-and-memories" + and ( + "notes and memories" in lower_text + or "notes-and-memories" in lower_text + or ("docs/dev/notes" in lower_text and "docs/dev/memories" in lower_text) + ) + and any( + phrase in lower_text + for phrase in [ + "prefer notes", + "prefer memories", + "dated notes", + "durable memories", + "dated observations", + "persist across many slices", + ] + ) + ): + scored_matches.append((100, path_str)) + continue + if module_id in heading or (isinstance(title, str) and title and title.lower() in heading): + scored_matches.append((100, path_str)) + continue + if explicit_match_paths[path_str]: + continue + if ( + module_id == "policy-management" + and "agents.md" in lower_text + and "docs/dev/policies" in lower_text + and "install" in lower_text + and ("wiring" in lower_text or "wire-in" in lower_text or "enumeration" in lower_text) + ): + scored_matches.append((80, path_str)) + continue + if ( + module_id == "notes-and-memories" + and ( + "notes and memories" in lower_text + or "notes-and-memories" in lower_text + or ("docs/dev/notes" in lower_text and "docs/dev/memories" in lower_text) + ) + and any( + phrase in lower_text + for phrase in [ + "prefer notes", + "prefer memories", + "dated observations", + "dated notes", + "durable memories", + "stable context", + "continuity", + ] + ) + ): + scored_matches.append((85, path_str)) + continue + if module_id == "notes-and-memories": + continue + if ( + module_id == "lms-cli-governance" + and ( + "canvas cli" in lower_text + or "canvas-cli" in lower_text + or "lms cli" in lower_text + or "lms-backed" in lower_text + ) + and any(token in lower_text for token in ["course", "assignment", "assignments", "quiz", "quizzes", "modules", "grades"]) + and any(token in lower_text for token in ["live", "read-only", "dry-run", "--apply", "validate", "export"]) + ): + scored_matches.append((85, path_str)) + continue + if ( + module_id == "graph-backed-memory-usage" + and ( + "graphiti" in lower_text + or "graph-backed memory" in lower_text + or "graph backed memory" in lower_text + or "durable memory system" in lower_text + ) + and any( + token in lower_text + for token in [ + "durable retrievable context", + "graphiti-discovery", + "memory-discovery", + "memory discovery", + "memory atlas", + "atlas/routing", + "repo group", + "scratchpad", + "search_memory_facts", + "search_nodes", + "add_memory", + "memory spam", + "duplicate", + "destructive", + "group_id", + ] + ) + ): + scored_matches.append((85, path_str)) + continue + if ( + module_id == "memory-service-runtime-governance" + and ( + "graphiti" in lower_text + or "memory service" in lower_text + or "memory-service" in lower_text + or "mcp memory server" in lower_text + or "durable memory service" in lower_text + ) + and any( + token in lower_text + for token in [ + "health", + "readiness", + "queue", + "dead-letter", + "dead letter", + "provider", + "embedding", + "installed release", + "manifest", + "runtime home", + "service manager", + "smoke", + "read-after-write", + ] + ) + ): + scored_matches.append((85, path_str)) + continue + if ( + module_id == "preview-artifact-review" + and ( + "previews" in lower_text + or "preview service" in lower_text + or "preview session" in lower_text + or "session url" in lower_text + ) + and any( + token in lower_text + for token in [ + "human review", + "approval", + "approve", + "feedback", + "session url", + "browser", + "local artifacts", + "generated artifacts", + "pdf", + "office documents", + "html", + "screenshots", + "galleries", + ] + ) + ): + scored_matches.append((85, path_str)) + continue + if ( + module_id == "policy-harvest-loop" + and "harvest loop" in heading + ): + scored_matches.append((85, path_str)) + continue + if ( + module_id == "policy-adoption-feedback-loop" + and any( + phrase in lower_text + for phrase in [ + "policy adoption", + "policy upgrade", + "installed policy bundle", + "selected profile", + ] + ) + and any( + phrase in lower_text + for phrase in [ + "feedback artifact", + "dated feedback", + "what worked", + "created friction", + "adoption lessons", + ] + ) + ): + scored_matches.append((85, path_str)) + continue + if ( + module_id == "codegraph-usage" + and ( + "codegraph" in lower_text + or "../codegraph" in lower_text + or "code graph" in lower_text + or "indexed code" in lower_text + or "symbol graph" in lower_text + ) + and any( + token in lower_text + for token in [ + "before code edits", + "before editing code", + "architecture", + "trace", + "callers", + "callees", + "impact", + "refactor", + "symbol", + "source reads", + ] + ) + ): + scored_matches.append((85, path_str)) + continue + if module_id in { + "codegraph-usage", + "graph-backed-memory-usage", + "memory-service-runtime-governance", + "policy-adoption-feedback-loop", + "policy-harvest-loop", + "preview-artifact-review", + }: + continue + overlap = signature & local_tokens[path_str] + anchor_overlap = anchors & local_tokens[path_str] + overlap_ratio = len(overlap) / max(len(signature), 1) + if len(anchor_overlap) >= 2 and len(overlap) >= 3 and overlap_ratio >= 0.20: + score = len(anchor_overlap) * 10 + len(overlap) + scored_matches.append((score, path_str)) + if scored_matches: + max_score = max(score for score, _ in scored_matches) + matched_paths = sorted({path for score, path in scored_matches if score == max_score}) + matches[module_id] = matched_paths + return matches + + +def is_thin_agents_wirein(text: str) -> bool: + lower = text.lower() + return ( + "docs/dev/policies/" in lower + and "## policy entry" in lower + and len(text.splitlines()) <= 80 + and len(text) <= 4000 + ) + + +def markdown_sections(text: str) -> list[tuple[str, str]]: + matches = list(re.finditer(r"(?m)^##\s+(.+?)\s*$", text)) + if not matches: + return [] + sections: list[tuple[str, str]] = [] + for index, match in enumerate(matches): + heading = match.group(1).strip() + start = match.end() + end = matches[index + 1].start() if index + 1 < len(matches) else len(text) + body = text[start:end].strip() + sections.append((heading, body)) + return sections + + +def infer_repo_local_policy_findings( + repo_root: Path, + recommended_modules: list[str], + profile_id: str, + signals: dict[str, Any], +) -> list[dict[str, Any]]: + agents_path = repo_root / "AGENTS.md" + text = read_text(agents_path) + if not text or is_thin_agents_wirein(text): + return [] + + findings: list[dict[str, Any]] = [] + shared_section_names = {"policy entry", "scope"} + keep_heading_names = { + "repo context", + "repo-specific guidance", + "project overview", + "current working set", + "cli shape", + "project context", + "quickstart", + "safety and data integrity rules", + } + merge_keyword_map = { + "planning-discipline": ["plan", "planning", "bounded plan", "definition of done"], + "goal-execution-governance": ["/goal", "goal execution", "long-running goal", "goal checkpoint", "goal-compatible"], + "roadmap-runbook-governance": ["roadmap", "runbook", "progress", "current working set"], + "git-worktree-hygiene": ["worktree", "worktrees"], + "commit-history-discipline": ["commit", "atomic", "history"], + "branch-and-integration-strategy": ["branch", "rebase", "merge", "integration"], + "commit-and-push-cadence": ["push cadence", "when should i push", "checkpoint"], + "versioning-and-release": ["release", "version", "tag", "deploy cut"], + "validation-and-handoff": ["validation", "handoff", "remaining risk", "verification"], + "architecture-guardrails": ["architecture", "boundary", "execution engine", "provider api logic"], + "runtime-vs-product-boundary": ["runtime home", "outside the repo", "user-scoped runtime", "product repo"], + "runtime-state-governance": ["version controlled", "runtime state", "redaction", "pruning"], + "tenant-isolation-and-operator-state": ["tenant", "tenant-scoped", "one profile per tenant", "state isolation"], + "fieldwork-productization": ["fieldwork", "keep as product", "refactor before keep", "archive as note only"], + "monolith-extraction-discipline": ["monolith", "monolithic", "strong trunk", "oversized cli"], + "policy-management": ["docs/dev/policies", "policy library", "agents.md"], + } + + for heading, body in markdown_sections(text): + lower_heading = heading.lower() + lower_body = body.lower() + if lower_heading in shared_section_names: + continue + + matched_modules = [ + module_id + for module_id, keywords in merge_keyword_map.items() + if module_id in recommended_modules and any(keyword in lower_heading or keyword in lower_body for keyword in keywords) + ] + + action = "keep" + rationale = "section appears repo-specific and should remain in AGENTS.md" + + if lower_heading in keep_heading_names: + action = "keep" + rationale = "section describes repo-specific context or operating guidance that should remain local" + elif "docs/dev/policies" in lower_body and ("full policy body" in lower_body or "entire policy body" in lower_body): + action = "review-conflict" + rationale = "section conflicts with the adopted model where AGENTS.md is the entrypoint and the durable policy body lives under docs/dev/policies" + elif signals.get("has_roadmap") and "do not use roadmap" in lower_body: + action = "review-conflict" + rationale = "section appears to conflict with the repo's canonical roadmap usage" + elif signals.get("has_runbook") and "do not use runbook" in lower_body: + action = "review-conflict" + rationale = "section appears to conflict with the repo's canonical runbook usage" + elif matched_modules: + action = "merge" + rationale = "section overlaps shared policy concepts and should be reviewed for merge into adopted local policy files" + + findings.append( + { + "path": str(agents_path), + "section_heading": heading, + "action": action, + "rationale": rationale, + "matched_modules": matched_modules, + } + ) + return findings + + +def md_file_count(path: Path) -> int: + if not path.exists(): + return 0 + return sum(1 for child in path.rglob("*.md") if child.is_file()) + + +def existing_paths(paths: list[Path]) -> list[str]: + return [str(path) for path in paths if path.exists()] + + +def canonical_planning_authorities(repo_root: Path) -> dict[str, list[str]]: + docs_dev = repo_root / "docs" / "dev" + candidates = { + "roadmap": [ + repo_root / "ROADMAP.md", + repo_root / "roadmap.md", + docs_dev / "ROADMAP.md", + docs_dev / "roadmap.md", + ], + "runbook": [ + repo_root / "RUNBOOK.md", + repo_root / "runbook.md", + docs_dev / "RUNBOOK.md", + docs_dev / "runbook.md", + ], + } + return { + surface_type: existing_paths(paths) + for surface_type, paths in candidates.items() + } + + +def inspect_clutter(repo_root: Path) -> dict: + docs_dev = repo_root / "docs" / "dev" + plans_dir = docs_dev / "plans" + notes_dir = docs_dev / "notes" + memories_dir = docs_dev / "memories" + planning_authorities = canonical_planning_authorities(repo_root) + + legacy_planning_candidates = [ + repo_root / "actionable-plan.md", + repo_root / "plan.md", + repo_root / "plans.md", + repo_root / "dev-journal.md", + repo_root / "execution-plan.md", + docs_dev / "roadmap.md", + docs_dev / "runbook.md", + docs_dev / "progress.md", + docs_dev / "dev-journal.md", + docs_dev / "execution-plan.md", + ] + legacy_note_candidates = [ + repo_root / "notes.md", + repo_root / "note.md", + repo_root / "memory.md", + repo_root / "memories.md", + repo_root / "journal.md", + repo_root / "dev-notes.md", + repo_root / "dev-journal.md", + docs_dev / "notes.md", + docs_dev / "memory.md", + docs_dev / "memories.md", + docs_dev / "journal.md", + docs_dev / "debrief.md", + ] + + docs_dev_loose_md: list[str] = [] + if docs_dev.exists(): + for child in sorted(docs_dev.glob("*.md")): + if child.is_file(): + docs_dev_loose_md.append(str(child)) + + legacy_planning_paths = existing_paths(legacy_planning_candidates) + legacy_note_paths = existing_paths(legacy_note_candidates) + duplicate_planning_authorities = { + surface_type: paths + for surface_type, paths in planning_authorities.items() + if len(paths) > 1 + } + + planning_migration_needed = bool(legacy_planning_paths or duplicate_planning_authorities) + notes_migration_needed = bool(legacy_note_paths) + + return { + "canonical_roadmap_paths": planning_authorities["roadmap"], + "canonical_runbook_paths": planning_authorities["runbook"], + "duplicate_planning_authorities": duplicate_planning_authorities, + "has_plans_dir": plans_dir.exists(), + "has_notes_dir": notes_dir.exists(), + "has_memories_dir": memories_dir.exists(), + "plans_file_count": md_file_count(plans_dir), + "notes_file_count": md_file_count(notes_dir), + "memories_file_count": md_file_count(memories_dir), + "legacy_planning_paths": legacy_planning_paths, + "legacy_note_paths": legacy_note_paths, + "docs_dev_loose_md": docs_dev_loose_md, + "planning_migration_needed": planning_migration_needed, + "notes_migration_needed": notes_migration_needed, + } + + +def extract_existing_policy_surfaces(repo_root: Path) -> list[dict]: + items: list[dict] = [] + seen: set[str] = set() + + def add(path: Path, source_type: str, canonical: bool) -> None: + resolved = str(path) + if resolved in seen or not path.exists() or not path.is_file(): + return + seen.add(resolved) + text = read_text(path) + action = "merge" + rationale = "existing policy should be reconciled with installed templates during adoption" + if source_type == "agents-entrypoint": + if is_thin_agents_wirein(text): + action = "keep" + rationale = "thin AGENTS.md wire-in already matches the desired entrypoint role" + else: + action = "merge" + rationale = "inline AGENTS policy should be thinned and merged into canonical repo-local policy files" + elif source_type == "canonical-policy": + action = "keep" + rationale = "repo-local policy already lives in the canonical adopted policy directory" + elif source_type == "legacy-policy": + action = "merge" + rationale = "legacy policy should be merged into canonical policy files or thinned into the AGENTS wire-in" + elif source_type == "duplicate-policy": + action = "retire" + rationale = "duplicate policy surface should be retired after canonical policy and wire-in are established" + items.append( + { + "path": resolved, + "source_type": source_type, + "canonical": canonical, + "action": action, + "rationale": rationale, + } + ) + + agents = repo_root / "AGENTS.md" + agent_upper = repo_root / "AGENT.MD" + if agents.exists(): + add(agents, "agents-entrypoint", True) + if agent_upper.exists(): + add(agent_upper, "duplicate-policy" if agents.exists() else "agents-entrypoint", not agents.exists()) + + policy_dir = repo_root / "docs" / "dev" / "policies" + if policy_dir.exists(): + for path in sorted(policy_dir.rglob("*.md")): + add(path, "canonical-policy", True) + + legacy_candidates = [ + repo_root / "POLICY.md", + repo_root / "policies.md", + repo_root / "policy.md", + repo_root / "agent-policy.md", + repo_root / "agents-policy.md", + repo_root / "dev-policy.md", + repo_root / "docs" / "dev" / "policy.md", + repo_root / "docs" / "dev" / "policies.md", + repo_root / "docs" / "dev" / "agent-policy.md", + ] + for path in legacy_candidates: + add(path, "legacy-policy", False) + + return items + + +def extract_existing_migration_surfaces(repo_root: Path) -> list[dict]: + items: list[dict] = [] + seen: set[str] = set() + docs_dev = repo_root / "docs" / "dev" + canonical_roots = { + "plan": docs_dev / "plans", + "note": docs_dev / "notes", + "memory": docs_dev / "memories", + } + + def add(path: Path, surface_type: str, canonical: bool) -> None: + resolved = str(path) + if resolved in seen or not path.exists() or not path.is_file(): + return + seen.add(resolved) + action = "keep" if canonical else "merge" + rationale = ( + f"{surface_type} already lives in the canonical location" + if canonical + else f"{surface_type} should be migrated into the canonical docs/dev location" + ) + items.append( + { + "path": resolved, + "surface_type": surface_type, + "canonical": canonical, + "action": action, + "rationale": rationale, + } + ) + + for surface_type, root in canonical_roots.items(): + if root.exists(): + for path in sorted(root.rglob("*.md")): + add(path, surface_type, True) + + legacy_surface_candidates = { + "plan": [ + repo_root / "actionable-plan.md", + repo_root / "plan.md", + repo_root / "plans.md", + repo_root / "dev-journal.md", + repo_root / "execution-plan.md", + docs_dev / "roadmap.md", + docs_dev / "runbook.md", + docs_dev / "progress.md", + docs_dev / "dev-journal.md", + docs_dev / "execution-plan.md", + ], + "note": [ + repo_root / "notes.md", + repo_root / "note.md", + repo_root / "journal.md", + repo_root / "dev-notes.md", + docs_dev / "notes.md", + docs_dev / "journal.md", + docs_dev / "debrief.md", + ], + "memory": [ + repo_root / "memory.md", + repo_root / "memories.md", + docs_dev / "memory.md", + docs_dev / "memories.md", + ], + } + for surface_type, paths in legacy_surface_candidates.items(): + for path in paths: + add(path, surface_type, False) + + return items + + +def detect_signals(repo_root: Path) -> dict: + agents_entry_text = read_text(repo_root / "AGENTS.md") or read_text(repo_root / "AGENT.MD") + policy_text = read_policy_text(repo_root) + readme = read_text(repo_root / "README.md") + planning_authorities = canonical_planning_authorities(repo_root) + runbook_text = "" + for candidate in planning_authorities["runbook"]: + runbook_text = read_text(Path(candidate)) + if runbook_text: + break + actionable_plan = read_text(repo_root / "actionable-plan.md") + roadmap = bool(planning_authorities["roadmap"]) + runbook = bool(planning_authorities["runbook"]) + progress = (repo_root / "PROGRESS.md").exists() + docs_dev = (repo_root / "docs" / "dev").exists() + policy_dir = (repo_root / "docs" / "dev" / "policies").exists() + memory = (repo_root / "memory").exists() or (repo_root / "MEMORY.md").exists() + proposalish = any( + token in str(repo_root).lower() + for token in ["proposal", "grant", "journal article", "manuscript", "patent application"] + ) + clutter = inspect_clutter(repo_root) + has_modules_dir = (repo_root / "modules").exists() + has_profiles_dir = (repo_root / "profiles").exists() + has_catalog = (repo_root / "catalog.yaml").exists() + has_selector_bundle = (repo_root / "repo-policy-selector").exists() + has_local_harvester = (repo_root / ".codex" / "skills" / "repo-policy-harvester").exists() + git_dir = repo_root / ".git" + git_config = read_text(git_dir / "config").lower() if git_dir.is_dir() else read_text(git_dir).lower() + top_level_files = [child for child in repo_root.iterdir() if child.is_file()] + document_suffixes = {".pdf", ".doc", ".docx", ".xls", ".xlsx", ".csv", ".tsv"} + code_manifest_names = { + "package.json", + "pyproject.toml", + "cargo.toml", + "go.mod", + "requirements.txt", + "makefile", + "dockerfile", + } + top_level_document_count = sum(1 for child in top_level_files if child.suffix.lower() in document_suffixes) + top_level_code_manifest_count = sum( + 1 for child in top_level_files if child.name.lower() in code_manifest_names + ) + course_config_paths = [ + repo_root / "canvas-cli.yml", + repo_root / "canvas-cli.yaml", + repo_root / "canvas.yml", + repo_root / "canvas.yaml", + repo_root / "course.yml", + repo_root / "course.yaml", + ] + course_config_text = "\n".join(read_text(path) for path in course_config_paths if path.exists()).lower() + top_level_names = " ".join(child.name.lower() for child in repo_root.iterdir()) + google_native_placeholder_count = 0 + try: + for path in repo_root.rglob("*"): + if path.is_file() and path.suffix.lower() in {".gsheet", ".gform", ".gdoc", ".gslides"}: + google_native_placeholder_count += 1 + if google_native_placeholder_count >= 3: + break + except OSError: + google_native_placeholder_count = 0 + + text = policy_text.lower() + entrypoint_text = agents_entry_text.lower() + semantic_text = "\n".join([entrypoint_text, readme.lower(), course_config_text, top_level_names]) + combined = "\n".join([text, readme.lower(), runbook_text.lower(), actionable_plan.lower(), course_config_text, top_level_names]) + memory_runtime_subject = any( + phrase in combined or phrase in semantic_text + for phrase in [ + "memory service runtime", + "memory-service runtime", + "mcp memory server", + "graphiti mcp server", + "installed memory service", + ] + ) + memory_runtime_operation = any( + phrase in combined or phrase in semantic_text + for phrase in [ + "memory queue", + "memory job", + "dead-letter memory", + "dead letter memory", + "get_memory_queue_status", + "memory service health", + "read-after-write smoke", + "graphiti-openclaw smoke", + "provider boundary", + "embedding provider", + "service manager", + "runtime home", + ] + ) + preview_channel = any( + phrase in combined or phrase in semantic_text + for phrase in [ + "previews skill", + "$previews", + "preview service", + "preview session", + "preview url", + "publish_session", + "publish preview", + ] + ) + preview_review_workflow = any( + phrase in combined or phrase in semantic_text + for phrase in [ + "browser review", + "browser-based review", + "review packet", + "approval packet", + "artifact review", + "approval feedback", + "human approval", + "human review", + "rendered document", + "office documents", + "image gallery", + "galleries", + ] + ) + repo_path = str(repo_root).lower() + has_explicit_goal_command = "/goal" in combined + has_agentic_goal_context = any( + phrase in combined + for phrase in [ + "agent loop", + "agent execution", + "agent orchestration", + "autonomous agent", + "autonomous", + "unattended", + "multi-session", + "multiple sessions", + "context window", + "subagent", + "sub-agent", + "checkpoint", + ] + ) + has_long_goal_marker = any( + phrase in combined + for phrase in [ + "goal mode", + "goal execution", + "goal-compatible", + "goal compatible", + "long-running goal", + "long running goal", + "goal checkpoint", + ] + ) + signals = { + "has_agents": bool(agents_entry_text), + "has_roadmap": roadmap, + "has_runbook": runbook, + "canonical_roadmap_paths": clutter["canonical_roadmap_paths"], + "canonical_runbook_paths": clutter["canonical_runbook_paths"], + "duplicate_planning_authorities": clutter["duplicate_planning_authorities"], + "has_progress": progress, + "has_docs_dev": docs_dev, + "has_policy_dir": policy_dir, + "has_modules_dir": has_modules_dir, + "has_profiles_dir": has_profiles_dir, + "has_catalog": has_catalog, + "has_selector_bundle": has_selector_bundle, + "has_local_harvester": has_local_harvester, + "has_plans_dir": clutter["has_plans_dir"], + "has_notes_dir": clutter["has_notes_dir"], + "has_memories_dir": clutter["has_memories_dir"], + "plans_file_count": clutter["plans_file_count"], + "notes_file_count": clutter["notes_file_count"], + "memories_file_count": clutter["memories_file_count"], + "legacy_planning_paths": clutter["legacy_planning_paths"], + "legacy_note_paths": clutter["legacy_note_paths"], + "docs_dev_loose_md": clutter["docs_dev_loose_md"], + "planning_migration_needed": clutter["planning_migration_needed"], + "notes_migration_needed": clutter["notes_migration_needed"], + "has_memory_files": memory, + "mentions_parallel": "parallel" in combined or "subagent" in combined, + "mentions_subagents": any( + phrase in combined + for phrase in [ + "subagent", + "sub-agent", + "multi-agent", + "delegation", + "delegate", + "parallel lane", + "parallel track", + ] + ), + "mentions_goal_execution": has_explicit_goal_command or (has_long_goal_marker and has_agentic_goal_context), + "mentions_subagent_runtime": any( + phrase in combined or phrase in semantic_text + for phrase in [ + "subagent runtime", + "sub-agent runtime", + "subagent run id", + "subagent session id", + "subagent session key", + "subagent transcript", + "transcript path", + "announce payload", + "announce message", + "non-blocking spawn", + "spawn depth", + "maxspawndepth", + "max spawn depth", + "nested subagent", + "nested sub-agent", + "maxchildrenperagent", + "max children per agent", + "maxconcurrent", + "concurrency cap", + "cascade stop", + "tool policy", + "subagent allowlist", + "subagent denylist", + "run timeout", + "runtime stats", + "subagent token usage", + "cost metadata", + "archive after", + "subagent cleanup", + ] + ), + "mentions_dev_speed_bias": any( + phrase in combined + for phrase in [ + "max-dev-speed", + "developer speed", + "dev speed", + "wall-clock speed", + "move fast", + "favor speed", + "parallel first", + ] + ), + "mentions_token_efficiency_bias": any( + phrase in combined + for phrase in [ + "max-token-efficiency", + "token efficiency", + "minimize tokens", + "reduce token cost", + "coordination cost", + "avoid duplicated context", + "context efficiency", + ] + ), + "mentions_upstream_fork": any( + phrase in combined or phrase in git_config + for phrase in [ + "[remote \"upstream\"]", + "active upstream", + "non-owned upstream", + "private fork", + "upstream sync", + "upstream rebase", + "force-push", + "force push", + "fork maintenance", + "downstream fork", + ] + ), + "mentions_git_policy": "git policy" in text or "worktree" in text or "branch" in text, + "mentions_active_lane_coordination": any( + phrase in combined or phrase in semantic_text + for phrase in [ + "active lane", + "active-lane", + "branch registry", + "concurrent worktree", + "multiple worktrees", + "multiple projects", + "multi-project", + "off-main plan", + "off-main work", + "parallel worktree", + ] + ), + "mentions_closeout": "closeout" in text or "best recommendation" in text, + "mentions_policy_harvest": "policy" in semantic_text and "harvest" in semantic_text, + "mentions_policy_library": any( + phrase in semantic_text + for phrase in [ + "policy library", + "shared policy templates", + "shared policy artifacts", + "source library", + "starter bundles", + "reusable policy modules", + "reusable policy templates", + "policy selection", + "policy adoption", + "install the selector", + "installed policy library", + "repo-policy-selector", + "repo-policy-harvester", + "catalog.yaml", + ] + ), + "mentions_memory": "memory.md" in combined or "heartbeats" in combined or "group chats" in combined, + "mentions_graph_backed_memory": any( + phrase in combined or phrase in semantic_text + for phrase in [ + "graph-backed memory", + "graph backed memory", + "graphiti", + "graphiti-discovery", + "memory-discovery", + "memory discovery", + "memory atlas", + "atlas/routing", + "repo group", + "durable memory system", + "search_memory_facts", + "search_nodes", + "add_memory", + "get_episodes", + "group_id", + "memory spam", + "duplicate writes", + ] + ), + "has_repo_memory_discovery_workflow": any( + phrase in semantic_text + for phrase in [ + "graphiti", + "graphiti-discovery", + "graphiti-runtime", + "memory atlas", + "atlas-discover", + "search_memory_facts", + ] + ), + "mentions_codegraph_usage": any( + phrase in combined or phrase in semantic_text + for phrase in [ + "../codegraph", + "codegraph", + "code graph", + "indexed code", + "code intelligence", + "symbol graph", + "call graph", + "caller", + "callers", + "callee", + "callees", + "impact analysis", + "trace analysis", + "refactor planning", + ] + ), + "mentions_memory_service_runtime": memory_runtime_subject and memory_runtime_operation, + "mentions_architecture_guardrails": any( + phrase in semantic_text + for phrase in [ + "current architecture", + "service-first", + "local service contract", + "shared seams", + "ownership boundaries", + "contract surfaces", + "canonical data stores", + "system-architecture", + "structural drift", + "top-level endpoint", + "operator workflow", + "deployment boundary", + "tightening semantics", + ] + ), + "mentions_doc_hygiene": any( + phrase in semantic_text + for phrase in [ + "doc hygiene", + "update the roadmap", + "update the runbook", + "user-facing docs", + "same slice", + "dev-journal", + "execution-plan", + "local-api.md", + ] + ), + "mentions_validation_handoff": any( + phrase in combined + for phrase in [ + "verification", + "validation", + "tests", + "handoff", + "manual-tests", + "green validation", + "relevant checks", + "remaining risk", + ] + ), + "mentions_writing_deliverable": any( + phrase in semantic_text or phrase in repo_path + for phrase in [ + "proposal", + "review outputs", + "review runbook", + "review context", + "journal article", + "patent application", + "manuscript", + "technical writing", + "project summary", + "project description", + ] + ) + or proposalish, + "mentions_analysis_workspace": any( + phrase in semantic_text or phrase in repo_path + for phrase in [ + "prior-art", + "prior art", + "patentability", + "ip analysis", + "ip scan", + "fto", + "claim strategy", + "closest-art", + "closest art", + "analysis phase", + "review_in_progress", + "competitor_analysis", + ] + ), + "mentions_review_workspace": any( + phrase in semantic_text or phrase in repo_path + for phrase in [ + "review runbook", + "review outputs", + "review context", + "rubric", + "debrief", + "review_in_progress", + "reviews/", + "/reviews/", + ] + ), + "mentions_website_surface": any( + phrase in semantic_text + for phrase in [ + "website", + "wordpress", + "cms", + "public site", + "canonical site", + "local mirror", + "staging", + "preview host", + "deprecated surface", + "legacy surface", + ] + ), + "mentions_db_backed_state": any( + phrase in semantic_text + for phrase in [ + "db-backed", + "database-backed", + "mysql", + "cms state", + "page builder", + "menus", + "widget settings", + "customizer", + "plugin settings", + "admin state", + ] + ), + "mentions_live_drift": any( + phrase in semantic_text + for phrase in [ + "live drift", + "drift", + "outside the repo workflow", + "admin-side changes", + "repo-owned code drift", + "before deploy", + ] + ), + "mentions_backup_recovery": any( + phrase in semantic_text + for phrase in [ + "backup", + "borg", + "recovery", + "restore", + "archive", + "snapshot", + "recoverable state", + ] + ), + "mentions_visual_release_qa": any( + phrase in semantic_text + for phrase in [ + "visual", + "browser review", + "screenshot", + "lighthouse", + "design-facing", + "ux-facing", + "local release qa", + "post-release live verification", + ] + ), + "mentions_preview_artifact_review": preview_channel and preview_review_workflow, + "mentions_tenant_runtime": any( + phrase in semantic_text + for phrase in [ + "tenant-scoped", + "tenant scoped", + "one profile as one tenant", + "one runtime profile per tenant", + "per-tenant runtime", + "per tenant runtime", + "tenant-specific", + "tenant specific", + "tenant isolation", + "multi-tenant", + "multi tenant", + "operator environment", + ] + ), + "mentions_runtime_home": any( + phrase in semantic_text + for phrase in [ + "~/.odollo", + "runtime home", + "user-scoped runtime", + "user scoped runtime", + "tenant home", + "actions.ndjson", + "tenant-scoped resources", + "tenant-scoped memories", + "tenant-scoped artifacts", + ] + ), + "mentions_fieldwork_productization": any( + phrase in semantic_text + for phrase in [ + "fieldwork", + "field/", + "live tenant work", + "reactive live tenant", + "keep as product", + "refactor before keep", + "archive as note only", + "discard", + "productization", + ] + ), + "mentions_monolith_debt": any( + phrase in semantic_text + for phrase in [ + "monolithic", + "monolith", + "strong trunk", + "oversized cli", + "huge cli", + "cli trunk", + ] + ), + "top_level_document_count": top_level_document_count, + "top_level_code_manifest_count": top_level_code_manifest_count, + "has_lms_config": any(path.exists() for path in course_config_paths), + "google_native_placeholder_count": google_native_placeholder_count, + "mentions_lms_course": any( + phrase in semantic_text or phrase in repo_path + for phrase in [ + "canvas course", + "course id", + "course_id", + "default course", + "canvas-cli", + "canvas cli", + "moodle", + "blackboard", + "google classroom", + "lms-backed", + "lms backed", + "assignment overrides", + "assignment group", + "submissions", + "gradebook", + "roster", + "announcements", + "quizzes", + ] + ), + "mentions_course_workspace": any( + phrase in semantic_text or phrase in repo_path + for phrase in [ + "course workspace", + "course folder", + "course operations", + "live course", + "spring 2026", + "fall 2026", + "syllabus", + "lecture notes", + "seminars", + "handouts", + "archive 2025", + "/isu/", + ] + ), + "mentions_student_assessment_data": any( + phrase in semantic_text + for phrase in [ + "ferpa", + "student-identifiable", + "student identifiable", + "student data", + "student work", + "graded", + "grading", + "submissions", + "reflections", + "evaluations", + "answer keys", + "answer key", + "private feedback", + ] + ), + "mentions_cloud_course_drive": ( + google_native_placeholder_count > 0 + or any( + phrase in semantic_text + for phrase in [ + ".gsheet", + ".gform", + "google drive for desktop", + "drive folder id", + "gog drive", + "google-native", + "google native", + "shared drive", + ] + ) + ), + "mentions_seminal_domain_workspace": any( + phrase in semantic_text or phrase in repo_path + for phrase in [ + "finance", + "tax", + "accounting", + "bookkeeping", + "compliance", + "legal", + "contracts", + "corporate records", + "cap table", + "entity formation", + "board consent", + "filing", + ] + ), + } + return signals + + +def classify_purpose(signals: dict) -> tuple[str, str | None, list[str]]: + reasons: list[str] = [] + subtype: str | None = None + strong_engineering = ( + signals["has_roadmap"] + or signals["has_runbook"] + or signals["mentions_architecture_guardrails"] + or signals["mentions_doc_hygiene"] + ) + policy_library_semantic_signals = 0 + if signals["mentions_policy_library"]: + policy_library_semantic_signals += 1 + if signals["mentions_policy_harvest"]: + policy_library_semantic_signals += 1 + if signals["has_policy_dir"] and signals["has_agents"]: + policy_library_semantic_signals += 1 + + policy_library_supporting_signals = 0 + if signals["has_catalog"]: + policy_library_supporting_signals += 1 + if signals["has_modules_dir"] and signals["has_profiles_dir"]: + policy_library_supporting_signals += 1 + if signals["has_selector_bundle"] or signals["has_local_harvester"]: + policy_library_supporting_signals += 1 + + policy_library_repo = ( + policy_library_semantic_signals >= 2 + and policy_library_supporting_signals >= 1 + ) + website_semantic_signals = sum( + 1 + for key in [ + "mentions_website_surface", + "mentions_db_backed_state", + "mentions_live_drift", + "mentions_backup_recovery", + "mentions_visual_release_qa", + ] + if signals[key] + ) + course_workspace_signals = sum( + 1 + for key in [ + "has_lms_config", + "mentions_lms_course", + "mentions_course_workspace", + "mentions_student_assessment_data", + "mentions_cloud_course_drive", + ] + if signals[key] + ) + course_operational_signals = sum( + 1 + for key in [ + "has_lms_config", + "mentions_lms_course", + "mentions_cloud_course_drive", + ] + if signals[key] + ) + seminal_workspace = ( + signals["top_level_document_count"] >= 2 + and signals["top_level_code_manifest_count"] == 0 + and signals["mentions_seminal_domain_workspace"] + ) + operations_platform_signals = sum( + 1 + for key in [ + "mentions_tenant_runtime", + "mentions_runtime_home", + "mentions_fieldwork_productization", + "mentions_monolith_debt", + ] + if signals[key] + ) + engineering_support_signals = sum( + 1 + for key in [ + "mentions_validation_handoff", + "mentions_git_policy", + "mentions_subagents", + "mentions_parallel", + ] + if signals[key] + ) + engineering_repo = ( + signals["top_level_code_manifest_count"] >= 1 + and signals["has_agents"] + and signals["has_policy_dir"] + and engineering_support_signals >= 2 + ) + + if signals["mentions_memory"]: + purpose = "workspace-agent" + reasons.append("repo uses memory/heartbeat/group-chat style operating rules") + elif policy_library_repo: + purpose = "workspace-agent" + reasons.append("repo appears to curate a reusable policy library and selector tooling") + elif course_workspace_signals >= 3 and course_operational_signals >= 1: + purpose = "course-workspace" + reasons.append("repo appears to be a live LMS-backed course workspace with course data or student assessment risk") + elif operations_platform_signals >= 2 and strong_engineering: + purpose = "operations-platform" + reasons.append("repo appears to mix reusable product code with tenant-scoped runtime and live operator workflows") + elif website_semantic_signals >= 3 and signals["mentions_website_surface"]: + purpose = "website" + reasons.append("repo appears to manage live website surfaces, drift, recovery, or visual release workflows") + elif seminal_workspace and not strong_engineering: + purpose = "seminal-workspace" + reasons.append("repo appears to be a document-heavy formative workspace that does not fit existing policy families cleanly") + elif (signals["mentions_writing_deliverable"] or signals["mentions_analysis_workspace"]) and not strong_engineering: + purpose = "writing-project" + reasons.append("repo appears deliverable-oriented rather than software-architecture-oriented") + elif ( + strong_engineering + or engineering_repo + or (signals["has_runbook"] and signals["mentions_validation_handoff"]) + ): + purpose = "product-engineering" + reasons.append("repo shows roadmap/runbook discipline or engineering-oriented validation and coordination signals") + elif signals["mentions_policy_harvest"]: + purpose = "workspace-agent" + reasons.append("repo appears to curate reusable policy or skill behavior") + else: + purpose = "library-cli" + reasons.append("repo lacks heavyweight roadmap/runbook signals") + + return purpose, subtype, reasons + + +def classify_execution_bias(signals: dict, purpose: str) -> tuple[str | None, list[str]]: + reasons: list[str] = [] + if signals["mentions_dev_speed_bias"] and not signals["mentions_token_efficiency_bias"]: + reasons.append("repo language explicitly favors wall-clock speed over coordination cost") + return "max-dev-speed", reasons + if signals["mentions_token_efficiency_bias"] and not signals["mentions_dev_speed_bias"]: + reasons.append("repo language explicitly favors token or coordination efficiency") + return "max-token-efficiency", reasons + if signals["mentions_subagents"] or signals["mentions_parallel"]: + reasons.append("repo language suggests delegated or parallel execution with coordination tradeoffs") + return "balanced", reasons + if purpose == "library-cli": + reasons.append("lighter library repos usually prefer lower coordination and token overhead by default") + return "max-token-efficiency", reasons + if purpose in { + "product-engineering", + "operations-platform", + "workspace-agent", + "writing-project", + "website", + "website-maintenance", + "course-workspace", + }: + reasons.append("repo shape suggests a balanced tradeoff between wall-clock speed and coordination cost") + return "balanced", reasons + return None, reasons + + +def base_modules_for_profile(profile_id: str, installed_library: dict[str, Any]) -> list[str]: + profile = installed_library.get("parsed_profiles", {}).get(profile_id, {}) + modules = profile.get("modules", []) + return list(modules) if isinstance(modules, list) else [] + + +def profile_override_value(profile_id: str, key: str, installed_library: dict[str, Any]) -> str | None: + profile = installed_library.get("parsed_profiles", {}).get(profile_id, {}) + overrides = profile.get("overrides", {}) + if isinstance(overrides, dict): + value = overrides.get(key) + return value if isinstance(value, str) else None + return None + + +def profile_expectation_gaps(profile_id: str, signals: dict, installed_library: dict[str, Any]) -> list[str]: + gaps: list[str] = [] + expects_runbook = profile_override_value(profile_id, "expects_runbook", installed_library) + expects_roadmap = profile_override_value(profile_id, "expects_roadmap", installed_library) + if expects_runbook == "true" and not signals["has_runbook"]: + gaps.append("selected profile expects RUNBOOK.md discipline but the repo does not currently expose a canonical runbook") + if expects_roadmap == "true" and not signals["has_roadmap"]: + gaps.append("selected profile expects ROADMAP.md discipline but the repo does not currently expose a canonical roadmap") + return gaps + + +def policy_adoption_coverage( + existing_policy_surfaces: list[dict], + recommended_modules: list[str], + installed_library: dict[str, Any], +) -> dict[str, Any]: + duplicate_policy_ids = duplicate_adopted_policy_ids(existing_policy_surfaces) + canonical_ids = sorted( + { + adopted_policy_id(Path(item["path"])) + for item in existing_policy_surfaces + if item["source_type"] == "canonical-policy" + } + ) + semantic_matches = semantic_module_matches(existing_policy_surfaces, installed_library) + semantically_adopted = sorted( + module_id for module_id in recommended_modules if module_id in semantic_matches + ) + adopted_set = set(canonical_ids) | set(semantically_adopted) + already_adopted = [module_id for module_id in recommended_modules if module_id in adopted_set] + missing = [module_id for module_id in recommended_modules if module_id not in adopted_set] + if duplicate_policy_ids: + readiness = "conflicted-local-policy" + elif not recommended_modules: + readiness = "fresh-adoption" + elif len(already_adopted) == len(recommended_modules): + readiness = "fully-installed" + else: + ratio = len(already_adopted) / len(recommended_modules) + if ratio >= 0.75: + readiness = "mostly-installed" + elif ratio > 0: + readiness = "partial-local-policy" + else: + readiness = "fresh-adoption" + return { + "readiness": readiness, + "canonical_policy_ids": canonical_ids, + "semantically_matched_modules": semantically_adopted, + "semantic_match_paths": { + module_id: semantic_matches[module_id] + for module_id in semantically_adopted + }, + "duplicate_policy_ids": duplicate_policy_ids, + "already_adopted_modules": already_adopted, + "missing_recommended_modules": missing, + } + + +def recommendation_mode(coverage: dict[str, Any]) -> str: + readiness = coverage["readiness"] + if readiness == "conflicted-local-policy": + return "identity-reconciliation-required" + if readiness == "fully-installed": + return "already-aligned" + if readiness in {"partial-local-policy", "mostly-installed"}: + return "patch-missing" + return "full-profile" + + +def module_catalog_entry(module_id: str, installed_library: dict[str, Any]) -> dict[str, Any] | None: + for item in installed_library.get("modules", []): + if item.get("id") == module_id: + return item + return None + + +def build_install_plan( + repo_root: Path, + next_modules: list[str], + coverage: dict[str, Any], + installed_library: dict[str, Any], +) -> list[dict[str, Any]]: + plan: list[dict[str, Any]] = [] + serial = next_policy_serial(repo_root) + for module_id in next_modules: + catalog_entry = module_catalog_entry(module_id, installed_library) + source_path = None + if catalog_entry and catalog_entry.get("path"): + source_path = str(Path(installed_library["policy_root"]) / str(catalog_entry["path"])) + target_name = f"{serial:04d}-{module_id}.md" + target_path = repo_root / "docs" / "dev" / "policies" / target_name + merge_candidates = coverage.get("semantic_match_paths", {}).get(module_id, []) + plan.append( + { + "module_id": module_id, + "action": "merge-existing" if merge_candidates else "install-new", + "source_module_path": source_path, + "target_policy_path": str(target_path), + "merge_candidates": merge_candidates, + "draft_content": render_local_policy_draft(module_id, installed_library), + } + ) + serial += 1 + return plan + + +def render_local_policy_draft(module_id: str, installed_library: dict[str, Any]) -> str: + module = installed_library.get("parsed_modules", {}).get(module_id, {}) + title = module.get("title", module_id.replace("-", " ").title()) + body = module.get("body", "").strip() + body = re.sub(r"^\s*## Adoption Notes\b", "## Adoption Notes", body, flags=re.MULTILINE) + lines = [f"# Policy | {title}", ""] + if body: + lines.append(body) + return "\n".join(lines).rstrip() + "\n" + + +def replace_or_append_section(text: str, heading: str, content: str) -> str: + pattern = re.compile(rf"(?ims)^## {re.escape(heading)}\n.*?(?=^## |\Z)") + replacement = content.rstrip() + "\n\n" + if pattern.search(text): + updated = pattern.sub(replacement, text, count=1) + else: + updated = text.rstrip() + "\n\n" + replacement + return updated.rstrip() + "\n" + + +def purpose_scaffold_text(repo_purpose: str | None) -> str: + if repo_purpose == "operations-platform": + return ( + "## Repo Context\n\n" + "- Describe the product boundary, runtime home, and tenant or operator model here.\n" + "- List the canonical roadmap, runbook, progress, and runtime-state authorities used in this repo.\n\n" + "## Repo-Specific Guidance\n\n" + "- Add the exact commands, profiles, runtime paths, and validation surfaces this repo expects.\n" + "- Document what stays in the product repo versus the user-scoped runtime home.\n" + ) + if repo_purpose in {"website", "website-maintenance"}: + return ( + "## Repo Context\n\n" + "- Describe the live surfaces, environments, and recovery-critical deploy constraints here.\n\n" + "## Repo-Specific Guidance\n\n" + "- Add the exact domains, deploy targets, validation surfaces, and backup constraints this repo expects.\n" + ) + if repo_purpose == "course-workspace": + return ( + "## Repo Context\n\n" + "- Describe the course, term, LMS course target, cloud-drive folder, and instructor workflow model here.\n" + "- Identify which folders are active course material, generated artifacts, submissions, grading work, handouts, and archives.\n\n" + "## Repo-Specific Guidance\n\n" + "- Add the exact LMS command path, course config file, secrets boundary, cloud-drive connector, and validation checks this workspace expects.\n" + "- Document student-data and assessment boundaries that agents must treat as sensitive.\n" + ) + if repo_purpose == "product-engineering": + return ( + "## Repo Context\n\n" + "- Describe the product area, architecture boundaries, and canonical planning surfaces here.\n\n" + "## Repo-Specific Guidance\n\n" + "- Add the exact build, test, deploy, and service-boundary rules this repo expects.\n" + ) + return ( + "## Repo Context\n\n" + "- Describe this repo's purpose, canonical planning surfaces, and operating model here.\n\n" + "## Repo-Specific Guidance\n\n" + "- Add the exact commands, constraints, and local conventions this repo expects.\n" + ) + + +def policy_loading_contract_section() -> str: + return "\n".join( + [ + "## Policy Loading Contract", + "", + "- `AGENTS.md` is a routing surface, not a one-time pointer.", + "- Re-read the relevant policy files under `docs/dev/policies/` at the start of any non-trivial turn.", + "- Re-read the relevant policy files when task scope changes mid-session.", + "- When behavior is ambiguous, prefer re-reading policy over improvising from stale assumptions.", + ] + ) + + +def policy_reread_triggers_section(repo_purpose: str | None) -> str: + lines = [ + "## Policy Re-read Triggers", + "", + "- re-read planning-related policy before opening, revising, or closing a substantive plan", + "- re-read documentation-related policy before changing docs, contracts, or canonical authorities", + "- re-read validation and closeout policy before claiming work complete", + ] + if repo_purpose in {"operations-platform", "website", "website-maintenance"}: + lines.append("- re-read runtime or environment-boundary policy before touching live state, tenant state, deploy state, or off-repo operator data") + if repo_purpose == "course-workspace": + lines.append("- re-read course, LMS, cloud-drive, and student-data policy before changing course config, assignments, submissions, grades, announcements, files, pages, modules, quizzes, forms, or course folders") + lines.append("- re-read validation policy before any live LMS write or course-data handoff") + if repo_purpose in {"operations-platform", "workspace-agent", "product-engineering"}: + lines.append("- re-read branch, commit, and integration policy before starting a multi-file or multi-step implementation slice") + return "\n".join(lines) + + +def runtime_boundary_reminder_section(repo_purpose: str | None) -> tuple[str, str] | None: + if repo_purpose != "operations-platform": + return None + return ( + "Tenant Boundary Reminder", + "\n".join( + [ + "## Tenant Boundary Reminder", + "", + "- Keep tenant-scoped or user-scoped runtime state out of the product repo unless the repo's runtime-state policy explicitly says it belongs in a separately governed tracked state surface.", + "- Re-check boundary policy before copying runtime facts, artifacts, or fieldwork output into tracked repo files.", + ]), + ) + + +def render_agents_wirein( + repo_root: Path, + install_plan: list[dict[str, Any]], + existing_policy_surfaces: list[dict], + repo_purpose: str | None = None, +) -> str: + existing_targets = sorted( + { + Path(item["path"]).relative_to(repo_root).as_posix() + for item in existing_policy_surfaces + if item["source_type"] == "canonical-policy" + } + ) + planned_targets = [Path(item["target_policy_path"]).relative_to(repo_root).as_posix() for item in install_plan] + all_targets = existing_targets + [target for target in planned_targets if target not in existing_targets] + repo_name = repo_root.name.replace("-", " ").title() + policy_lines = [ + "## Policy Entry", + "", + "This repo keeps its durable repo-local policy under `docs/dev/policies/`.", + "", + "Read and follow:", + ] + policy_lines.extend(f"- `{target}`" for target in all_targets) + policy_section = "\n".join(policy_lines) + loading_section = policy_loading_contract_section() + reread_section = policy_reread_triggers_section(repo_purpose) + runtime_section = runtime_boundary_reminder_section(repo_purpose) + scope_section = "\n".join( + [ + "## Scope", + "", + "- `AGENTS.md` includes repo-local guidance plus the policy entry section.", + "- The durable policy body lives under `docs/dev/policies/`.", + "- Keep repo-specific commands, environment details, and operational caveats in this file or adjacent local docs.", + ] + ) + + agents_path = repo_root / "AGENTS.md" + existing_text = read_text(agents_path) + if existing_text and not is_thin_agents_wirein(existing_text): + updated = replace_or_append_section(existing_text, "Policy Loading Contract", loading_section) + updated = replace_or_append_section(updated, "Policy Re-read Triggers", reread_section) + if runtime_section: + runtime_heading, runtime_content = runtime_section + updated = replace_or_append_section(updated, runtime_heading, runtime_content) + updated = replace_or_append_section(updated, "Policy Entry", policy_section) + updated = replace_or_append_section(updated, "Scope", scope_section) + return updated + + lines = [ + f"# {repo_name}", + "", + purpose_scaffold_text(repo_purpose).rstrip(), + "", + loading_section, + "", + reread_section, + ] + if runtime_section: + _, runtime_content = runtime_section + lines.extend(["", runtime_content]) + lines.extend(["", policy_section, "", scope_section]) + return "\n".join(lines).rstrip() + "\n" + + +def choose_profile(signals: dict, installed_library: dict[str, Any]) -> tuple[str, str | None, str | None, str, list[str], list[str]]: + purpose, subtype, reasons = classify_purpose(signals) + if purpose == "product-engineering": + profile = "repo-product-engineering" + elif purpose == "operations-platform": + profile = "operations-platform" + elif purpose in {"website", "website-maintenance"}: + profile = "website-maintenance" + elif purpose == "course-workspace": + profile = "course-workspace" + elif purpose == "seminal-workspace": + profile = "seminal-workspace" + elif purpose == "writing-project": + profile = "writing-project" + if signals["mentions_review_workspace"]: + subtype = "grant-proposal-review" + elif signals["mentions_analysis_workspace"]: + subtype = "patent-application-writing" + elif signals["mentions_writing_deliverable"]: + subtype = "grant-proposal-writing" + elif purpose == "workspace-agent": + profile = "skill-repo-maintainer" + else: + profile = "standalone-library" + modules = base_modules_for_profile(profile, installed_library) + if not modules: + modules = ["policy-management"] + execution_bias, bias_reasons = classify_execution_bias(signals, purpose) + if execution_bias is None: + execution_bias = profile_override_value(profile, "execution_bias", installed_library) + reasons.extend(bias_reasons) + + if signals["mentions_parallel"] and "planning-discipline" not in modules: + modules.insert(0, "planning-discipline") + reasons.append("repo policy already expects parallel execution") + if signals["mentions_goal_execution"] and "planning-discipline" not in modules: + modules.insert(0, "planning-discipline") + reasons.append("repo language explicitly references long-running goal execution") + if signals["mentions_goal_execution"] and "goal-execution-governance" not in modules: + modules.append("goal-execution-governance") + reasons.append("repo language explicitly references /goal or long-running goal execution") + if signals["mentions_goal_execution"] and "subagent-workflow-optimization" not in modules: + modules.append("subagent-workflow-optimization") + reasons.append("long-running goal execution benefits from explicit context and delegation governance") + if signals["mentions_goal_execution"] and "parallel-plan-design" not in modules: + modules.append("parallel-plan-design") + reasons.append("long-running goal execution needs explicit dependencies, joins, and bounded feedback edges") + if signals["mentions_goal_execution"] and "validation-and-handoff" not in modules: + modules.append("validation-and-handoff") + reasons.append("long-running goal execution needs independent outcome verification and bounded review") + if signals["mentions_subagents"] and "subagent-workflow-optimization" not in modules: + modules.append("subagent-workflow-optimization") + reasons.append("repo language explicitly references delegated or subagent workflows") + if signals["mentions_subagents"] and "parallel-plan-design" not in modules and purpose in {"product-engineering", "workspace-agent"}: + modules.append("parallel-plan-design") + reasons.append("repo language suggests plan structure should support parallel delegated work") + if signals["mentions_subagents"] and "multi-agent-reconciliation" not in modules and purpose in {"product-engineering", "workspace-agent"}: + modules.append("multi-agent-reconciliation") + reasons.append("repo language suggests explicit multi-agent reconciliation rules") + if signals["mentions_subagent_runtime"] and "subagent-runtime-governance" not in modules: + modules.append("subagent-runtime-governance") + reasons.append("repo language suggests spawned-agent runtime lifecycle, tool, transcript, or concurrency governance") + if (signals["notes_migration_needed"] or signals["has_notes_dir"] or signals["has_memories_dir"]) and "notes-and-memories" not in modules: + modules.append("notes-and-memories") + reasons.append("repo shows notes/memories continuity needs or legacy note clutter") + if signals["mentions_graph_backed_memory"] and "graph-backed-memory-usage" not in modules: + modules.append("graph-backed-memory-usage") + reasons.append("repo language suggests installed graph-backed durable memory usage") + if signals["mentions_graph_backed_memory"] and "notes-and-memories" not in modules: + modules.append("notes-and-memories") + reasons.append("graph-backed memory still needs richer human-readable continuity alongside retrievable facts") + if signals["mentions_codegraph_usage"] and "codegraph-usage" not in modules: + modules.append("codegraph-usage") + reasons.append("repo language suggests codegraph-backed code discovery or impact analysis") + if signals["mentions_memory_service_runtime"] and "memory-service-runtime-governance" not in modules: + modules.append("memory-service-runtime-governance") + reasons.append("repo language suggests installed memory-service runtime operations") + if signals["mentions_architecture_guardrails"] and "architecture-guardrails" not in modules: + modules.append("architecture-guardrails") + reasons.append("repo policy emphasizes architecture or service-boundary discipline") + if signals["mentions_doc_hygiene"] and "documentation-change-control" not in modules: + modules.append("documentation-change-control") + reasons.append("repo policy requires same-slice documentation upkeep") + if signals["mentions_validation_handoff"] and "validation-and-handoff" not in modules: + modules.append("validation-and-handoff") + reasons.append("repo policy emphasizes explicit verification and handoff quality") + if signals["mentions_preview_artifact_review"] and "preview-artifact-review" not in modules: + modules.append("preview-artifact-review") + reasons.append("repo language suggests preview sessions or browser-based human review for generated artifacts") + if signals["mentions_policy_harvest"] and "policy-harvest-loop" not in modules: + modules.append("policy-harvest-loop") + reasons.append("repo language suggests reusable policy harvesting") + if signals["mentions_git_policy"]: + for module_id in ( + "git-worktree-hygiene", + "commit-history-discipline", + "branch-and-integration-strategy", + "commit-and-push-cadence", + ): + if module_id not in modules: + modules.append(module_id) + reasons.append("repo language requests complete Git branch, commit, push, and worktree discipline") + if signals["mentions_active_lane_coordination"] and "active-lane-coordination" not in modules: + modules.append("active-lane-coordination") + reasons.append("repo language indicates concurrent off-main lanes need default-branch discovery") + if signals["mentions_upstream_fork"] and "upstream-fork-maintenance" not in modules: + modules.append("upstream-fork-maintenance") + reasons.append("repo signals indicate private or local work layered on a non-owned upstream") + deduped_modules: list[str] = [] + for module_id in modules: + if module_id not in deduped_modules: + deduped_modules.append(module_id) + return purpose, subtype, execution_bias, profile, deduped_modules, reasons + + +def memory_discovery_assessment( + signals: dict[str, Any], recommended_modules: list[str] +) -> dict[str, Any]: + policy_selected = "graph-backed-memory-usage" in recommended_modules + repo_signals = bool(signals.get("has_repo_memory_discovery_workflow")) + return { + "policy_module": "graph-backed-memory-usage", + "policy_selected": policy_selected, + "repo_graph_memory_signals": repo_signals, + "repo_default": "use" if repo_signals else "task-conditional", + "task_decisions": ["use", "skip", "unavailable"], + "rationale": ( + "repo policy or guidance explicitly names a graph-backed memory workflow" + if repo_signals + else "shared policy is selected, but repo evidence does not establish a concrete graph-memory workflow" + ), + } + + +def choose_adoption_mode(signals: dict, expectation_gaps: list[str], coverage: dict[str, Any]) -> tuple[str, list[str], dict[str, str]]: + reasons: list[str] = [] + duplicate_authorities = signals.get("duplicate_planning_authorities", {}) + if duplicate_authorities: + for surface_type, paths in duplicate_authorities.items(): + joined = ", ".join(paths) + reasons.append( + f"duplicate canonical {surface_type} authorities must be consolidated before policy adoption completes: {joined}" + ) + if signals["planning_migration_needed"]: + reasons.append("legacy, cluttered, or duplicate planning files should be migrated into canonical planning surfaces first") + if signals["notes_migration_needed"]: + reasons.append("legacy or cluttered notes/memories should be migrated into canonical docs/dev locations first") + reasons.extend(expectation_gaps) + + if reasons: + return ( + "migration-first", + reasons, + { + "canonical_roadmap": "choose one canonical ROADMAP.md authority and retire or merge duplicates", + "canonical_runbook": "choose one canonical RUNBOOK.md authority and retire or merge duplicates", + "plans": "docs/dev/plans/", + "notes": "docs/dev/notes/", + "memories": "docs/dev/memories/", + "policies": "docs/dev/policies/", + "policy_entrypoint": "AGENTS.md", + }, + ) + return ( + "clean-adoption", + [], + { + "policies": "docs/dev/policies/", + "policy_entrypoint": "AGENTS.md", + }, + ) + + +def summarize_policy_surface_actions(items: list[dict]) -> list[dict]: + return [ + { + "path": item["path"], + "action": item["action"], + "rationale": item["rationale"], + } + for item in items + ] + + +def summarize_migration_surface_actions(items: list[dict]) -> list[dict]: + return [ + { + "path": item["path"], + "surface_type": item["surface_type"], + "action": item["action"], + "rationale": item["rationale"], + } + for item in items + ] + + +def validate_recommendations(profile: str, modules: list[str], library: dict[str, Any]) -> list[str]: + problems: list[str] = [] + module_ids = set(library["module_ids"]) + profile_ids = set(library["profile_ids"]) + if not library["catalog_found"]: + problems.append("installed policy library catalog.yaml not found") + return problems + if profile not in profile_ids: + problems.append(f"recommended profile missing from installed library: {profile}") + for module_id in modules: + if module_id not in module_ids: + problems.append(f"recommended module missing from installed library: {module_id}") + return problems + + +def write_drafts(repo_root: Path, install_plan: list[dict[str, Any]], agents_patch: str) -> list[str]: + written: list[str] = [] + for item in install_plan: + if item["action"] != "install-new": + continue + target_path = Path(item["target_policy_path"]) + if target_path.exists(): + raise FileExistsError(f"refusing to overwrite existing policy file: {target_path}") + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_text(item["draft_content"], encoding="utf-8") + written.append(str(target_path)) + agents_path = repo_root / "AGENTS.md" + agents_path.write_text(agents_patch, encoding="utf-8") + written.append(str(agents_path)) + return written + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--repo-root", required=True) + parser.add_argument("--policy-root", required=False) + parser.add_argument("--json", action="store_true") + parser.add_argument("--write-drafts", action="store_true") + args = parser.parse_args() + + repo_root = Path(args.repo_root).resolve() + policy_root = Path(args.policy_root).resolve() if args.policy_root else None + installed_library = enumerate_policy_library(policy_root) + signals = detect_signals(repo_root) + existing_migration_surfaces = extract_existing_migration_surfaces(repo_root) + existing_policy_surfaces = extract_existing_policy_surfaces(repo_root) + purpose, subtype, execution_bias, profile, modules, reasons = choose_profile(signals, installed_library) + memory_discovery = memory_discovery_assessment(signals, modules) + repo_local_policy_findings = infer_repo_local_policy_findings(repo_root, modules, profile, signals) + coverage = policy_adoption_coverage(existing_policy_surfaces, modules, installed_library) + expectation_gaps = profile_expectation_gaps(profile, signals, installed_library) + adoption_mode, migration_reasons, migration_targets = choose_adoption_mode(signals, expectation_gaps, coverage) + validation_problems = validate_recommendations(profile, modules, installed_library) + validation_problems.extend(policy_identity_problems(coverage["duplicate_policy_ids"])) + rec_mode = recommendation_mode(coverage) + next_modules = ( + [] + if rec_mode == "identity-reconciliation-required" + else coverage["missing_recommended_modules"] + if rec_mode == "patch-missing" + else modules + ) + install_plan = build_install_plan(repo_root, next_modules, coverage, installed_library) + agents_patch = render_agents_wirein(repo_root, install_plan, existing_policy_surfaces, purpose) + written_paths: list[str] = [] + if args.write_drafts: + require_unique_policy_identities(coverage) + written_paths = write_drafts(repo_root, install_plan, agents_patch) + out = { + "repo_root": str(repo_root), + "policy_root": installed_library["policy_root"], + "installed_policy_library": installed_library, + "repo_purpose": purpose, + "workflow_subtype": subtype, + "execution_bias": execution_bias, + "adoption_mode": adoption_mode, + "recommendation_mode": rec_mode, + "migration_reasons": migration_reasons, + "migration_targets": migration_targets, + "profile_expectation_gaps": expectation_gaps, + "policy_adoption_coverage": coverage, + "existing_policy_surfaces": existing_policy_surfaces, + "repo_local_policy_findings": repo_local_policy_findings, + "policy_surface_actions": summarize_policy_surface_actions(existing_policy_surfaces), + "existing_migration_surfaces": existing_migration_surfaces, + "migration_surface_actions": summarize_migration_surface_actions(existing_migration_surfaces), + "recommended_profile": profile, + "recommended_modules": modules, + "memory_discovery": memory_discovery, + "next_modules": next_modules, + "install_plan": install_plan, + "agents_wirein_patch": agents_patch, + "written_paths": written_paths, + "validation_problems": validation_problems, + "signals": signals, + "reasons": reasons, + } + if args.json: + print(json.dumps(out, indent=2, sort_keys=True)) + else: + print(f"repo_purpose: {purpose}") + print(f"workflow_subtype: {subtype or '-'}") + print(f"execution_bias: {execution_bias or '-'}") + print(f"profile: {profile}") + print(f"adoption_readiness: {coverage['readiness']}") + print(f"recommendation_mode: {rec_mode}") + print(f"modules: {', '.join(modules)}") + print(f"memory_discovery: {memory_discovery['repo_default']}") + print(f"next_modules: {', '.join(next_modules) if next_modules else '-'}") + if install_plan: + print("install_plan:") + for item in install_plan: + print(f"- {item['action']} {item['module_id']} -> {item['target_policy_path']}") + print("agents_wirein_patch:") + print(agents_patch.rstrip()) + if written_paths: + print("written_paths:") + for path in written_paths: + print(f"- {path}") + print("reasons:") + for reason in reasons: + print(f"- {reason}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/repo-policy-selector/scripts/sync_policy_library.py b/.agents/skills/repo-policy-selector/scripts/sync_policy_library.py new file mode 100755 index 00000000..325108c9 --- /dev/null +++ b/.agents/skills/repo-policy-selector/scripts/sync_policy_library.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import sys + +sys.dont_write_bytecode = True + +import argparse +import shutil +from pathlib import Path + + +def reset_dir(path: Path) -> None: + if path.exists(): + shutil.rmtree(path) + path.mkdir(parents=True, exist_ok=True) + + +def copy_tree(src: Path, dst: Path) -> None: + if src.exists(): + shutil.copytree(src, dst, dirs_exist_ok=True) + + +def sync_policy_library(source_root: Path, output_root: Path) -> Path: + reset_dir(output_root) + copy_tree(source_root / "modules", output_root / "modules") + copy_tree(source_root / "profiles", output_root / "profiles") + shutil.copy2(source_root / "catalog.yaml", output_root / "catalog.yaml") + shutil.copy2(source_root / "SCHEMA.md", output_root / "SCHEMA.md") + return output_root + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--source-root", required=True) + parser.add_argument("--output-root") + args = parser.parse_args() + + source_root = Path(args.source_root).resolve() + output_root = ( + Path(args.output_root).resolve() + if args.output_root + else Path(__file__).resolve().parents[1] / "policy-library" + ) + + sync_policy_library(source_root, output_root) + + print(f"synced policy library to {output_root}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/repo-policy-selector/tests/test_audit_active_lanes.py b/.agents/skills/repo-policy-selector/tests/test_audit_active_lanes.py new file mode 100644 index 00000000..e02e08c5 --- /dev/null +++ b/.agents/skills/repo-policy-selector/tests/test_audit_active_lanes.py @@ -0,0 +1,997 @@ +from __future__ import annotations + +import json +import re +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).parents[1] / "scripts" / "audit_active_lanes.py" + + +class ActiveLaneAuditTests(unittest.TestCase): + def git(self, repo: Path, *args: str) -> str: + result = subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + def write(self, path: Path, body: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + + def make_registered_active_repo(self) -> tuple[Path, tempfile.TemporaryDirectory[str]]: + temporary = tempfile.TemporaryDirectory() + root = Path(temporary.name) + remote = root / "remote.git" + repo = root / "repo" + worktree = root / "lane-p42" + + subprocess.run(["git", "init", "--bare", "-q", str(remote)], check=True) + subprocess.run(["git", "init", "-q", "-b", "main", str(repo)], check=True) + self.git(repo, "config", "user.email", "test@example.com") + self.git(repo, "config", "user.name", "Test") + self.git(repo, "remote", "add", "origin", str(remote)) + + self.write(repo / "README.md", "fixture\n") + self.git(repo, "add", "README.md") + self.git(repo, "commit", "-q", "-m", "Initialize fixture") + self.git(repo, "branch", "feature/p42-carrier-reconciliation") + self.git(repo, "worktree", "add", "-q", str(worktree), "feature/p42-carrier-reconciliation") + + plan_path = "docs/dev/plans/0042-2026-08-20-carrier-reconciliation.md" + self.write( + worktree / plan_path, + """# Plan 0042 | Carrier Reconciliation + +State: OPEN +Lane: P42 +Branch: feature/p42-carrier-reconciliation +Target: main +Integration: merge + +## Current State + +Implementation is active. +""", + ) + self.git(worktree, "add", plan_path) + self.git(worktree, "commit", "-q", "-m", "Open carrier lane") + checkpoint = self.git(worktree, "rev-parse", "HEAD") + + self.write( + repo / "docs/dev/active-lanes.yaml", + f"""schema_version: 1 +lanes: + - id: P42 + objective: Carrier reconciliation + plan: {plan_path} + plan_ref: refs/heads/feature/p42-carrier-reconciliation + branch: feature/p42-carrier-reconciliation + target: main + plan_state: OPEN + custody_state: ACTIVE_WORKTREE + checkpoint: {checkpoint} + remote_ref: refs/remotes/origin/feature/p42-carrier-reconciliation + integration: merge + dependencies: [] + overlaps: [] + updated_at: 2026-08-20 +""", + ) + self.git(repo, "add", "docs/dev/active-lanes.yaml") + self.git(repo, "commit", "-q", "-m", "Register carrier lane") + self.git(repo, "push", "-q", "-u", "origin", "main") + self.git(worktree, "push", "-q", "-u", "origin", "feature/p42-carrier-reconciliation") + return repo, temporary + + def run_audit(self, repo: Path, *extra_args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--repo-root", + str(repo), + "--default-ref", + "refs/heads/main", + "--json", + *extra_args, + ], + capture_output=True, + text=True, + ) + + def test_registered_pushed_lane_with_worktree_is_active(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 0, result.stderr) + report = json.loads(result.stdout) + self.assertTrue(report["ok"], report["problems"]) + self.assertEqual(report["default_ref"], "refs/heads/main") + self.assertEqual(len(report["lanes"]), 1) + lane = report["lanes"][0] + self.assertEqual(lane["id"], "P42") + self.assertIn("registered_active", lane["findings"]) + self.assertEqual(lane["local_tip"], lane["remote_tip"]) + self.assertEqual(lane["local_remote_relation"], "equal") + self.assertEqual(lane["checkpoint"], lane["local_tip"]) + self.assertEqual(len(lane["worktrees"]), 1) + + def test_duplicate_lane_ids_fail_closed(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + catalog = repo / "docs/dev/active-lanes.yaml" + catalog.write_text( + catalog.read_text(encoding="utf-8") + + """ - id: P42 + objective: Duplicate carrier lane + plan: docs/dev/plans/duplicate.md + plan_ref: refs/heads/feature/duplicate + branch: feature/duplicate + target: main + plan_state: OPEN + custody_state: ACTIVE_WORKTREE + checkpoint: deadbeef + remote_ref: refs/remotes/origin/feature/duplicate + integration: merge + dependencies: [] + overlaps: [] + updated_at: 2026-08-20 +""", + encoding="utf-8", + ) + self.git(repo, "add", "docs/dev/active-lanes.yaml") + self.git(repo, "commit", "-q", "-m", "Duplicate lane fixture") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 1) + report = json.loads(result.stdout) + self.assertFalse(report["ok"]) + self.assertIn("duplicate lane id: P42", report["problems"]) + + def test_local_only_lane_is_not_remotely_custodied(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + self.git(repo, "push", "-q", "origin", "--delete", "feature/p42-carrier-reconciliation") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 1) + report = json.loads(result.stdout) + lane = report["lanes"][0] + self.assertIn("registered_active", lane["findings"]) + self.assertIn("local_only", lane["findings"]) + self.assertIsNone(lane["remote_tip"]) + self.assertIn("P42: local branch has no configured remote custody", report["problems"]) + + def test_dirty_worktree_is_uncheckpointed(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + worktree = Path(self.git(repo, "worktree", "list", "--porcelain").split("worktree ")[2].splitlines()[0]) + self.write(worktree / "untracked.txt", "not committed\n") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 1) + report = json.loads(result.stdout) + lane = report["lanes"][0] + self.assertIn("dirty_uncheckpointed", lane["findings"]) + self.assertEqual(lane["worktrees"][0]["status"], ["?? untracked.txt"]) + self.assertIn("P42: assigned worktree has uncommitted state", report["problems"]) + + def test_newer_pushed_tip_makes_catalog_checkpoint_stale(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + worktree = Path(self.git(repo, "worktree", "list", "--porcelain").split("worktree ")[2].splitlines()[0]) + self.write(worktree / "next.txt", "new checkpoint\n") + self.git(worktree, "add", "next.txt") + self.git(worktree, "commit", "-q", "-m", "Advance carrier lane") + self.git(worktree, "push", "-q", "origin", "feature/p42-carrier-reconciliation") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 1) + report = json.loads(result.stdout) + lane = report["lanes"][0] + self.assertIn("stale_checkpoint", lane["findings"]) + self.assertEqual(lane["local_tip"], lane["remote_tip"]) + self.assertNotEqual(lane["checkpoint"], lane["local_tip"]) + self.assertIn("P42: catalog checkpoint does not match the local branch tip", report["problems"]) + + def test_active_local_checkpoint_ahead_of_remote_fails_closed(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + worktree = Path(self.git(repo, "worktree", "list", "--porcelain").split("worktree ")[2].splitlines()[0]) + self.write(worktree / "local-only.txt", "unpublished checkpoint\n") + self.git(worktree, "add", "local-only.txt") + self.git(worktree, "commit", "-q", "-m", "Advance local checkpoint") + checkpoint = self.git(worktree, "rev-parse", "HEAD") + catalog = repo / "docs/dev/active-lanes.yaml" + body = catalog.read_text(encoding="utf-8") + body = re.sub( + r"(?m)^ checkpoint: .+$", + f" checkpoint: {checkpoint}", + body, + ) + catalog.write_text(body, encoding="utf-8") + self.git(repo, "add", "docs/dev/active-lanes.yaml") + self.git(repo, "commit", "-q", "-m", "Record local checkpoint") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 1) + report = json.loads(result.stdout) + lane = report["lanes"][0] + self.assertIn("local_ahead_of_remote", lane["findings"]) + self.assertEqual(lane["local_remote_relation"], "local_ahead") + self.assertEqual(lane["checkpoint"], lane["local_tip"]) + self.assertNotEqual(lane["local_tip"], lane["remote_tip"]) + self.assertIn("P42: active local checkpoint is ahead of remote custody", report["problems"]) + + def test_active_remote_tip_ahead_of_local_checkpoint_fails_closed(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + writer = repo.parent / "remote-writer" + remote = self.git(repo, "remote", "get-url", "origin") + subprocess.run(["git", "clone", "-q", remote, str(writer)], check=True) + self.git(writer, "config", "user.email", "test@example.com") + self.git(writer, "config", "user.name", "Test") + self.git(writer, "checkout", "-q", "feature/p42-carrier-reconciliation") + self.write(writer / "remote-only.txt", "remote checkpoint\n") + self.git(writer, "add", "remote-only.txt") + self.git(writer, "commit", "-q", "-m", "Advance remote checkpoint") + self.git(writer, "push", "-q", "origin", "feature/p42-carrier-reconciliation") + self.git(repo, "fetch", "-q", "origin", "feature/p42-carrier-reconciliation") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 1) + report = json.loads(result.stdout) + lane = report["lanes"][0] + self.assertIn("remote_ahead_of_local", lane["findings"]) + self.assertEqual(lane["local_remote_relation"], "remote_ahead") + self.assertEqual(lane["checkpoint"], lane["local_tip"]) + self.assertNotEqual(lane["local_tip"], lane["remote_tip"]) + self.assertIn("P42: active remote custody is ahead of the local checkpoint", report["problems"]) + + def test_active_local_and_remote_divergence_fails_closed(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + worktree = Path(self.git(repo, "worktree", "list", "--porcelain").split("worktree ")[2].splitlines()[0]) + self.write(worktree / "local-only.txt", "local checkpoint\n") + self.git(worktree, "add", "local-only.txt") + self.git(worktree, "commit", "-q", "-m", "Advance local checkpoint") + checkpoint = self.git(worktree, "rev-parse", "HEAD") + catalog = repo / "docs/dev/active-lanes.yaml" + body = re.sub( + r"(?m)^ checkpoint: .+$", + f" checkpoint: {checkpoint}", + catalog.read_text(encoding="utf-8"), + ) + catalog.write_text(body, encoding="utf-8") + self.git(repo, "add", "docs/dev/active-lanes.yaml") + self.git(repo, "commit", "-q", "-m", "Record local checkpoint") + + writer = repo.parent / "remote-writer" + remote = self.git(repo, "remote", "get-url", "origin") + subprocess.run(["git", "clone", "-q", remote, str(writer)], check=True) + self.git(writer, "config", "user.email", "test@example.com") + self.git(writer, "config", "user.name", "Test") + self.git(writer, "checkout", "-q", "feature/p42-carrier-reconciliation") + self.write(writer / "remote-only.txt", "remote checkpoint\n") + self.git(writer, "add", "remote-only.txt") + self.git(writer, "commit", "-q", "-m", "Advance remote checkpoint") + self.git(writer, "push", "-q", "origin", "feature/p42-carrier-reconciliation") + self.git(repo, "fetch", "-q", "origin", "feature/p42-carrier-reconciliation") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 1) + report = json.loads(result.stdout) + lane = report["lanes"][0] + self.assertIn("local_remote_diverged", lane["findings"]) + self.assertEqual(lane["local_remote_relation"], "diverged") + self.assertEqual(lane["checkpoint"], lane["local_tip"]) + self.assertNotEqual(lane["local_tip"], lane["remote_tip"]) + self.assertIn("P42: active local and remote custody have diverged", report["problems"]) + + def test_active_worktree_registration_without_worktree_is_missing(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + worktree = Path(self.git(repo, "worktree", "list", "--porcelain").split("worktree ")[2].splitlines()[0]) + self.git(repo, "worktree", "remove", str(worktree)) + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 1) + report = json.loads(result.stdout) + lane = report["lanes"][0] + self.assertIn("registered_but_missing", lane["findings"]) + self.assertEqual(lane["worktrees"], []) + self.assertIn("P42: ACTIVE_WORKTREE lane has no assigned worktree", report["problems"]) + + def test_pushed_branch_without_worktree_can_be_paused(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + worktree = Path(self.git(repo, "worktree", "list", "--porcelain").split("worktree ")[2].splitlines()[0]) + self.git(repo, "worktree", "remove", str(worktree)) + catalog = repo / "docs/dev/active-lanes.yaml" + catalog.write_text( + catalog.read_text(encoding="utf-8").replace( + "custody_state: ACTIVE_WORKTREE", "custody_state: PAUSED_REF" + ), + encoding="utf-8", + ) + self.git(repo, "add", "docs/dev/active-lanes.yaml") + self.git(repo, "commit", "-q", "-m", "Pause carrier lane") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 0, result.stderr) + report = json.loads(result.stdout) + lane = report["lanes"][0] + self.assertEqual(lane["findings"], ["paused_ref"]) + self.assertEqual(lane["worktrees"], []) + + def test_open_plan_on_unregistered_topic_branch_is_discovered(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + root = repo.parent + worktree = root / "lane-p43" + self.git(repo, "worktree", "add", "-q", "-b", "feature/p43-unregistered", str(worktree), "main") + plan_path = "docs/dev/plans/0043-2026-08-20-unregistered.md" + self.write( + worktree / plan_path, + """# Plan 0043 | Unregistered + +State: OPEN +Lane: P43 +Branch: feature/p43-unregistered +Target: main +Integration: merge + +## Current State + +Implementation is active. +""", + ) + self.git(worktree, "add", plan_path) + self.git(worktree, "commit", "-q", "-m", "Open unregistered lane") + self.git(worktree, "push", "-q", "-u", "origin", "feature/p43-unregistered") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 1) + report = json.loads(result.stdout) + lane = next(item for item in report["lanes"] if item["id"] == "P43") + self.assertEqual(lane["branch"], "feature/p43-unregistered") + self.assertIn("unregistered_active", lane["findings"]) + self.assertIn("P43: active branch is absent from the default-ref catalog", report["problems"]) + + def test_catalog_only_mode_skips_unregistered_branch_discovery(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + worktree = repo.parent / "lane-p43" + self.git(repo, "worktree", "add", "-q", "-b", "feature/p43-unregistered", str(worktree), "main") + plan_path = "docs/dev/plans/0043-2026-08-21-unregistered.md" + self.write( + worktree / plan_path, + """# Plan 0043 | Unregistered + +State: OPEN +Lane: P43 +Branch: feature/p43-unregistered +Target: main +Integration: merge + +## Current State + +Implementation is active. +""", + ) + self.git(worktree, "add", plan_path) + self.git(worktree, "commit", "-q", "-m", "Open unregistered lane") + self.git(worktree, "push", "-q", "-u", "origin", "feature/p43-unregistered") + + result = self.run_audit(repo, "--catalog-only") + + self.assertEqual(result.returncode, 0, result.stderr) + report = json.loads(result.stdout) + self.assertTrue(report["ok"], report["problems"]) + self.assertEqual(report["discovery_mode"], "catalog-only") + self.assertEqual(report["branch_prefixes"], []) + self.assertEqual([lane["id"] for lane in report["lanes"]], ["P42"]) + + def test_catalog_only_mode_skips_unregistered_detached_discovery(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + worktree = repo.parent / "lane-p44-detached" + self.git(repo, "worktree", "add", "-q", "--detach", str(worktree), "main") + plan_path = "docs/dev/plans/0044-2026-08-21-detached.md" + self.write( + worktree / plan_path, + """# Plan 0044 | Detached + +State: OPEN +Lane: P44 +Branch: feature/p44-detached +Target: main +Integration: merge + +## Current State + +Implementation exists only at detached HEAD. +""", + ) + self.git(worktree, "add", plan_path) + self.git(worktree, "commit", "-q", "-m", "Create detached work") + + results = ( + self.run_audit(repo, "--catalog-only"), + self.run_audit(repo, "--branch", "feature/p42-carrier-reconciliation"), + ) + + for result in results: + self.assertEqual(result.returncode, 0, result.stderr) + report = json.loads(result.stdout) + self.assertTrue(report["ok"], report["problems"]) + self.assertEqual([lane["id"] for lane in report["lanes"]], ["P42"]) + + def test_detached_open_plan_without_shared_ref_is_orphaned(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + worktree = repo.parent / "lane-p44-detached" + self.git(repo, "worktree", "add", "-q", "--detach", str(worktree), "main") + plan_path = "docs/dev/plans/0044-2026-08-20-detached.md" + self.write( + worktree / plan_path, + """# Plan 0044 | Detached + +State: OPEN +Lane: P44 +Branch: feature/p44-detached +Target: main +Integration: merge + +## Current State + +Implementation exists only at detached HEAD. +""", + ) + self.git(worktree, "add", plan_path) + self.git(worktree, "commit", "-q", "-m", "Create detached work") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 1) + report = json.loads(result.stdout) + lane = next(item for item in report["lanes"] if item["id"] == "P44") + self.assertIn("orphaned_detached", lane["findings"]) + self.assertEqual(lane["worktrees"][0]["detached"], True) + self.assertIn("P44: detached worktree commit is not reachable from a shared ref", report["problems"]) + + def test_declared_overlap_without_disposition_is_unreconciled(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + catalog = repo / "docs/dev/active-lanes.yaml" + catalog.write_text( + catalog.read_text(encoding="utf-8").replace( + "overlaps: []", "overlaps: [P43]\n reconciled_overlaps: []" + ), + encoding="utf-8", + ) + self.git(repo, "add", "docs/dev/active-lanes.yaml") + self.git(repo, "commit", "-q", "-m", "Declare unresolved overlap") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 1) + report = json.loads(result.stdout) + lane = report["lanes"][0] + self.assertIn("overlap_unreconciled", lane["findings"]) + self.assertEqual(lane["unreconciled_overlaps"], ["P43"]) + self.assertIn("P42: declared overlap lacks disposition: P43", report["problems"]) + + def test_validated_published_lane_can_be_integration_ready(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + catalog = repo / "docs/dev/active-lanes.yaml" + body = catalog.read_text(encoding="utf-8") + checkpoint = next( + line.split(":", 1)[1].strip() + for line in body.splitlines() + if line.strip().startswith("checkpoint:") + ) + body = body.replace("custody_state: ACTIVE_WORKTREE", "custody_state: INTEGRATION_READY") + body = body.replace( + "integration: merge", + f"integration: merge\n validation_status: passed\n validation_ref: {checkpoint}", + ) + catalog.write_text(body, encoding="utf-8") + self.git(repo, "add", "docs/dev/active-lanes.yaml") + self.git(repo, "commit", "-q", "-m", "Mark carrier lane ready") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 0, result.stderr) + report = json.loads(result.stdout) + lane = report["lanes"][0] + self.assertEqual(lane["findings"], ["integration_ready"]) + self.assertFalse(lane["integrated_into_target"]) + + def test_integrated_state_without_ancestry_or_receipt_is_ambiguous(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + catalog = repo / "docs/dev/active-lanes.yaml" + catalog.write_text( + catalog.read_text(encoding="utf-8") + .replace("plan_state: OPEN", "plan_state: CLOSED") + .replace("custody_state: ACTIVE_WORKTREE", "custody_state: INTEGRATED"), + encoding="utf-8", + ) + self.git(repo, "add", "docs/dev/active-lanes.yaml") + self.git(repo, "commit", "-q", "-m", "Claim ambiguous integration") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 1) + report = json.loads(result.stdout) + lane = report["lanes"][0] + self.assertIn("integration_ambiguous", lane["findings"]) + self.assertFalse(lane["integrated_into_target"]) + self.assertIn("P42: INTEGRATED state lacks ancestry or a verified integration receipt", report["problems"]) + + def test_merged_lane_with_live_branch_is_cleanup_pending(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + self.git( + repo, + "merge", + "-q", + "--no-ff", + "-m", + "Integrate carrier lane", + "feature/p42-carrier-reconciliation", + ) + catalog = repo / "docs/dev/active-lanes.yaml" + catalog.write_text( + catalog.read_text(encoding="utf-8") + .replace("plan_state: OPEN", "plan_state: CLOSED") + .replace("custody_state: ACTIVE_WORKTREE", "custody_state: INTEGRATED"), + encoding="utf-8", + ) + self.git(repo, "add", "docs/dev/active-lanes.yaml") + self.git(repo, "commit", "-q", "-m", "Record carrier integration") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 0, result.stderr) + report = json.loads(result.stdout) + lane = report["lanes"][0] + self.assertEqual(lane["findings"], ["integrated_cleanup_pending"]) + self.assertTrue(lane["integrated_into_target"]) + + def test_squash_receipt_can_prove_integration_without_branch_ancestry(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + self.write(repo / "carrier-result.txt", "squashed result\n") + self.git(repo, "add", "carrier-result.txt") + self.git(repo, "commit", "-q", "-m", "Squash carrier result") + receipt = self.git(repo, "rev-parse", "HEAD") + catalog = repo / "docs/dev/active-lanes.yaml" + catalog.write_text( + catalog.read_text(encoding="utf-8") + .replace("plan_state: OPEN", "plan_state: CLOSED") + .replace("custody_state: ACTIVE_WORKTREE", "custody_state: INTEGRATED") + .replace("integration: merge", f"integration: squash\n integration_receipt: {receipt}"), + encoding="utf-8", + ) + self.git(repo, "add", "docs/dev/active-lanes.yaml") + self.git(repo, "commit", "-q", "-m", "Record squash integration") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 0, result.stderr) + report = json.loads(result.stdout) + lane = report["lanes"][0] + self.assertFalse(lane["integrated_into_target"]) + self.assertTrue(lane["integration_receipt_verified"]) + self.assertIn("integrated_cleanup_pending", lane["findings"]) + + def test_archive_refs_preserve_unmerged_lane_without_worktree(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + worktree = Path(self.git(repo, "worktree", "list", "--porcelain").split("worktree ")[2].splitlines()[0]) + checkpoint = self.git(worktree, "rev-parse", "HEAD") + archive_branch = "archive/p42-carrier-20260820" + self.git(repo, "branch", archive_branch, checkpoint) + self.git(repo, "push", "-q", "-u", "origin", archive_branch) + self.git(repo, "worktree", "remove", str(worktree)) + self.git(repo, "branch", "-d", "feature/p42-carrier-reconciliation") + self.git(repo, "push", "-q", "origin", "--delete", "feature/p42-carrier-reconciliation") + catalog = repo / "docs/dev/active-lanes.yaml" + catalog.write_text( + catalog.read_text(encoding="utf-8") + .replace("plan_state: OPEN", "plan_state: CANCELLED") + .replace("custody_state: ACTIVE_WORKTREE", "custody_state: ARCHIVED") + .replace( + "integration: merge", + "integration: merge\n" + f" archive_ref: refs/heads/{archive_branch}\n" + f" archive_remote_ref: refs/remotes/origin/{archive_branch}", + ), + encoding="utf-8", + ) + self.git(repo, "add", "docs/dev/active-lanes.yaml") + self.git(repo, "commit", "-q", "-m", "Archive carrier lane") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 0, result.stderr) + report = json.loads(result.stdout) + lane = report["lanes"][0] + self.assertEqual(lane["findings"], ["archived_ref"]) + self.assertEqual(lane["archive_local_tip"], checkpoint) + self.assertEqual(lane["archive_remote_tip"], checkpoint) + + def test_closed_plan_with_active_worktree_is_inconsistent(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + catalog = repo / "docs/dev/active-lanes.yaml" + catalog.write_text( + catalog.read_text(encoding="utf-8").replace("plan_state: OPEN", "plan_state: CLOSED"), + encoding="utf-8", + ) + self.git(repo, "add", "docs/dev/active-lanes.yaml") + self.git(repo, "commit", "-q", "-m", "Close plan without Git disposition") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 1) + report = json.loads(result.stdout) + lane = report["lanes"][0] + self.assertIn("closed_plan_live_branch", lane["findings"]) + self.assertIn("P42: CLOSED plan still has active worktree custody", report["problems"]) + + def test_missing_required_catalog_field_fails_closed(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + catalog = repo / "docs/dev/active-lanes.yaml" + catalog.write_text( + catalog.read_text(encoding="utf-8").replace( + " objective: Carrier reconciliation\n", "" + ), + encoding="utf-8", + ) + self.git(repo, "add", "docs/dev/active-lanes.yaml") + self.git(repo, "commit", "-q", "-m", "Remove required lane field") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 1) + report = json.loads(result.stdout) + self.assertIn("P42: missing required catalog field: objective", report["problems"]) + + def test_two_lanes_cannot_claim_the_same_branch(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + catalog = repo / "docs/dev/active-lanes.yaml" + catalog.write_text( + catalog.read_text(encoding="utf-8") + + """ - id: P43 + objective: Conflicting owner + plan: docs/dev/plans/0043-conflict.md + plan_ref: refs/heads/feature/p42-carrier-reconciliation + branch: feature/p42-carrier-reconciliation + target: main + plan_state: OPEN + custody_state: ACTIVE_WORKTREE + checkpoint: deadbeef + remote_ref: refs/remotes/origin/feature/p42-carrier-reconciliation + integration: merge + dependencies: [] + overlaps: [] + updated_at: 2026-08-20 +""", + encoding="utf-8", + ) + self.git(repo, "add", "docs/dev/active-lanes.yaml") + self.git(repo, "commit", "-q", "-m", "Create conflicting branch ownership") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 1) + report = json.loads(result.stdout) + self.assertIn( + "branch claimed by multiple lanes: feature/p42-carrier-reconciliation", + report["problems"], + ) + + def test_registered_plan_metadata_must_match_catalog(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + catalog = repo / "docs/dev/active-lanes.yaml" + catalog.write_text( + catalog.read_text(encoding="utf-8").replace( + "plan_state: OPEN", "plan_state: BLOCKED" + ), + encoding="utf-8", + ) + self.git(repo, "add", "docs/dev/active-lanes.yaml") + self.git(repo, "commit", "-q", "-m", "Drift plan state in catalog") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 1) + report = json.loads(result.stdout) + lane = report["lanes"][0] + self.assertIn("plan_catalog_drift", lane["findings"]) + self.assertIn("P42: plan state OPEN does not match catalog state BLOCKED", report["problems"]) + + def test_unknown_dependency_fails_closed(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + catalog = repo / "docs/dev/active-lanes.yaml" + catalog.write_text( + catalog.read_text(encoding="utf-8").replace( + "dependencies: []", "dependencies: [P99]" + ), + encoding="utf-8", + ) + self.git(repo, "add", "docs/dev/active-lanes.yaml") + self.git(repo, "commit", "-q", "-m", "Reference unknown dependency") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 1) + report = json.loads(result.stdout) + self.assertIn("P42: unknown dependency lane: P99", report["problems"]) + + def test_catalog_branch_that_does_not_exist_is_missing(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + worktree = Path(self.git(repo, "worktree", "list", "--porcelain").split("worktree ")[2].splitlines()[0]) + self.git(repo, "worktree", "remove", str(worktree)) + self.git(repo, "branch", "-D", "feature/p42-carrier-reconciliation") + self.git(repo, "push", "-q", "origin", "--delete", "feature/p42-carrier-reconciliation") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 1) + report = json.loads(result.stdout) + lane = report["lanes"][0] + self.assertIn("registered_but_missing", lane["findings"]) + self.assertIn("P42: registered branch has no local or remote ref", report["problems"]) + + def test_audit_does_not_change_refs_worktrees_status_config_or_index(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + + def fingerprint() -> tuple[str, str, tuple[tuple[str, str, bytes], ...], bytes]: + worktree_rows: list[tuple[str, str, bytes]] = [] + worktree_output = self.git(repo, "worktree", "list", "--porcelain") + for line in worktree_output.splitlines(): + if not line.startswith("worktree "): + continue + worktree = Path(line.removeprefix("worktree ")) + git_dir = Path(self.git(worktree, "rev-parse", "--absolute-git-dir")) + worktree_rows.append( + ( + str(worktree), + self.git(worktree, "status", "--porcelain=v1", "--untracked-files=all"), + (git_dir / "index").read_bytes(), + ) + ) + return ( + self.git(repo, "show-ref"), + worktree_output, + tuple(worktree_rows), + (repo / ".git/config").read_bytes(), + ) + + before = fingerprint() + results = ( + self.run_audit(repo), + self.run_audit(repo, "--catalog-only"), + self.run_audit(repo, "--branch", "feature/p42-carrier-reconciliation"), + ) + after = fingerprint() + + for result in results: + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(after, before) + + def test_invalid_catalog_syntax_returns_stable_json_error(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + catalog = repo / "docs/dev/active-lanes.yaml" + catalog.write_text("schema_version: 1\nlanes:\n - not-a-mapping\n", encoding="utf-8") + self.git(repo, "add", "docs/dev/active-lanes.yaml") + self.git(repo, "commit", "-q", "-m", "Break lane catalog syntax") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 1) + report = json.loads(result.stdout) + self.assertEqual(report["problems"], ["invalid catalog syntax at line 3: expected key: value"]) + + def test_unknown_state_vocabulary_fails_closed(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + catalog = repo / "docs/dev/active-lanes.yaml" + catalog.write_text( + catalog.read_text(encoding="utf-8").replace( + "custody_state: ACTIVE_WORKTREE", "custody_state: MAYBE_DONE" + ), + encoding="utf-8", + ) + self.git(repo, "add", "docs/dev/active-lanes.yaml") + self.git(repo, "commit", "-q", "-m", "Use unknown custody state") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 1) + report = json.loads(result.stdout) + self.assertIn("P42: invalid custody_state: MAYBE_DONE", report["problems"]) + + def test_integration_ready_state_requires_complete_readiness_evidence(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + catalog = repo / "docs/dev/active-lanes.yaml" + catalog.write_text( + catalog.read_text(encoding="utf-8").replace( + "custody_state: ACTIVE_WORKTREE", "custody_state: INTEGRATION_READY" + ), + encoding="utf-8", + ) + self.git(repo, "add", "docs/dev/active-lanes.yaml") + self.git(repo, "commit", "-q", "-m", "Claim readiness without validation") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 1) + report = json.loads(result.stdout) + lane = report["lanes"][0] + self.assertIn("integration_ambiguous", lane["findings"]) + self.assertIn("P42: INTEGRATION_READY state lacks complete readiness evidence", report["problems"]) + + def test_backup_namespace_is_excluded_from_unregistered_discovery(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + worktree = repo.parent / "backup-old-lane" + self.git(repo, "worktree", "add", "-q", "-b", "backup/old-lane", str(worktree), "main") + plan_path = "docs/dev/plans/0099-2026-01-01-old-lane.md" + self.write( + worktree / plan_path, + """# Plan 0099 | Old Lane + +State: OPEN +Lane: P99 +Branch: backup/old-lane +Target: main +Integration: merge +""", + ) + self.git(worktree, "add", plan_path) + self.git(worktree, "commit", "-q", "-m", "Preserve historical backup plan") + self.git(worktree, "push", "-q", "-u", "origin", "backup/old-lane") + + result = self.run_audit(repo) + + self.assertEqual(result.returncode, 0, result.stderr) + report = json.loads(result.stdout) + self.assertEqual([lane["id"] for lane in report["lanes"]], ["P42"]) + + def test_custom_topic_prefix_can_expand_bounded_discovery(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + worktree = repo.parent / "topic-p43" + self.git(repo, "worktree", "add", "-q", "-b", "topic/p43", str(worktree), "main") + plan_path = "docs/dev/plans/0043-2026-08-20-custom-topic.md" + self.write( + worktree / plan_path, + """# Plan 0043 | Custom Topic + +State: OPEN +Lane: P43 +Branch: topic/p43 +Target: main +Integration: merge +""", + ) + self.git(worktree, "add", plan_path) + self.git(worktree, "commit", "-q", "-m", "Open custom topic lane") + self.git(worktree, "push", "-q", "-u", "origin", "topic/p43") + + result = self.run_audit(repo, "--branch-prefix", "topic/") + + self.assertEqual(result.returncode, 1) + report = json.loads(result.stdout) + lane = next(item for item in report["lanes"] if item["id"] == "P43") + self.assertIn("unregistered_active", lane["findings"]) + + def test_exact_branch_selection_excludes_other_unregistered_branches(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + + for lane_id in ("P43", "P44"): + branch = f"feature/{lane_id.lower()}-unregistered" + worktree = repo.parent / f"lane-{lane_id.lower()}" + self.git(repo, "worktree", "add", "-q", "-b", branch, str(worktree), "main") + plan_path = f"docs/dev/plans/00{lane_id[1:]}-2026-08-21-unregistered.md" + self.write( + worktree / plan_path, + f"""# Plan 00{lane_id[1:]} | Unregistered + +State: OPEN +Lane: {lane_id} +Branch: {branch} +Target: main +Integration: merge + +## Current State + +Implementation is active. +""", + ) + self.git(worktree, "add", plan_path) + self.git(worktree, "commit", "-q", "-m", f"Open {lane_id} lane") + self.git(worktree, "push", "-q", "-u", "origin", branch) + + result = self.run_audit(repo, "--branch", "feature/p43-unregistered") + + self.assertEqual(result.returncode, 1) + report = json.loads(result.stdout) + self.assertEqual(report["discovery_mode"], "exact-branches") + self.assertEqual(report["selected_branches"], ["feature/p43-unregistered"]) + self.assertEqual(report["branch_prefixes"], []) + self.assertEqual([lane["id"] for lane in report["lanes"]], ["P42", "P43"]) + + def test_discovery_modes_are_mutually_exclusive(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + + result = self.run_audit( + repo, + "--catalog-only", + "--branch", + "feature/p42-carrier-reconciliation", + ) + + self.assertEqual(result.returncode, 2) + self.assertIn("not allowed with argument --catalog-only", result.stderr) + + def test_configured_remote_bounds_remote_ref_discovery(self) -> None: + repo, temporary = self.make_registered_active_repo() + self.addCleanup(temporary.cleanup) + self.git(repo, "remote", "rename", "origin", "upstream") + catalog = repo / "docs/dev/active-lanes.yaml" + catalog.write_text( + catalog.read_text(encoding="utf-8").replace( + "refs/remotes/origin/", "refs/remotes/upstream/" + ), + encoding="utf-8", + ) + self.git(repo, "add", "docs/dev/active-lanes.yaml") + self.git(repo, "commit", "-q", "-m", "Configure alternate custody remote") + + result = self.run_audit(repo, "--remote", "upstream") + + self.assertEqual(result.returncode, 0, result.stderr) + report = json.loads(result.stdout) + self.assertEqual(report["remote"], "upstream") + self.assertEqual(report["lanes"][0]["local_tip"], report["lanes"][0]["remote_tip"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/.agents/skills/repo-policy-selector/tests/test_audit_planning_contract.py b/.agents/skills/repo-policy-selector/tests/test_audit_planning_contract.py new file mode 100644 index 00000000..bfea3a08 --- /dev/null +++ b/.agents/skills/repo-policy-selector/tests/test_audit_planning_contract.py @@ -0,0 +1,320 @@ +import importlib.util +import json +import tempfile +import textwrap +import unittest +from pathlib import Path + + +AUDIT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "audit_planning_contract.py" + + +def load_audit_module(): + spec = importlib.util.spec_from_file_location("audit_planning_contract", AUDIT_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +class GoalExecutionContractAuditTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.audit = load_audit_module() + + def make_repo(self, policy_text: str | None = None) -> Path: + temp_dir = tempfile.TemporaryDirectory() + self.addCleanup(temp_dir.cleanup) + repo_root = Path(temp_dir.name) + if policy_text is not None: + policy_dir = repo_root / "docs" / "dev" / "policies" + policy_dir.mkdir(parents=True) + (policy_dir / "0001-goal-execution-governance.md").write_text( + textwrap.dedent(policy_text).strip() + "\n", + encoding="utf-8", + ) + return repo_root + + def test_goal_contract_is_not_required_when_policy_is_absent(self): + report = self.audit.audit_goal_execution_contract(self.make_repo()) + + self.assertFalse(report["applicable"]) + self.assertTrue(report["ok"]) + + def test_goal_contract_requires_concrete_bounds_and_checkpoint_fields(self): + report = self.audit.audit_goal_execution_contract( + self.make_repo(""" + # Policy | Goal Execution Governance + + Keep long goals bounded. + """) + ) + + self.assertTrue(report["applicable"]) + self.assertFalse(report["ok"]) + self.assertTrue(any("max_work_unit_attempts" in problem for problem in report["problems"])) + self.assertTrue(any("acceptance_state" in problem for problem in report["problems"])) + + def test_goal_contract_accepts_complete_local_bounds(self): + report = self.audit.audit_goal_execution_contract( + self.make_repo(""" + # Policy | Goal Execution Governance + + ## Local Goal Bounds + + max_work_unit_attempts: 2 + max_review_rework_cycles: 1 + max_hardening_checkpoints: 2 + checkpoint_interval: 3 slices or 60 minutes + authorization_gate: material_departure_or_explicit_action_gate_only + continuation_default: execute_obvious_in_scope_low_risk + bound_exhaustion_mode: local_replan_before_escalation + max_review_discovery_passes: 1 + review_verification_mode: closed_world_if_reviewed + checkpoint_mode: material_boundary_with_cadence_backstop + checkpoint_record_fields: state_transition, acceptance_state, progress_classification, evidence, material_blockers, next_action_or_stop_reason + """) + ) + + self.assertTrue(report["applicable"]) + self.assertTrue(report["ok"], report["problems"]) + + def test_goal_contract_requires_autonomous_continuation_and_action_scoped_gates(self): + report = self.audit.audit_goal_execution_contract( + self.make_repo(""" + # Policy | Goal Execution Governance + + ## Local Goal Bounds + + max_work_unit_attempts: 2 + max_review_rework_cycles: 1 + max_hardening_checkpoints: 2 + checkpoint_interval: 3 slices or 60 minutes + checkpoint_record_fields: state_transition, acceptance_state, progress_classification, evidence, material_blockers, next_action_or_stop_reason + """) + ) + + self.assertFalse(report["ok"]) + self.assertTrue(any("authorization_gate" in problem for problem in report["problems"])) + self.assertTrue(any("continuation_default" in problem for problem in report["problems"])) + self.assertTrue(any("bound_exhaustion_mode" in problem for problem in report["problems"])) + self.assertTrue(any("review_verification_mode" in problem for problem in report["problems"])) + + def test_goal_contract_rejects_legacy_approval_ceremony_controls(self): + report = self.audit.audit_goal_execution_contract( + self.make_repo(""" + # Policy | Goal Execution Governance + + ## Local Goal Bounds + + max_work_unit_attempts: 2 + max_review_rework_cycles: 1 + max_hardening_checkpoints: 2 + checkpoint_interval: 1 slices + authorization_gate: significant_departure_only + retry_budget_mode: renewable_execution_window + review_discovery_passes: 1 + review_verification_mode: closed_world + checkpoint_record_fields: plan_version, state_transition, progress_classification, evidence, subagent_status, authority_classification, review_disposition_summary, next_action_or_stop_reason + """) + ) + + self.assertFalse(report["ok"]) + self.assertTrue(any("continuation_default" in problem for problem in report["problems"])) + self.assertTrue(any("max_review_discovery_passes" in problem for problem in report["problems"])) + self.assertTrue(any("acceptance_state" in problem for problem in report["problems"])) + + +class PlanningContractAuditTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.audit = load_audit_module() + + def make_repo(self, policies: tuple[str, ...] = (), *, wired: bool = True) -> Path: + temp_dir = tempfile.TemporaryDirectory() + self.addCleanup(temp_dir.cleanup) + root = Path(temp_dir.name) + policy_dir = root / "docs/dev/policies" + policy_dir.mkdir(parents=True) + for index, policy in enumerate(policies, start=1): + (policy_dir / f"{index:04d}-{policy}.md").write_text("policy\n", encoding="utf-8") + if policies and wired: + (root / "AGENTS.md").write_text( + "Read and follow the files under docs/dev/policies.\n", + encoding="utf-8", + ) + return root + + def test_is_not_applicable_without_adopted_planning_policy(self): + root = self.make_repo() + report = self.audit.audit_repo(root) + + self.assertFalse(report["applicable"]) + self.assertTrue(report["ok"]) + self.assertEqual(report["problems"], []) + + def test_normal_audit_still_enforces_adopted_goal_contract(self): + root = self.make_repo() + (root / "docs/dev/policies/0001-goal-execution-governance.md").write_text( + "# Goal policy without local bounds\n", + encoding="utf-8", + ) + + report = self.audit.audit_repo(root) + + self.assertFalse(report["applicable"]) + self.assertFalse(report["ok"]) + self.assertTrue(any("max_work_unit_attempts" in item for item in report["problems"])) + + def test_unwired_planning_policy_is_available_but_not_adopted(self): + root = self.make_repo(("planning-discipline",), wired=False) + + report = self.audit.audit_repo(root) + + self.assertTrue(report["available_contracts"]["planning_discipline"]) + self.assertFalse(report["adopted_contracts"]["planning_discipline"]) + self.assertFalse(report["applicable"]) + self.assertTrue(report["ok"]) + + def test_planning_only_does_not_require_roadmap_runbook_or_lane(self): + root = self.make_repo(("planning-discipline",)) + plans = root / "custom/plans" + plans.mkdir(parents=True) + (plans / "0001-2026-07-20-work.md").write_text( + "State: CLOSED\n", + encoding="utf-8", + ) + + report = self.audit.audit_repo(root, plans_dir_path="custom/plans") + + self.assertTrue(report["applicable"]) + self.assertTrue(report["ok"], report["problems"]) + + def test_roadmap_contract_requires_wiring_but_allows_non_turn_sections(self): + root = self.make_repo(("planning-discipline", "roadmap-runbook-governance")) + plans = root / "docs/dev/plans" + plans.mkdir(parents=True) + plan_name = "0001-2026-07-20-work.md" + (plans / plan_name).write_text("State: OPEN\nLane: P01\n## Current State\nReady.\n", encoding="utf-8") + (root / "ROADMAP.md").write_text( + f"# Roadmap\n\n## Introduction\nText.\n\n## P01 | Work\nState: OPEN\nCurrent State: Ready\n{plan_name}\n", + encoding="utf-8", + ) + (root / "RUNBOOK.md").write_text( + f"# Runbook\n\n## Operating rules\nText.\n\n## Turn 1 | 2026-07-20\n{plan_name}\n", + encoding="utf-8", + ) + + report = self.audit.audit_repo(root) + + self.assertTrue(report["ok"], report["problems"]) + + def test_active_only_excludes_closed_and_unclassified_legacy_plans(self): + root = self.make_repo(("planning-discipline",)) + plans = root / "docs/dev/plans" + plans.mkdir(parents=True) + (plans / "0001-2026-07-20-closed.md").write_text("State: CLOSED\n", encoding="utf-8") + (plans / "legacy.md").write_text("No state.\n", encoding="utf-8") + (plans / "0002-2026-07-20-open.md").write_text( + "State: OPEN\n## Current State\nReady.\n", + encoding="utf-8", + ) + + report = self.audit.audit_repo(root, active_only=True) + + self.assertTrue(report["ok"], report["problems"]) + self.assertEqual([item["file"] for item in report["plans"]], ["0002-2026-07-20-open.md"]) + self.assertEqual(report["excluded_closed_plans"], ["0001-2026-07-20-closed.md"]) + self.assertEqual(report["excluded_unclassified_plans"], ["legacy.md"]) + + def test_active_only_allows_planning_repo_without_a_plans_directory(self): + root = self.make_repo(("planning-discipline",)) + + report = self.audit.audit_repo(root, active_only=True) + + self.assertTrue(report["ok"], report["problems"]) + self.assertEqual(report["problems"], []) + + def test_active_only_accepts_only_exact_repo_baseline_findings(self): + root = self.make_repo(("planning-discipline", "roadmap-runbook-governance")) + (root / "docs/dev/plans").mkdir(parents=True) + baseline = root / "docs/dev/planning-audit-baseline.json" + baseline.write_text( + json.dumps( + { + "schema_version": 1, + "rationale": "Canonical planning authorities are intentionally deferred.", + "review_condition": "Re-evaluate when a roadmap is introduced.", + "accepted_findings": ["missing ROADMAP.md"], + } + ), + encoding="utf-8", + ) + + report = self.audit.audit_repo(root, active_only=True) + + self.assertFalse(report["ok"]) + self.assertEqual(report["problems"], ["missing RUNBOOK.md"]) + self.assertEqual(report["accepted_baseline_findings"], ["missing ROADMAP.md"]) + self.assertEqual(report["unused_baseline_findings"], []) + + def test_full_audit_does_not_accept_active_scope_baseline(self): + root = self.make_repo(("planning-discipline", "roadmap-runbook-governance")) + (root / "docs/dev/plans").mkdir(parents=True) + (root / "docs/dev/planning-audit-baseline.json").write_text( + json.dumps( + { + "schema_version": 1, + "rationale": "Canonical planning authorities are intentionally deferred.", + "review_condition": "Re-evaluate when a roadmap is introduced.", + "accepted_findings": ["missing ROADMAP.md"], + } + ), + encoding="utf-8", + ) + + report = self.audit.audit_repo(root) + + self.assertFalse(report["ok"]) + self.assertIn("missing ROADMAP.md", report["problems"]) + self.assertEqual(report["accepted_baseline_findings"], []) + + def test_active_only_rejects_invalid_baseline_schema_without_crashing(self): + root = self.make_repo(("planning-discipline", "roadmap-runbook-governance")) + (root / "docs/dev/plans").mkdir(parents=True) + (root / "docs/dev/planning-audit-baseline.json").write_text( + json.dumps( + { + "schema_version": 2, + "accepted_findings": ["missing ROADMAP.md", "missing RUNBOOK.md"], + } + ), + encoding="utf-8", + ) + + report = self.audit.audit_repo(root, active_only=True) + + self.assertFalse(report["ok"]) + self.assertIn("invalid planning audit baseline: schema_version must be 1", report["problems"]) + self.assertEqual(report["accepted_baseline_findings"], []) + + def test_force_preserves_strict_pre_adoption_authority_checks(self): + root = self.make_repo() + + report = self.audit.audit_repo(root, force=True) + + self.assertFalse(report["ok"]) + self.assertIn("missing ROADMAP.md", report["problems"]) + self.assertIn("missing RUNBOOK.md", report["problems"]) + self.assertTrue(any("missing plans directory" in item for item in report["problems"])) + + def test_active_only_still_rejects_malformed_turn_headings(self): + root = self.make_repo(("planning-discipline", "roadmap-runbook-governance")) + (root / "docs/dev/plans").mkdir(parents=True) + (root / "ROADMAP.md").write_text("# Roadmap\n", encoding="utf-8") + (root / "RUNBOOK.md").write_text("# Runbook\n\n## Turn latest\n", encoding="utf-8") + + report = self.audit.audit_repo(root, active_only=True) + + self.assertFalse(report["ok"]) + self.assertTrue(any("RUNBOOK.md has headings" in item for item in report["problems"])) diff --git a/.agents/skills/repo-policy-selector/tests/test_select_policy.py b/.agents/skills/repo-policy-selector/tests/test_select_policy.py new file mode 100644 index 00000000..9ff6835a --- /dev/null +++ b/.agents/skills/repo-policy-selector/tests/test_select_policy.py @@ -0,0 +1,650 @@ +import importlib.util +import tempfile +import textwrap +import unittest +from pathlib import Path + + +SELECT_POLICY_PATH = Path(__file__).resolve().parents[1] / "scripts" / "select_policy.py" + + +def load_select_policy_module(): + spec = importlib.util.spec_from_file_location("select_policy", SELECT_POLICY_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +class SelectPolicyRegressionTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.select_policy = load_select_policy_module() + cls.policy_root = Path(__file__).resolve().parents[2] / "repo-policy-selector" / "policy-library" + + def make_repo(self, agents_text: str = "", readme_text: str = "") -> Path: + temp_dir = tempfile.TemporaryDirectory() + self.addCleanup(temp_dir.cleanup) + repo_root = Path(temp_dir.name) + if agents_text: + (repo_root / "AGENTS.md").write_text(textwrap.dedent(agents_text).strip() + "\n", encoding="utf-8") + if readme_text: + (repo_root / "README.md").write_text(textwrap.dedent(readme_text).strip() + "\n", encoding="utf-8") + (repo_root / "pyproject.toml").write_text("[project]\nname = 'fixture'\nversion = '0.0.0'\n", encoding="utf-8") + return repo_root + + def test_agent_skill_repo_is_not_misclassified_as_course_workspace(self): + repo_root = self.make_repo( + agents_text=""" + # Agent Browser + + This repo is a browser automation tool for agents. + + ## Project Structure + - scripts/ + - tests/ + - docs/dev/ + """, + readme_text=""" + Browser automation CLI for agent workflows. + Use it to inspect websites, forms, and rendered pages. + """, + ) + + signals = self.select_policy.detect_signals(repo_root) + + self.assertFalse(signals["has_lms_config"]) + self.assertFalse(signals["mentions_lms_course"]) + self.assertFalse(signals["mentions_course_workspace"]) + self.assertFalse(signals["mentions_student_assessment_data"]) + + purpose, _subtype, reasons = self.select_policy.classify_purpose(signals) + self.assertNotEqual(purpose, "course-workspace", reasons) + + installed_library = self.select_policy.enumerate_policy_library(self.policy_root) + purpose, _subtype, _execution_bias, profile, _modules, reasons = self.select_policy.choose_profile( + signals, installed_library + ) + self.assertNotEqual(purpose, "course-workspace", reasons) + self.assertNotEqual(profile, "course-workspace", reasons) + + def test_website_repo_uses_general_purpose_and_maintenance_profile(self): + repo_root = self.make_repo( + readme_text=""" + # Public Website + + This repo owns the canonical public site and its MySQL-backed CMS. + Reconcile live drift before deploy, preserve a tested backup and + restore path, and run visual browser review after staging changes. + """, + ) + + signals = self.select_policy.detect_signals(repo_root) + installed_library = self.select_policy.enumerate_policy_library(self.policy_root) + self.assertEqual( + "website", + installed_library["parsed_profiles"]["website-maintenance"]["repo_purpose"], + ) + purpose, _subtype, _execution_bias, profile, modules, reasons = self.select_policy.choose_profile( + signals, installed_library + ) + + self.assertTrue(signals["mentions_website_surface"]) + self.assertEqual("website", purpose, reasons) + self.assertEqual("website-maintenance", profile, reasons) + self.assertIn("website-surface-targeting", modules) + self.assertIn("live-drift-reconciliation", modules) + self.assertIn("backup-and-recovery-operations", modules) + self.assertIn("visual-release-qa", modules) + + def test_website_purpose_renders_live_surface_wirein_guidance(self): + repo_root = self.make_repo() + + agents_draft = self.select_policy.render_agents_wirein( + repo_root, + install_plan=[], + existing_policy_surfaces=[], + repo_purpose="website", + ) + + self.assertIn( + "Describe the live surfaces, environments, and recovery-critical deploy constraints", + agents_draft, + ) + self.assertIn( + "re-read runtime or environment-boundary policy before touching live state", + agents_draft, + ) + + def test_graphiti_memory_policy_maps_to_shared_graph_memory_module(self): + repo_root = self.make_repo() + policy_dir = repo_root / "docs" / "dev" / "policies" + policy_dir.mkdir(parents=True, exist_ok=True) + (policy_dir / "0014-graphiti-memory-usage.md").write_text( + textwrap.dedent( + """ + # Policy | Graphiti Memory Usage + + ## Policy + + - Treat Graphiti as durable retrievable context, not as a scratchpad for every turn. + - Before re-asking the user for likely durable context, prefer a bounded memory read with `search_memory_facts` or `search_nodes`. + - Avoid memory spam and near-duplicate writes through `add_memory`. + - Treat destructive maintenance tools and `group_id` partitioning as explicit operations. + """ + ).strip() + + "\n", + encoding="utf-8", + ) + + installed_library = self.select_policy.enumerate_policy_library(self.policy_root) + surfaces = self.select_policy.extract_existing_policy_surfaces(repo_root) + semantic_matches = self.select_policy.semantic_module_matches(surfaces, installed_library) + + self.assertIn("graph-backed-memory-usage", semantic_matches) + self.assertEqual( + semantic_matches["graph-backed-memory-usage"], + [str(policy_dir / "0014-graphiti-memory-usage.md")], + ) + self.assertNotIn("notes-and-memories", semantic_matches) + + def test_graph_backed_memory_is_in_every_starter_profile(self): + installed_library = self.select_policy.enumerate_policy_library(self.policy_root) + for profile_id in installed_library["profile_ids"]: + modules = self.select_policy.base_modules_for_profile(profile_id, installed_library) + self.assertIn("graph-backed-memory-usage", modules, profile_id) + + def test_graph_memory_module_defines_use_skip_unavailable_decision(self): + module_text = (self.policy_root / "modules" / "graph-backed-memory-usage.md").read_text( + encoding="utf-8" + ) + for required in ("`use`", "`skip`", "`unavailable`", "graphiti-discovery", "one or two focused"): + self.assertIn(required, module_text) + + def test_memory_discovery_assessment_uses_repo_signals(self): + assessment = self.select_policy.memory_discovery_assessment( + {"has_repo_memory_discovery_workflow": True}, + ["planning-discipline", "graph-backed-memory-usage"], + ) + + self.assertTrue(assessment["policy_selected"]) + self.assertTrue(assessment["repo_graph_memory_signals"]) + self.assertEqual(assessment["repo_default"], "use") + self.assertEqual(assessment["task_decisions"], ["use", "skip", "unavailable"]) + + def test_memory_discovery_assessment_is_task_conditional_without_repo_signals(self): + assessment = self.select_policy.memory_discovery_assessment( + {"has_repo_memory_discovery_workflow": False}, + ["planning-discipline", "graph-backed-memory-usage"], + ) + + self.assertTrue(assessment["policy_selected"]) + self.assertFalse(assessment["repo_graph_memory_signals"]) + self.assertEqual(assessment["repo_default"], "task-conditional") + + def test_generic_adopted_module_does_not_invent_repo_graphiti_workflow(self): + repo_root = self.make_repo( + agents_text="# Fixture\n\n## Policy Entry\n\nRead shared policy files." + ) + policy_dir = repo_root / "docs" / "dev" / "policies" + policy_dir.mkdir(parents=True, exist_ok=True) + (policy_dir / "0001-graph-backed-memory-usage.md").write_text( + (self.policy_root / "modules" / "graph-backed-memory-usage.md").read_text( + encoding="utf-8" + ), + encoding="utf-8", + ) + + signals = self.select_policy.detect_signals(repo_root) + assessment = self.select_policy.memory_discovery_assessment( + signals, + ["planning-discipline", "graph-backed-memory-usage"], + ) + + self.assertTrue(signals["mentions_graph_backed_memory"]) + self.assertFalse(signals["has_repo_memory_discovery_workflow"]) + self.assertEqual(assessment["repo_default"], "task-conditional") + + def test_planning_discipline_is_in_every_starter_profile(self): + installed_library = self.select_policy.enumerate_policy_library(self.policy_root) + for profile_id in installed_library["profile_ids"]: + modules = self.select_policy.base_modules_for_profile(profile_id, installed_library) + self.assertIn("planning-discipline", modules, profile_id) + + def test_code_testing_discipline_is_in_software_oriented_profiles(self): + installed_library = self.select_policy.enumerate_policy_library(self.policy_root) + for profile_id in ( + "repo-product-engineering", + "standalone-library", + "skill-repo-maintainer", + "operations-platform", + "website-maintenance", + "seminal-workspace", + ): + modules = self.select_policy.base_modules_for_profile(profile_id, installed_library) + self.assertIn("code-testing-discipline", modules, profile_id) + + for profile_id in ("course-workspace", "writing-project"): + modules = self.select_policy.base_modules_for_profile(profile_id, installed_library) + self.assertNotIn("code-testing-discipline", modules, profile_id) + + def test_code_testing_discipline_keeps_budget_and_retry_contracts(self): + module_text = (self.policy_root / "modules" / "code-testing-discipline.md").read_text(encoding="utf-8") + for required in ( + "cheapest layer that can prove it reliably", + "Unknown impact", + "periodic comprehensive run", + "Preserve the first failure", + "pass-on-retry as flaky", + "If no such seam exists", + "architecture or testability gap", + "retained-risk mapping", + "presubmit_blocking_budget", + "presubmit_compute_budget", + "flaky_test_disposition_sla", + ): + self.assertIn(required, module_text) + + def test_complete_policy_coverage_reports_already_aligned(self): + repo_root = self.make_repo() + policy_dir = repo_root / "docs" / "dev" / "policies" + policy_dir.mkdir(parents=True, exist_ok=True) + installed_library = self.select_policy.enumerate_policy_library(self.policy_root) + modules = self.select_policy.base_modules_for_profile("standalone-library", installed_library) + for index, module_id in enumerate(modules, start=1): + (policy_dir / f"{index:04d}-{module_id}.md").write_text( + f"# Policy | {module_id}\n\n## Policy\n\n- Adopted.\n", + encoding="utf-8", + ) + + surfaces = self.select_policy.extract_existing_policy_surfaces(repo_root) + coverage = self.select_policy.policy_adoption_coverage(surfaces, modules, installed_library) + + self.assertEqual(coverage["readiness"], "fully-installed") + self.assertEqual(coverage["missing_recommended_modules"], []) + self.assertEqual(self.select_policy.recommendation_mode(coverage), "already-aligned") + + def test_duplicate_policy_identity_is_reported_and_blocks_writes(self): + repo_root = self.make_repo() + policy_dir = repo_root / "docs" / "dev" / "policies" + policy_dir.mkdir(parents=True, exist_ok=True) + first = policy_dir / "0001-planning-discipline.md" + second = policy_dir / "0008-planning-discipline.md" + first.write_text("# Policy | Planning Discipline\n\n- Old.\n", encoding="utf-8") + second.write_text("# Policy | Planning Discipline\n\n- Current.\n", encoding="utf-8") + + installed_library = self.select_policy.enumerate_policy_library(self.policy_root) + modules = self.select_policy.base_modules_for_profile("standalone-library", installed_library) + surfaces = self.select_policy.extract_existing_policy_surfaces(repo_root) + coverage = self.select_policy.policy_adoption_coverage(surfaces, modules, installed_library) + + self.assertEqual(coverage["readiness"], "conflicted-local-policy") + self.assertEqual( + coverage["duplicate_policy_ids"], + {"planning-discipline": [str(first), str(second)]}, + ) + self.assertEqual( + self.select_policy.recommendation_mode(coverage), + "identity-reconciliation-required", + ) + with self.assertRaisesRegex(ValueError, "duplicate adopted policy identity planning-discipline"): + self.select_policy.require_unique_policy_identities(coverage) + + def test_harvest_policy_does_not_semantically_adopt_feedback_or_preview_policy(self): + repo_root = self.make_repo() + policy_dir = repo_root / "docs" / "dev" / "policies" + policy_dir.mkdir(parents=True, exist_ok=True) + (policy_dir / "0002-policy-harvest-loop.md").write_text( + textwrap.dedent( + """ + # Policy | Harvest Loop + + ## Policy + + - Assess available, adopted, and evidenced policy separately. + - Review generated artifacts and feedback before changing shared policy. + - Record reusable findings from sibling-repo surveys. + """ + ).strip() + + "\n", + encoding="utf-8", + ) + + installed_library = self.select_policy.enumerate_policy_library(self.policy_root) + surfaces = self.select_policy.extract_existing_policy_surfaces(repo_root) + semantic_matches = self.select_policy.semantic_module_matches(surfaces, installed_library) + + self.assertEqual( + semantic_matches["policy-harvest-loop"], + [str(policy_dir / "0002-policy-harvest-loop.md")], + ) + self.assertNotIn("policy-adoption-feedback-loop", semantic_matches) + self.assertNotIn("preview-artifact-review", semantic_matches) + + def test_memory_consumer_language_does_not_imply_runtime_operations(self): + repo_root = self.make_repo( + agents_text=""" + # Graph Memory Consumer + + Use graphiti-discovery for durable memory reads. Treat memory as + advisory and verify it against repo evidence. + """, + ) + + signals = self.select_policy.detect_signals(repo_root) + + self.assertTrue(signals["mentions_graph_backed_memory"]) + self.assertTrue(signals["has_repo_memory_discovery_workflow"]) + self.assertFalse(signals["mentions_memory_service_runtime"]) + + def test_goal_language_recommends_goal_and_subagent_governance(self): + repo_root = self.make_repo( + agents_text=""" + # Long-Running Agent Repo + + Use /goal for long-running goal execution across several bounded + checkpoints. Stop on repeated hardening without outcome progress. + """, + ) + + signals = self.select_policy.detect_signals(repo_root) + installed_library = self.select_policy.enumerate_policy_library(self.policy_root) + _purpose, _subtype, _execution_bias, _profile, modules, reasons = self.select_policy.choose_profile( + signals, installed_library + ) + + self.assertTrue(signals["mentions_goal_execution"]) + self.assertIn("goal-execution-governance", modules, reasons) + self.assertIn("subagent-workflow-optimization", modules, reasons) + self.assertIn("parallel-plan-design", modules, reasons) + self.assertIn("validation-and-handoff", modules, reasons) + + def test_ordinary_product_goal_language_does_not_signal_long_goal_execution(self): + repo_root = self.make_repo( + readme_text=""" + Our product goal is a fast and reliable command-line interface. + The long-running goal execution strategy belongs to the business + roadmap and should stay goal-compatible with customer priorities. + Browser user agent strings are preserved for interoperability. + """, + ) + + signals = self.select_policy.detect_signals(repo_root) + + self.assertFalse(signals["mentions_goal_execution"]) + + def test_multi_track_worktree_language_selects_active_lane_coordination(self): + repo_root = self.make_repo( + agents_text=""" + # Concurrent Engineering Work + + Multiple projects use concurrent worktrees and off-main plans. + Check the active lane branch registry before opening another lane. + """, + ) + + signals = self.select_policy.detect_signals(repo_root) + installed_library = self.select_policy.enumerate_policy_library(self.policy_root) + _purpose, _subtype, _execution_bias, _profile, modules, reasons = self.select_policy.choose_profile( + signals, installed_library + ) + + self.assertTrue(signals["mentions_active_lane_coordination"]) + self.assertIn("active-lane-coordination", modules, reasons) + + def test_lightweight_writing_repo_does_not_gain_active_lane_coordination(self): + repo_root = self.make_repo( + readme_text=""" + # Grant Proposal + + This repository contains the authoritative proposal narrative and + supporting submission documents. + """, + ) + + signals = self.select_policy.detect_signals(repo_root) + installed_library = self.select_policy.enumerate_policy_library(self.policy_root) + purpose, _subtype, _execution_bias, profile, modules, _reasons = self.select_policy.choose_profile( + signals, installed_library + ) + + self.assertEqual((purpose, profile), ("writing-project", "writing-project")) + self.assertFalse(signals["mentions_active_lane_coordination"]) + self.assertNotIn("active-lane-coordination", modules) + + def test_general_git_policy_signal_selects_complete_git_discipline(self): + repo_root = self.make_repo( + readme_text="# Grant Proposal\n\nAuthoritative proposal deliverables.\n" + ) + policy_dir = repo_root / "docs" / "dev" / "policies" + policy_dir.mkdir(parents=True, exist_ok=True) + (policy_dir / "0001-git-policy.md").write_text( + "# Git Policy\n\nUse a branch and worktree for revisions.\n", + encoding="utf-8", + ) + + signals = self.select_policy.detect_signals(repo_root) + installed_library = self.select_policy.enumerate_policy_library(self.policy_root) + purpose, _subtype, _execution_bias, profile, modules, reasons = self.select_policy.choose_profile( + signals, installed_library + ) + + self.assertEqual((purpose, profile), ("writing-project", "writing-project")) + self.assertTrue(signals["mentions_git_policy"]) + for module_id in ( + "git-worktree-hygiene", + "commit-history-discipline", + "branch-and-integration-strategy", + "commit-and-push-cadence", + ): + self.assertIn(module_id, modules, reasons) + + def test_long_horizon_profiles_include_goal_execution_governance(self): + installed_library = self.select_policy.enumerate_policy_library(self.policy_root) + for profile_id in ["repo-product-engineering", "operations-platform", "skill-repo-maintainer"]: + modules = self.select_policy.base_modules_for_profile(profile_id, installed_library) + self.assertIn("goal-execution-governance", modules, profile_id) + + def test_active_lane_coordination_is_limited_to_multi_track_profiles(self): + installed_library = self.select_policy.enumerate_policy_library(self.policy_root) + for profile_id in ["repo-product-engineering", "operations-platform"]: + modules = self.select_policy.base_modules_for_profile(profile_id, installed_library) + self.assertIn("active-lane-coordination", modules, profile_id) + for profile_id in ["writing-project", "standalone-library"]: + modules = self.select_policy.base_modules_for_profile(profile_id, installed_library) + self.assertNotIn("active-lane-coordination", modules, profile_id) + + def test_codegraph_policy_maps_to_shared_codegraph_module(self): + repo_root = self.make_repo( + agents_text=""" + # Codegraph-Aware Repo + + Use ../codegraph before non-trivial code edits, architecture tracing, + callers/callees inspection, refactor planning, or impact analysis. + Treat codegraph output as discovery evidence and still verify with source reads + and targeted tests. + """, + ) + policy_dir = repo_root / "docs" / "dev" / "policies" + policy_dir.mkdir(parents=True, exist_ok=True) + (policy_dir / "0017-codegraph-usage.md").write_text( + textwrap.dedent( + """ + # Policy | Codegraph Usage + + ## Policy + + - Consult codegraph before code edits, architecture trace work, callers/callees inspection, refactor planning, or impact analysis. + - Treat codegraph as discovery evidence; verify with source reads and targeted tests. + """ + ).strip() + + "\n", + encoding="utf-8", + ) + + signals = self.select_policy.detect_signals(repo_root) + installed_library = self.select_policy.enumerate_policy_library(self.policy_root) + surfaces = self.select_policy.extract_existing_policy_surfaces(repo_root) + semantic_matches = self.select_policy.semantic_module_matches(surfaces, installed_library) + _purpose, _subtype, _execution_bias, _profile, modules, reasons = self.select_policy.choose_profile( + signals, installed_library + ) + + self.assertTrue(signals["mentions_codegraph_usage"]) + self.assertIn("codegraph-usage", semantic_matches) + self.assertEqual( + semantic_matches["codegraph-usage"], + [str(policy_dir / "0017-codegraph-usage.md")], + ) + self.assertIn("codegraph-usage", modules, reasons) + + def test_codegraph_module_proactively_repairs_expected_local_indexes(self): + installed_library = self.select_policy.enumerate_policy_library(self.policy_root) + body = installed_library["parsed_modules"]["codegraph-usage"]["body"] + + self.assertIn("missing index in a verified local worktree", body) + self.assertIn("Do not require a fresh approval solely because the worktree is new", body) + self.assertIn("run the documented explicit sync once", body) + self.assertIn("unexpected tracked-file changes", body) + + def test_graphiti_runtime_policy_maps_to_memory_service_runtime_module(self): + repo_root = self.make_repo( + agents_text=""" + # Graphiti Runtime + + This repo operates a Graphiti MCP server as an installed memory service runtime. + Agents must verify the installed release manifest, health endpoint, memory queue, + dead-letter state, provider boundary, and read-after-write smoke before claiming + install or restart work is complete. + """, + ) + policy_dir = repo_root / "docs" / "dev" / "policies" + policy_dir.mkdir(parents=True, exist_ok=True) + (policy_dir / "0015-graphiti-runtime.md").write_text( + textwrap.dedent( + """ + # Policy | Graphiti Runtime Operations + + ## Policy + + - Treat Graphiti as an installed memory-service runtime, not just a client tool. + - Verify the installed release manifest, service manager state, runtime home, health endpoint, and bound listener before diagnosis. + - Keep memory queue status durable and expose dead-letter list, requeue, and drop operations. + - Run a read-after-write smoke after install, restart, provider, or backend changes. + """ + ).strip() + + "\n", + encoding="utf-8", + ) + + installed_library = self.select_policy.enumerate_policy_library(self.policy_root) + surfaces = self.select_policy.extract_existing_policy_surfaces(repo_root) + semantic_matches = self.select_policy.semantic_module_matches(surfaces, installed_library) + signals = self.select_policy.detect_signals(repo_root) + _purpose, _subtype, _execution_bias, _profile, modules, reasons = self.select_policy.choose_profile( + signals, installed_library + ) + + self.assertIn("memory-service-runtime-governance", semantic_matches) + self.assertEqual( + semantic_matches["memory-service-runtime-governance"], + [str(policy_dir / "0015-graphiti-runtime.md")], + ) + self.assertIn("memory-service-runtime-governance", modules, reasons) + + def test_memory_discovery_policy_maps_to_graph_memory_module(self): + repo_root = self.make_repo( + agents_text=""" + # Policy Repo + + Use the graphiti-discovery skill before non-trivial harvest work. + Query the repo group agent_policies_main first. If the right memory group + is unclear, use the memory atlas and verify any memory-derived claim against + repo files before changing policy. + """, + ) + + signals = self.select_policy.detect_signals(repo_root) + installed_library = self.select_policy.enumerate_policy_library(self.policy_root) + _purpose, _subtype, _execution_bias, _profile, modules, reasons = self.select_policy.choose_profile( + signals, installed_library + ) + + self.assertTrue(signals["mentions_graph_backed_memory"]) + self.assertIn("graph-backed-memory-usage", modules, reasons) + + def test_preview_artifact_policy_maps_to_shared_preview_module(self): + repo_root = self.make_repo( + agents_text=""" + # Artifact Review Repo + + Use the $previews skill when generated artifacts require human review. + Publish report packets, rendered documents, local HTML builds, PDFs, + Office documents, screenshots, and galleries as one preview session URL. + Stop for approval feedback before release, upload, or mutation. + """, + ) + policy_dir = repo_root / "docs" / "dev" / "policies" + policy_dir.mkdir(parents=True, exist_ok=True) + (policy_dir / "0016-preview-artifact-review.md").write_text( + textwrap.dedent( + """ + # Policy | Preview Artifact Review + + ## Policy + + - Use the Previews service for browser review when generated artifacts require human approval. + - Group review packets, PDFs, Office documents, screenshots, galleries, and local HTML builds into one preview session URL. + - Stop and read approval feedback before performing the gated mutation. + """ + ).strip() + + "\n", + encoding="utf-8", + ) + + signals = self.select_policy.detect_signals(repo_root) + installed_library = self.select_policy.enumerate_policy_library(self.policy_root) + surfaces = self.select_policy.extract_existing_policy_surfaces(repo_root) + semantic_matches = self.select_policy.semantic_module_matches(surfaces, installed_library) + _purpose, _subtype, _execution_bias, _profile, modules, reasons = self.select_policy.choose_profile( + signals, installed_library + ) + + self.assertTrue(signals["mentions_preview_artifact_review"]) + self.assertIn("preview-artifact-review", semantic_matches) + self.assertEqual( + semantic_matches["preview-artifact-review"], + [str(policy_dir / "0016-preview-artifact-review.md")], + ) + self.assertIn("preview-artifact-review", modules, reasons) + + def test_subagent_runtime_signal_is_specific_to_runtime_lifecycle(self): + workflow_repo = self.make_repo( + agents_text=""" + # Workflow Repo + + This repo may use subagents for bounded delegated verification work. + Keep write scopes disjoint and reconcile results in the main agent. + """, + ) + runtime_repo = self.make_repo( + agents_text=""" + # Runtime Repo + + This repo operates a subagent runtime. + Track subagent run id, subagent session id, transcript path, announce payload, + max spawn depth, max children per agent, concurrency cap, cascade stop, and subagent cleanup. + """, + ) + + workflow_signals = self.select_policy.detect_signals(workflow_repo) + runtime_signals = self.select_policy.detect_signals(runtime_repo) + + self.assertTrue(workflow_signals["mentions_subagents"]) + self.assertFalse(workflow_signals["mentions_subagent_runtime"]) + self.assertTrue(runtime_signals["mentions_subagents"]) + self.assertTrue(runtime_signals["mentions_subagent_runtime"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 4db2aee1..32e07ecc 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -24,6 +24,6 @@ ], "metadata": { "description": "Marketplace for the dev-browser skill", - "version": "0.2.6" + "version": "0.2.9" } } diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..81b3c00d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,22 @@ +--- +name: Bug report +about: Reproducible fork behavior that differs from expectations +title: '' +labels: '' +assignees: '' +--- +## Problem + +Expected behavior and observed behavior: + +## Reproduction and evidence + +Minimal steps, sanitized logs, source/artifact links: + +## Environment + +Fork commit/version, OS/WSL, browser version and artifact, headed/headless: + +## Acceptance and scope + +Acceptance criteria, non-goals, dependencies, owner, and state (initially triage): diff --git a/.github/ISSUE_TEMPLATE/task.md b/.github/ISSUE_TEMPLATE/task.md new file mode 100644 index 00000000..ba6788af --- /dev/null +++ b/.github/ISSUE_TEMPLATE/task.md @@ -0,0 +1,22 @@ +--- +name: Bounded task +about: Feature, maintenance, upstream sync, or migration work +title: '' +labels: '' +assignees: '' +--- +## Outcome + +Problem and intended user-visible result: + +## Scope + +Included work, non-goals, affected track, upstream relationship: + +## Acceptance criteria + +Observable completion criteria and validation: + +## Coordination + +Owner, dependencies, overlaps, plan/branch/PR links, state (initially triage): diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..4f16b3b3 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## Problem and resulting behavior + +Describe the trigger, before/after behavior, and why this belongs in the fork. + +## Tracking and scope + +- Issue or local plan: +- Base branch / tested head SHA: +- Maintenance, upstream sync, or migration: +- Upstream relationship and retained fork behavior: + +## Validation + +List actual commands/results, failures, exclusions, and any browser/platform checks. + +## Risks and rollout + +Compatibility impact, rollback, and whether an installed runtime or release changes. diff --git a/.github/workflows/policy.yml b/.github/workflows/policy.yml new file mode 100644 index 00000000..4a2e6d8f --- /dev/null +++ b/.github/workflows/policy.yml @@ -0,0 +1,17 @@ +name: Repository Policy +on: + pull_request: + branches: [main] + push: + branches: [main] +permissions: + contents: read +jobs: + policy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python3 scripts/check-repo-policy.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4bd1d66d..b733fe5a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,6 +94,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + # Required for npm trusted publishing (OIDC); also enables provenance. + id-token: write steps: - uses: actions/checkout@v4 @@ -107,6 +109,8 @@ jobs: with: node-version: 22 registry-url: 'https://registry.npmjs.org' + # Trusted publishing requires npm >= 11.5.1; node 22 ships an older npm. + - run: npm install -g npm@latest - run: cd daemon && pnpm install && pnpm run bundle && pnpm run bundle:sandbox-client - run: | mkdir -p dist/bin dist/scripts dist/daemon/dist @@ -118,9 +122,8 @@ jobs: cp package.json dist/ cp README.md dist/ cp LICENSE dist/ 2>/dev/null || true + # No NODE_AUTH_TOKEN: auth comes from the OIDC trusted publisher config. - run: cd dist && npm publish - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - uses: softprops/action-gh-release@v2 with: files: artifacts/**/* diff --git a/AGENTS.md b/AGENTS.md index 51608893..79dada27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# CLAUDE.md +# AGENTS.md This repository ships `dev-browser`: a Rust CLI plus a Node.js daemon for browser automation with a QuickJS sandbox. Use this file as the repo-specific guide when making code changes. @@ -26,3 +26,42 @@ cd daemon && pnpm bundle:sandbox-client ``` `cli/src/daemon.rs` embeds `daemon/dist/daemon.bundle.mjs` and `daemon/dist/sandbox-client.js` via `include_str!`, so `cargo build` only sees the latest daemon changes after those bundles are regenerated. + +## Policy loading contract + +Re-read relevant policies at the start of non-trivial work and when scope changes. +Local adaptations in these policies refine the shared library. The installed +library is source material, not an additional set of active instructions. + +| When | Read | +| --- | --- | +| Installing or wiring policy | [0001-policy-management](docs/dev/policies/0001-policy-management.md) | +| Reviewing shared policy updates | [0002-policy-upgrade-management](docs/dev/policies/0002-policy-upgrade-management.md) | +| Closing adoption or recording policy friction | [0003-policy-adoption-feedback-loop](docs/dev/policies/0003-policy-adoption-feedback-loop.md) | +| Planning, debugging, prior-context lookup, or durable memory writes | [0004-graph-backed-memory-usage](docs/dev/policies/0004-graph-backed-memory-usage.md) | +| Opening, revising, or closing substantive work | [0005-planning-discipline](docs/dev/policies/0005-planning-discipline.md) | +| Structural code discovery, refactoring, or index maintenance | [0006-codegraph-usage](docs/dev/policies/0006-codegraph-usage.md) | +| Choosing tests, retrying failures, or changing test budgets | [0007-code-testing-discipline](docs/dev/policies/0007-code-testing-discipline.md) | +| Creating, moving, or removing worktrees | [0008-git-worktree-hygiene](docs/dev/policies/0008-git-worktree-hygiene.md) | +| Preparing commits or preserving checkpoints | [0009-commit-history-discipline](docs/dev/policies/0009-commit-history-discipline.md) | +| Choosing a branch, integration target, or merge method | [0010-branch-and-integration-strategy](docs/dev/policies/0010-branch-and-integration-strategy.md) | +| Publishing, handing off, or pausing a branch | [0011-commit-and-push-cadence](docs/dev/policies/0011-commit-and-push-cadence.md) | +| Versioning, tagging, or releasing | [0012-versioning-and-release](docs/dev/policies/0012-versioning-and-release.md) | +| Ending a substantive turn | [0013-turn-closeout](docs/dev/policies/0013-turn-closeout.md) | +| Validating, reviewing, or handing off work | [0014-validation-and-handoff](docs/dev/policies/0014-validation-and-handoff.md) | +| Fetching or integrating upstream changes | [0015-upstream-fork-maintenance](docs/dev/policies/0015-upstream-fork-maintenance.md) | +| Managing issues, opening/reviewing/merging PRs | [0016-pull-request-and-issue-management](docs/dev/policies/0016-pull-request-and-issue-management.md) | + +Use `docs/dev/plans/` for bounded plans and `docs/dev/notes/` for dated feedback. +The adoption-time branch inventory is [workstreams](docs/dev/workstreams.md). +Graphiti routing and write authority are defined in policy 0004; the primary +group is `dev_browser_main`. Preserve operator-provided CodeGraph/SysRAG routing +and its missing-index approval rule as detailed in policy 0006. + +Policy-only validation: + +```sh +python3 .agents/skills/repo-policy-selector/scripts/audit_planning_contract.py --repo-root . --json +python3 .agents/skills/repo-policy-selector/scripts/audit_planning_contract.py --repo-root . --active-only --json +python3 .agents/skills/repo-policy-selector/scripts/select_policy.py --repo-root . --policy-root .agents/skills/repo-policy-selector/policy-library --json +``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 99da1264..2c70f24b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ 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/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## [0.2.9] - 2026-07-14 + +- Added configurable per-browser idle cleanup with `--idle-timeout`, `DEV_BROWSER_IDLE_TIMEOUT_MS`, and `~/.dev-browser/config.json` support. Idle cleanup preserves persistent profiles, excludes externally connected Chrome, and safely rechecks activity under the per-browser lock before closing. + +## [0.2.8] - 2026-06-05 + +- Added the `page.cua.*` pixel/vision toolset: coordinate-based `click`, `doubleClick`, `drag`, `move`, `scroll`, `keypress`, and `type`, plus a JPEG `screenshot()` whose pixels map 1:1 onto click coordinates at any DPR. +- Added the `page.domCua.*` DOM-id toolset: `getVisibleDom()` snapshots visible interactive elements as `node_id=N` lines, with `click`, `doubleClick`, `scroll`, `type`, and `keypress` acting against the latest snapshot's ids. +- Fixed script error messages being dropped from CLI output; thrown errors now report their name and message alongside the stack. +- Documented the vision and DOM-id workflows in the `--help` LLM usage guide. +- Capped the daemon's per-connection request buffer so a local client can no longer exhaust daemon memory with an unterminated frame. +- Serialized `browser-stop` with the per-browser lock so a browser can no longer be torn down while another client's script is running. +- Hardened daemon cold start against duplicate daemons when concurrent CLI invocations race to spawn one. +- Defaulted `PW_CHROMIUM_ATTACH_TO_OTHER=1` so attaching over CDP to Chrome 147's built-in remote debugging no longer hangs. + +## [0.2.7] - 2026-04-09 + +- Updated documentation to recommend `domcontentloaded` for dev server navigation. + ## [0.2.6] - 2026-03-30 - Pinned Playwright version. diff --git a/README.md b/README.md index ff10c93e..8bac6dbc 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,10 @@ A browser automation tool that lets AI agents and developers control browsers wi - **Auto-connect** - Connect to your running Chrome or launch a fresh Chromium - **Full Playwright API** - goto, click, fill, locators, evaluate, screenshots, and more +## Demo + +https://github.com/user-attachments/assets/c6cf7fb9-b1dc-46ed-93b9-6e7240990c53 + ## CLI Installation ```bash @@ -96,7 +100,7 @@ Windows npm installs download the native `dev-browser-windows-x64.exe` release a When `dev-browser` runs inside WSL: -- daemon-managed launch mode still uses Playwright's bundled Chromium profile under `~/.dev-browser` +- daemon-managed launch mode uses a persistent profile under `~/.dev-browser`; the browser executable can be configured as described below - `--connect` can auto-discover Chrome or Brave instances started on the Windows side when remote debugging is enabled - if auto-discovery still misses your browser, point directly at the Windows profile root with `--profile-path "/mnt/c/Users//AppData/Local/Google/Chrome/User Data"` @@ -106,9 +110,58 @@ Example: dev-browser --connect --profile-path "/mnt/c/Users//AppData/Local/Google/Chrome/User Data" ``` +### Default browser executable + +To launch a custom Chromium build, such as native Linux `chromium-stealthcdp` +inside WSL, set its absolute executable path in `~/.dev-browser/config.json`: + +```json +{ + "executablePath": "/absolute/path/to/chromium-stealthcdp/chrome-linux/chrome" +} +``` + +This setting applies to both headed and headless daemon-managed browsers. +`dev-browser status` and `dev-browser browsers` report the configured executable +for launched browsers. Existing browser instances keep their executable until +closed; new launches read the current configuration. A missing or invalid custom +executable produces an error. Omit `executablePath` to use Playwright's bundled +Chromium. `--connect` continues to attach to the requested external browser. + ### Using with AI agents -After installing, just tell your agent to run `dev-browser --help` — the help output includes a full LLM usage guide with examples and API reference. No plugin or skill installation needed. +After installing, tell your agent to run `dev-browser --help` — the help output includes the current LLM usage guide and API reference. + +For agents that discover local skills, install or refresh the embedded skill explicitly: + +```bash +dev-browser install-skill --codex # ~/.codex/skills/dev-browser/SKILL.md +dev-browser install-skill --claude # ~/.claude/skills/dev-browser/SKILL.md +dev-browser install-skill --agents # ~/.agents/skills/dev-browser/SKILL.md +``` + +Flags may be combined. With an interactive terminal, `dev-browser install-skill` prompts for targets. In non-interactive environments it updates all three locations, including Codex, so an older copied skill does not survive a CLI upgrade. + +### Idle browser cleanup + +Daemon-launched named Chromium instances can be closed automatically after they have been idle for a configured duration: + +```bash +dev-browser --idle-timeout 5m < script.js +DEV_BROWSER_IDLE_TIMEOUT_MS=300000 dev-browser status +``` + +The flag accepts `30s`, `5m`, `1h`, or raw milliseconds. You can also set a user default in `~/.dev-browser/config.json`: + +```json +{ + "idleTimeout": "5m" +} +``` + +Precedence is `--idle-timeout`, then `DEV_BROWSER_IDLE_TIMEOUT_MS`, then `idleTimeout` in the user config, then disabled. Set any source to `0` to disable cleanup. The effective setting is sent to an already-running daemon and shown by `dev-browser status`. + +Cleanup is applied independently to each named browser. Activity is measured from both the start and completion of each request, so running requests are never reaped. Only Chromium instances launched by dev-browser are eligible; browsers attached with `--connect` are never closed by idle cleanup. Closing an idle browser does not delete its profile directory, cookies, or login state, and the next request relaunches it from the same persistent profile. `dev-browser stop` keeps its existing behavior of stopping the daemon and all managed browser connections.
Allowing dev-browser in Claude Code without permission prompts @@ -159,7 +212,7 @@ You can also allow related commands in the same list:
-Legacy plugin installation (Claude Code / Amp / Codex) +Legacy Claude Code plugin installation ### Claude Code @@ -170,26 +223,6 @@ You can also allow related commands in the same list: Restart Claude Code after installation. -### Amp / Codex - -Copy the skill to your skills directory: - -```bash -# For Amp: ~/.claude/skills | For Codex: ~/.codex/skills -SKILLS_DIR=~/.claude/skills # or ~/.codex/skills - -mkdir -p $SKILLS_DIR -git clone https://github.com/sawyerhood/dev-browser /tmp/dev-browser-skill -cp -r /tmp/dev-browser-skill/skills/dev-browser $SKILLS_DIR/dev-browser -rm -rf /tmp/dev-browser-skill -``` - -If you already have the `dev-browser` CLI installed locally, you can also install the bundled skill directly: - -```bash -dev-browser install-skill --codex -``` -
## Script API @@ -214,6 +247,11 @@ console.log/warn/error/info // Routed to CLI stdout/stderr Pages are full [Playwright Page objects](https://playwright.dev/docs/api/class-page) — `goto`, `click`, `fill`, `locator`, `evaluate`, `screenshot`, and everything else, including `page.snapshotForAI({ track?, depth?, timeout? })`, which returns `{ full, incremental? }` for AI-friendly page snapshots. +Every page also exposes two computer-use toolsets: + +- `page.cua.*` — pixel/vision tier: `screenshot()` saves a JPEG whose pixels map 1:1 onto CSS coordinates at any DPR and returns `{ path, width, height }`; `click`, `doubleClick`, `drag`, `move`, `scroll`, `keypress`, and `type` act at those coordinates. +- `page.domCua.*` — DOM-id tier: `getVisibleDom()` snapshots visible interactive elements as pseudo-HTML lines with `node_id=N`; `click`, `doubleClick`, and `scroll` act by node id (ids are only valid against the latest snapshot of the current document), plus `type` and `keypress` for the focused element. + ## Benchmarks | Method | Time | Cost | Turns | Success | diff --git a/RELEASING.md b/RELEASING.md index 3d0be698..e05f87eb 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,69 +1,110 @@ # Releasing dev-browser -## First Time Setup +## First-Time Setup -### 1. npm authentication -```bash -npm login -``` +npm publishing uses GitHub Actions trusted publishing (OIDC), so the release +workflow does not need an `NPM_TOKEN`. In the npm package settings for +`dev-browser`, configure a trusted publisher with: + +- Organization or user: `SawyerHood` +- Repository: `dev-browser` +- Workflow filename: `release.yml` -### 2. GitHub secrets -Go to **GitHub repo → Settings → Secrets and variables → Actions** and add: -- `NPM_TOKEN` — your npm access token (create at https://www.npmjs.com/settings/tokens) +The workflow needs `id-token: write`, which is already configured in +`.github/workflows/release.yml`. ## Publishing a New Version -### 1. Bump the version +### 1. Prepare the release + +Start from an up-to-date `main` branch with a clean working tree. Move the +relevant entries from `Unreleased` into a dated version section in +`CHANGELOG.md`, then bump the version: + ```bash -node scripts/sync-version.js 0.2.0 +npm version 0.2.9 --no-git-tag-version ``` -This updates both `package.json` and `cli/Cargo.toml`. -### 2. Commit +The npm lifecycle hook updates all version-bearing files: + +- `package.json` +- `package-lock.json` +- `cli/Cargo.toml` +- `cli/Cargo.lock` +- `.claude-plugin/marketplace.json` + +Confirm that they all contain the intended version before tagging. + +### 2. Build and validate + +The Rust binary embeds the generated daemon bundles, so regenerate both bundles +before building the CLI: + ```bash -git add -A && git commit -m "release: v0.2.0" +cd daemon +pnpm install +pnpm bundle +pnpm bundle:sandbox-client +npx tsc --noEmit +pnpm vitest run +cd ../cli +cargo build +cd .. ``` -### 3. Tag and push +Also confirm that the normal CI checks for `main` are green before publishing. + +### 3. Commit + ```bash -git tag v0.2.0 -git push && git push --tags +git add -A +git commit -m "release: v0.2.9" ``` -The GitHub Actions release workflow triggers automatically and: -1. Cross-compiles the Rust CLI for 6 platforms (macOS ARM64/x64, Linux x64/ARM64/musl, Windows x64) -2. Bundles the daemon and sandbox client -3. Creates a GitHub release with all binaries attached -4. Publishes to npm +### 4. Merge, tag, and push + +Merge the release commit to `main`, update the local branch, and create the tag +on the resulting `main` commit. The tag must exactly match the version in +`package.json`: -### 4. Verify ```bash -npm info dev-browser version # should show 0.2.0 -npm install -g dev-browser # test the install -dev-browser --help # verify it works +git switch main +git pull --ff-only origin main +git tag v0.2.9 +git push origin v0.2.9 ``` -## Quick Patch Release +Pushing any `v*` tag triggers the GitHub Actions release workflow. Do not push +the tag until the release commit is merged and CI is green: the workflow does +not independently verify that the tag and package versions match. + +### 5. Monitor and verify + +Wait for the `Release` workflow to finish, then verify both distribution +channels: -Same flow, just use a patch version: ```bash -node scripts/sync-version.js 0.1.1 -git add -A && git commit -m "release: v0.1.1" -git tag v0.1.1 -git push && git push --tags +gh run list --workflow release.yml --limit 1 +npm info dev-browser version +npm install -g dev-browser +dev-browser --version +dev-browser --help ``` +If publishing fails after npm accepts the version, do not reuse that version; +fix the release workflow and publish a new patch version. + ## What the CI Does See `.github/workflows/release.yml`. On tag push (`v*`): | Step | What happens | |------|-------------| -| **Build** | Cross-compiles Rust CLI for each platform target | -| **Bundle** | Runs `pnpm run bundle` and `pnpm run bundle:sandbox-client` in `daemon/` | +| **Bundle** | Runs `pnpm bundle` and `pnpm bundle:sandbox-client` in `daemon/` | +| **Build** | Cross-compiles the Rust CLI for each platform target, embedding the generated daemon bundles | | **Assemble** | Copies bin wrapper, postinstall, daemon bundles, README, LICENSE into publish dir | -| **Publish npm** | `npm publish` from the assembled directory | -| **GitHub Release** | Creates a release with platform binaries attached | +| **Publish npm** | Uses OIDC trusted publishing to run `npm publish` from the assembled directory | +| **GitHub Release** | Creates a release with generated notes and the platform binaries attached | ## Platform Binaries diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 2e0e6ba2..ab07bb8d 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -130,7 +130,7 @@ dependencies = [ [[package]] name = "dev-browser" -version = "0.2.6" +version = "0.2.9" dependencies = [ "clap", "dialoguer", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 31fb589c..23f6ad52 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dev-browser" -version = "0.2.6" +version = "0.2.9" edition = "2021" [dependencies] diff --git a/cli/llm-guide.txt b/cli/llm-guide.txt index 53f1d445..f9ceb21f 100644 --- a/cli/llm-guide.txt +++ b/cli/llm-guide.txt @@ -1,6 +1,6 @@ LLM USAGE GUIDE: - Write small, focused scripts. Each script should do ONE thing: navigate, click, fill, or check. - End each script by logging the state you need for the next decision. + Make each script one decision-sized step. Batch tightly coupled inspect/act/verify work when the target is already known. + End each script by logging only the state needed for the next decision. Use descriptive page names like "login", "checkout", or "results" instead of "page1". Named pages from browser.getPage("name") persist between script runs, so you usually do not need to re-navigate. Inside page.evaluate(...), write plain JavaScript only - no TypeScript syntax in the browser context. @@ -27,19 +27,72 @@ LLM USAGE GUIDE: AI snapshots for element discovery: dev-browser <<'EOF' const page = await browser.getPage("main"); - const result = await page.snapshotForAI(); + const result = await page.snapshotForAI({ track: "main", timeout: 5000 }); console.log(result.full); // Returns { full: string, incremental?: string }. // Optional args: { track?: string, depth?: number, timeout?: number }. - // Read result.full to identify the right element. - // Then interact with it using Playwright: - // await page.getByRole("button", { name: "Continue" }).click(); - // Re-run page.snapshotForAI({ track: "main" }) after the page changes. + // Read result.full and copy the target's ref, such as e12 or f2e5. + // In the next decision-sized script: + // await page.getByRef("e12").click({ timeout: 5000 }); + // console.log((await page.snapshotForAI({ track: "main", timeout: 5000 })).incremental); EOF Choosing your approach: - Unknown pages: use page.snapshotForAI() first to discover the page, then interact based on what you find. - Known pages/selectors: skip the snapshot and use direct Playwright selectors like page.click(), page.fill(), or page.locator() for faster, more reliable automation. + Unknown pages: snapshotForAI({ track, timeout: 5000 }), act with getByRef(ref), then take a tracked snapshot to verify the change. + Known pages/selectors: skip the snapshot and use direct Playwright selectors with short explicit action timeouts. Use getByRole(...) as a semantic fallback when no stable direct selector is known. + Switch to page.domCua node ids when a stable locator cannot be built; use page.cua coordinates when visual structure is clearer than the DOM. + After acting, collect the cheapest state check; don't take both a snapshot and a screenshot by default. + + Vision workflow (page.cua): + Coordinate-based control across two scripts on a named page. + Script 1 - look: take a screenshot, then read the saved image to pick coordinates. + dev-browser <<'EOF' + const page = await browser.getPage("checkout"); + const shot = await page.cua.screenshot(); + console.log(JSON.stringify(shot)); + // {"path":"/Users/you/.dev-browser/tmp/cua-page_abc123.jpeg","width":1280,"height":720} + EOF + Script 2 - act: click at the coordinates measured on the image. + dev-browser <<'EOF' + const page = await browser.getPage("checkout"); + await page.cua.click({ x: 412, y: 233 }); + console.log(page.url()); + EOF + Pixel coordinates measured on the saved image map 1:1 onto page.cua coordinates (any display, any DPR). + Always use a named page so coordinates stay valid between scripts. + This holds for viewport and clip screenshots only — never derive click coordinates from a fullPage capture; scroll, then re-screenshot. + Also available: cua.doubleClick({x, y}), cua.drag({path: [{x, y}, ...]}), cua.move({x, y}), + cua.scroll({x, y, scrollX, scrollY}) (positive scrollY scrolls content down), + cua.keypress({keys: ["ctrl", "a"]}), cua.type({text}). + cua.click, cua.doubleClick, domCua.click, and domCua.doubleClick do not wait for navigation by default. + For a known destination, pair the action with page.waitForURL(...): + await Promise.all([ + page.waitForURL("**/confirmation", { timeout: 5000 }), + page.cua.click({ x: 412, y: 233 }), + ]); + For an unknown click destination, pass waitForNavigation: true to settle a main-frame navigation. + + DOM-id workflow (page.domCua): + Snapshot the visible interactive elements, then act on them by node id. + dev-browser <<'EOF' + const page = await browser.getPage("checkout"); + console.log(await page.domCua.getVisibleDom()); + // + // + // Example link + EOF + dev-browser <<'EOF' + const page = await browser.getPage("checkout"); + await page.domCua.click({ nodeId: 2 }); + console.log(page.url()); + EOF + Ids are only valid against the latest snapshot of the current document. + A "DOM node N is stale or missing — re-run getVisibleDom()" error means the id predates the latest snapshot or the document changed; re-run getVisibleDom() and use the fresh ids. + Re-snapshot after every navigation - ids from the old document never act on the new one. + The snapshot only includes elements visible in the viewport; scroll and re-snapshot to see more. + A truncation marker line appears when the snapshot budget is hit. + Also available: domCua.doubleClick({nodeId}), domCua.scroll({scrollX, scrollY, nodeId?}), + domCua.type({text}) and domCua.keypress({keys}) (both act on the focused element - click first). Screenshots for visual state: dev-browser <<'EOF' @@ -52,8 +105,8 @@ LLM USAGE GUIDE: Waiting patterns: dev-browser <<'EOF' const page = await browser.getPage("search-results"); - await page.waitForSelector(".results"); - await page.waitForURL("**/success"); + await page.waitForSelector(".results", { timeout: 5000 }); + await page.waitForURL("**/success", { timeout: 5000 }); console.log(JSON.stringify({ url: page.url(), title: await page.title(), @@ -86,6 +139,7 @@ LLM USAGE GUIDE: page.url() Get the current URL page.snapshotForAI(options) Get an AI-optimized snapshot; returns { full, incremental? } Options: { track?: string, depth?: number, timeout?: number } + page.getByRef(ref) Target an eN or iframe fNeN ref from snapshotForAI() page.getByRole(role, { name }) Target elements discovered from the snapshot page.textContent(selector) Get the text content of an element page.innerHTML(selector) Get the inner HTML of an element @@ -96,6 +150,13 @@ LLM USAGE GUIDE: page.waitForSelector(selector) Wait for an element to appear page.waitForURL(url) Wait for navigation to a URL page.screenshot() Capture a screenshot buffer; save it with saveScreenshot(...) + page.cua.screenshot(options) Save a JPEG for the vision workflow; returns { path, width, height } + Options: { name?: string, fullPage?: boolean, clip? } + page.cua.click({ x, y, waitForNavigation? }) + Click at viewport coordinates; navigation wait defaults false + page.domCua.getVisibleDom() Snapshot visible interactive elements as node_id=N lines + page.domCua.click({ nodeId, waitForNavigation? }) + Click a current node id; navigation wait defaults false page.$$eval(selector, fn) Run a function on all matching elements page.$eval(selector, fn) Run a function on the first matching element page.evaluate(fn) Run JavaScript in the page context (plain JS only) @@ -125,6 +186,7 @@ LLM USAGE GUIDE: - Prefer page.snapshotForAI() for structure; use screenshots when visual layout or styling matters. - Keep page names stable across scripts so you can resume work after failures. - Each --browser name maps to a separate daemon-managed browser instance. + - For unattended work, --idle-timeout 5m closes each idle daemon-launched browser while preserving its profile; it never closes --connect browsers. Use 0 to disable. - Use --connect to attach to an existing browser; omit the URL to auto-discover Chrome with debugging enabled. - Use short timeouts (--timeout 10) so scripts fail fast instead of hanging on missing elements. - Add --headless for unattended automation; omit it when you want to watch the browser window. diff --git a/cli/src/config.rs b/cli/src/config.rs new file mode 100644 index 00000000..fba3aa8e --- /dev/null +++ b/cli/src/config.rs @@ -0,0 +1,190 @@ +use serde::Deserialize; +use std::env; +use std::error::Error; +use std::fs; +use std::io; +use std::path::Path; + +const MAX_SAFE_TIMEOUT_MS: u64 = 9_007_199_254_740_991; + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum IdleTimeoutValue { + String(String), + Milliseconds(u64), +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct UserConfig { + idle_timeout: Option, +} + +pub fn parse_idle_timeout(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err("idle timeout cannot be empty".to_string()); + } + + let (number, multiplier) = match value.as_bytes().last().copied() { + Some(b's') => (&value[..value.len() - 1], 1_000_u64), + Some(b'm') => (&value[..value.len() - 1], 60_000_u64), + Some(b'h') => (&value[..value.len() - 1], 3_600_000_u64), + Some(last) if last.is_ascii_alphabetic() => { + return Err("invalid unit (use s, m, h, or raw milliseconds)".to_string()); + } + _ => (value, 1_u64), + }; + + if number.is_empty() { + return Err("idle timeout is missing a number".to_string()); + } + + let amount = number + .parse::() + .map_err(|_| "idle timeout must be a non-negative integer".to_string())?; + let milliseconds = amount + .checked_mul(multiplier) + .ok_or_else(|| "idle timeout is too large".to_string())?; + + if milliseconds > MAX_SAFE_TIMEOUT_MS { + return Err("idle timeout is too large".to_string()); + } + + Ok(milliseconds) +} + +pub fn effective_idle_timeout_ms(cli_value: Option) -> Result> { + let config_path = dirs::home_dir().map(|home| home.join(".dev-browser").join("config.json")); + resolve_idle_timeout( + cli_value, + env::var("DEV_BROWSER_IDLE_TIMEOUT_MS").ok().as_deref(), + config_path.as_deref(), + ) +} + +fn resolve_idle_timeout( + cli_value: Option, + environment_value: Option<&str>, + config_path: Option<&Path>, +) -> Result> { + if let Some(milliseconds) = cli_value { + return Ok(milliseconds); + } + + if let Some(value) = environment_value { + return parse_idle_timeout(value) + .map_err(|error| format!("Invalid DEV_BROWSER_IDLE_TIMEOUT_MS: {error}").into()); + } + + let Some(config_path) = config_path else { + return Ok(0); + }; + + let contents = match fs::read_to_string(config_path) { + Ok(contents) => contents, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(0), + Err(error) => return Err(error.into()), + }; + let config: UserConfig = serde_json::from_str(&contents) + .map_err(|error| format!("Invalid user config at {}: {error}", config_path.display()))?; + + match config.idle_timeout { + Some(IdleTimeoutValue::String(value)) => parse_idle_timeout(&value).map_err(|error| { + format!("Invalid idleTimeout in {}: {error}", config_path.display()).into() + }), + Some(IdleTimeoutValue::Milliseconds(milliseconds)) => { + if milliseconds > MAX_SAFE_TIMEOUT_MS { + Err(format!( + "Invalid idleTimeout in {}: idle timeout is too large", + config_path.display() + ) + .into()) + } else { + Ok(milliseconds) + } + } + None => Ok(0), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_config(contents: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = env::temp_dir().join(format!( + "dev-browser-config-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("config.json"); + fs::write(&path, contents).unwrap(); + path + } + + #[test] + fn parses_human_friendly_and_raw_timeouts() { + assert_eq!(parse_idle_timeout("30s").unwrap(), 30_000); + assert_eq!(parse_idle_timeout("5m").unwrap(), 300_000); + assert_eq!(parse_idle_timeout("1h").unwrap(), 3_600_000); + assert_eq!(parse_idle_timeout("2500").unwrap(), 2_500); + assert_eq!(parse_idle_timeout("0").unwrap(), 0); + } + + #[test] + fn rejects_invalid_timeouts() { + assert!(parse_idle_timeout("").is_err()); + assert!(parse_idle_timeout("1d").is_err()); + assert!(parse_idle_timeout("1.5m").is_err()); + assert!(parse_idle_timeout("-1").is_err()); + } + + #[test] + fn resolves_cli_then_environment_then_config_then_disabled() { + let config_path = temp_config(r#"{"idleTimeout":"1h"}"#); + + assert_eq!( + resolve_idle_timeout(Some(30_000), Some("5m"), Some(&config_path)).unwrap(), + 30_000 + ); + assert_eq!( + resolve_idle_timeout(Some(0), Some("5m"), Some(&config_path)).unwrap(), + 0 + ); + assert_eq!( + resolve_idle_timeout(None, Some("5m"), Some(&config_path)).unwrap(), + 300_000 + ); + assert_eq!( + resolve_idle_timeout(None, None, Some(&config_path)).unwrap(), + 3_600_000 + ); + assert_eq!(resolve_idle_timeout(None, None, None).unwrap(), 0); + + fs::remove_dir_all(config_path.parent().unwrap()).unwrap(); + } + + #[test] + fn accepts_numeric_config_and_zero_disables_cleanup() { + let numeric_path = temp_config(r#"{"idleTimeout":45000}"#); + assert_eq!( + resolve_idle_timeout(None, None, Some(&numeric_path)).unwrap(), + 45_000 + ); + fs::remove_dir_all(numeric_path.parent().unwrap()).unwrap(); + + let zero_path = temp_config(r#"{"idleTimeout":"0s"}"#); + assert_eq!( + resolve_idle_timeout(None, None, Some(&zero_path)).unwrap(), + 0 + ); + fs::remove_dir_all(zero_path.parent().unwrap()).unwrap(); + } +} diff --git a/cli/src/daemon.rs b/cli/src/daemon.rs index 037f978e..5c50c061 100644 --- a/cli/src/daemon.rs +++ b/cli/src/daemon.rs @@ -37,6 +37,14 @@ pub fn ensure_daemon() -> Result<(), Box> { return Ok(()); } + // Hold an exclusive lock while spawning so concurrent CLI invocations on a + // cold start cannot each spawn a daemon and race on the socket path. The + // lock is released when the file handle drops. + let _spawn_lock = acquire_spawn_lock()?; + if is_daemon_running() { + return Ok(()); + } + let command = find_daemon_command()?; if command.requires_runtime_install && !embedded_runtime_installed(&command.current_dir) { return Err( @@ -87,6 +95,30 @@ pub fn is_daemon_running() -> bool { connect_to_daemon().is_ok() } +fn acquire_spawn_lock() -> Result> { + let base_dir = daemon_base_dir()?; + fs::create_dir_all(&base_dir)?; + let lock_file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(base_dir.join("daemon-spawn.lock"))?; + + // Use flock(2) rather than std::fs::File::lock so the CLI keeps a low MSRV + // (File::lock was only stabilized in Rust 1.89). The advisory lock releases + // when the returned handle drops. Best-effort on non-Unix: the daemon-side + // bind also serializes concurrent starts. + #[cfg(unix)] + { + use std::os::unix::io::AsRawFd; + if unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX) } != 0 { + return Err(io::Error::last_os_error().into()); + } + } + + Ok(lock_file) +} + pub fn current_daemon_pid() -> Option { daemon_pid() } @@ -237,7 +269,12 @@ fn sync_text_file(path: &Path, contents: &str) -> Result<(), Box> { }; if needs_update { - fs::write(path, contents)?; + // Write to a per-process temp file and rename into place so a + // concurrent daemon spawn reading this file never observes a partial + // (truncated) write. + let tmp_path = path.with_extension(format!("tmp.{}", std::process::id())); + fs::write(&tmp_path, contents)?; + fs::rename(&tmp_path, path)?; } Ok(()) diff --git a/cli/src/main.rs b/cli/src/main.rs index 0a42a52b..776b1f53 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,8 +1,10 @@ +mod config; mod connection; mod daemon; mod skill; use clap::{CommandFactory, Parser, Subcommand}; +use config::{effective_idle_timeout_ms, parse_idle_timeout}; use connection::{connect_to_daemon, read_line, send_message}; use daemon::{ current_daemon_pid, ensure_daemon, install_daemon_runtime, is_daemon_running, @@ -170,6 +172,16 @@ struct Cli { )] timeout: u32, + #[arg( + long, + global = true, + value_name = "DURATION", + value_parser = parse_idle_timeout, + help = "Close idle daemon-launched browsers after a duration", + long_help = "Close each idle daemon-launched browser after the specified duration.\n\nAccepts human-friendly values such as 30s, 5m, and 1h, or raw milliseconds. The policy is applied per named browser, preserves browser profiles, and never closes externally connected Chrome. Use 0 to disable cleanup.\n\nPrecedence: --idle-timeout, DEV_BROWSER_IDLE_TIMEOUT_MS, ~/.dev-browser/config.json idleTimeout, then disabled." + )] + idle_timeout: Option, + #[command(subcommand)] command: Option, } @@ -195,7 +207,7 @@ enum Command { Install, #[command( about = "Install the dev-browser skill into agent skill directories", - long_about = "Install the embedded dev-browser skill into agent skill directories.\n\nBy default, launches an interactive multi-select prompt for the supported install targets when a TTY is available.\n\nIn non-interactive environments, installs to all supported skill directories.\n\nUse `--claude`, `--agents`, and/or `--codex` to skip prompting and install to specific targets." + long_about = "Install the embedded dev-browser skill into agent skill directories.\n\nBy default, launches an interactive multi-select prompt for the supported install targets when a TTY is available.\n\nIn non-interactive environments, installs to all supported skill directories, including Codex, so upgrades replace stale skill copies.\n\nUse `--claude`, `--agents`, and/or `--codex` to skip prompting and install to specific targets." )] InstallSkill { #[arg( @@ -221,7 +233,7 @@ enum Command { Browsers, #[command( about = "Show daemon status", - long_about = "Show daemon status.\n\nPrints daemon process details, socket path, uptime, and the current set of managed browsers." + long_about = "Show daemon status.\n\nPrints daemon process details, socket path, uptime, the effective idle timeout, and useful per-browser idle information." )] Status, #[command( @@ -238,6 +250,12 @@ struct BrowserSummary { kind: String, status: String, pages: Vec, + #[serde(default, rename = "idleForMs")] + idle_for_ms: Option, + #[serde(default, rename = "idleRemainingMs")] + idle_remaining_ms: Option, + #[serde(default, rename = "activeRequests")] + active_requests: usize, } #[derive(Debug, Deserialize)] @@ -249,6 +267,9 @@ struct StatusSummary { browser_count: usize, #[serde(rename = "socketPath")] socket_path: String, + #[serde(rename = "idleTimeoutMs")] + #[serde(default)] + idle_timeout_ms: u64, browsers: Vec, } @@ -280,11 +301,13 @@ fn run() -> Result> { run_script(&cli, script) } Some(Command::Browsers) => { + let idle_timeout_ms = effective_idle_timeout_ms(cli.idle_timeout)?; ensure_daemon()?; send_request( json!({ "id": request_id("browsers"), "type": "browsers", + "idleTimeoutMs": idle_timeout_ms, }), ResultMode::Browsers, ) @@ -302,11 +325,13 @@ fn run() -> Result> { Ok(0) } Some(Command::Status) => { + let idle_timeout_ms = effective_idle_timeout_ms(cli.idle_timeout)?; ensure_daemon()?; send_request( json!({ "id": request_id("status"), "type": "status", + "idleTimeoutMs": idle_timeout_ms, }), ResultMode::Status, ) @@ -351,6 +376,7 @@ fn run() -> Result> { } fn run_script(cli: &Cli, script: String) -> Result> { + let idle_timeout_ms = effective_idle_timeout_ms(cli.idle_timeout)?; ensure_daemon()?; if let Some(connect) = &cli.connect { @@ -376,6 +402,7 @@ fn run_script(cli: &Cli, script: String) -> Result> { "browser": cli.browser, "script": script, "timeoutMs": timeout_ms, + "idleTimeoutMs": idle_timeout_ms, }); if cli.headless { @@ -529,13 +556,45 @@ fn print_status(data: &Value) -> Result<(), Box> { println!("PID: {}", status.pid); println!("Uptime: {}", format_duration_ms(status.uptime_ms)); println!("Browsers: {}", status.browser_count); + println!( + "Idle timeout: {}", + if status.idle_timeout_ms == 0 { + "disabled".to_string() + } else { + format_duration_ms(status.idle_timeout_ms) + } + ); println!("Socket: {}", status.socket_path); if !status.browsers.is_empty() { let managed = status .browsers .iter() - .map(|browser| format!("{} ({}, {})", browser.name, browser.kind, browser.status)) + .map(|browser| { + let idle = if browser.kind == "connected" { + "idle cleanup exempt".to_string() + } else if browser.active_requests > 0 { + format!("{} active request(s)", browser.active_requests) + } else if status.idle_timeout_ms == 0 { + match browser.idle_for_ms { + Some(idle_for_ms) => format!("idle {}", format_duration_ms(idle_for_ms)), + None => "idle time unavailable".to_string(), + } + } else { + match (browser.idle_for_ms, browser.idle_remaining_ms) { + (Some(idle_for_ms), Some(remaining_ms)) => format!( + "idle {}, closes in {}", + format_duration_ms(idle_for_ms), + format_duration_ms(remaining_ms) + ), + _ => "idle time unavailable".to_string(), + } + }; + format!( + "{} ({}, {}, {})", + browser.name, browser.kind, browser.status, idle + ) + }) .collect::>() .join(", "); println!("Managed: {managed}"); @@ -576,3 +635,25 @@ fn format_duration_ms(duration_ms: u64) -> String { let seconds = total_seconds % 60; format!("{minutes}m {seconds}s") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn idle_timeout_is_global_and_accepts_human_friendly_values() { + let before = + Cli::try_parse_from(["dev-browser", "--idle-timeout", "5m", "status"]).unwrap(); + assert_eq!(before.idle_timeout, Some(300_000)); + + let after = + Cli::try_parse_from(["dev-browser", "status", "--idle-timeout", "30s"]).unwrap(); + assert_eq!(after.idle_timeout, Some(30_000)); + } + + #[test] + fn idle_timeout_zero_is_accepted() { + let cli = Cli::try_parse_from(["dev-browser", "--idle-timeout", "0", "status"]).unwrap(); + assert_eq!(cli.idle_timeout, Some(0)); + } +} diff --git a/cli/src/skill.rs b/cli/src/skill.rs index 466c3b42..61cc564f 100644 --- a/cli/src/skill.rs +++ b/cli/src/skill.rs @@ -240,32 +240,38 @@ mod tests { #[test] fn explicit_claude_flag_skips_prompt() { - let selection = resolve_install_target_selection(true, false, true); + let selection = resolve_install_target_selection(true, false, false, true); assert_selected(selection, &[0]); } #[test] fn explicit_agents_flag_skips_prompt() { - let selection = resolve_install_target_selection(false, true, true); + let selection = resolve_install_target_selection(false, true, false, true); assert_selected(selection, &[1]); } #[test] - fn explicit_flags_can_select_both_targets() { - let selection = resolve_install_target_selection(true, true, false); - assert_selected(selection, &[0, 1]); + fn explicit_codex_flag_skips_prompt() { + let selection = resolve_install_target_selection(false, false, true, true); + assert_selected(selection, &[2]); + } + + #[test] + fn explicit_flags_can_select_all_targets() { + let selection = resolve_install_target_selection(true, true, true, false); + assert_selected(selection, &[0, 1, 2]); } #[test] fn interactive_terminal_without_flags_prompts() { - let selection = resolve_install_target_selection(false, false, true); + let selection = resolve_install_target_selection(false, false, false, true); assert!(matches!(selection, InstallTargetSelection::Prompt)); } #[test] - fn non_interactive_without_flags_defaults_to_both_targets() { - let selection = resolve_install_target_selection(false, false, false); - assert_selected(selection, &[0, 1]); + fn non_interactive_without_flags_defaults_to_all_targets() { + let selection = resolve_install_target_selection(false, false, false, false); + assert_selected(selection, &[0, 1, 2]); } fn assert_selected(selection: InstallTargetSelection, expected: &[usize]) { diff --git a/daemon/scripts/bundle-sandbox-client.ts b/daemon/scripts/bundle-sandbox-client.ts index 30a0c48a..43b937ad 100644 --- a/daemon/scripts/bundle-sandbox-client.ts +++ b/daemon/scripts/bundle-sandbox-client.ts @@ -9,6 +9,10 @@ const outfile = resolve(daemonDir, "dist/sandbox-client.js"); await mkdir(dirname(outfile), { recursive: true }); +// WARNING: src/sandbox/forked-client/src/client/domCuaInjected.ts exports +// functions that are serialized with String(fn) and re-evaluated inside the +// page. Do not add flags that rewrite function bodies (minify, keepNames) — +// they would corrupt the serialized source. await build({ entryPoints: [entryPoint], bundle: true, diff --git a/daemon/src/browser-manager-title-timeout.test.ts b/daemon/src/browser-manager-title-timeout.test.ts index 8d7b1760..52c6f78a 100644 --- a/daemon/src/browser-manager-title-timeout.test.ts +++ b/daemon/src/browser-manager-title-timeout.test.ts @@ -10,9 +10,10 @@ type BrowserManagerInternals = { getPageTargetId: (context: BrowserContext, page: Page) => Promise; }; -function createMockEntry(page: Page): BrowserEntry { +function createMockEntry(pages: Page | Page[]): BrowserEntry { + const pageList = Array.isArray(pages) ? pages : [pages]; const context = { - pages: () => [page], + pages: () => pageList, } as unknown as BrowserContext; const browser = { @@ -66,6 +67,49 @@ describe("BrowserManager listPages title handling", () => { ]); }); + it("starts every title lookup concurrently and uses one timeout window", async () => { + vi.useFakeTimers(); + + const started: number[] = []; + const pages = [0, 1, 2].map( + (index) => + ({ + isClosed: () => false, + on: () => undefined, + title: () => { + started.push(index); + return new Promise(() => {}); + }, + url: () => `chrome://page-${index}`, + }) as unknown as Page + ); + + const manager = new BrowserManager("/tmp/dev-browser-concurrent-title-timeout"); + const internals = manager as unknown as BrowserManagerInternals; + internals.browsers.set(browserName, createMockEntry(pages)); + vi.spyOn(internals, "getPageTargetId").mockImplementation(async (_context, page) => { + return `target-${pages.indexOf(page)}`; + }); + + let settled = false; + const pagesPromise = manager.listPages(browserName).finally(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(0); + + expect(started).toEqual([0, 1, 2]); + + await vi.advanceTimersByTimeAsync(1_499); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await expect(pagesPromise).resolves.toEqual([ + { id: "target-0", name: null, title: "", url: "chrome://page-0" }, + { id: "target-1", name: null, title: "", url: "chrome://page-1" }, + { id: "target-2", name: null, title: "", url: "chrome://page-2" }, + ]); + }); + it("still surfaces page.title errors when the page remains open", async () => { const page = { isClosed: () => false, diff --git a/daemon/src/browser-manager.ts b/daemon/src/browser-manager.ts index c5414e8c..5b4d07b6 100644 --- a/daemon/src/browser-manager.ts +++ b/daemon/src/browser-manager.ts @@ -11,6 +11,7 @@ export interface BrowserEntry { context: BrowserContext; pages: Map; profileDir?: string; + executablePath?: string; endpoint?: string; headless: boolean; ignoreHTTPSErrors: boolean; @@ -21,6 +22,7 @@ interface BrowserSummary { type: BrowserEntry["type"]; status: "running" | "connected" | "disconnected"; pages: string[]; + executablePath?: string; } interface BrowserPageSummary { @@ -42,6 +44,11 @@ type BrowserManagerDependencies = { readFile: typeof readFile; }; +interface BrowserOperationOptions { + deadline?: number; + signal?: AbortSignal; +} + type DebuggerWebSocketLookupResult = | { status: "ok"; @@ -95,7 +102,9 @@ export class BrowserManager { } private static detectWsl(): boolean { - return process.platform === "linux" && !!(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP); + return ( + process.platform === "linux" && !!(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) + ); } async ensureBrowser( @@ -103,9 +112,13 @@ export class BrowserManager { options: { headless?: boolean; ignoreHTTPSErrors?: boolean; + deadline?: number; + signal?: AbortSignal; } = {} ): Promise { + this.throwIfOperationAborted(options); await this.ensureBaseDir(); + this.throwIfOperationAborted(options); const existing = this.browsers.get(name); const requestedHeadless = options.headless ?? existing?.headless ?? false; const requestedIgnoreHTTPSErrors = @@ -126,17 +139,19 @@ export class BrowserManager { await this.stopBrowser(name); } - return this.launchBrowser(name, requestedHeadless, requestedIgnoreHTTPSErrors); + return this.launchBrowser(name, requestedHeadless, requestedIgnoreHTTPSErrors, options); } async autoConnect( name: string, - options: { + options: BrowserOperationOptions & { port?: number; profilePath?: string; } = {} ): Promise { + this.throwIfOperationAborted(options); await this.ensureBaseDir(); + this.throwIfOperationAborted(options); const existing = this.browsers.get(name); if (existing?.type === "connected" && existing.browser.isConnected()) { @@ -158,20 +173,23 @@ export class BrowserManager { attemptedEndpoints.add(endpoint); try { - return await this.openConnectedBrowser(name, endpoint); + return await this.openConnectedBrowser(name, endpoint, options); } catch (error) { + this.throwIfOperationAborted(options); lastError = error; return null; } }; const devToolsEndpoint = await this.readDevToolsActivePort(undefined, options.profilePath); + this.throwIfOperationAborted(options); const devToolsBrowser = await tryEndpoint(devToolsEndpoint); if (devToolsBrowser) { return devToolsBrowser; } for (const endpoint of await this.discoverAgentBrowserEndpoints()) { + this.throwIfOperationAborted(options); const connectedBrowser = await tryEndpoint(endpoint); if (connectedBrowser) { return connectedBrowser; @@ -180,7 +198,9 @@ export class BrowserManager { const candidatePorts = options.port !== undefined ? [options.port] : DISCOVERY_PORTS; for (const port of candidatePorts) { + this.throwIfOperationAborted(options); const endpoint = await this.probePort(port); + this.throwIfOperationAborted(options); const connectedBrowser = await tryEndpoint(endpoint); if (connectedBrowser) { return connectedBrowser; @@ -193,7 +213,7 @@ export class BrowserManager { async connectBrowser( name: string, endpoint: string, - options: { + options: BrowserOperationOptions & { port?: number; profilePath?: string; } = {} @@ -202,8 +222,11 @@ export class BrowserManager { return this.autoConnect(name, options); } + this.throwIfOperationAborted(options); await this.ensureBaseDir(); + this.throwIfOperationAborted(options); const resolvedEndpoint = await this.resolveEndpoint(endpoint, options); + this.throwIfOperationAborted(options); const existing = this.browsers.get(name); if (existing) { @@ -219,7 +242,7 @@ export class BrowserManager { await this.stopBrowser(name); } - return this.openConnectedBrowser(name, resolvedEndpoint); + return this.openConnectedBrowser(name, resolvedEndpoint, options); } getBrowser(name: string): BrowserEntry | undefined { @@ -266,34 +289,36 @@ export class BrowserManager { this.pruneClosedPages(entry); const namesByPage = this.getNamedPagesByPage(entry); - const summaries: BrowserPageSummary[] = []; - - for (const { context, page } of this.getContextPages(entry)) { - const id = await this.getPageTargetId(context, page); - if (!id) { - continue; - } - - let title = ""; - try { - title = await this.getPageTitle(page); - } catch (error) { - if (page.isClosed()) { - continue; + const summaries = await Promise.all( + this.getContextPages(entry).map( + async ({ context, page }): Promise => { + const id = await this.getPageTargetId(context, page); + if (!id) { + return null; + } + + let title = ""; + try { + title = await this.getPageTitle(page); + } catch (error) { + if (page.isClosed()) { + return null; + } + + throw error; + } + + return { + id, + url: page.url(), + title, + name: namesByPage.get(page) ?? null, + }; } + ) + ); - throw error; - } - - summaries.push({ - id, - url: page.url(), - title, - name: namesByPage.get(page) ?? null, - }); - } - - return summaries; + return summaries.filter((summary): summary is BrowserPageSummary => summary !== null); } async closePage(browserName: string, pageName: string): Promise { @@ -331,6 +356,7 @@ export class BrowserManager { type: entry.type, status, pages: this.listNamedPages(entry), + ...(entry.executablePath ? { executablePath: entry.executablePath } : {}), }; }) .sort((left, right) => left.name.localeCompare(right.name)); @@ -381,21 +407,36 @@ export class BrowserManager { private async launchBrowser( name: string, headless: boolean, - ignoreHTTPSErrors: boolean + ignoreHTTPSErrors: boolean, + operation: BrowserOperationOptions = {} ): Promise { const profileDir = path.join(this.baseDir, name, "chromium-profile"); await this.dependencies.mkdir(profileDir, { recursive: true }); + const executablePath = await this.configuredExecutablePath(); + const timeout = this.remainingOperationTimeout(operation); const context = await this.dependencies.launchPersistentContext(profileDir, { + ...(executablePath === undefined ? {} : { executablePath }), headless, viewport: headless ? undefined : null, ignoreHTTPSErrors, handleSIGINT: false, handleSIGTERM: false, handleSIGHUP: false, + ...(timeout === undefined ? {} : { timeout }), }); const browser = context.browser(); + try { + this.throwIfOperationAborted(operation); + } catch (error) { + await context.close().catch(() => undefined); + if (browser?.isConnected()) { + await browser.close().catch(() => undefined); + } + throw error; + } + if (!browser) { await context.close(); throw new Error(`Playwright did not expose a browser handle for "${name}"`); @@ -408,6 +449,7 @@ export class BrowserManager { context, pages: new Map(), profileDir, + ...(executablePath === undefined ? {} : { executablePath }), headless, ignoreHTTPSErrors, }; @@ -417,8 +459,54 @@ export class BrowserManager { return entry; } - private async openConnectedBrowser(name: string, endpoint: string): Promise { - const browser = await this.dependencies.connectOverCDP(endpoint); + private async configuredExecutablePath(): Promise { + const configPath = path.join(this.dependencies.homedir(), ".dev-browser", "config.json"); + let contents: string; + try { + contents = await this.dependencies.readFile(configPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return undefined; + } + throw error; + } + + let config: unknown; + try { + config = JSON.parse(contents); + } catch { + throw new Error(`Invalid JSON in ${configPath}`); + } + if (!config || typeof config !== "object" || Array.isArray(config)) { + throw new Error(`Invalid user config at ${configPath}: expected an object`); + } + const executablePath = (config as { executablePath?: unknown }).executablePath; + if (executablePath === undefined) { + return undefined; + } + const platformPath = this.dependencies.platform === "win32" ? path.win32 : path.posix; + if (typeof executablePath !== "string" || !platformPath.isAbsolute(executablePath)) { + throw new Error(`Invalid executablePath in ${configPath}: expected an absolute path`); + } + return executablePath; + } + + private async openConnectedBrowser( + name: string, + endpoint: string, + operation: BrowserOperationOptions = {} + ): Promise { + const timeout = this.remainingOperationTimeout(operation); + const browser = + timeout === undefined + ? await this.dependencies.connectOverCDP(endpoint) + : await this.dependencies.connectOverCDP(endpoint, { timeout }); + try { + this.throwIfOperationAborted(operation); + } catch (error) { + await browser.close().catch(() => undefined); + throw error; + } const contexts = browser.contexts(); // Enumerate existing tabs for connected browsers, but leave them unnamed so getPage(name) @@ -460,6 +548,25 @@ export class BrowserManager { }); } + private throwIfOperationAborted(options: BrowserOperationOptions): void { + if (options.signal?.aborted) { + throw options.signal.reason instanceof Error + ? options.signal.reason + : new Error(String(options.signal.reason)); + } + if (options.deadline !== undefined && Date.now() >= options.deadline) { + throw new Error("Browser setup deadline exceeded"); + } + } + + private remainingOperationTimeout(options: BrowserOperationOptions): number | undefined { + this.throwIfOperationAborted(options); + if (options.deadline === undefined) { + return undefined; + } + return Math.max(1, options.deadline - Date.now()); + } + private async closeLaunchedBrowser(entry: BrowserEntry): Promise { const contexts = this.getBrowserContexts(entry); await Promise.allSettled(contexts.map(async (context) => context.close())); @@ -581,7 +688,9 @@ export class BrowserManager { path.join(homeDir, ".config", "google-chrome-beta", "DevToolsActivePort"), path.join(homeDir, ".config", "google-chrome-unstable", "DevToolsActivePort"), path.join(homeDir, ".config", "BraveSoftware", "Brave-Browser", "DevToolsActivePort"), - ...(this.dependencies.isWsl ? await this.getWslWindowsDevToolsActivePortCandidates() : []), + ...(this.dependencies.isWsl + ? await this.getWslWindowsDevToolsActivePortCandidates() + : []), ]); case "win32": return this.dedupePaths([ @@ -667,7 +776,15 @@ export class BrowserManager { const userDir = path.join(windowsUsersRoot, entry.name); candidates.push( - path.join(userDir, "AppData", "Local", "Google", "Chrome", "User Data", "DevToolsActivePort"), + path.join( + userDir, + "AppData", + "Local", + "Google", + "Chrome", + "User Data", + "DevToolsActivePort" + ), path.join( userDir, "AppData", @@ -756,8 +873,9 @@ export class BrowserManager { ): Promise { let token: string; try { - token = (await this.dependencies.readFile(path.join(socketDir, `${session}.token`), "utf8")) - .trim(); + token = ( + await this.dependencies.readFile(path.join(socketDir, `${session}.token`), "utf8") + ).trim(); } catch (error) { if (isIgnorableFileError(error)) { return null; @@ -773,7 +891,10 @@ export class BrowserManager { if (this.dependencies.platform === "win32") { let portContents: string; try { - portContents = await this.dependencies.readFile(path.join(socketDir, `${session}.port`), "utf8"); + portContents = await this.dependencies.readFile( + path.join(socketDir, `${session}.port`), + "utf8" + ); } catch (error) { if (isIgnorableFileError(error)) { return null; diff --git a/daemon/src/daemon.ts b/daemon/src/daemon.ts index a3e74718..cf35fe4a 100644 --- a/daemon/src/daemon.ts +++ b/daemon/src/daemon.ts @@ -3,6 +3,9 @@ import { chmod, mkdir, unlink, writeFile } from "node:fs/promises"; import net from "node:net"; import path from "node:path"; import { BrowserManager } from "./browser-manager.js"; +import { executeRequest } from "./execute-request.js"; +import { formatError } from "./format-error.js"; +import { IdleBrowserReaper } from "./idle-browser-reaper.js"; import { createKeyedLock, createMutex } from "./lock.js"; import { getBrowsersDir, @@ -24,6 +27,10 @@ const SOCKET_CLOSE_TIMEOUT_MS = 500; const UNIX_DEV_BROWSER_DIR_MODE = 0o700; const UNIX_DAEMON_SOCKET_MODE = 0o600; const UNIX_DAEMON_PID_MODE = 0o600; +// Bounds the in-memory request buffer. The socket decodes to UTF-8 strings, so +// this is measured in JavaScript string length (UTF-16 code units), which is +// what caps the JS string we actually retain. +const MAX_FRAME_CHARS = 10 * 1024 * 1024; const EMBEDDED_PACKAGE_JSON = JSON.stringify({ name: "dev-browser-runtime", private: true, @@ -35,25 +42,29 @@ const EMBEDDED_PACKAGE_JSON = JSON.stringify({ }, }); +// Chrome 147's built-in remote debugging does not emit Target.attachedToTarget +// for some target types, which hangs connectOverCDP unless Playwright is +// allowed to attach to "other" targets. Respect an explicit user override. +// See https://github.com/SawyerHood/dev-browser/issues/103 and +// https://github.com/microsoft/playwright/issues/40027. +if (process.env.PW_CHROMIUM_ATTACH_TO_OTHER === undefined) { + process.env.PW_CHROMIUM_ATTACH_TO_OTHER = "1"; +} + const manager = new BrowserManager(BROWSERS_DIR); const startedAt = Date.now(); const withBrowserLock = createKeyedLock(); const withInstallLock = createMutex(); const clients = new Set(); +const idleReaper = new IdleBrowserReaper({ + listBrowsers: () => manager.listBrowsers(), + stopBrowser: (name) => manager.stopBrowser(name), + withBrowserLock, +}); let server: net.Server | null = null; let shuttingDown: Promise | null = null; - -function formatError(error: unknown): string { - if (error instanceof Error) { - if (error.name === "ScriptTimeoutError") { - return error.message; - } - return error.stack ?? error.message; - } - - return String(error); -} +let ownsEndpoint = false; async function writeMessage(socket: net.Socket, message: Response): Promise { if (socket.destroyed) { @@ -138,68 +149,56 @@ function createMessageQueue(socket: net.Socket) { } async function handleExecute(socket: net.Socket, request: ExecuteRequest): Promise { - await withBrowserLock(request.browser, async () => { - if (request.connect === "auto") { - await manager.autoConnect(request.browser, { - port: request.connectPort, - profilePath: request.connectProfilePath, - }); - } else if (request.connect) { - await manager.connectBrowser(request.browser, request.connect, { - port: request.connectPort, - profilePath: request.connectProfilePath, - }); - } else { - await manager.ensureBrowser(request.browser, { - headless: request.headless, - ignoreHTTPSErrors: request.ignoreHTTPSErrors, - }); - } - - const output = createMessageQueue(socket); - const timeoutMs = request.timeoutMs ?? DEFAULT_SCRIPT_TIMEOUT_MS; - - try { - await runScript( - request.script, - manager, - request.browser, - { - onStdout: (data) => { - void output.push({ - id: request.id, - type: "stdout", - data, - }); - }, - onStderr: (data) => { - void output.push({ - id: request.id, - type: "stderr", - data, + idleReaper.requestStarted(request.browser); + try { + await executeRequest( + request, + request.timeoutMs ?? DEFAULT_SCRIPT_TIMEOUT_MS, + { + isOpen: () => !socket.destroyed && socket.writable && !socket.writableEnded, + onDisconnect: (listener) => { + const onDisconnect = () => listener(); + socket.once("close", onDisconnect); + socket.once("error", onDisconnect); + return () => { + socket.off("close", onDisconnect); + socket.off("error", onDisconnect); + }; + }, + send: (message) => writeMessage(socket, message), + }, + { + withBrowserLock, + prepareBrowser: async (currentRequest, context) => { + const operation = { + deadline: context.deadline, + signal: context.signal, + port: currentRequest.connectPort, + profilePath: currentRequest.connectProfilePath, + }; + if (currentRequest.connect === "auto") { + await manager.autoConnect(currentRequest.browser, operation); + } else if (currentRequest.connect) { + await manager.connectBrowser(currentRequest.browser, currentRequest.connect, operation); + } else { + await manager.ensureBrowser(currentRequest.browser, { + headless: currentRequest.headless, + ignoreHTTPSErrors: currentRequest.ignoreHTTPSErrors, + ...operation, }); - }, + } }, - { - timeout: timeoutMs, - } - ); - - await output.drain(); - await writeMessage(socket, { - id: request.id, - type: "complete", - success: true, - }); - } catch (error) { - await output.drain().catch(() => undefined); - await writeMessage(socket, { - id: request.id, - type: "error", - message: formatError(error), - }); - } - }); + runScript: async (currentRequest, output, context) => { + await runScript(currentRequest.script, manager, currentRequest.browser, output, { + signal: context.signal, + timeout: Math.max(1, context.deadline - Date.now()), + }); + }, + } + ); + } finally { + idleReaper.requestFinished(request.browser); + } } async function handleInstall(socket: net.Socket, request: { id: string }): Promise { @@ -302,6 +301,10 @@ async function handleRequest(socket: net.Socket, line: string): Promise { const { request } = parsed; + if (request.idleTimeoutMs !== undefined) { + idleReaper.configure(request.idleTimeoutMs); + } + if (shuttingDown && request.type !== "stop") { await writeMessage(socket, { id: request.id, @@ -317,10 +320,11 @@ async function handleRequest(socket: net.Socket, line: string): Promise { return; case "browsers": + const browsers = manager.listBrowsers(); await writeMessage(socket, { id: request.id, type: "result", - data: manager.listBrowsers(), + data: browsers.map((browser) => ({ ...browser, ...idleReaper.idleInfo(browser) })), }); await writeMessage(socket, { id: request.id, @@ -330,7 +334,8 @@ async function handleRequest(socket: net.Socket, line: string): Promise { return; case "browser-stop": - await manager.stopBrowser(request.browser); + await withBrowserLock(request.browser, () => manager.stopBrowser(request.browser)); + idleReaper.browserStopped(request.browser); await writeMessage(socket, { id: request.id, type: "result", @@ -344,6 +349,7 @@ async function handleRequest(socket: net.Socket, line: string): Promise { return; case "status": + const statusBrowsers = manager.listBrowsers(); await writeMessage(socket, { id: request.id, type: "result", @@ -351,8 +357,12 @@ async function handleRequest(socket: net.Socket, line: string): Promise { pid: process.pid, uptimeMs: Date.now() - startedAt, browserCount: manager.browserCount(), - browsers: manager.listBrowsers(), + browsers: statusBrowsers.map((browser) => ({ + ...browser, + ...idleReaper.idleInfo(browser), + })), socketPath: SOCKET_PATH, + idleTimeoutMs: idleReaper.idleTimeoutMs, }, }); await writeMessage(socket, { @@ -393,11 +403,18 @@ async function shutdown(exitCode = 0): Promise { const serverClosed = serverToClose ? closeServerInstance(serverToClose) : Promise.resolve(); await manager.stopAll(); + idleReaper.dispose(); await Promise.allSettled(Array.from(clients, (socket) => closeClientSocket(socket))); await serverClosed; - const cleanup = [unlinkIfExists(PID_PATH)]; - if (requiresDaemonEndpointCleanup()) { - cleanup.push(unlinkIfExists(SOCKET_PATH)); + // Only remove the pid file and socket path if this process successfully + // bound them; otherwise a daemon that lost the startup race would delete + // the live daemon's endpoint. + const cleanup: Promise[] = []; + if (ownsEndpoint) { + cleanup.push(unlinkIfExists(PID_PATH)); + if (requiresDaemonEndpointCleanup()) { + cleanup.push(unlinkIfExists(SOCKET_PATH)); + } } await Promise.allSettled(cleanup); @@ -409,6 +426,54 @@ async function shutdown(exitCode = 0): Promise { return shuttingDown; } +async function isEndpointActive(endpoint: string): Promise { + return await new Promise((resolve) => { + const probe = net.connect(endpoint); + const finish = (active: boolean) => { + probe.destroy(); + resolve(active); + }; + probe.once("connect", () => finish(true)); + probe.once("error", () => finish(false)); + }); +} + +function listenOnEndpoint(target: net.Server): Promise { + return new Promise((resolve, reject) => { + const onError = (error: Error) => reject(error); + target.once("error", onError); + target.listen(SOCKET_PATH, () => { + target.off("error", onError); + resolve(); + }); + }); +} + +async function bindEndpoint(target: net.Server): Promise { + try { + await listenOnEndpoint(target); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + // Binding is the atomic claim. Only fall back to replacing the path when + // the bind actually failed because the path already exists. + if (code !== "EADDRINUSE" || !requiresDaemonEndpointCleanup()) { + throw error; + } + } + + // The path exists. If a live daemon answers, defer to it; unlinking a bound + // Unix socket would not stop it and would only split clients between daemons. + if (await isEndpointActive(SOCKET_PATH)) { + process.stderr.write("daemon already running\n"); + process.exit(0); + } + + // Stale socket file from a crashed daemon — remove it and claim the path. + await unlinkIfExists(SOCKET_PATH); + await listenOnEndpoint(target); +} + async function start(): Promise { await mkdir(BASE_DIR, { recursive: true, @@ -416,13 +481,6 @@ async function start(): Promise { }); await chmodIfSupported(BASE_DIR, UNIX_DEV_BROWSER_DIR_MODE); await ensureDevBrowserTempDir(); - if (requiresDaemonEndpointCleanup()) { - await unlinkIfExists(SOCKET_PATH); - } - await writeFile(PID_PATH, `${process.pid}\n`, { - mode: UNIX_DAEMON_PID_MODE, - }); - await chmodIfSupported(PID_PATH, UNIX_DAEMON_PID_MODE); server = net.createServer((socket) => { if (shuttingDown) { @@ -441,6 +499,24 @@ async function start(): Promise { const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; + if (buffer.length > MAX_FRAME_CHARS || lines.some((line) => line.length > MAX_FRAME_CHARS)) { + // Pause synchronously so the unparsed remainder of an oversized frame + // cannot be reinterpreted as fresh requests while the error response + // drains (the write callback may be deferred under backpressure). + socket.pause(); + buffer = ""; + void writeMessage(socket, { + id: "unknown", + type: "error", + message: `Request exceeds the maximum frame size of ${MAX_FRAME_CHARS} characters`, + }) + .catch(() => undefined) + .finally(() => { + socket.destroy(); + }); + return; + } + for (const rawLine of lines) { const line = rawLine.trim(); if (!line) { @@ -471,19 +547,19 @@ async function start(): Promise { }); }); + await bindEndpoint(server); + + // Only attach the runtime error handler after a successful bind so a bind + // failure handled by bindEndpoint cannot also trip a shutdown. server.on("error", (error) => { console.error("Daemon server error:", error); void shutdown(1); }); - await new Promise((resolve, reject) => { - server?.once("error", reject); - server?.listen(SOCKET_PATH, () => { - server?.off("error", reject); - resolve(); - }); - }); + ownsEndpoint = true; await chmodIfSupported(SOCKET_PATH, UNIX_DAEMON_SOCKET_MODE); + await writeFile(PID_PATH, `${process.pid}\n`, { mode: UNIX_DAEMON_PID_MODE }); + await chmodIfSupported(PID_PATH, UNIX_DAEMON_PID_MODE); process.stderr.write("daemon ready\n"); } diff --git a/daemon/src/execute-request.test.ts b/daemon/src/execute-request.test.ts new file mode 100644 index 00000000..deaa92d2 --- /dev/null +++ b/daemon/src/execute-request.test.ts @@ -0,0 +1,254 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { executeRequest, type ExecuteRequestTransport } from "./execute-request.js"; +import { createKeyedLock } from "./lock.js"; +import type { ExecuteRequest, Response } from "./protocol.js"; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve }; +} + +class FakeTransport implements ExecuteRequestTransport { + readonly messages: Response[] = []; + readonly listeners = new Set<() => void>(); + open = true; + + isOpen(): boolean { + return this.open; + } + + onDisconnect(listener: () => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async send(message: Response): Promise { + this.messages.push(message); + } + + disconnect(): void { + this.open = false; + for (const listener of [...this.listeners]) { + listener(); + } + } +} + +function request(id: string, browser = "shared"): ExecuteRequest { + return { + id, + type: "execute", + browser, + script: "", + }; +} + +describe("executeRequest", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("expires in the browser queue and never starts later", async () => { + vi.useFakeTimers(); + const withBrowserLock = createKeyedLock(); + const releaseFirst = deferred(); + const firstStarted = deferred(); + const first = withBrowserLock("shared", async () => { + firstStarted.resolve(); + await releaseFirst.promise; + }); + await firstStarted.promise; + + const transport = new FakeTransport(); + const prepareBrowser = vi.fn(async () => undefined); + const execution = executeRequest(request("queued"), 50, transport, { + withBrowserLock, + prepareBrowser, + runScript: vi.fn(async () => undefined), + }); + + await vi.advanceTimersByTimeAsync(50); + await execution; + + expect(prepareBrowser).not.toHaveBeenCalled(); + expect(transport.messages).toEqual([ + { + id: "queued", + type: "error", + message: "Script timed out after 50ms and was terminated.", + }, + ]); + expect(transport.listeners.size).toBe(0); + expect(vi.getTimerCount()).toBe(0); + + releaseFirst.resolve(); + await first; + await vi.advanceTimersByTimeAsync(0); + expect(prepareBrowser).not.toHaveBeenCalled(); + }); + + it("holds the browser lock through disconnect cancellation before the next request", async () => { + const withBrowserLock = createKeyedLock(); + const firstTransport = new FakeTransport(); + const secondTransport = new FakeTransport(); + const firstRunning = deferred(); + const stopStarted = deferred(); + const allowStop = deferred(); + const events: string[] = []; + + const runScript = async ( + current: ExecuteRequest, + _output: unknown, + { signal }: { signal: AbortSignal } + ) => { + if (current.id !== "first") { + events.push("second:run"); + return; + } + + events.push("first:run"); + firstRunning.resolve(); + await new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + events.push("first:stop-start"); + stopStarted.resolve(); + void allowStop.promise.then(() => { + events.push("first:stop-end"); + reject(signal.reason); + }); + }, + { once: true } + ); + }); + }; + + const dependencies = { + withBrowserLock, + prepareBrowser: async (current: ExecuteRequest) => { + events.push(`${current.id}:prepare`); + }, + runScript, + }; + + const first = executeRequest(request("first"), 10_000, firstTransport, dependencies); + await firstRunning.promise; + firstTransport.disconnect(); + await stopStarted.promise; + + const second = executeRequest(request("second"), 10_000, secondTransport, dependencies); + await Promise.resolve(); + expect(events).not.toContain("second:prepare"); + + allowStop.resolve(); + await first; + await second; + + expect(firstTransport.messages).toEqual([]); + expect(secondTransport.messages).toEqual([{ id: "second", type: "complete", success: true }]); + expect(events).toEqual([ + "first:prepare", + "first:run", + "first:stop-start", + "first:stop-end", + "second:prepare", + "second:run", + ]); + expect(firstTransport.listeners.size).toBe(0); + expect(secondTransport.listeners.size).toBe(0); + }); + + it("suppresses output and duplicate terminal frames after the deadline", async () => { + vi.useFakeTimers(); + const transport = new FakeTransport(); + const withBrowserLock = createKeyedLock(); + + const execution = executeRequest(request("running"), 25, transport, { + withBrowserLock, + prepareBrowser: async () => undefined, + runScript: async (_request, output, { signal }) => { + output.onStdout("before\n"); + await new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + output.onStdout("late\n"); + output.onStderr("later\n"); + reject(new Error("late inner failure")); + }, + { once: true } + ); + }); + }, + }); + + await vi.advanceTimersByTimeAsync(25); + await execution; + + expect(transport.messages).toEqual([ + { id: "running", type: "stdout", data: "before\n" }, + { + id: "running", + type: "error", + message: "Script timed out after 25ms and was terminated.", + }, + ]); + expect(transport.messages.filter((message) => message.type === "error")).toHaveLength(1); + expect(transport.messages.some((message) => message.type === "complete")).toBe(false); + expect(transport.listeners.size).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("preserves an inner error that wins before the hard deadline", async () => { + vi.useFakeTimers(); + const transport = new FakeTransport(); + + await executeRequest(request("inner-error"), 1_000, transport, { + withBrowserLock: createKeyedLock(), + prepareBrowser: async () => undefined, + runScript: async () => { + throw new Error("locator click failed: target closed"); + }, + }); + + expect(transport.messages).toHaveLength(1); + expect(transport.messages[0]).toEqual( + expect.objectContaining({ + id: "inner-error", + type: "error", + message: expect.stringContaining("locator click failed: target closed"), + }) + ); + expect(transport.listeners.size).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it("returns the deadline error instead of success when the timer callback is delayed", async () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + const transport = new FakeTransport(); + + await executeRequest(request("boundary"), 100, transport, { + withBrowserLock: createKeyedLock(), + prepareBrowser: async () => undefined, + runScript: async () => { + vi.setSystemTime(10_100); + }, + }); + + expect(transport.messages).toEqual([ + { + id: "boundary", + type: "error", + message: "Script timed out after 100ms and was terminated.", + }, + ]); + expect(transport.listeners.size).toBe(0); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/daemon/src/execute-request.ts b/daemon/src/execute-request.ts new file mode 100644 index 00000000..8f589c82 --- /dev/null +++ b/daemon/src/execute-request.ts @@ -0,0 +1,225 @@ +import { formatError } from "./format-error.js"; +import type { ExecuteRequest, Response } from "./protocol.js"; + +export interface ExecuteRequestTransport { + isOpen(): boolean; + onDisconnect(listener: () => void): () => void; + send(message: Response): Promise; +} + +interface ScriptOutput { + onStdout(data: string): void; + onStderr(data: string): void; +} + +export interface ExecuteRequestDependencies { + prepareBrowser( + request: ExecuteRequest, + context: { deadline: number; signal: AbortSignal } + ): Promise; + runScript( + request: ExecuteRequest, + output: ScriptOutput, + context: { deadline: number; signal: AbortSignal } + ): Promise; + withBrowserLock( + browser: string, + action: () => Promise, + options: { signal: AbortSignal } + ): Promise; +} + +class RequestTimeoutError extends Error { + constructor(timeoutMs: number) { + super(`Script timed out after ${formatTimeoutDuration(timeoutMs)} and was terminated.`); + this.name = "ScriptTimeoutError"; + } +} + +class RequestDisconnectedError extends Error { + constructor() { + super("Client disconnected"); + this.name = "RequestDisconnectedError"; + } +} + +function formatTimeoutDuration(timeoutMs: number): string { + if (timeoutMs % 1_000 === 0) { + return `${timeoutMs / 1_000}s`; + } + + return `${timeoutMs}ms`; +} + +function abortReason(signal: AbortSignal): Error { + return signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)); +} + +class RequestSession { + readonly deadline: number; + readonly signal: AbortSignal; + + readonly #controller = new AbortController(); + readonly #detachDisconnect: () => void; + readonly #request: ExecuteRequest; + readonly #timeout: ReturnType; + readonly #timeoutMs: number; + readonly #transport: ExecuteRequestTransport; + + #active = true; + #messages = Promise.resolve(); + #terminal = Promise.resolve(); + + constructor(request: ExecuteRequest, timeoutMs: number, transport: ExecuteRequestTransport) { + this.#request = request; + this.#timeoutMs = timeoutMs; + this.#transport = transport; + this.deadline = Date.now() + timeoutMs; + this.signal = this.#controller.signal; + this.#detachDisconnect = transport.onDisconnect(() => { + this.#disconnect(); + }); + this.#timeout = setTimeout(() => { + this.#finish( + { + id: request.id, + type: "error", + message: new RequestTimeoutError(timeoutMs).message, + }, + new RequestTimeoutError(timeoutMs) + ); + }, timeoutMs); + this.#timeout.unref?.(); + } + + stream(type: "stdout" | "stderr", data: string): void { + if (!this.#active) { + return; + } + + this.#messages = this.#messages + .then(async () => { + if (this.#transport.isOpen()) { + await this.#transport.send({ id: this.#request.id, type, data }); + } + }) + .catch(() => undefined); + } + + async complete(): Promise { + this.#finish({ id: this.#request.id, type: "complete", success: true }); + await this.#terminal; + } + + async fail(error: unknown): Promise { + this.#finish({ id: this.#request.id, type: "error", message: formatError(error) }); + await this.#terminal; + } + + throwIfAborted(): void { + if (this.signal.aborted) { + throw abortReason(this.signal); + } + } + + throwIfAbortedOrExpired(): void { + this.throwIfAborted(); + if (Date.now() < this.deadline) { + return; + } + + const error = new RequestTimeoutError(this.#timeoutMs); + this.#finish( + { + id: this.#request.id, + type: "error", + message: error.message, + }, + error + ); + throw error; + } + + async dispose(): Promise { + clearTimeout(this.#timeout); + this.#detachDisconnect(); + await this.#terminal; + } + + #disconnect(): void { + if (!this.#active) { + return; + } + + this.#active = false; + clearTimeout(this.#timeout); + this.#controller.abort(new RequestDisconnectedError()); + } + + #finish(message: Response, abortError?: Error): void { + if (!this.#active) { + return; + } + + this.#active = false; + clearTimeout(this.#timeout); + if (abortError) { + this.#controller.abort(abortError); + } + this.#terminal = this.#messages + .then(async () => { + if (this.#transport.isOpen()) { + await this.#transport.send(message); + } + }) + .catch(() => undefined); + } +} + +export async function executeRequest( + request: ExecuteRequest, + timeoutMs: number, + transport: ExecuteRequestTransport, + dependencies: ExecuteRequestDependencies +): Promise { + const session = new RequestSession(request, timeoutMs, transport); + + try { + await dependencies.withBrowserLock( + request.browser, + async () => { + session.throwIfAbortedOrExpired(); + await dependencies.prepareBrowser(request, { + deadline: session.deadline, + signal: session.signal, + }); + session.throwIfAbortedOrExpired(); + await dependencies.runScript( + request, + { + onStdout: (data) => session.stream("stdout", data), + onStderr: (data) => session.stream("stderr", data), + }, + { + deadline: session.deadline, + signal: session.signal, + } + ); + session.throwIfAbortedOrExpired(); + }, + { signal: session.signal } + ); + + session.throwIfAbortedOrExpired(); + await session.complete(); + } catch (error) { + try { + session.throwIfAbortedOrExpired(); + } catch { + // A timeout or disconnect already owns the terminal outcome. + } + await session.fail(error); + } finally { + await session.dispose(); + } +} diff --git a/daemon/src/format-error.test.ts b/daemon/src/format-error.test.ts new file mode 100644 index 00000000..602ac665 --- /dev/null +++ b/daemon/src/format-error.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { formatError } from "./format-error.js"; + +describe("formatError", () => { + it("composes a name/message header when the stack has none", () => { + const error = new Error("QuickJS promise rejected: boom message"); + error.stack = " at (user-script.js:2:15)"; + + const formatted = formatError(error); + + expect(formatted).toContain("Error: QuickJS promise rejected: boom message"); + expect(formatted).toContain("at (user-script.js:2:15)"); + }); + + it("does not duplicate the header when the stack already has one", () => { + const error = new Error("native failure"); + + const formatted = formatError(error); + + expect(formatted).toBe(error.stack); + expect(formatted.indexOf("Error: native failure")).toBe( + formatted.lastIndexOf("Error: native failure") + ); + }); + + it("falls back to the header when there is no stack", () => { + const error = new Error("no stack here"); + error.stack = undefined; + + expect(formatError(error)).toBe("Error: no stack here"); + }); + + it("returns only the message for script timeouts", () => { + const error = new Error("Script timed out after 30000ms"); + error.name = "ScriptTimeoutError"; + + expect(formatError(error)).toBe("Script timed out after 30000ms"); + }); + + it("stringifies non-error values", () => { + expect(formatError("plain failure")).toBe("plain failure"); + }); +}); diff --git a/daemon/src/format-error.ts b/daemon/src/format-error.ts new file mode 100644 index 00000000..90957094 --- /dev/null +++ b/daemon/src/format-error.ts @@ -0,0 +1,14 @@ +export function formatError(error: unknown): string { + if (error instanceof Error) { + if (error.name === "ScriptTimeoutError") { + return error.message; + } + const header = `${error.name}: ${error.message}`; + if (!error.stack) { + return header; + } + return error.stack.startsWith(header) ? error.stack : `${header}\n${error.stack}`; + } + + return String(error); +} diff --git a/daemon/src/idle-browser-reaper.test.ts b/daemon/src/idle-browser-reaper.test.ts new file mode 100644 index 00000000..98535c3b --- /dev/null +++ b/daemon/src/idle-browser-reaper.test.ts @@ -0,0 +1,175 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { IdleBrowserReaper, type IdleBrowserSummary } from "./idle-browser-reaper.js"; +import { createKeyedLock } from "./lock.js"; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((innerResolve) => { + resolve = innerResolve; + }); + return { promise, resolve }; +} + +function createHarness(initialBrowsers: IdleBrowserSummary[]) { + const browsers = new Map(initialBrowsers.map((browser) => [browser.name, browser])); + const stopped: string[] = []; + const withBrowserLock = createKeyedLock(); + const reaper = new IdleBrowserReaper({ + listBrowsers: () => [...browsers.values()], + stopBrowser: async (name) => { + stopped.push(name); + browsers.delete(name); + }, + withBrowserLock, + }); + return { browsers, reaper, stopped, withBrowserLock }; +} + +describe("IdleBrowserReaper", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("fires at the pinned idle deadline despite recurring background work", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { reaper, stopped } = createHarness([{ name: "managed", type: "launched" }]); + reaper.configure(1_000); + reaper.requestStarted("managed"); + reaper.requestFinished("managed"); + + let backgroundTicks = 0; + const backgroundWork = setInterval(() => { + backgroundTicks += 1; + }, 100); + + await vi.advanceTimersByTimeAsync(999); + expect(backgroundTicks).toBeGreaterThan(0); + expect(stopped).toEqual([]); + + await vi.advanceTimersByTimeAsync(1); + expect(stopped).toEqual(["managed"]); + + clearInterval(backgroundWork); + reaper.dispose(); + }); + + it("does not close an active request and starts its idle window at completion", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { reaper, stopped } = createHarness([{ name: "active", type: "launched" }]); + reaper.configure(100); + reaper.requestStarted("active"); + + await vi.advanceTimersByTimeAsync(500); + expect(stopped).toEqual([]); + expect(reaper.idleInfo({ name: "active", type: "launched" }).activeRequests).toBe(1); + + reaper.requestFinished("active"); + await vi.advanceTimersByTimeAsync(99); + expect(stopped).toEqual([]); + await vi.advanceTimersByTimeAsync(1); + expect(stopped).toEqual(["active"]); + reaper.dispose(); + }); + + it("tracks idle deadlines independently for each named browser", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { reaper, stopped } = createHarness([ + { name: "first", type: "launched" }, + { name: "second", type: "launched" }, + ]); + reaper.configure(100); + reaper.requestStarted("first"); + reaper.requestFinished("first"); + reaper.requestStarted("second"); + reaper.requestFinished("second"); + + await vi.advanceTimersByTimeAsync(50); + reaper.requestStarted("second"); + reaper.requestFinished("second"); + + await vi.advanceTimersByTimeAsync(50); + expect(stopped).toEqual(["first"]); + await vi.advanceTimersByTimeAsync(50); + expect(stopped).toEqual(["first", "second"]); + reaper.dispose(); + }); + + it("acquires the browser lock and rechecks activity before closing", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { reaper, stopped, withBrowserLock } = createHarness([ + { name: "racing", type: "launched" }, + ]); + reaper.configure(100); + reaper.requestStarted("racing"); + reaper.requestFinished("racing"); + + const releaseLock = deferred(); + const lockAcquired = deferred(); + const heldLock = withBrowserLock("racing", async () => { + lockAcquired.resolve(); + await releaseLock.promise; + }); + await lockAcquired.promise; + + await vi.advanceTimersByTimeAsync(100); + reaper.requestStarted("racing"); + releaseLock.resolve(); + await heldLock; + await vi.advanceTimersByTimeAsync(0); + expect(stopped).toEqual([]); + + reaper.requestFinished("racing"); + await vi.advanceTimersByTimeAsync(100); + expect(stopped).toEqual(["racing"]); + reaper.dispose(); + }); + + it("never closes externally connected Chrome", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { reaper, stopped } = createHarness([{ name: "external", type: "connected" }]); + reaper.configure(100); + reaper.requestStarted("external"); + reaper.requestFinished("external"); + + await vi.advanceTimersByTimeAsync(1_000); + expect(stopped).toEqual([]); + reaper.dispose(); + }); + + it("disables cleanup when configured to zero", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { reaper, stopped } = createHarness([{ name: "disabled", type: "launched" }]); + reaper.configure(100); + reaper.requestStarted("disabled"); + reaper.requestFinished("disabled"); + reaper.configure(0); + + await vi.advanceTimersByTimeAsync(1_000); + expect(stopped).toEqual([]); + reaper.dispose(); + }); + + it("applies timeout changes without restarting the daemon", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const { reaper, stopped } = createHarness([{ name: "managed", type: "launched" }]); + reaper.configure(1_000); + reaper.requestStarted("managed"); + reaper.requestFinished("managed"); + await vi.advanceTimersByTimeAsync(200); + + reaper.configure(300); + await vi.advanceTimersByTimeAsync(99); + expect(stopped).toEqual([]); + await vi.advanceTimersByTimeAsync(1); + expect(stopped).toEqual(["managed"]); + reaper.dispose(); + }); +}); diff --git a/daemon/src/idle-browser-reaper.ts b/daemon/src/idle-browser-reaper.ts new file mode 100644 index 00000000..f93d53ea --- /dev/null +++ b/daemon/src/idle-browser-reaper.ts @@ -0,0 +1,198 @@ +export interface IdleBrowserSummary { + name: string; + type: "launched" | "connected"; +} + +export interface BrowserIdleInfo { + activeRequests: number; + idleForMs?: number; + idleRemainingMs?: number; +} + +interface ActivityState { + activeRequests: number; + lastActivityAt: number; +} + +interface IdleBrowserReaperDependencies { + listBrowsers(): IdleBrowserSummary[]; + stopBrowser(name: string): Promise; + withBrowserLock(name: string, action: () => Promise): Promise; + now?: () => number; +} + +const MAX_TIMER_DELAY_MS = 2_147_483_647; + +export class IdleBrowserReaper { + readonly #activity = new Map(); + readonly #dependencies: IdleBrowserReaperDependencies; + readonly #now: () => number; + + #idleTimeoutMs = 0; + #timer: ReturnType | null = null; + + constructor(dependencies: IdleBrowserReaperDependencies) { + this.#dependencies = dependencies; + this.#now = dependencies.now ?? Date.now; + } + + configure(idleTimeoutMs: number): void { + if (idleTimeoutMs === this.#idleTimeoutMs) { + return; + } + + this.#idleTimeoutMs = idleTimeoutMs; + this.#scheduleNextDeadline(); + } + + get idleTimeoutMs(): number { + return this.#idleTimeoutMs; + } + + requestStarted(browserName: string): void { + const state = this.#getOrCreateActivity(browserName); + state.activeRequests += 1; + state.lastActivityAt = this.#now(); + this.#scheduleNextDeadline(); + } + + requestFinished(browserName: string): void { + const state = this.#getOrCreateActivity(browserName); + state.activeRequests = Math.max(0, state.activeRequests - 1); + state.lastActivityAt = this.#now(); + this.#scheduleNextDeadline(); + } + + browserStopped(browserName: string): void { + this.#activity.delete(browserName); + this.#scheduleNextDeadline(); + } + + idleInfo(browser: IdleBrowserSummary): BrowserIdleInfo { + const state = this.#activity.get(browser.name); + if (!state) { + return { activeRequests: 0 }; + } + + const now = this.#now(); + const idleForMs = Math.max(0, now - state.lastActivityAt); + const info: BrowserIdleInfo = { + activeRequests: state.activeRequests, + idleForMs, + }; + + if (browser.type === "launched" && this.#idleTimeoutMs > 0 && state.activeRequests === 0) { + info.idleRemainingMs = Math.max(0, this.#idleTimeoutMs - idleForMs); + } + + return info; + } + + dispose(): void { + if (this.#timer) { + clearTimeout(this.#timer); + this.#timer = null; + } + } + + #getOrCreateActivity(browserName: string): ActivityState { + let state = this.#activity.get(browserName); + if (!state) { + state = { activeRequests: 0, lastActivityAt: this.#now() }; + this.#activity.set(browserName, state); + } + return state; + } + + #scheduleNextDeadline(): void { + if (this.#timer) { + clearTimeout(this.#timer); + this.#timer = null; + } + + if (this.#idleTimeoutMs === 0) { + return; + } + + const now = this.#now(); + let earliestDeadline: number | undefined; + + for (const browser of this.#dependencies.listBrowsers()) { + if (browser.type !== "launched") { + continue; + } + + const state = this.#getOrCreateActivity(browser.name); + if (state.activeRequests > 0) { + continue; + } + + const deadline = state.lastActivityAt + this.#idleTimeoutMs; + earliestDeadline = + earliestDeadline === undefined ? deadline : Math.min(earliestDeadline, deadline); + } + + if (earliestDeadline === undefined) { + return; + } + + const delay = Math.min(MAX_TIMER_DELAY_MS, Math.max(0, earliestDeadline - now)); + this.#timer = setTimeout(() => { + this.#timer = null; + void this.#reapDueBrowsers().finally(() => this.#scheduleNextDeadline()); + }, delay); + this.#timer.unref?.(); + } + + async #reapDueBrowsers(): Promise { + if (this.#idleTimeoutMs === 0) { + return; + } + + const now = this.#now(); + const candidates = this.#dependencies + .listBrowsers() + .filter((browser) => { + const state = this.#activity.get(browser.name); + return ( + browser.type === "launched" && + state !== undefined && + state.activeRequests === 0 && + now - state.lastActivityAt >= this.#idleTimeoutMs + ); + }) + .map((browser) => browser.name); + + await Promise.allSettled( + candidates.map(async (browserName) => { + await this.#dependencies.withBrowserLock(browserName, async () => { + const browser = this.#dependencies + .listBrowsers() + .find((candidate) => candidate.name === browserName); + const state = this.#activity.get(browserName); + + // Recheck after acquiring the same lock used by scripts and explicit stops. + // A request that started or completed while the reaper waited gets a fresh deadline. + if ( + !browser || + browser.type !== "launched" || + !state || + state.activeRequests > 0 || + this.#idleTimeoutMs === 0 || + this.#now() - state.lastActivityAt < this.#idleTimeoutMs + ) { + return; + } + + try { + await this.#dependencies.stopBrowser(browserName); + this.#activity.delete(browserName); + } catch { + // Avoid a tight retry loop if a close fails unexpectedly. + state.lastActivityAt = this.#now(); + } + }); + }) + ); + } +} diff --git a/daemon/src/lock.ts b/daemon/src/lock.ts index df893738..301465a8 100644 --- a/daemon/src/lock.ts +++ b/daemon/src/lock.ts @@ -1,26 +1,61 @@ type AsyncAction = () => Promise; +function abortReason(signal: AbortSignal): Error { + return signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)); +} + +async function waitForTurn(previous: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + throw abortReason(signal); + } + + await new Promise((resolve, reject) => { + const onAbort = () => { + signal.removeEventListener("abort", onAbort); + reject(abortReason(signal)); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + previous.then(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }); + }); +} + export function createKeyedLock() { const locks = new Map>(); - return async function withLock(key: K, action: AsyncAction): Promise { - const previous = locks.get(key) ?? Promise.resolve(); + return async function withLock( + key: K, + action: AsyncAction, + options: { signal?: AbortSignal } = {} + ): Promise { + const previous = (locks.get(key) ?? Promise.resolve()).catch(() => undefined); let release!: () => void; const current = new Promise((resolve) => { release = resolve; }); - const tail = previous.catch(() => undefined).then(() => current); + const tail = previous.then(() => current); locks.set(key, tail); - await previous.catch(() => undefined); - try { + if (options.signal) { + await waitForTurn(previous, options.signal); + } else { + await previous; + } + if (options.signal?.aborted) { + throw abortReason(options.signal); + } return await action(); } finally { release(); - if (locks.get(key) === tail) { - locks.delete(key); - } + void tail.then(() => { + if (locks.get(key) === tail) { + locks.delete(key); + } + }); } }; } diff --git a/daemon/src/protocol.test.ts b/daemon/src/protocol.test.ts new file mode 100644 index 00000000..4b4bf2f3 --- /dev/null +++ b/daemon/src/protocol.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { parseRequest } from "./protocol.js"; + +describe("idle timeout protocol configuration", () => { + it("accepts a non-negative safe integer on requests", () => { + expect( + parseRequest( + JSON.stringify({ + id: "status-1", + type: "status", + idleTimeoutMs: 300_000, + }) + ) + ).toEqual({ + success: true, + request: { + id: "status-1", + type: "status", + idleTimeoutMs: 300_000, + }, + }); + + expect( + parseRequest(JSON.stringify({ id: "status-2", type: "status", idleTimeoutMs: 0 })) + ).toEqual({ + success: true, + request: { id: "status-2", type: "status", idleTimeoutMs: 0 }, + }); + }); + + it("rejects negative or unsafe timeout values", () => { + expect( + parseRequest(JSON.stringify({ id: "negative", type: "status", idleTimeoutMs: -1 })).success + ).toBe(false); + expect( + parseRequest( + JSON.stringify({ + id: "unsafe", + type: "status", + idleTimeoutMs: Number.MAX_SAFE_INTEGER + 1, + }) + ).success + ).toBe(false); + }); +}); diff --git a/daemon/src/protocol.ts b/daemon/src/protocol.ts index 07e7a703..9003307e 100644 --- a/daemon/src/protocol.ts +++ b/daemon/src/protocol.ts @@ -2,6 +2,7 @@ import { z } from "zod"; const RequestBaseSchema = z.object({ id: z.string().min(1), + idleTimeoutMs: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).optional(), }); const ExecuteRequestSchema = RequestBaseSchema.extend({ diff --git a/daemon/src/sandbox/__tests__/auto-connect.test.ts b/daemon/src/sandbox/__tests__/auto-connect.test.ts index 56022523..0025c34c 100644 --- a/daemon/src/sandbox/__tests__/auto-connect.test.ts +++ b/daemon/src/sandbox/__tests__/auto-connect.test.ts @@ -169,7 +169,7 @@ function createManager( options.fetch ?? (vi.fn(async () => { throw new Error("unexpected fetch"); - }) as typeof globalThis.fetch); + }) as unknown as typeof globalThis.fetch); const readFile = options.readFile ?? (vi.fn(async (filePath: string) => { @@ -177,9 +177,7 @@ function createManager( }) as ReturnType); const launchPersistentContext = options.launchPersistentContext ?? (vi.fn() as ReturnType); - const readdir = - options.readdir ?? - (vi.fn(async () => []) as ReturnType); + const readdir = options.readdir ?? (vi.fn(async () => []) as ReturnType); const manager = new BrowserManager(path.join("/tmp", "dev-browser-auto-connect-tests"), { connectOverCDP: connectOverCDP as never, @@ -212,6 +210,94 @@ afterEach(() => { }); describe("BrowserManager auto-connect", () => { + it.each([false, true])( + "launches the configured executable with headless=%s", + async (headless) => { + const context = new MockContext(); + context.setBrowser(new MockBrowser([context])); + const launchPersistentContext = vi.fn(async () => context); + const executablePath = "/opt/chromium-stealthcdp/chrome"; + const readFile = vi.fn(async (filePath: string) => { + expect(filePath).toBe(path.join("/Users/tester", ".dev-browser", "config.json")); + return JSON.stringify({ executablePath, idleTimeout: "5m" }); + }); + const { manager } = createManager({ + platform: "linux", + isWsl: true, + readFile, + launchPersistentContext, + }); + await manager.ensureBrowser("stealth", { headless }); + expect(launchPersistentContext).toHaveBeenCalledWith( + expect.stringContaining(path.join("stealth", "chromium-profile")), + expect.objectContaining({ executablePath, headless }) + ); + expect(manager.listBrowsers()).toEqual([ + expect.objectContaining({ name: "stealth", executablePath }), + ]); + await manager.stopAll(); + } + ); + + it.each(["{}", '{"idleTimeout":"5m"}'])( + "keeps bundled Chromium for config %s", + async (contents) => { + const context = new MockContext(); + context.setBrowser(new MockBrowser([context])); + const launchPersistentContext = vi.fn(async () => context); + const { manager } = createManager({ + readFile: vi.fn(async () => contents), + launchPersistentContext, + }); + await manager.ensureBrowser("bundled"); + expect(launchPersistentContext).toHaveBeenCalledWith( + expect.any(String), + expect.not.objectContaining({ executablePath: expect.anything() }) + ); + await manager.stopAll(); + } + ); + + it.each([ + "invalid-json", + "null", + "[]", + '{"executablePath":""}', + '{"executablePath":"relative/chrome"}', + '{"executablePath":42}', + ])("rejects invalid browser configuration without launching: %s", async (contents) => { + const { manager, launchPersistentContext } = createManager({ + readFile: vi.fn(async () => contents), + }); + await expect(manager.ensureBrowser("invalid")).rejects.toThrow("Invalid"); + expect(launchPersistentContext).not.toHaveBeenCalled(); + }); + + it("does not silently use bundled Chromium when the configured executable fails", async () => { + const launchPersistentContext = vi.fn(async () => { + throw new Error("executable does not exist"); + }); + const { manager } = createManager({ + readFile: vi.fn(async () => '{"executablePath":"/missing/chrome"}'), + launchPersistentContext, + }); + await expect(manager.ensureBrowser("missing")).rejects.toThrow("executable does not exist"); + expect(launchPersistentContext).toHaveBeenCalledTimes(1); + }); + + it("does not read the launch executable configuration when attaching over CDP", async () => { + const readFile = vi.fn(async () => { + throw new Error("must not read launch config"); + }); + const { manager } = createManager({ + readFile, + connectOverCDP: vi.fn(async () => new MockBrowser([new MockContext()])), + }); + await manager.connectBrowser("external", "ws://127.0.0.1:9333/devtools/browser/external"); + expect(readFile).not.toHaveBeenCalled(); + await manager.stopAll(); + }); + it("passes ignoreHTTPSErrors to launched browsers and only relaunches when it changes", async () => { const launchPersistentContext = vi.fn(async () => { const context = new MockContext(); @@ -289,6 +375,29 @@ describe("BrowserManager auto-connect", () => { expect(relaunchedEntry.ignoreHTTPSErrors).toBe(true); }); + it("closes a persistent context that returns after its request is aborted", async () => { + const controller = new AbortController(); + const context = new MockContext(); + const browser = new MockBrowser([context]); + context.setBrowser(browser); + const launchPersistentContext = vi.fn(async () => { + controller.abort(new Error("launch request disconnected")); + return context; + }); + const { manager } = createManager({ launchPersistentContext }); + + await expect( + manager.ensureBrowser("late-launch", { + headless: true, + signal: controller.signal, + }) + ).rejects.toThrow("launch request disconnected"); + + expect(context.closeCalls).toBe(1); + expect(browser.closeCalls).toBe(1); + expect(manager.getBrowser("late-launch")).toBeUndefined(); + }); + it("parses DevToolsActivePort and returns the browser websocket endpoint", async () => { const homeDir = "/Users/tester"; const devToolsPath = path.join( @@ -345,7 +454,7 @@ describe("BrowserManager auto-connect", () => { }); it("checks a custom profile path for DevToolsActivePort before default locations", async () => { - const customProfilePath = "/tmp/custom-chrome-profile"; + const customProfilePath = path.resolve("/tmp/custom-chrome-profile"); const devToolsPath = path.join(customProfilePath, "DevToolsActivePort"); const readFile = vi.fn(async (filePath: string) => { if (filePath === devToolsPath) { @@ -413,7 +522,7 @@ describe("BrowserManager auto-connect", () => { throw createEnoentError(filePath); }); - const fetch = vi.fn() as typeof globalThis.fetch; + const fetch = vi.fn() as unknown as typeof globalThis.fetch; const { manager } = createManager({ fetch, homedir: () => homeDir, readFile }); await expect(getInternals(manager).discoverChrome()).resolves.toBe(websocketUrl); @@ -489,7 +598,7 @@ describe("BrowserManager auto-connect", () => { }, } ); - }) as typeof globalThis.fetch; + }) as unknown as typeof globalThis.fetch; const { manager } = createManager({ connectOverCDP, fetch, @@ -527,6 +636,25 @@ describe("BrowserManager auto-connect", () => { ]); }); + it("closes a CDP browser connection that returns after its request is aborted", async () => { + const controller = new AbortController(); + const browser = new MockBrowser([new MockContext()]); + const connectOverCDP = vi.fn(async () => { + controller.abort(new Error("connect request disconnected")); + return browser; + }); + const { manager } = createManager({ connectOverCDP }); + + await expect( + manager.connectBrowser("late-connect", "ws://127.0.0.1:9222/devtools/browser/late", { + signal: controller.signal, + }) + ).rejects.toThrow("connect request disconnected"); + + expect(browser.closeCalls).toBe(1); + expect(manager.getBrowser("late-connect")).toBeUndefined(); + }); + it("getBrowser returns connected entries without relaunching them", async () => { const browser = new MockBrowser([new MockContext()]); const connectOverCDP = vi.fn(async () => browser); @@ -568,7 +696,7 @@ describe("BrowserManager auto-connect", () => { const fetch = vi.fn(async (input: RequestInfo | URL) => { expect(String(input)).toBe("http://127.0.0.1:9222/json/version"); return new Response("not found", { status: 404 }); - }) as typeof globalThis.fetch; + }) as unknown as typeof globalThis.fetch; const readFile = vi.fn(async (filePath: string) => { if (filePath === devToolsPath) { return "9222\n/devtools/browser/from-active-port\n"; @@ -601,7 +729,7 @@ describe("BrowserManager auto-connect", () => { }); const fetch = vi.fn( async () => new Response("not found", { status: 404 }) - ) as typeof globalThis.fetch; + ) as unknown as typeof globalThis.fetch; const { manager } = createManager({ fetch, homedir: () => homeDir, @@ -662,7 +790,7 @@ describe("BrowserManager auto-connect", () => { } throw new Error("connection refused"); - }) as typeof globalThis.fetch; + }) as unknown as typeof globalThis.fetch; const readFile = vi.fn(async (filePath: string) => { throw createEnoentError(filePath); }); @@ -728,6 +856,56 @@ describe("BrowserManager auto-connect", () => { expect(connectOverCDP).toHaveBeenCalledWith("ws://127.0.0.1:9333/devtools/browser/custom-port"); }); + it("preserves cancellation and deadlines when connecting through a custom profile", async () => { + const controller = new AbortController(); + const browser = new MockBrowser([new MockContext()]); + const endpoint = "ws://127.0.0.1:9333/devtools/browser/custom-profile"; + const connectOverCDP = vi.fn(async (_endpoint: string, options?: { timeout: number }) => { + expect(options?.timeout).toBeGreaterThan(0); + expect(options?.timeout).toBeLessThanOrEqual(5000); + controller.abort(new Error("custom profile request disconnected")); + return browser; + }); + const readFile = vi.fn(async (filePath: string) => { + if (filePath === path.join(path.resolve("/custom/profile"), "DevToolsActivePort")) { + return "9333\n/devtools/browser/custom-profile\n"; + } + throw createEnoentError(filePath); + }); + const { manager, fetch } = createManager({ connectOverCDP, readFile }); + await expect( + manager.connectBrowser("custom", "auto", { + profilePath: "/custom/profile", + port: 9333, + deadline: Date.now() + 5000, + signal: controller.signal, + }) + ).rejects.toThrow("custom profile request disconnected"); + expect(connectOverCDP).toHaveBeenCalledWith(endpoint, { timeout: expect.any(Number) }); + expect(browser.closeCalls).toBe(1); + expect(manager.getBrowser("custom")).toBeUndefined(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("does not attach after a custom port probe is cancelled", async () => { + const controller = new AbortController(); + const fetch = vi.fn(async () => { + controller.abort(new Error("custom port request disconnected")); + return new Response( + JSON.stringify({ + webSocketDebuggerUrl: "ws://127.0.0.1:9333/devtools/browser/custom-port", + }), + { status: 200 } + ); + }); + const { manager, connectOverCDP } = createManager({ fetch: fetch as typeof globalThis.fetch }); + await expect( + manager.autoConnect("custom-port", { port: 9333, signal: controller.signal }) + ).rejects.toThrow("custom port request disconnected"); + expect(fetch).toHaveBeenCalledTimes(1); + expect(connectOverCDP).not.toHaveBeenCalled(); + }); + it("autoConnect discovers Windows Chrome profiles when running under WSL", async () => { const browser = new MockBrowser([new MockContext()]); const connectOverCDP = vi.fn(async () => browser); @@ -740,9 +918,7 @@ describe("BrowserManager auto-connect", () => { const readFile = vi.fn(async (filePath: string) => { if ( filePath === - path.join( - "/mnt/c/Users/ecoch/AppData/Local/Google/Chrome/User Data/DevToolsActivePort" - ) + path.join("/mnt/c/Users/ecoch/AppData/Local/Google/Chrome/User Data/DevToolsActivePort") ) { return "9222\n/devtools/browser/wsl-discovered\n"; } @@ -759,11 +935,13 @@ describe("BrowserManager auto-connect", () => { await manager.autoConnect("wsl-browser"); - expect(readdir).toHaveBeenCalledWith("/mnt/c/Users", { + expect(readdir).toHaveBeenCalledWith(path.join("/mnt", "c", "Users"), { encoding: "utf8", withFileTypes: true, }); - expect(connectOverCDP).toHaveBeenCalledWith("ws://127.0.0.1:9222/devtools/browser/wsl-discovered"); + expect(connectOverCDP).toHaveBeenCalledWith( + "ws://127.0.0.1:9222/devtools/browser/wsl-discovered" + ); }); it("autoConnect falls back from DevToolsActivePort to port probing when the direct websocket is stale", async () => { @@ -810,7 +988,7 @@ describe("BrowserManager auto-connect", () => { } throw new Error("connection refused"); - }) as typeof globalThis.fetch; + }) as unknown as typeof globalThis.fetch; const readFile = vi.fn(async (filePath: string) => { if (filePath === devToolsPath) { return "9222\n/devtools/browser/from-active-port\n"; @@ -836,7 +1014,7 @@ describe("BrowserManager auto-connect", () => { ]); }); - it("autoConnect discovers agent-browser managed sessions via the local daemon socket", async () => { + it("autoConnect discovers agent-browser managed sessions via the native daemon transport", async () => { const socketDir = await mkdtemp(path.join(os.tmpdir(), "dev-browser-agent-browser-")); const sessionName = "managed-session"; const socketPath = path.join(socketDir, `${sessionName}.sock`); @@ -877,12 +1055,20 @@ describe("BrowserManager auto-connect", () => { await new Promise((resolve, reject) => { server.once("error", reject); - server.listen(socketPath, () => { - server.off("error", reject); - resolve(); - }); + server.listen( + process.platform === "win32" ? { host: "127.0.0.1", port: 0 } : socketPath, + () => { + server.off("error", reject); + resolve(); + } + ); }); + if (process.platform === "win32") { + const address = server.address() as net.AddressInfo; + await writeFile(path.join(socketDir, `${sessionName}.port`), `${address.port}\n`); + } + const connectOverCDP = vi.fn(async (endpoint: string) => { expect(endpoint).toBe(cdpUrl); const context = new MockContext(); @@ -897,6 +1083,7 @@ describe("BrowserManager auto-connect", () => { try { const { manager } = createManager({ connectOverCDP, + platform: process.platform, readFile: vi.fn(async (filePath: string) => readFileFromFs(filePath, "utf8")), }); @@ -924,7 +1111,7 @@ describe("BrowserManager auto-connect", () => { }); const fetch = vi.fn(async () => { throw new Error("connection refused"); - }) as typeof globalThis.fetch; + }) as unknown as typeof globalThis.fetch; const { manager } = createManager({ fetch, readFile, @@ -941,7 +1128,7 @@ describe("BrowserManager auto-connect", () => { }); const fetch = vi.fn(async () => { throw new Error("connection refused"); - }) as typeof globalThis.fetch; + }) as unknown as typeof globalThis.fetch; const { manager } = createManager({ fetch, platform: "win32", diff --git a/daemon/src/sandbox/__tests__/cua.test.ts b/daemon/src/sandbox/__tests__/cua.test.ts new file mode 100644 index 00000000..08f14d8e --- /dev/null +++ b/daemon/src/sandbox/__tests__/cua.test.ts @@ -0,0 +1,939 @@ +import { once } from "node:events"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import os from "node:os"; +import path from "node:path"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { BrowserManager } from "../../browser-manager.js"; +import { DEV_BROWSER_TMP_DIR } from "../../temp-files.js"; +import { removeDirectoryWithRetries } from "../../test-cleanup.js"; +import { QuickJSSandbox } from "../quickjs-sandbox.js"; +import { ensureSandboxClientBundle } from "./bundle-test-helpers.js"; + +const SANDBOX_TIMEOUT_MS = 60_000; + +const CUA_TEST_PAGE_HTML = String.raw` + + + CUA Test Page + + + +
+ +
+ + +`; + +interface CapturedOutput { + stdout: string[]; + stderr: string[]; +} + +interface JsonSandboxHarness { + dispose: () => Promise; + runJson: (script: string) => Promise; +} + +interface NavigationServer { + baseUrl: string; + close: () => Promise; +} + +interface RecordedClick { + x: number; + y: number; + button: number; + detail: number; + shiftKey: boolean; + altKey: boolean; +} + +interface RecordedMouseEvent { + type: string; + x: number; + y: number; + button: number; +} + +interface RecordedKeyEvent { + key: string; + code: string; + shiftKey: boolean; + ctrlKey: boolean; + metaKey: boolean; +} + +interface ScreenshotResult { + path: string; + width: number; + height: number; +} + +function createOutput(): CapturedOutput & { + sink: { + onStdout: (data: string) => void; + onStderr: (data: string) => void; + }; +} { + const stdout: string[] = []; + const stderr: string[] = []; + + return { + stdout, + stderr, + sink: { + onStdout: (data) => { + stdout.push(data); + }, + onStderr: (data) => { + stderr.push(data); + }, + }, + }; +} + +function clearOutput(output: CapturedOutput): void { + output.stdout.length = 0; + output.stderr.length = 0; +} + +function outputLines(output: CapturedOutput): string[] { + return output.stdout.map((line) => line.trim()).filter((line) => line.length > 0); +} + +function parseLastJsonLine(output: CapturedOutput): T { + const lines = outputLines(output); + expect(lines.length).toBeGreaterThan(0); + return JSON.parse(lines.at(-1)!) as T; +} + +function withCuaPage(pageName: string, body: string): string { + return ` + const page = await browser.getPage(${JSON.stringify(pageName)}); + await page.setContent(${JSON.stringify(CUA_TEST_PAGE_HTML)}, { waitUntil: "load" }); + ${body} + `; +} + +async function createSandboxHarness( + manager: BrowserManager, + browserName: string +): Promise { + await manager.ensureBrowser(browserName, { + headless: true, + }); + + const output = createOutput(); + const sandbox = new QuickJSSandbox({ + manager, + browserName, + onStdout: output.sink.onStdout, + onStderr: output.sink.onStderr, + timeoutMs: SANDBOX_TIMEOUT_MS, + }); + + await sandbox.initialize(); + + return { + dispose: async () => { + await sandbox.dispose(); + }, + runJson: async (script: string): Promise => { + clearOutput(output); + await sandbox.executeScript(`(async () => {\n${script}\n})()`); + expect(output.stderr).toEqual([]); + return parseLastJsonLine(output); + }, + }; +} + +function readJpegDimensions(data: Buffer): { width: number; height: number } { + expect(data[0]).toBe(0xff); + expect(data[1]).toBe(0xd8); + expect(data[2]).toBe(0xff); + + let offset = 2; + while (offset + 4 <= data.length) { + if (data[offset] !== 0xff) { + throw new Error("Invalid JPEG segment"); + } + const marker = data[offset + 1]; + if (marker === undefined) { + break; + } + if (marker === 0xff) { + offset += 1; + continue; + } + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) { + offset += 2; + continue; + } + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { + return { + height: data.readUInt16BE(offset + 5), + width: data.readUInt16BE(offset + 7), + }; + } + offset += 2 + data.readUInt16BE(offset + 2); + } + + throw new Error("No JPEG SOF marker found"); +} + +function navigationPageHtml(pathname: string): string { + switch (pathname) { + case "/cua/first": + return ` + + First Page + + + +`; + case "/cua/second": + return ` + + Second Page +

Second

+`; + case "/cua/iframe-host": + return ` + + Iframe Host + + + + +`; + case "/cua/frame-a": + return ` + + Frame A + frame a +`; + case "/cua/frame-b": + return ` + + Frame B + frame b +`; + default: + return ""; + } +} + +function handleNavigationRequest(request: IncomingMessage, response: ServerResponse): void { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + const html = navigationPageHtml(url.pathname); + + if (!html) { + response.writeHead(404, { + "content-type": "text/plain; charset=utf-8", + }); + response.end("not found"); + return; + } + + response.writeHead(200, { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + }); + response.end(html); +} + +async function createNavigationServer(): Promise { + const server = createServer(handleNavigationRequest); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Navigation test server did not expose a TCP address"); + } + + const { port } = address as AddressInfo; + + return { + baseUrl: `http://127.0.0.1:${port}`, + close: async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + }, + }; +} + +describe.sequential("QuickJS page.cua toolset", () => { + let browserRootDir = ""; + let manager: BrowserManager; + const screenshotCleanup = new Set(); + + beforeAll(async () => { + await ensureSandboxClientBundle(); + + browserRootDir = await mkdtemp(path.join(os.tmpdir(), "dev-browser-cua-")); + manager = new BrowserManager(path.join(browserRootDir, "browsers")); + }, 180_000); + + afterAll(async () => { + await manager.stopAll(); + await removeDirectoryWithRetries(browserRootDir); + for (const filePath of screenshotCleanup) { + await rm(filePath, { + force: true, + }); + } + }, 180_000); + + describe.sequential("pointer and keyboard actions", () => { + const browserName = "cua-input"; + let harness: JsonSandboxHarness; + + beforeAll(async () => { + harness = await createSandboxHarness(manager, browserName); + }, 180_000); + + afterAll(async () => { + await harness.dispose(); + await manager.stopBrowser(browserName); + }, 180_000); + + it("clicks at exact coordinates with the left button by default", async () => { + const result = await harness.runJson<{ clicks: RecordedClick[]; elapsed: number }>( + withCuaPage( + "cua-click", + ` + await page.cua.click({ x: 350, y: 250, waitForNavigation: false }); + console.log(JSON.stringify({ clicks: await page.evaluate(() => window.clicks) })); + ` + ) + ); + + expect(result.clicks).toEqual([ + { + x: 350, + y: 250, + button: 0, + detail: 1, + shiftKey: false, + altKey: false, + }, + ]); + }, 15_000); + + it("supports middle and right buttons and rejects unsupported buttons", async () => { + const result = await harness.runJson<{ + downs: RecordedMouseEvent[]; + buttonError: string | null; + }>( + withCuaPage( + "cua-buttons", + ` + await page.cua.click({ x: 350, y: 250, button: "right", waitForNavigation: false }); + await page.cua.click({ x: 350, y: 250, button: "middle", waitForNavigation: false }); + const downs = await page.evaluate(() => { + return window.mouseEvents.filter((event) => event.type === "mousedown"); + }); + let buttonError = null; + try { + await page.cua.click({ x: 350, y: 250, button: "back" }); + } catch (error) { + buttonError = String((error && error.message) || error); + } + console.log(JSON.stringify({ downs, buttonError })); + ` + ) + ); + + expect(result.downs.map((event) => event.button)).toEqual([2, 1]); + expect(result.buttonError).toContain('Unsupported mouse button "back"'); + expect(result.buttonError).toContain('"left", "middle", or "right"'); + }, 15_000); + + it("doubleClick clicks twice at the same point", async () => { + const result = await harness.runJson<{ clicks: RecordedClick[]; elapsed: number }>( + withCuaPage( + "cua-double-click", + ` + const start = Date.now(); + await page.cua.doubleClick({ x: 350, y: 250 }); + const elapsed = Date.now() - start; + console.log(JSON.stringify({ elapsed, clicks: await page.evaluate(() => window.clicks) })); + ` + ) + ); + + expect(result.elapsed).toBeLessThan(900); + expect(result.clicks).toHaveLength(2); + expect(result.clicks.map((click) => click.detail)).toEqual([1, 2]); + for (const click of result.clicks) { + expect(click.x).toBe(350); + expect(click.y).toBe(250); + } + }, 15_000); + + it("holds modifiers during clicks and releases them afterwards", async () => { + const result = await harness.runJson<{ clicks: RecordedClick[] }>( + withCuaPage( + "cua-modifiers", + ` + await page.cua.click({ x: 350, y: 250, modifiers: ["shift"], waitForNavigation: false }); + await page.cua.click({ x: 350, y: 250, waitForNavigation: false }); + console.log(JSON.stringify({ clicks: await page.evaluate(() => window.clicks) })); + ` + ) + ); + + expect(result.clicks).toHaveLength(2); + expect(result.clicks[0]!.shiftKey).toBe(true); + expect(result.clicks[1]!.shiftKey).toBe(false); + }, 15_000); + + it("releases already-pressed modifiers when a later key in the sequence is invalid", async () => { + const result = await harness.runJson<{ + clickError: string | null; + keypressError: string | null; + clicks: RecordedClick[]; + keyEvents: RecordedKeyEvent[]; + }>( + withCuaPage( + "cua-modifier-release", + ` + let clickError = null; + try { + await page.cua.click({ + x: 350, + y: 250, + modifiers: ["shift", "bogus"], + waitForNavigation: false, + }); + } catch (error) { + clickError = String((error && error.message) || error); + } + let keypressError = null; + try { + await page.cua.keypress({ keys: ["ctrl", "bogus", "c"] }); + } catch (error) { + keypressError = String((error && error.message) || error); + } + await page.cua.click({ x: 350, y: 250, waitForNavigation: false }); + await page.evaluate(() => { + window.keyEvents = []; + }); + await page.cua.keypress({ keys: ["a"] }); + console.log(JSON.stringify({ + clickError, + keypressError, + clicks: await page.evaluate(() => window.clicks), + keyEvents: await page.evaluate(() => window.keyEvents), + })); + ` + ) + ); + + expect(result.clickError).toContain("bogus"); + expect(result.keypressError).toContain("bogus"); + expect(result.clicks).toHaveLength(1); + expect(result.clicks[0]!.shiftKey).toBe(false); + expect(result.keyEvents).toHaveLength(1); + expect(result.keyEvents[0]!.shiftKey).toBe(false); + expect(result.keyEvents[0]!.ctrlKey).toBe(false); + expect(result.keyEvents[0]!.metaKey).toBe(false); + }, 15_000); + + it("moves the pointer", async () => { + const result = await harness.runJson<{ moves: RecordedMouseEvent[] }>( + withCuaPage( + "cua-move", + ` + await page.cua.move({ x: 123, y: 217 }); + const moves = await page.evaluate(() => { + return window.mouseEvents.filter((event) => event.type === "mousemove"); + }); + console.log(JSON.stringify({ moves })); + ` + ) + ); + + const lastMove = result.moves.at(-1); + expect(lastMove).toMatchObject({ x: 123, y: 217 }); + }, 15_000); + + it("drags along a path with pressed moves", async () => { + const result = await harness.runJson<{ events: RecordedMouseEvent[] }>( + withCuaPage( + "cua-drag", + ` + await page.cua.drag({ + path: [ + { x: 310, y: 210 }, + { x: 360, y: 260 }, + { x: 390, y: 290 }, + ], + }); + console.log(JSON.stringify({ events: await page.evaluate(() => window.mouseEvents) })); + ` + ) + ); + + const downs = result.events.filter((event) => event.type === "mousedown"); + const ups = result.events.filter((event) => event.type === "mouseup"); + expect(downs).toEqual([{ type: "mousedown", x: 310, y: 210, button: 0 }]); + expect(ups).toEqual([{ type: "mouseup", x: 390, y: 290, button: 0 }]); + + const downIndex = result.events.findIndex((event) => event.type === "mousedown"); + const upIndex = result.events.findIndex((event) => event.type === "mouseup"); + expect(downIndex).toBeLessThan(upIndex); + + const pressedMoves = result.events + .slice(downIndex + 1, upIndex) + .filter((event) => event.type === "mousemove"); + expect(pressedMoves.length).toBeGreaterThan(2); + expect(pressedMoves.some((event) => event.x === 360 && event.y === 260)).toBe(true); + expect(pressedMoves.at(-1)).toMatchObject({ x: 390, y: 290 }); + }, 15_000); + + it("scrolls delta-direct on both axes", async () => { + const result = await harness.runJson<{ + afterDown: { x: number; y: number }; + afterRight: { x: number; y: number }; + afterUp: { x: number; y: number }; + }>( + withCuaPage( + "cua-scroll", + ` + const readScroll = () => page.evaluate(() => ({ x: window.scrollX, y: window.scrollY })); + await page.cua.scroll({ x: 400, y: 300, scrollX: 0, scrollY: 400 }); + await page.waitForFunction(() => window.scrollY === 400, { timeout: 5000 }); + const afterDown = await readScroll(); + await page.cua.scroll({ x: 400, y: 300, scrollX: 250, scrollY: 0 }); + await page.waitForFunction(() => window.scrollX === 250, { timeout: 5000 }); + const afterRight = await readScroll(); + await page.cua.scroll({ x: 400, y: 300, scrollX: 0, scrollY: -150 }); + await page.waitForFunction(() => window.scrollY === 250, { timeout: 5000 }); + const afterUp = await readScroll(); + console.log(JSON.stringify({ afterDown, afterRight, afterUp })); + ` + ) + ); + + expect(result.afterDown).toEqual({ x: 0, y: 400 }); + expect(result.afterRight).toEqual({ x: 250, y: 400 }); + expect(result.afterUp).toEqual({ x: 250, y: 250 }); + }, 15_000); + + it("normalizes key aliases in keypress", async () => { + const result = await harness.runJson>( + withCuaPage( + "cua-key-aliases", + ` + const record = async (keys) => { + await page.evaluate(() => { + window.keyEvents = []; + }); + await page.cua.keypress({ keys }); + return await page.evaluate(() => window.keyEvents); + }; + console.log(JSON.stringify({ + esc: await record(["esc"]), + left: await record(["left"]), + pageup: await record(["pageup"]), + del: await record(["del"]), + ret: await record(["return"]), + space: await record(["space"]), + })); + ` + ) + ); + + expect(result.esc).toHaveLength(1); + expect(result.esc![0]!.key).toBe("Escape"); + expect(result.left![0]!.key).toBe("ArrowLeft"); + expect(result.pageup![0]!.key).toBe("PageUp"); + expect(result.del![0]!.key).toBe("Delete"); + expect(result.ret![0]!.key).toBe("Enter"); + expect(result.space![0]!.code).toBe("Space"); + }, 15_000); + + it("applies chord rewrites: ctrl+a selects all, ctrl+y becomes redo", async () => { + const result = await harness.runJson<{ + selection: { start: number; end: number }; + selectAllKeys: RecordedKeyEvent[]; + redoKeys: RecordedKeyEvent[]; + }>( + withCuaPage( + "cua-chords", + ` + await page.fill("#field", "hello world"); + await page.focus("#field"); + await page.evaluate(() => { + window.keyEvents = []; + }); + await page.cua.keypress({ keys: ["ctrl", "a"] }); + const selection = await page.evaluate(() => { + const field = document.getElementById("field"); + return { start: field.selectionStart, end: field.selectionEnd }; + }); + const selectAllKeys = await page.evaluate(() => window.keyEvents); + await page.evaluate(() => { + window.keyEvents = []; + }); + await page.cua.keypress({ keys: ["ctrl", "y"] }); + const redoKeys = await page.evaluate(() => window.keyEvents); + console.log(JSON.stringify({ selection, selectAllKeys, redoKeys })); + ` + ) + ); + + expect(result.selection).toEqual({ start: 0, end: 11 }); + const selectAllLast = result.selectAllKeys.at(-1)!; + expect(selectAllLast.key.toLowerCase()).toBe("a"); + expect(selectAllLast.ctrlKey || selectAllLast.metaKey).toBe(true); + + expect(result.redoKeys).toHaveLength(3); + const redoLast = result.redoKeys.at(-1)!; + expect(redoLast.key.toLowerCase()).toBe("z"); + expect(redoLast.shiftKey).toBe(true); + expect(redoLast.ctrlKey || redoLast.metaKey).toBe(true); + }, 15_000); + + it("types text with real keystrokes", async () => { + const result = await harness.runJson<{ value: string }>( + withCuaPage( + "cua-type", + ` + await page.focus("#field"); + await page.cua.type({ text: "hello world" }); + console.log(JSON.stringify({ value: await page.inputValue("#field") })); + ` + ) + ); + + expect(result.value).toBe("hello world"); + }, 15_000); + }); + + describe.sequential("screenshots", () => { + const browserName = "cua-screenshots"; + let harness: JsonSandboxHarness; + + beforeAll(async () => { + harness = await createSandboxHarness(manager, browserName); + }, 180_000); + + afterAll(async () => { + await harness.dispose(); + await manager.stopBrowser(browserName); + }, 180_000); + + it("returns path and css-pixel viewport dimensions", async () => { + const result = await harness.runJson<{ + shot: ScreenshotResult; + dims: [number, number]; + }>( + withCuaPage( + "cua-shot-viewport", + ` + const shot = await page.cua.screenshot(); + const dims = await page.evaluate(() => [innerWidth, innerHeight]); + console.log(JSON.stringify({ shot, dims })); + ` + ) + ); + screenshotCleanup.add(result.shot.path); + + expect(path.isAbsolute(result.shot.path)).toBe(true); + expect(result.shot.path.startsWith(`${path.resolve(DEV_BROWSER_TMP_DIR)}${path.sep}`)).toBe( + true + ); + expect(path.basename(result.shot.path)).toMatch(/^cua-page.*\.jpeg$/); + expect(result.shot.width).toBe(result.dims[0]); + expect(result.shot.height).toBe(result.dims[1]); + + expect((await stat(result.shot.path)).size).toBeGreaterThan(0); + const data = await readFile(result.shot.path); + expect(readJpegDimensions(data)).toEqual({ + width: result.shot.width, + height: result.shot.height, + }); + }, 15_000); + + it("pins clip coordinate semantics as viewport-relative", async () => { + const result = await harness.runJson<{ shot: ScreenshotResult }>( + withCuaPage( + "cua-shot-clip", + ` + await page.evaluate(() => window.scrollTo(0, 150)); + await page.waitForFunction(() => window.scrollY === 150, { timeout: 5000 }); + const shot = await page.cua.screenshot({ + name: "cua-clip-test", + clip: { x: 300, y: 50, width: 100, height: 100 }, + }); + console.log(JSON.stringify({ shot })); + ` + ) + ); + screenshotCleanup.add(result.shot.path); + + expect(path.basename(result.shot.path)).toBe("cua-clip-test.jpeg"); + expect(result.shot.width).toBe(100); + expect(result.shot.height).toBe(100); + + const data = await readFile(result.shot.path); + expect(readJpegDimensions(data)).toEqual({ width: 100, height: 100 }); + + const decoded = await harness.runJson<{ + pixel: { width: number; height: number; r: number; g: number; b: number }; + }>(` + const page = await browser.getPage("cua-shot-clip"); + const pixel = await page.evaluate(async (encoded) => { + const image = new Image(); + image.src = "data:image/jpeg;base64," + encoded; + await image.decode(); + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext("2d"); + context.drawImage(image, 0, 0); + const data = context.getImageData(50, 50, 1, 1).data; + return { + width: image.naturalWidth, + height: image.naturalHeight, + r: data[0], + g: data[1], + b: data[2], + }; + }, ${JSON.stringify(data.toString("base64"))}); + console.log(JSON.stringify({ pixel })); + `); + + expect(decoded.pixel.width).toBe(100); + expect(decoded.pixel.height).toBe(100); + expect(decoded.pixel.r).toBeGreaterThan(200); + expect(decoded.pixel.g).toBeLessThan(80); + expect(decoded.pixel.b).toBeLessThan(80); + }, 30_000); + + it("supports fullPage screenshots with document dimensions", async () => { + const result = await harness.runJson<{ + shot: ScreenshotResult; + docDims: [number, number]; + viewport: [number, number]; + }>( + withCuaPage( + "cua-shot-fullpage", + ` + const shot = await page.cua.screenshot({ name: "cua-fullpage-test", fullPage: true }); + const docDims = await page.evaluate(() => [ + document.documentElement.scrollWidth, + document.documentElement.scrollHeight, + ]); + const viewport = await page.evaluate(() => [innerWidth, innerHeight]); + console.log(JSON.stringify({ shot, docDims, viewport })); + ` + ) + ); + screenshotCleanup.add(result.shot.path); + + expect(path.basename(result.shot.path)).toBe("cua-fullpage-test.jpeg"); + expect(result.shot.width).toBe(result.docDims[0]); + expect(result.shot.height).toBe(result.docDims[1]); + expect(result.shot.height).toBeGreaterThan(result.viewport[1]); + + const data = await readFile(result.shot.path); + expect(readJpegDimensions(data)).toEqual({ + width: result.shot.width, + height: result.shot.height, + }); + }, 30_000); + + it("downscales device-pixel screenshots back to css pixels", async () => { + const result = await harness.runJson<{ + shot: ScreenshotResult; + dims: [number, number]; + }>( + withCuaPage( + "cua-shot-retina", + ` + const dims = await page.evaluate(() => [innerWidth, innerHeight]); + const oversized = await page.screenshot({ + type: "jpeg", + quality: 80, + clip: { x: 0, y: 0, width: dims[0] * 2, height: dims[1] * 2 }, + }); + page.screenshot = async () => oversized; + const shot = await page.cua.screenshot({ name: "cua-retina-test" }); + console.log(JSON.stringify({ shot, dims })); + ` + ) + ); + screenshotCleanup.add(result.shot.path); + + expect(result.shot.width).toBe(result.dims[0]); + expect(result.shot.height).toBe(result.dims[1]); + + const data = await readFile(result.shot.path); + expect(readJpegDimensions(data)).toEqual({ + width: result.shot.width, + height: result.shot.height, + }); + }, 30_000); + }); + + describe.sequential("navigation waiting", () => { + const browserName = "cua-navigation"; + let harness: JsonSandboxHarness; + let navigationServer: NavigationServer; + + beforeAll(async () => { + harness = await createSandboxHarness(manager, browserName); + navigationServer = await createNavigationServer(); + }, 180_000); + + afterAll(async () => { + await harness.dispose(); + await navigationServer.close(); + await manager.stopBrowser(browserName); + }, 180_000); + + it("does not pay the navigation grace delay by default", async () => { + const firstUrl = `${navigationServer.baseUrl}/cua/first`; + const result = await harness.runJson<{ elapsed: number; url: string }>(` + const page = await browser.getPage("cua-nav-default"); + await page.goto(${JSON.stringify(firstUrl)}, { waitUntil: "load" }); + const box = await page.locator("#nav").boundingBox(); + const start = Date.now(); + await page.cua.click({ x: box.x + box.width / 2, y: box.y + box.height / 2 }); + const elapsed = Date.now() - start; + await page.waitForURL("**/cua/second"); + console.log(JSON.stringify({ elapsed, url: page.url() })); + `); + + expect(result.elapsed).toBeLessThan(900); + expect(result.url).toBe(`${navigationServer.baseUrl}/cua/second`); + }, 30_000); + + it("settles main-frame navigation when explicitly requested", async () => { + const firstUrl = `${navigationServer.baseUrl}/cua/first`; + const result = await harness.runJson<{ title: string; url: string }>(` + const page = await browser.getPage("cua-nav-explicit"); + await page.goto(${JSON.stringify(firstUrl)}, { waitUntil: "load" }); + const box = await page.locator("#nav").boundingBox(); + await page.cua.click({ + x: box.x + box.width / 2, + y: box.y + box.height / 2, + waitForNavigation: true, + }); + console.log(JSON.stringify({ title: await page.title(), url: page.url() })); + `); + + expect(result.url).toBe(`${navigationServer.baseUrl}/cua/second`); + expect(result.title).toBe("Second Page"); + }, 30_000); + + it("ignores child-frame navigations via the main-frame predicate", async () => { + const hostUrl = `${navigationServer.baseUrl}/cua/iframe-host`; + const result = await harness.runJson<{ + elapsed: number; + url: string; + frameUrls: string[]; + }>(` + const page = await browser.getPage("cua-nav-iframe"); + await page.goto(${JSON.stringify(hostUrl)}, { waitUntil: "load" }); + const box = await page.locator("#swap").boundingBox(); + const start = Date.now(); + await page.cua.click({ + x: box.x + box.width / 2, + y: box.y + box.height / 2, + waitForNavigation: true, + }); + const elapsed = Date.now() - start; + console.log(JSON.stringify({ + elapsed, + url: page.url(), + frameUrls: page.frames().map((frame) => frame.url()), + })); + `); + + expect(result.elapsed).toBeGreaterThanOrEqual(900); + expect(result.elapsed).toBeLessThan(5000); + expect(result.url).toBe(hostUrl); + expect(result.frameUrls).toContain(`${navigationServer.baseUrl}/cua/frame-b`); + }, 30_000); + }); +}); diff --git a/daemon/src/sandbox/__tests__/dom-cua.test.ts b/daemon/src/sandbox/__tests__/dom-cua.test.ts new file mode 100644 index 00000000..6453d9c5 --- /dev/null +++ b/daemon/src/sandbox/__tests__/dom-cua.test.ts @@ -0,0 +1,973 @@ +import { once } from "node:events"; +import { mkdtemp } from "node:fs/promises"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import os from "node:os"; +import path from "node:path"; +import vm from "node:vm"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { BrowserManager } from "../../browser-manager.js"; +import { removeDirectoryWithRetries } from "../../test-cleanup.js"; +import { domCuaRegister, domCuaWalker } from "../forked-client/src/client/domCuaInjected.js"; +import { QuickJSSandbox } from "../quickjs-sandbox.js"; +import { runScript } from "../script-runner-quickjs.js"; +import { ensureSandboxClientBundle } from "./bundle-test-helpers.js"; + +const SANDBOX_TIMEOUT_MS = 60_000; + +const DOM_TEST_PAGE_HTML = ` + + + DomCua Test Page + + + + + Example link + +
x
+ + + + + + + + + + +
plain text
+ +
+ +`; + +const HIDDEN_ACT_PAGE_HTML = ` + + + + + +`; + +const TYPE_ACT_PAGE_HTML = ` + + + + +`; + +const SCROLL_ACT_PAGE_HTML = ` + + +
+
+ Pane link +
+
+
+ +`; + +const BUDGET_PAGE_HTML = ` + + + ${Array.from( + { length: 250 }, + (_, i) => + `L${i}` + ).join("\n ")} + +`; + +const ID_HELPERS = ` + const idFor = (snapshot, needle) => { + const line = snapshot.split("\\n").find((entry) => entry.includes(needle)); + if (!line) throw new Error("no snapshot line contains " + needle); + return Number(line.match(/node_id=(\\d+)/)[1]); + }; + const allIds = (snapshot) => + Array.from(snapshot.matchAll(/node_id=(\\d+)/g)).map((match) => Number(match[1])); +`; + +interface CapturedOutput { + stdout: string[]; + stderr: string[]; +} + +interface JsonSandboxHarness { + dispose: () => Promise; + runJson: (script: string) => Promise; +} + +interface DomServer { + baseUrl: string; + close: () => Promise; +} + +function createOutput(): CapturedOutput & { + sink: { + onStdout: (data: string) => void; + onStderr: (data: string) => void; + }; +} { + const stdout: string[] = []; + const stderr: string[] = []; + + return { + stdout, + stderr, + sink: { + onStdout: (data) => { + stdout.push(data); + }, + onStderr: (data) => { + stderr.push(data); + }, + }, + }; +} + +function clearOutput(output: CapturedOutput): void { + output.stdout.length = 0; + output.stderr.length = 0; +} + +function parseLastJsonLine(output: CapturedOutput): T { + const lines = output.stdout.map((line) => line.trim()).filter((line) => line.length > 0); + expect(lines.length).toBeGreaterThan(0); + return JSON.parse(lines.at(-1)!) as T; +} + +async function createSandboxHarness( + manager: BrowserManager, + browserName: string +): Promise { + await manager.ensureBrowser(browserName, { + headless: true, + }); + + const output = createOutput(); + const sandbox = new QuickJSSandbox({ + manager, + browserName, + onStdout: output.sink.onStdout, + onStderr: output.sink.onStderr, + timeoutMs: SANDBOX_TIMEOUT_MS, + }); + + await sandbox.initialize(); + + return { + dispose: async () => { + await sandbox.dispose(); + }, + runJson: async (script: string): Promise => { + clearOutput(output); + await sandbox.executeScript(`(async () => {\n${script}\n})()`); + expect(output.stderr).toEqual([]); + return parseLastJsonLine(output); + }, + }; +} + +const RECORDER_SCRIPT = ``; + +function manyLinksHtml(count: number, prefix: string): string { + const links = Array.from( + { length: count }, + (_, i) => + `${prefix.toUpperCase()}${i}` + ).join(""); + return `${links}`; +} + +function domPageHtml(pathname: string): string { + switch (pathname) { + case "/dom/first": + return ` + + Dom First + + ${RECORDER_SCRIPT} + + + + +`; + case "/dom/second": + return ` + + Dom Second + + ${RECORDER_SCRIPT} + + + + + + +`; + case "/dom/iframe-host": + return ` + + Iframe Host + + + + +`; + case "/dom/iframe-content": + return ` + + + + + +`; + case "/dom/named-frame-host": + return ` + + Named Frame Host + + + +`; + case "/dom/frame-one": + return ` + + + + +`; + case "/dom/frame-two": + return ` + + + + + +`; + case "/dom/frame-budget-host": + return ` + + + Host link + + +`; + case "/dom/many-links": + return manyLinksHtml(60, "m"); + default: + return ""; + } +} + +function handleDomRequest(request: IncomingMessage, response: ServerResponse): void { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + const html = domPageHtml(url.pathname); + + if (!html) { + response.writeHead(404, { + "content-type": "text/plain; charset=utf-8", + }); + response.end("not found"); + return; + } + + response.writeHead(200, { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + }); + response.end(html); +} + +async function createDomServer(): Promise { + const server = createServer(handleDomRequest); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Dom test server did not expose a TCP address"); + } + + const { port } = address as AddressInfo; + + return { + baseUrl: `http://127.0.0.1:${port}`, + close: async () => { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + }, + }; +} + +interface StubText { + nodeType: 3; + nodeValue: string; +} + +interface StubElement { + nodeType: 1; + tagName: string; + childNodes: Array; + children: StubElement[]; + shadowRoot: { childNodes: Array; children: StubElement[] } | null; + getAttribute: (name: string) => string | null; + hasAttribute: (name: string) => boolean; + getClientRects: () => Array<{ + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + }>; +} + +function stubText(value: string): StubText { + return { nodeType: 3, nodeValue: value }; +} + +function stubElement( + tag: string, + attrs: Record = {}, + children: Array = [], + shadowChildren?: Array +): StubElement { + return { + nodeType: 1, + tagName: tag.toUpperCase(), + childNodes: children, + children: children.filter((child): child is StubElement => child.nodeType === 1), + shadowRoot: shadowChildren + ? { + childNodes: shadowChildren, + children: shadowChildren.filter((child): child is StubElement => child.nodeType === 1), + } + : null, + getAttribute: (name) => + Object.prototype.hasOwnProperty.call(attrs, name) ? attrs[name]! : null, + hasAttribute: (name) => Object.prototype.hasOwnProperty.call(attrs, name), + getClientRects: () => [{ left: 10, top: 10, right: 110, bottom: 40, width: 100, height: 30 }], + }; +} + +function createIsolatedRealm(root: StubElement): vm.Context { + return vm.createContext({ + document: { body: root, documentElement: root }, + getComputedStyle: () => ({ + visibility: "visible", + display: "block", + pointerEvents: "auto", + opacity: "1", + }), + innerWidth: 1280, + innerHeight: 720, + }); +} + +describe.sequential("QuickJS page.domCua toolset", () => { + let browserRootDir = ""; + let manager: BrowserManager; + let server: DomServer; + let crossOriginServer: DomServer; + + beforeAll(async () => { + await ensureSandboxClientBundle(); + + browserRootDir = await mkdtemp(path.join(os.tmpdir(), "dev-browser-dom-cua-")); + manager = new BrowserManager(path.join(browserRootDir, "browsers")); + server = await createDomServer(); + crossOriginServer = await createDomServer(); + }, 180_000); + + afterAll(async () => { + await manager.stopAll(); + await server.close(); + await crossOriginServer.close(); + await removeDirectoryWithRetries(browserRootDir); + }, 180_000); + + describe.sequential("snapshots", () => { + const browserName = "dom-cua-snapshots"; + let harness: JsonSandboxHarness; + + beforeAll(async () => { + harness = await createSandboxHarness(manager, browserName); + }, 180_000); + + afterAll(async () => { + await harness.dispose(); + await manager.stopBrowser(browserName); + }, 180_000); + + it("renders interactive elements as pseudo-HTML lines with node ids", async () => { + const { snapshot } = await harness.runJson<{ snapshot: string }>(` + const page = await browser.getPage("dom-cua-format"); + await page.setContent(${JSON.stringify(DOM_TEST_PAGE_HTML)}, { waitUntil: "load" }); + console.log(JSON.stringify({ snapshot: await page.domCua.getVisibleDom() })); + `); + + expect(snapshot).toMatch(//); + expect(snapshot).toMatch(/' + ); + }); + const second = await page.domCua.getVisibleDom(); + console.log(JSON.stringify({ + firstIds: allIds(first), + submitFirst: idFor(first, ">Submit<"), + submitSecond: idFor(second, ">Submit<"), + freshId: idFor(second, ">Fresh<"), + })); + `); + + expect(result.submitSecond).toBe(result.submitFirst); + expect(result.firstIds).not.toContain(result.freshId); + expect(result.freshId).toBeGreaterThan(Math.max(...result.firstIds)); + }, 30_000); + + it("appends a truncation marker when the element budget trips", async () => { + const { snapshot } = await harness.runJson<{ snapshot: string }>(` + const page = await browser.getPage("dom-cua-budget"); + await page.setContent(${JSON.stringify(BUDGET_PAGE_HTML)}, { waitUntil: "load" }); + console.log(JSON.stringify({ snapshot: await page.domCua.getVisibleDom() })); + `); + + const lines = snapshot.split("\n"); + expect(lines.filter((line) => line.startsWith(" { + const hostUrl = `${server.baseUrl}/dom/frame-budget-host`; + const { snapshot } = await harness.runJson<{ snapshot: string }>(` + const page = await browser.getPage("dom-cua-frame-budget"); + await page.goto(${JSON.stringify(hostUrl)}, { waitUntil: "load" }); + console.log(JSON.stringify({ snapshot: await page.domCua.getVisibleDom() })); + `); + + const frameLines = snapshot.split("\n").filter((line) => line.includes('href="#m')); + expect(frameLines.length).toBe(50); + expect(snapshot).toContain(">Host link<"); + expect(snapshot).toContain("output truncated"); + }, 30_000); + }); + + describe.sequential("acting by node id", () => { + const browserName = "dom-cua-act"; + let harness: JsonSandboxHarness; + + beforeAll(async () => { + harness = await createSandboxHarness(manager, browserName); + }, 180_000); + + afterAll(async () => { + await harness.dispose(); + await manager.stopBrowser(browserName); + }, 180_000); + + it("clicks the element that owns a node id", async () => { + const firstUrl = `${server.baseUrl}/dom/first`; + const result = await harness.runJson<{ + clicks: Array<{ target: string; x: number; y: number }>; + elapsed: number; + }>(` + ${ID_HELPERS} + const page = await browser.getPage("dom-cua-act-click"); + await page.goto(${JSON.stringify(firstUrl)}, { waitUntil: "load" }); + const snapshot = await page.domCua.getVisibleDom(); + const start = Date.now(); + await page.domCua.click({ nodeId: idFor(snapshot, ">Two<") }); + const elapsed = Date.now() - start; + console.log(JSON.stringify({ elapsed, clicks: await page.evaluate(() => window.clicks) })); + `); + + expect(result.elapsed).toBeLessThan(900); + expect(result.clicks).toEqual([{ target: "two", x: 80, y: 86 }]); + }, 30_000); + + it("accepts a numeric-string nodeId as regexed from the snapshot text", async () => { + const firstUrl = `${server.baseUrl}/dom/first`; + const result = await harness.runJson<{ + clicks: Array<{ target: string; x: number; y: number }>; + }>(` + ${ID_HELPERS} + const page = await browser.getPage("dom-cua-act-string-id"); + await page.goto(${JSON.stringify(firstUrl)}, { waitUntil: "load" }); + const snapshot = await page.domCua.getVisibleDom(); + await page.domCua.click({ nodeId: String(idFor(snapshot, ">Two<")), waitForNavigation: false }); + console.log(JSON.stringify({ clicks: await page.evaluate(() => window.clicks) })); + `); + + expect(result.clicks).toEqual([{ target: "two", x: 80, y: 86 }]); + }, 30_000); + + it("clicks elements inside an iframe at frame-offset coordinates", async () => { + const hostUrl = `${server.baseUrl}/dom/iframe-host`; + const result = await harness.runJson<{ + snapshot: string; + frameClicks: Array<{ target: string; x: number; y: number }>; + }>(` + ${ID_HELPERS} + const page = await browser.getPage("dom-cua-act-iframe"); + await page.goto(${JSON.stringify(hostUrl)}, { waitUntil: "load" }); + const snapshot = await page.domCua.getVisibleDom(); + await page.domCua.click({ nodeId: idFor(snapshot, ">Inner button<"), waitForNavigation: false }); + const frame = page.frames().find((candidate) => candidate.url().includes("/dom/iframe-content")); + const frameClicks = await frame.evaluate(() => window.frameClicks); + console.log(JSON.stringify({ snapshot, frameClicks })); + `); + + expect(result.snapshot).toContain(">Outer<"); + expect(result.snapshot).toContain(">Inner button<"); + expect(result.frameClicks).toEqual([{ target: "inner", x: 60, y: 25 }]); + }, 30_000); + + it("doubleClick clicks twice at the node center", async () => { + const firstUrl = `${server.baseUrl}/dom/first`; + const result = await harness.runJson<{ + clicks: Array<{ target: string; x: number; y: number }>; + elapsed: number; + }>(` + ${ID_HELPERS} + const page = await browser.getPage("dom-cua-act-double"); + await page.goto(${JSON.stringify(firstUrl)}, { waitUntil: "load" }); + const snapshot = await page.domCua.getVisibleDom(); + const start = Date.now(); + await page.domCua.doubleClick({ nodeId: idFor(snapshot, ">One<") }); + const elapsed = Date.now() - start; + console.log(JSON.stringify({ elapsed, clicks: await page.evaluate(() => window.clicks) })); + `); + + expect(result.elapsed).toBeLessThan(900); + expect(result.clicks).toHaveLength(2); + for (const click of result.clicks) { + expect(click).toEqual({ target: "one", x: 80, y: 36 }); + } + }, 30_000); + + it("scrolls at the node center when nodeId is given, viewport center otherwise", async () => { + const result = await harness.runJson<{ + paneScroll: number; + windowScrollAfterPane: number; + windowScrollAfterViewport: number; + }>(` + ${ID_HELPERS} + const page = await browser.getPage("dom-cua-act-scroll"); + await page.setContent(${JSON.stringify(SCROLL_ACT_PAGE_HTML)}, { waitUntil: "load" }); + const snapshot = await page.domCua.getVisibleDom(); + await page.domCua.scroll({ scrollY: 200, nodeId: idFor(snapshot, ">Pane link<") }); + await page.waitForFunction(() => document.getElementById("pane").scrollTop === 200, { timeout: 5000 }); + const paneScroll = await page.evaluate(() => document.getElementById("pane").scrollTop); + const windowScrollAfterPane = await page.evaluate(() => window.scrollY); + await page.domCua.scroll({ scrollY: 300 }); + await page.waitForFunction(() => window.scrollY === 300, { timeout: 5000 }); + const windowScrollAfterViewport = await page.evaluate(() => window.scrollY); + console.log(JSON.stringify({ paneScroll, windowScrollAfterPane, windowScrollAfterViewport })); + `); + + expect(result.paneScroll).toBe(200); + expect(result.windowScrollAfterPane).toBe(0); + expect(result.windowScrollAfterViewport).toBe(300); + }, 30_000); + + it("types into the element focused by a click by id", async () => { + const result = await harness.runJson<{ value: string }>(` + ${ID_HELPERS} + const page = await browser.getPage("dom-cua-act-type"); + await page.setContent(${JSON.stringify(TYPE_ACT_PAGE_HTML)}, { waitUntil: "load" }); + const snapshot = await page.domCua.getVisibleDom(); + await page.domCua.click({ nodeId: idFor(snapshot, " { + const result = await harness.runJson<{ + error: string | null; + elapsed: number; + clicks: string[]; + }>(` + ${ID_HELPERS} + const page = await browser.getPage("dom-cua-act-hidden"); + await page.setContent(${JSON.stringify(HIDDEN_ACT_PAGE_HTML)}, { waitUntil: "load" }); + const snapshot = await page.domCua.getVisibleDom(); + const nodeId = idFor(snapshot, ">Target<"); + await page.evaluate(() => { + document.getElementById("target").style.display = "none"; + }); + const start = Date.now(); + let error = null; + try { + await page.domCua.click({ nodeId }); + } catch (caught) { + error = String((caught && caught.message) || caught); + } + const elapsed = Date.now() - start; + console.log(JSON.stringify({ error, elapsed, clicks: await page.evaluate(() => window.clicks) })); + `); + + expect(result.error).toContain("stale or missing — re-run getVisibleDom()"); + expect(result.elapsed).toBeLessThan(10_000); + expect(result.clicks).toEqual([]); + }, 30_000); + }); + + describe.sequential("navigation and staleness", () => { + const browserName = "dom-cua-stale"; + let harness: JsonSandboxHarness; + + beforeAll(async () => { + harness = await createSandboxHarness(manager, browserName); + }, 180_000); + + afterAll(async () => { + await harness.dispose(); + await manager.stopBrowser(browserName); + }, 180_000); + + it("fails fast on ids from before a reload", async () => { + const firstUrl = `${server.baseUrl}/dom/first`; + const result = await harness.runJson<{ error: string | null; elapsed: number }>(` + ${ID_HELPERS} + const page = await browser.getPage("dom-cua-reload"); + await page.goto(${JSON.stringify(firstUrl)}, { waitUntil: "load" }); + const snapshot = await page.domCua.getVisibleDom(); + const nodeId = idFor(snapshot, ">Three<"); + await page.reload({ waitUntil: "load" }); + const start = Date.now(); + let error = null; + try { + await page.domCua.click({ nodeId }); + } catch (caught) { + error = String((caught && caught.message) || caught); + } + console.log(JSON.stringify({ error, elapsed: Date.now() - start })); + `); + + expect(result.error).toContain("stale or missing — re-run getVisibleDom()"); + expect(result.elapsed).toBeLessThan(3000); + }, 30_000); + + it("never reuses pre-navigation ids: acting on one errors instead of clicking", async () => { + const firstUrl = `${server.baseUrl}/dom/first`; + const secondUrl = `${server.baseUrl}/dom/second`; + const result = await harness.runJson<{ + preNavIds: number[]; + postNavIds: number[]; + error: string | null; + clicks: Array<{ target: string }>; + }>(` + ${ID_HELPERS} + const page = await browser.getPage("dom-cua-id-reuse"); + await page.goto(${JSON.stringify(firstUrl)}, { waitUntil: "load" }); + const preNavIds = allIds(await page.domCua.getVisibleDom()); + await page.goto(${JSON.stringify(secondUrl)}, { waitUntil: "load" }); + const postNavIds = allIds(await page.domCua.getVisibleDom()); + let error = null; + try { + await page.domCua.click({ nodeId: preNavIds[0] }); + } catch (caught) { + error = String((caught && caught.message) || caught); + } + const clicks = await page.evaluate(() => window.clicks); + console.log(JSON.stringify({ preNavIds, postNavIds, error, clicks })); + `); + + expect(result.preNavIds).toHaveLength(3); + expect(result.postNavIds).toHaveLength(5); + expect(Math.min(...result.postNavIds)).toBeGreaterThan(Math.max(...result.preNavIds)); + expect(result.error).toContain("stale or missing — re-run getVisibleDom()"); + expect(result.clicks).toEqual([]); + }, 30_000); + + it("never reuses pre-navigation ids across cross-origin navigations", async () => { + const firstUrl = `${server.baseUrl}/dom/first`; + const secondUrl = `${crossOriginServer.baseUrl}/dom/second`; + const result = await harness.runJson<{ + preNavIds: number[]; + postNavIds: number[]; + error: string | null; + clicks: Array<{ target: string }>; + }>(` + ${ID_HELPERS} + const page = await browser.getPage("dom-cua-cross-origin"); + await page.goto(${JSON.stringify(firstUrl)}, { waitUntil: "load" }); + const preNavIds = allIds(await page.domCua.getVisibleDom()); + await page.goto(${JSON.stringify(secondUrl)}, { waitUntil: "load" }); + const postNavIds = allIds(await page.domCua.getVisibleDom()); + let error = null; + try { + await page.domCua.click({ nodeId: preNavIds[0] }); + } catch (caught) { + error = String((caught && caught.message) || caught); + } + const clicks = await page.evaluate(() => window.clicks); + console.log(JSON.stringify({ preNavIds, postNavIds, error, clicks })); + `); + + expect(result.preNavIds).toHaveLength(3); + expect(result.postNavIds).toHaveLength(5); + expect(result.postNavIds.filter((id) => result.preNavIds.includes(id))).toEqual([]); + expect(result.error).toContain("stale or missing — re-run getVisibleDom()"); + expect(result.clicks).toEqual([]); + }, 30_000); + + it("assigns fresh ids after a child-frame navigation and stales the old ones", async () => { + const hostUrl = `${server.baseUrl}/dom/named-frame-host`; + const frameTwoUrl = `${server.baseUrl}/dom/frame-two`; + const result = await harness.runJson<{ + oldId: number; + newId: number; + error: string | null; + frameClicks: string[]; + }>(` + ${ID_HELPERS} + const page = await browser.getPage("dom-cua-frame-nav"); + await page.goto(${JSON.stringify(hostUrl)}, { waitUntil: "load" }); + const first = await page.domCua.getVisibleDom(); + const oldId = idFor(first, ">FrameOne<"); + const frame = page.frames().find((candidate) => candidate.name() === "child"); + await frame.goto(${JSON.stringify(frameTwoUrl)}, { waitUntil: "load" }); + const second = await page.domCua.getVisibleDom(); + const newId = idFor(second, ">FrameTwo<"); + let error = null; + try { + await page.domCua.click({ nodeId: oldId, waitForNavigation: false }); + } catch (caught) { + error = String((caught && caught.message) || caught); + } + const frameClicks = await frame.evaluate(() => window.frameClicks); + console.log(JSON.stringify({ oldId, newId, error, frameClicks })); + `); + + expect(result.newId).toBeGreaterThan(result.oldId); + expect(result.error).toContain("stale or missing — re-run getVisibleDom()"); + expect(result.frameClicks).toEqual([]); + }, 30_000); + }); + + describe.sequential("cross-invocation", () => { + const browserName = "dom-cua-cross"; + + beforeAll(async () => { + await manager.ensureBrowser(browserName, { + headless: true, + }); + }, 180_000); + + afterAll(async () => { + await manager.stopBrowser(browserName); + }, 180_000); + + it("acts on ids from a snapshot taken in a previous invocation", async () => { + const firstUrl = `${server.baseUrl}/dom/first`; + + const snapshotOutput = createOutput(); + await runScript( + ` + ${ID_HELPERS} + const page = await browser.getPage("dom-cua-cross-page"); + await page.goto(${JSON.stringify(firstUrl)}, { waitUntil: "load" }); + const snapshot = await page.domCua.getVisibleDom(); + console.log(JSON.stringify({ nodeId: idFor(snapshot, ">Two<") })); + `, + manager, + browserName, + snapshotOutput.sink + ); + expect(snapshotOutput.stderr).toEqual([]); + const { nodeId } = parseLastJsonLine<{ nodeId: number }>(snapshotOutput); + expect(nodeId).toBeGreaterThan(0); + + const clickOutput = createOutput(); + await runScript( + ` + const page = await browser.getPage("dom-cua-cross-page"); + await page.domCua.click({ nodeId: ${nodeId}, waitForNavigation: false }); + console.log(JSON.stringify({ clicks: await page.evaluate(() => window.clicks) })); + `, + manager, + browserName, + clickOutput.sink + ); + expect(clickOutput.stderr).toEqual([]); + const { clicks } = parseLastJsonLine<{ + clicks: Array<{ target: string; x: number; y: number }>; + }>(clickOutput); + expect(clicks).toEqual([{ target: "two", x: 80, y: 86 }]); + }, 60_000); + }); + + describe("walker self-containment", () => { + it("runs the serialized walker in an isolated realm against a DOM stub", () => { + const input = stubElement("input", { type: "text", placeholder: "name here" }); + const button = stubElement("button", {}, [stubText("Submit")]); + const link = stubElement("a", { href: "https://example.com" }, [stubText("Example link")]); + const shadowed = stubElement("button", {}, [], [stubText("Shadow label")]); + const plain = stubElement("div", {}, [stubText("ignore me")]); + const root = stubElement("body", {}, [input, button, link, shadowed, plain]); + const realm = createIsolatedRealm(root); + + const walkerInRealm = vm.runInContext(`(${String(domCuaWalker)})`, realm); + const result = walkerInRealm({ maxElements: 50 }); + + expect(result.blocked).toBe(false); + expect(result.truncated).toBe(false); + expect(typeof result.docToken).toBe("string"); + expect(result.entries.map((entry: { line: string }) => entry.line)).toEqual([ + '', + "", + 'Example link', + "", + ]); + + const again = walkerInRealm({ maxElements: 50 }); + expect(again.entries.map((entry: { ref: number }) => entry.ref)).toEqual([1, 2, 3, 4]); + expect(again.docToken).toBe(result.docToken); + + const capped = walkerInRealm({ maxElements: 2 }); + expect(capped.truncated).toBe(true); + expect(capped.entries).toHaveLength(2); + }); + + it("runs the serialized register function in an isolated realm", () => { + const realm = createIsolatedRealm(stubElement("body")); + const registerInRealm = vm.runInContext(`(${String(domCuaRegister)})`, realm); + + const first = registerInRealm({ + frames: [{ key: "main", docToken: "doc-1", refs: [1, 2, 3] }], + }); + expect(first.blocked).toBe(false); + expect(first.ids[0]).toHaveLength(3); + expect(first.ids[0][0]).toBeGreaterThanOrEqual(1_000_000); + + const second = registerInRealm({ + frames: [{ key: "main", docToken: "doc-1", refs: [2, 3, 9] }], + }); + expect(second.blocked).toBe(false); + expect(second.ids[0][0]).toBe(first.ids[0][1]); + expect(second.ids[0][1]).toBe(first.ids[0][2]); + expect(second.ids[0][2]).toBeGreaterThan(first.ids[0][2]); + + const replaced = registerInRealm({ + frames: [{ key: "main", docToken: "doc-2", refs: [1, 2, 3] }], + }); + expect(replaced.blocked).toBe(false); + for (const id of replaced.ids[0]) { + expect(id).toBeGreaterThan(second.ids[0][2]); + } + }); + + it("starts at a high base when sessionStorage works but holds no counter", () => { + const storage = new Map(); + const realm = vm.createContext({ + sessionStorage: { + getItem: (key: string) => (storage.has(key) ? storage.get(key)! : null), + setItem: (key: string, value: string) => { + storage.set(key, String(value)); + }, + }, + }); + const registerInRealm = vm.runInContext(`(${String(domCuaRegister)})`, realm); + + const result = registerInRealm({ + frames: [{ key: "main", docToken: "doc-1", refs: [1, 2] }], + }); + expect(result.blocked).toBe(false); + expect(Math.min(...result.ids[0])).toBeGreaterThanOrEqual(1_000_000); + expect(Number(storage.get("__devBrowserDomCuaNextPublicId"))).toBeGreaterThan( + Math.max(...result.ids[0]) + ); + }); + }); +}); diff --git a/daemon/src/sandbox/__tests__/playwright-api.test.ts b/daemon/src/sandbox/__tests__/playwright-api.test.ts index 40dc7d85..8ba92212 100644 --- a/daemon/src/sandbox/__tests__/playwright-api.test.ts +++ b/daemon/src/sandbox/__tests__/playwright-api.test.ts @@ -739,6 +739,70 @@ describe.sequential("QuickJS Playwright Page API coverage", () => { expect(result.full).toContain('heading "Hello World"'); expect(result.full).toContain('button "Submit"'); }); + + it("uses refs from snapshotForAI() as injection-safe locators", async () => { + const result = await harness.runJson<{ + clicked: string; + invalidRefError: string; + ref: string; + }>( + withTestPage( + "snapshot-ref", + ` + const snapshot = await page.snapshotForAI({ timeout: 5000 }); + const submitLine = snapshot.full + .split("\\n") + .find((line) => line.includes('button "Submit"')); + const ref = submitLine?.match(/ref=((?:f\\d+)?e\\d+)/)?.[1]; + if (!ref) throw new Error("Submit snapshot ref not found"); + + await page.getByRef(ref).click({ timeout: 5000 }); + + let invalidRefError = ""; + try { + page.getByRef('e1 >> button'); + } catch (error) { + invalidRefError = error.message; + } + + console.log(JSON.stringify({ + clicked: await page.locator("#result").textContent(), + invalidRefError, + ref, + })); + ` + ) + ); + + expect(result.ref).toMatch(/^(?:f\d+)?e\d+$/); + expect(result.clicked).toBe("clicked::red"); + expect(result.invalidRefError).toContain("Invalid snapshot ref"); + }); + + it("uses iframe refs from snapshotForAI()", async () => { + const result = await harness.runJson<{ clicked: string; ref: string }>(` + const page = await browser.getPage("snapshot-iframe-ref"); + await page.setContent( + '', + { waitUntil: "load" }, + ); + const snapshot = await page.snapshotForAI({ timeout: 5000 }); + const insideLine = snapshot.full + .split("\\n") + .find((line) => line.includes('button "Inside"')); + const ref = insideLine?.match(/ref=(f\\d+e\\d+)/)?.[1]; + if (!ref) throw new Error("Iframe snapshot ref not found"); + await page.getByRef(ref).click({ timeout: 5000 }); + const child = page.frames().find((frame) => frame !== page.mainFrame()); + console.log(JSON.stringify({ + clicked: await child.locator("#inside").getAttribute("data-clicked"), + ref, + })); + `); + + expect(result.ref).toMatch(/^f\d+e\d+$/); + expect(result.clicked).toBe("1"); + }); }); describe.sequential("screenshots and input devices", () => { diff --git a/daemon/src/sandbox/__tests__/sandbox-integration.test.ts b/daemon/src/sandbox/__tests__/sandbox-integration.test.ts index 2dc301e1..923ce8bc 100644 --- a/daemon/src/sandbox/__tests__/sandbox-integration.test.ts +++ b/daemon/src/sandbox/__tests__/sandbox-integration.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { BrowserManager } from "../../browser-manager.js"; +import { formatError } from "../../format-error.js"; import { removeDirectoryWithRetries } from "../../test-cleanup.js"; import { runScript } from "../script-runner-quickjs.js"; import { ensureSandboxClientBundle } from "./bundle-test-helpers.js"; @@ -132,6 +133,25 @@ describe.sequential("QuickJS sandbox integration", () => { ).rejects.toThrow("boom"); }); + it("surfaces thrown error messages in formatted errors", async () => { + const output = createOutput(); + + const error = await runScript( + ` + throw new Error("boom message"); + `, + manager, + "default", + output.sink + ).then( + () => null, + (caught: unknown) => caught + ); + + expect(error).toBeInstanceOf(Error); + expect(formatError(error)).toContain("boom message"); + }); + it("enforces CPU timeouts", async () => { const output = createOutput(); @@ -168,6 +188,66 @@ describe.sequential("QuickJS sandbox integration", () => { ).rejects.toThrow(/timed out|terminated|interrupted/i); }, 120_000); + it("stops pending Playwright operations on abort before reusing the browser", async () => { + const controller = new AbortController(); + let announceReady!: () => void; + const ready = new Promise((resolve) => { + announceReady = resolve; + }); + const firstOutput = createOutput(); + const onStdout = firstOutput.sink.onStdout; + firstOutput.sink.onStdout = (data) => { + onStdout(data); + if (data.includes("waiting")) announceReady(); + }; + + const blocked = runScript( + ` + const page = await browser.getPage("abort-reuse"); + await page.setContent(""); + console.log("waiting"); + await page.locator("#never").click({ timeout: 60000 }); + console.log("late"); + `, + manager, + "default", + firstOutput.sink, + { signal: controller.signal, timeout: 60_000 } + ); + const blockedOutcome = blocked.then( + () => null, + (error: unknown) => error + ); + + await ready; + const abortStarted = Date.now(); + controller.abort(new Error("client disconnected")); + const blockedError = await Promise.race([ + blockedOutcome, + new Promise((_, reject) => + setTimeout(() => reject(new Error("sandbox abort did not settle")), 2_000) + ), + ]); + expect(blockedError).toBeInstanceOf(Error); + expect((blockedError as Error).message).toContain("client disconnected"); + expect(Date.now() - abortStarted).toBeLessThan(2_000); + expect(firstOutput.stdout.join("")).not.toContain("late"); + + const nextOutput = createOutput(); + await runScript( + ` + const page = await browser.getPage("abort-reuse"); + await page.locator("#ok").click({ timeout: 5000 }); + console.log(await page.locator("#ok").textContent()); + `, + manager, + "default", + nextOutput.sink, + { timeout: 10_000 } + ); + expect(nextOutput.stdout.join("")).toContain("Ready"); + }, 30_000); + it("routes console output to stdout", async () => { const output = createOutput(); @@ -183,4 +263,24 @@ describe.sequential("QuickJS sandbox integration", () => { expect(output.stdout.join("")).toContain("sandbox 42 { ok: true }"); expect(output.stderr.join("")).toBe(""); }); + + it("supports Buffer.isBuffer", async () => { + const output = createOutput(); + + await runScript( + ` + console.log( + Buffer.isBuffer(Buffer.from([1, 2, 3])), + Buffer.isBuffer(new Uint8Array(3)), + Buffer.isBuffer("nope") + ); + `, + manager, + "default", + output.sink + ); + + expect(output.stdout.join("")).toContain("true false false"); + expect(output.stderr.join("")).toBe(""); + }); }); diff --git a/daemon/src/sandbox/__tests__/sandbox-security.test.ts b/daemon/src/sandbox/__tests__/sandbox-security.test.ts index 67fb9f22..1806c84b 100644 --- a/daemon/src/sandbox/__tests__/sandbox-security.test.ts +++ b/daemon/src/sandbox/__tests__/sandbox-security.test.ts @@ -170,6 +170,35 @@ describe.sequential("QuickJS sandbox security", () => { expect(payload.browserHasNullPrototype).toBe(true); }, 120_000); + it("exposes the cua and domCua namespaces on pages", async () => { + const output = await runSandboxScript(` + const page = await browser.newPage(); + console.log( + JSON.stringify({ + cuaClick: typeof page.cua.click, + cuaScreenshot: typeof page.cua.screenshot, + domCuaGetVisibleDom: typeof page.domCua.getVisibleDom, + domCuaClick: typeof page.domCua.click, + }), + ); + `); + + expect(output.stderr).toEqual([]); + expect(output.stdout).toHaveLength(1); + + const reportLine = output.stdout[0]; + if (reportLine === undefined) { + throw new Error("Sandbox namespace report was not captured"); + } + + expect(JSON.parse(reportLine)).toEqual({ + cuaClick: "function", + cuaScreenshot: "function", + domCuaGetVisibleDom: "function", + domCuaClick: "function", + }); + }, 120_000); + it("captures console output without leaking to host stdout", async () => { const output = createOutput(); const stdoutSpy = vi.spyOn(process.stdout, "write"); diff --git a/daemon/src/sandbox/forked-client/src/client/cua.ts b/daemon/src/sandbox/forked-client/src/client/cua.ts new file mode 100644 index 00000000..e73920f4 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/cua.ts @@ -0,0 +1,238 @@ +// @ts-nocheck +import { normalizeKeys } from "./cuaKeys"; +import type { Page } from "./page"; + +const SUPPORTED_BUTTONS = ["left", "middle", "right"]; + +function assertButton(button: string): void { + if (!SUPPORTED_BUTTONS.includes(button)) { + throw new Error( + `Unsupported mouse button "${button}" — must be one of "left", "middle", or "right"` + ); + } +} + +function jpegDimensions(buffer: Buffer): { width: number; height: number } | null { + if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) return null; + let offset = 2; + while (offset + 9 < buffer.length) { + if (buffer[offset] !== 0xff) { + offset++; + continue; + } + const marker = buffer[offset + 1]; + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) + return { + height: (buffer[offset + 5] << 8) | buffer[offset + 6], + width: (buffer[offset + 7] << 8) | buffer[offset + 8], + }; + offset += 2 + ((buffer[offset + 2] << 8) | buffer[offset + 3]); + } + return null; +} + +export class Cua { + #page: Page; + + constructor(page: Page) { + this.#page = page; + } + + /** Click at viewport coordinates. Opt into navigation settling when needed. */ + async click({ + x, + y, + button = "left", + clickCount = 1, + modifiers = [], + waitForNavigation = false, + }: { + x: number; + y: number; + button?: "left" | "middle" | "right"; + clickCount?: number; + modifiers?: string[]; + waitForNavigation?: boolean; + }): Promise { + assertButton(button); + const act = () => + this.#withModifiers(modifiers, () => this.#page.mouse.click(x, y, { button, clickCount })); + if (waitForNavigation) await this.#actAndSettle(act); + else await act(); + } + + async doubleClick({ + x, + y, + modifiers = [], + waitForNavigation = false, + }: { + x: number; + y: number; + modifiers?: string[]; + waitForNavigation?: boolean; + }): Promise { + await this.click({ x, y, clickCount: 2, modifiers, waitForNavigation }); + } + + async drag({ + path, + modifiers = [], + }: { + path: Array<{ x: number; y: number }>; + modifiers?: string[]; + }): Promise { + if (!Array.isArray(path) || path.length === 0) + throw new Error("cua.drag requires a non-empty path of {x, y} points"); + await this.#withModifiers(modifiers, async () => { + await this.#page.mouse.move(path[0].x, path[0].y); + await this.#page.mouse.down(); + try { + for (const point of path.slice(1)) + await this.#page.mouse.move(point.x, point.y, { steps: 10 }); + } finally { + await this.#page.mouse.up(); + } + }); + } + + async move({ x, y }: { x: number; y: number }): Promise { + await this.#page.mouse.move(x, y); + } + + async scroll({ + x, + y, + scrollX = 0, + scrollY = 0, + modifiers = [], + }: { + x: number; + y: number; + scrollX?: number; + scrollY?: number; + modifiers?: string[]; + }): Promise { + await this.#withModifiers(modifiers, async () => { + await this.#page.mouse.move(x, y); + await this.#page.mouse.wheel(scrollX, scrollY); + }); + } + + async keypress({ keys }: { keys: string[] }): Promise { + const normalized = normalizeKeys(keys); + if (normalized.length === 0) return; + const held = normalized.slice(0, -1); + const pressed: string[] = []; + try { + for (const key of held) { + await this.#page.keyboard.down(key); + pressed.push(key); + } + await this.#page.keyboard.press(normalized[normalized.length - 1]); + } finally { + for (const key of pressed.reverse()) await this.#page.keyboard.up(key); + } + } + + async type({ text }: { text: string }): Promise { + await this.#page.keyboard.type(text); + } + + /** + * Save a JPEG screenshot whose pixels map 1:1 onto cua coordinates + * (CSS pixels at any DPR). Never derive click coordinates from a + * `fullPage` image — scroll, then take a viewport screenshot instead. + */ + async screenshot({ + name, + fullPage, + clip, + }: { + name?: string; + fullPage?: boolean; + clip?: { x: number; y: number; width: number; height: number }; + } = {}): Promise<{ path: string; width: number; height: number }> { + let buffer = await this.#page.screenshot({ + type: "jpeg", + quality: 80, + scale: "css", + fullPage, + clip, + }); + let width: number; + let height: number; + if (clip) { + width = clip.width; + height = clip.height; + } else if (fullPage) { + [width, height] = await this.#page.evaluate(() => [ + document.documentElement.scrollWidth, + document.documentElement.scrollHeight, + ]); + } else { + [width, height] = await this.#page.evaluate(() => [innerWidth, innerHeight]); + } + width = Math.round(width); + height = Math.round(height); + // Playwright ignores scale:"css" on viewport:null pages (headed and + // connected Chrome), returning device-pixel images that break the 1:1 + // coordinate contract — downscale in-page when the dims disagree. + const actual = jpegDimensions(buffer); + if (actual && (Math.abs(actual.width - width) > 1 || Math.abs(actual.height - height) > 1)) + buffer = await this.#downscaleToCssPixels(buffer, width, height); + const save = globalThis.saveScreenshot; + if (typeof save !== "function") + throw new Error("saveScreenshot() is not available in the QuickJS sandbox"); + const path = await save(buffer, (name ?? `cua-${this.#page._guid}`) + ".jpeg"); + return { path, width, height }; + } + + async #downscaleToCssPixels(buffer: Buffer, width: number, height: number): Promise { + const base64 = await this.#page.evaluate( + async ({ data, width, height }) => { + const raw = atob(data); + const bytes = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i); + const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/jpeg" })); + const canvas = new OffscreenCanvas(width, height); + canvas.getContext("2d").drawImage(bitmap, 0, 0, width, height); + bitmap.close(); + const blob = await canvas.convertToBlob({ type: "image/jpeg", quality: 0.8 }); + const out = new Uint8Array(await blob.arrayBuffer()); + let binary = ""; + for (let i = 0; i < out.length; i += 0x8000) + binary += String.fromCharCode.apply(null, out.subarray(i, i + 0x8000)); + return btoa(binary); + }, + { data: buffer.toString("base64"), width, height } + ); + return Buffer.from(base64, "base64"); + } + + async #withModifiers(modifiers: string[], act: () => Promise): Promise { + const keys = normalizeKeys(modifiers ?? []); + const pressed: string[] = []; + try { + for (const key of keys) { + await this.#page.keyboard.down(key); + pressed.push(key); + } + await act(); + } finally { + for (const key of pressed.reverse()) await this.#page.keyboard.up(key); + } + } + + async #actAndSettle(act: () => Promise): Promise { + const nav = this.#page + .waitForEvent("framenavigated", { + predicate: (frame) => frame === this.#page.mainFrame(), + timeout: 1000, + }) + .catch(() => null); + await act(); + if (await nav) + await this.#page.waitForLoadState("domcontentloaded", { timeout: 10_000 }).catch(() => {}); + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/cuaKeys.ts b/daemon/src/sandbox/forked-client/src/client/cuaKeys.ts new file mode 100644 index 00000000..43806f3c --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/cuaKeys.ts @@ -0,0 +1,66 @@ +// @ts-nocheck +const KEY_ALIASES: Record = { + alt: "Alt", + option: "Alt", + arrowdown: "ArrowDown", + arrowleft: "ArrowLeft", + arrowright: "ArrowRight", + arrowup: "ArrowUp", + down: "ArrowDown", + left: "ArrowLeft", + right: "ArrowRight", + up: "ArrowUp", + backspace: "Backspace", + capslock: "CapsLock", + cmd: "Meta", + command: "Meta", + meta: "Meta", + super: "Meta", + win: "Meta", + ctrl: "ControlOrMeta", + control: "ControlOrMeta", + del: "Delete", + delete: "Delete", + end: "End", + enter: "Enter", + return: "Enter", + esc: "Escape", + escape: "Escape", + home: "Home", + insert: "Insert", + pagedown: "PageDown", + pgdn: "PageDown", + pageup: "PageUp", + pgup: "PageUp", + shift: "Shift", + space: "Space", + spacebar: "Space", + tab: "Tab", +}; + +const CHORD_REWRITES: Record = { + "ctrl+a": ["ControlOrMeta", "a"], + "ctrl+c": ["ControlOrMeta", "c"], + "ctrl+l": ["ControlOrMeta", "l"], + "ctrl+n": ["ControlOrMeta", "n"], + "ctrl+v": ["ControlOrMeta", "v"], + "ctrl+x": ["ControlOrMeta", "x"], + "ctrl+y": ["ControlOrMeta", "Shift", "z"], + "ctrl+z": ["ControlOrMeta", "z"], +}; + +function normalizeKey(key: string): string { + const lowered = String(key).trim().toLowerCase(); + const alias = KEY_ALIASES[lowered]; + if (alias) return alias; + if (/^f\d{1,2}$/.test(lowered)) return "F" + lowered.slice(1); + if (lowered.length === 1) return lowered; + return key; +} + +export function normalizeKeys(keys: string[]): string[] { + const lowered = keys.map((key) => String(key).trim().toLowerCase()); + const chord = CHORD_REWRITES[lowered.join("+")]; + if (chord) return chord.slice(); + return keys.map(normalizeKey); +} diff --git a/daemon/src/sandbox/forked-client/src/client/domCua.ts b/daemon/src/sandbox/forked-client/src/client/domCua.ts new file mode 100644 index 00000000..c1c9fd00 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/domCua.ts @@ -0,0 +1,199 @@ +// @ts-nocheck +import { domCuaRegister, domCuaWalker } from "./domCuaInjected"; +import { TimeoutError } from "./errors"; +import type { Frame } from "./frame"; +import type { Page } from "./page"; + +const MAIN_FRAME_ELEMENT_BUDGET = 200; +const CHILD_FRAME_ELEMENT_BUDGET = 50; +const MAX_LINES = 200; +const MAX_CHARS = 20_000; +const FRAME_TRUNCATION_MARKER = ""; +const SNAPSHOT_TRUNCATION_MARKER = ""; + +function frameKey(frame: Frame): string { + const name = frame.name(); + if (name) return name; + const indexPath: number[] = []; + let current = frame; + for (let parent = current.parentFrame(); parent; parent = current.parentFrame()) { + indexPath.unshift(parent.childFrames().indexOf(current)); + current = parent; + } + return `${frame.url()}@${indexPath.join(".")}`; +} + +function staleNodeError(nodeId: number): Error { + return new Error(`DOM node ${nodeId} is stale or missing — re-run getVisibleDom()`); +} + +function blockedStateError(): Error { + return new Error("this page blocks domCua state — domCua cannot track elements here"); +} + +export class DomCua { + #page: Page; + + constructor(page: Page) { + this.#page = page; + } + + /** + * Snapshot the visible interactive elements of every frame as pseudo-HTML + * lines with `node_id=N` attributes. Ids are only valid against the latest + * snapshot of the current document — re-run after any navigation. + */ + async getVisibleDom(): Promise { + const mainFrame = this.#page.mainFrame(); + const frames = [mainFrame, ...this.#page.frames().filter((frame) => frame !== mainFrame)]; + const snapshots: Array<{ + key: string; + docToken: string; + entries: Array<{ ref: number; line: string }>; + truncated: boolean; + }> = []; + for (const frame of frames) { + const isMain = frame === mainFrame; + let result; + try { + result = await frame.evaluate(domCuaWalker, { + maxElements: isMain ? MAIN_FRAME_ELEMENT_BUDGET : CHILD_FRAME_ELEMENT_BUDGET, + }); + } catch (error) { + if (isMain) throw error; + continue; + } + if (result.blocked) { + if (isMain) throw blockedStateError(); + continue; + } + snapshots.push({ + key: frameKey(frame), + docToken: result.docToken, + entries: result.entries, + truncated: result.truncated, + }); + } + + const registration = await mainFrame.evaluate(domCuaRegister, { + frames: snapshots.map((snapshot) => ({ + key: snapshot.key, + docToken: snapshot.docToken, + refs: snapshot.entries.map((entry) => entry.ref), + })), + }); + if (registration.blocked) throw blockedStateError(); + + const lines: string[] = []; + let chars = 0; + let budgetExceeded = false; + for (let i = 0; i < snapshots.length && !budgetExceeded; i++) { + const snapshot = snapshots[i]; + const ids = registration.ids[i]; + for (let j = 0; j < snapshot.entries.length; j++) { + const line = snapshot.entries[j].line.replace(/node_id=\d+/, `node_id=${ids[j]}`); + if (lines.length >= MAX_LINES || chars + line.length > MAX_CHARS) { + budgetExceeded = true; + break; + } + lines.push(line); + chars += line.length + 1; + } + if (!budgetExceeded && snapshot.truncated) lines.push(FRAME_TRUNCATION_MARKER); + } + if (budgetExceeded) lines.push(SNAPSHOT_TRUNCATION_MARKER); + return lines.join("\n"); + } + + async click({ + nodeId, + button = "left", + modifiers = [], + waitForNavigation = false, + }: { + nodeId: number | string; + button?: "left" | "middle" | "right"; + modifiers?: string[]; + waitForNavigation?: boolean; + }): Promise { + const { x, y } = await this.#resolveNodeCenter(nodeId); + await this.#page.cua.click({ x, y, button, modifiers, waitForNavigation }); + } + + async doubleClick({ + nodeId, + waitForNavigation = false, + }: { + nodeId: number | string; + waitForNavigation?: boolean; + }): Promise { + const { x, y } = await this.#resolveNodeCenter(nodeId); + await this.#page.cua.click({ x, y, clickCount: 2, waitForNavigation }); + } + + async scroll({ + scrollX = 0, + scrollY = 0, + nodeId, + }: { + scrollX?: number; + scrollY?: number; + nodeId?: number | string; + }): Promise { + let x: number; + let y: number; + if (nodeId !== undefined) { + ({ x, y } = await this.#resolveNodeCenter(nodeId)); + } else { + const [width, height] = await this.#page.evaluate(() => [innerWidth, innerHeight]); + x = width / 2; + y = height / 2; + } + await this.#page.cua.scroll({ x, y, scrollX, scrollY }); + } + + async type({ text }: { text: string }): Promise { + await this.#page.cua.type({ text }); + } + + async keypress({ keys }: { keys: string[] }): Promise { + await this.#page.cua.keypress({ keys }); + } + + async #resolveNodeCenter(nodeId: number | string): Promise<{ x: number; y: number }> { + if (typeof nodeId === "string" && /^\d+$/.test(nodeId)) nodeId = Number(nodeId); + if (typeof nodeId !== "number") + throw new Error("domCua requires a numeric nodeId from getVisibleDom()"); + const target = await this.#page + .mainFrame() + .evaluate( + (id) => globalThis.__devBrowserDomCua?.actionableByPublicId?.get(id) ?? null, + nodeId + ); + if (!target) throw staleNodeError(nodeId); + const frame = this.#page.frames().find((candidate) => frameKey(candidate) === target.frameKey); + if (!frame) throw staleNodeError(nodeId); + const handle = await frame.evaluateHandle( + (ref) => globalThis.__devBrowserDomCua?.refToElement?.get(ref) ?? null, + target.ref + ); + const element = handle.asElement(); + if (!element) { + await handle.dispose(); + throw staleNodeError(nodeId); + } + try { + try { + await element.scrollIntoViewIfNeeded({ timeout: 3000 }); + } catch (error) { + if (error instanceof TimeoutError) throw staleNodeError(nodeId); + throw error; + } + const box = await element.boundingBox(); + if (!box) throw staleNodeError(nodeId); + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + } finally { + await element.dispose(); + } + } +} diff --git a/daemon/src/sandbox/forked-client/src/client/domCuaInjected.ts b/daemon/src/sandbox/forked-client/src/client/domCuaInjected.ts new file mode 100644 index 00000000..63b3f041 --- /dev/null +++ b/daemon/src/sandbox/forked-client/src/client/domCuaInjected.ts @@ -0,0 +1,308 @@ +// @ts-nocheck +// WARNING: every export in this file is serialized with String(fn) and +// re-evaluated inside the page (frame.evaluate ships source text over the +// wire). Each export must stay ONE truly self-contained function expression — +// no references to module-level helpers, constants, or imports. A closure +// would bundle and stringify fine but explode as a ReferenceError in-page at +// runtime. Bundler flags that rewrite function bodies (minify, keepNames) +// would also corrupt the serialized source; see +// daemon/scripts/bundle-sandbox-client.ts. + +export const domCuaWalker = function (options) { + const maxElements = options && typeof options.maxElements === "number" ? options.maxElements : 50; + + const INTERACTIVE_TAGS = { + a: 1, + button: 1, + details: 1, + input: 1, + option: 1, + select: 1, + summary: 1, + textarea: 1, + }; + const INTERACTIVE_ROLES = { + button: 1, + checkbox: 1, + combobox: 1, + link: 1, + menuitem: 1, + option: 1, + radio: 1, + slider: 1, + spinbutton: 1, + switch: 1, + tab: 1, + textbox: 1, + }; + const SKIPPED_TAGS = { script: 1, style: 1, template: 1, noscript: 1 }; + const TEXT_ATTRIBUTES = [ + "aria-disabled", + "aria-label", + "contenteditable", + "href", + "name", + "placeholder", + "role", + "title", + "type", + "value", + ]; + const BOOLEAN_ATTRIBUTES = [ + ["checked", "checked"], + ["disabled", "disabled"], + ["multiple", "multiple"], + ["readonly", "readOnly"], + ["required", "required"], + ["selected", "selected"], + ]; + + let state = globalThis.__devBrowserDomCua; + if (!state || typeof state !== "object") { + state = {}; + globalThis.__devBrowserDomCua = state; + if (globalThis.__devBrowserDomCua !== state) + return { blocked: true, entries: [], truncated: false }; + } + if (!(state.elementToRef instanceof WeakMap)) state.elementToRef = new WeakMap(); + if (typeof state.nextRef !== "number") state.nextRef = 1; + if (typeof state.docToken !== "string") state.docToken = Date.now() + "-" + Math.random(); + const refToElement = new Map(); + state.refToElement = refToElement; + if (state.refToElement !== refToElement || !(state.elementToRef instanceof WeakMap)) + return { blocked: true, entries: [], truncated: false }; + + const viewport = + typeof visualViewport !== "undefined" && visualViewport + ? { + left: visualViewport.offsetLeft, + top: visualViewport.offsetTop, + width: visualViewport.width, + height: visualViewport.height, + } + : { left: 0, top: 0, width: innerWidth, height: innerHeight }; + + function collapseWhitespace(text) { + return text.replace(/\s+/g, " ").trim(); + } + + function escapeHtml(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function isStyleVisible(element) { + const style = getComputedStyle(element); + return ( + style.visibility === "visible" && + style.display !== "none" && + style.pointerEvents !== "none" && + parseFloat(style.opacity) > 0.01 + ); + } + + function isTextVisible(element) { + const style = getComputedStyle(element); + return ( + style.visibility === "visible" && style.display !== "none" && parseFloat(style.opacity) > 0.01 + ); + } + + function intersectsViewport(element) { + const rects = element.getClientRects(); + for (let i = 0; i < rects.length; i++) { + const rect = rects[i]; + if ( + rect.width > 0 && + rect.height > 0 && + rect.right > viewport.left && + rect.left < viewport.left + viewport.width && + rect.bottom > viewport.top && + rect.top < viewport.top + viewport.height + ) + return true; + } + return false; + } + + function visibleText(root) { + let out = ""; + const nodes = root.childNodes || []; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (node.nodeType === 3) { + out += node.nodeValue || ""; + continue; + } + if (node.nodeType !== 1) continue; + if (SKIPPED_TAGS[node.tagName.toLowerCase()] === 1) continue; + if (node.getAttribute("aria-hidden") === "true" || node.hasAttribute("hidden")) continue; + if (!isTextVisible(node)) continue; + if (node.shadowRoot) out += visibleText(node.shadowRoot) + " "; + out += visibleText(node); + } + return out; + } + + function isInteractive(element) { + const tag = element.tagName.toLowerCase(); + if (INTERACTIVE_TAGS[tag] === 1) return true; + if ( + element.hasAttribute("contenteditable") && + element.getAttribute("contenteditable") !== "false" + ) + return true; + if (element.hasAttribute("href")) return true; + if (element.hasAttribute("onclick")) return true; + const role = element.getAttribute("role"); + if (role && INTERACTIVE_ROLES[role.toLowerCase()] === 1) return true; + const tabIndex = element.getAttribute("tabindex"); + if (tabIndex !== null && parseInt(tabIndex, 10) >= 0) return true; + return false; + } + + function renderLine(element, ref) { + const tag = element.tagName.toLowerCase(); + const parts = ["<" + tag + " node_id=" + ref]; + for (let i = 0; i < TEXT_ATTRIBUTES.length; i++) { + const name = TEXT_ATTRIBUTES[i]; + const value = + name === "value" && typeof element.value === "string" + ? element.value + : element.getAttribute(name); + if (value === null || value === undefined || value === "") continue; + parts.push(name + '="' + escapeHtml(collapseWhitespace(String(value))) + '"'); + } + for (let j = 0; j < BOOLEAN_ATTRIBUTES.length; j++) { + const attrName = BOOLEAN_ATTRIBUTES[j][0]; + const propName = BOOLEAN_ATTRIBUTES[j][1]; + const enabled = + propName in element ? element[propName] === true : element.hasAttribute(attrName); + if (enabled) parts.push(attrName + '="true"'); + } + let text = visibleText(element); + if (element.shadowRoot) text = visibleText(element.shadowRoot) + " " + text; + text = collapseWhitespace(text); + if (text.length > 160) text = text.slice(0, 160); + const opener = parts.join(" "); + if (!text) return opener + " />"; + return opener + ">" + escapeHtml(text) + ""; + } + + const entries = []; + let truncated = false; + + function visit(node) { + if (truncated) return; + if (node.nodeType !== 1) return; + const tag = node.tagName.toLowerCase(); + if (SKIPPED_TAGS[tag] === 1) return; + if (node.getAttribute("aria-hidden") === "true" || node.hasAttribute("hidden")) return; + const hiddenInput = + tag === "input" && (node.getAttribute("type") || "").toLowerCase() === "hidden"; + if (!hiddenInput && isInteractive(node) && isStyleVisible(node) && intersectsViewport(node)) { + if (entries.length >= maxElements) { + truncated = true; + return; + } + let ref = state.elementToRef.get(node); + if (ref === undefined) { + ref = state.nextRef++; + state.elementToRef.set(node, ref); + } + refToElement.set(ref, node); + entries.push({ ref, line: renderLine(node, ref) }); + } + if (node.shadowRoot) { + const shadowChildren = node.shadowRoot.children; + for (let i = 0; i < shadowChildren.length; i++) { + visit(shadowChildren[i]); + if (truncated) return; + } + } + const children = node.children; + for (let j = 0; j < children.length; j++) { + visit(children[j]); + if (truncated) return; + } + } + + const root = document.body || document.documentElement; + if (root) visit(root); + return { blocked: false, entries, truncated, docToken: state.docToken }; +}; + +export const domCuaRegister = function (data) { + const STORAGE_KEY = "__devBrowserDomCuaNextPublicId"; + + let state = globalThis.__devBrowserDomCua; + if (!state || typeof state !== "object") { + state = {}; + globalThis.__devBrowserDomCua = state; + if (globalThis.__devBrowserDomCua !== state) return { blocked: true, ids: [] }; + } + + let next = state.nextPublicId; + if (typeof next !== "number" || !isFinite(next)) { + let stored = null; + try { + stored = sessionStorage.getItem(STORAGE_KEY); + } catch (error) { + stored = null; + } + const parsed = stored === null ? NaN : parseInt(stored, 10); + next = parsed >= 1 ? parsed : 1_000_000 + Math.floor(Math.random() * 2_000_000_000); + } + + if (!(state.publicIdByFrameKey instanceof Map)) state.publicIdByFrameKey = new Map(); + const sticky = state.publicIdByFrameKey; + let total = 0; + sticky.forEach((frameMap) => { + total += frameMap.size; + }); + if (total > 5000) { + sticky.clear(); + next += 1_000_000; + } + + const actionable = new Map(); + const ids = []; + for (let i = 0; i < data.frames.length; i++) { + const frame = data.frames[i]; + const stickyKey = frame.key + "::" + frame.docToken; + let frameMap = sticky.get(stickyKey); + if (!frameMap) { + frameMap = new Map(); + sticky.set(stickyKey, frameMap); + } + const frameIds = []; + for (let j = 0; j < frame.refs.length; j++) { + const ref = frame.refs[j]; + let id = frameMap.get(ref); + if (id === undefined) { + id = next++; + frameMap.set(ref, id); + } + actionable.set(id, { frameKey: frame.key, ref }); + frameIds.push(id); + } + ids.push(frameIds); + } + state.actionableByPublicId = actionable; + state.nextPublicId = next; + try { + sessionStorage.setItem(STORAGE_KEY, String(next)); + } catch (error) { + // sessionStorage may be blocked; the random high base covers the next document + } + if ( + globalThis.__devBrowserDomCua !== state || + state.actionableByPublicId !== actionable || + state.publicIdByFrameKey !== sticky + ) + return { blocked: true, ids: [] }; + return { blocked: false, ids }; +}; diff --git a/daemon/src/sandbox/forked-client/src/client/page.ts b/daemon/src/sandbox/forked-client/src/client/page.ts index f66ef97f..a742b69a 100644 --- a/daemon/src/sandbox/forked-client/src/client/page.ts +++ b/daemon/src/sandbox/forked-client/src/client/page.ts @@ -20,7 +20,9 @@ import { Artifact } from "./artifact"; import { ChannelOwner } from "./channelOwner"; import { evaluationScript } from "./clientHelper"; import { Coverage } from "./coverage"; +import { Cua } from "./cua"; import { DisposableObject, DisposableStub } from "./disposable"; +import { DomCua } from "./domCua"; import { Download } from "./download"; import { ElementHandle, determineScreenshotType } from "./elementHandle"; import { TargetClosedError, isTargetClosedError, parseError, serializeError } from "./errors"; @@ -113,6 +115,8 @@ export class Page extends ChannelOwner implements api.Page _webSocketRoutes: WebSocketRouteHandler[] = []; readonly coverage: Coverage; + readonly cua: Cua; + readonly domCua: DomCua; readonly keyboard: Keyboard; readonly mouse: Mouse; readonly request: APIRequestContext; @@ -155,6 +159,8 @@ export class Page extends ChannelOwner implements api.Page this._browserContext._timeoutSettings ); + this.cua = new Cua(this); + this.domCua = new DomCua(this); this.keyboard = new Keyboard(this); this.mouse = new Mouse(this); this.request = this._browserContext.request; @@ -911,6 +917,12 @@ export class Page extends ChannelOwner implements api.Page return this.mainFrame().locator(selector, options); } + getByRef(ref: string): Locator { + if (!/^(?:f\d+)?e\d+$/.test(ref)) + throw new Error(`Invalid snapshot ref "${ref}" — expected e or fe`); + return this.locator("aria-ref=" + ref); + } + getByTestId(testId: string | RegExp): Locator { return this.mainFrame().getByTestId(testId); } diff --git a/daemon/src/sandbox/host-bridge.ts b/daemon/src/sandbox/host-bridge.ts index 34907da0..24ff5be5 100644 --- a/daemon/src/sandbox/host-bridge.ts +++ b/daemon/src/sandbox/host-bridge.ts @@ -55,6 +55,21 @@ export class HostBridge { await this.dispatcherConnection.dispatch(JSON.parse(json) as Record); } + async stopPendingOperations(error: Error): Promise { + const dispatchers = this.dispatcherConnection._dispatcherByGuid; + if (!dispatchers) { + await this.rootDispatcher.stopPendingOperations(error); + return; + } + + const controllers = new Set( + [...new Set(dispatchers.values())].flatMap((dispatcher) => [ + ...(dispatcher._activeProgressControllers ?? []), + ]) + ); + await Promise.all([...controllers].map((controller) => controller.abort(error))); + } + async dispose(): Promise { if (this.disposed) { return; @@ -63,10 +78,22 @@ export class HostBridge { this.disposed = true; this.dispatcherConnection.onmessage = () => {}; + let cleanupError: unknown; + try { + await this.stopPendingOperations(new Error("Sandbox bridge disposed")); + } catch (error) { + cleanupError = error; + } try { await this.playwrightDispatcher?.cleanup(); + } catch (error) { + cleanupError ??= error; } finally { this.rootDispatcher._dispose(); } + + if (cleanupError) { + throw cleanupError; + } } } diff --git a/daemon/src/sandbox/playwright-internals.ts b/daemon/src/sandbox/playwright-internals.ts index 3d7ce96f..ce8425b0 100644 --- a/daemon/src/sandbox/playwright-internals.ts +++ b/daemon/src/sandbox/playwright-internals.ts @@ -23,12 +23,19 @@ export interface ClientConnectionLike { } export interface DispatcherConnectionLike { + _dispatcherByGuid?: Map; onmessage: (message: WireMessage) => void; dispatch(message: WireMessage): Promise; } export interface RootDispatcherLike { + _activeProgressControllers?: Set; _dispose(): void; + stopPendingOperations(error: Error): Promise; +} + +export interface ProgressControllerLike { + abort(error: Error): Promise; } export interface PlaywrightDispatcherLike { diff --git a/daemon/src/sandbox/quickjs-sandbox.ts b/daemon/src/sandbox/quickjs-sandbox.ts index b6097574..113f55b8 100644 --- a/daemon/src/sandbox/quickjs-sandbox.ts +++ b/daemon/src/sandbox/quickjs-sandbox.ts @@ -179,10 +179,13 @@ interface QuickJSSandboxOptions { export class QuickJSSandbox { readonly #options: QuickJSSandboxOptions; readonly #anonymousPages = new Set(); + readonly #abortWakeup: Promise; readonly #pendingHostOperations = new Set>(); readonly #transportInbox: string[] = []; #asyncError?: Error; + #abortError?: Error; + #resolveAbortWakeup!: () => void; #host?: QuickJSHost; #hostBridge?: HostBridge; #flushPromise?: Promise; @@ -191,16 +194,21 @@ export class QuickJSSandbox { constructor(options: QuickJSSandboxOptions) { this.#options = options; + this.#abortWakeup = new Promise((resolve) => { + this.#resolveAbortWakeup = resolve; + }); } async initialize(): Promise { this.#assertAlive(); + this.#throwIfAborted(); if (this.#initialized) { return; } try { await ensureDevBrowserTempDir(); + this.#throwIfAborted(); this.#host = await QuickJSHost.create({ memoryLimitBytes: this.#options.memoryLimitBytes ?? DEFAULT_MEMORY_LIMIT_BYTES, @@ -222,6 +230,7 @@ export class QuickJSSandbox { this.#handleTransportSend(message); }, }); + this.#throwIfAborted(); this.#host.executeScriptSync( ` @@ -301,6 +310,10 @@ export class QuickJSSandbox { super(value); } + static isBuffer(value) { + return value instanceof Buffer; + } + static from(value, encodingOrOffset, length) { if (typeof value === "string") { if (encodingOrOffset !== undefined && encodingOrOffset !== "base64") { @@ -350,6 +363,7 @@ export class QuickJSSandbox { ); const bundleCode = await getSandboxClientBundleCode(); + this.#throwIfAborted(); const bundleFactorySource = JSON.stringify(`${bundleCode}\nreturn __PlaywrightClient;`); this.#host.executeScriptSync( ` @@ -376,6 +390,7 @@ export class QuickJSSandbox { sharedBrowser: true, denyLaunch: true, }); + this.#throwIfAborted(); await this.#host.executeScript( ` @@ -527,8 +542,10 @@ export class QuickJSSandbox { filename: "sandbox-init.js", } ); + this.#throwIfAborted(); await this.#flushTransportQueue(); + this.#throwIfAborted(); this.#throwIfAsyncError(); this.#initialized = true; } catch (error) { @@ -539,6 +556,7 @@ export class QuickJSSandbox { async executeScript(script: string): Promise { this.#assertInitialized(); + this.#throwIfAborted(); let executionError: unknown; try { @@ -550,6 +568,7 @@ export class QuickJSSandbox { filename: "user-script.js", } ); + this.#throwIfAborted(); await this.#flushTransportQueue(); this.#throwIfAsyncError(); @@ -573,6 +592,12 @@ export class QuickJSSandbox { return; } + try { + await this.stopPendingOperations(new Error("QuickJS sandbox disposed")); + } catch { + // Best effort cleanup during sandbox teardown. + } + this.#disposed = true; await this.#cleanupAnonymousPages({ @@ -594,6 +619,18 @@ export class QuickJSSandbox { } } + async abort(error: Error): Promise { + if (!this.#abortError) { + this.#abortError = error; + this.#resolveAbortWakeup(); + } + await this.stopPendingOperations(this.#abortError); + } + + async stopPendingOperations(error: Error): Promise { + await this.#hostBridge?.stopPendingOperations(error); + } + #routeConsole(level: QuickJSConsoleLevel, args: unknown[]): void { const line = `${formatArgs(args)}\n`; if (level === "warn" || level === "error") { @@ -623,17 +660,21 @@ export class QuickJSSandbox { } async #drainAsyncOps(): Promise { + this.#throwIfAborted(); this.#throwIfAsyncError(); await this.#flushTransportQueue(); + this.#throwIfAborted(); this.#throwIfAsyncError(); if (this.#pendingHostOperations.size === 0) { return; } - await Promise.race(this.#pendingHostOperations); + await Promise.race([Promise.race(this.#pendingHostOperations), this.#abortWakeup]); + this.#throwIfAborted(); this.#throwIfAsyncError(); await this.#flushTransportQueue(); + this.#throwIfAborted(); this.#throwIfAsyncError(); } @@ -736,6 +777,12 @@ export class QuickJSSandbox { } } + #throwIfAborted(): void { + if (this.#abortError) { + throw this.#abortError; + } + } + #assertAlive(): void { if (this.#disposed) { throw new Error("QuickJS sandbox has been disposed"); diff --git a/daemon/src/sandbox/script-runner-quickjs.test.ts b/daemon/src/sandbox/script-runner-quickjs.test.ts new file mode 100644 index 00000000..f16f8aa4 --- /dev/null +++ b/daemon/src/sandbox/script-runner-quickjs.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it, vi } from "vitest"; + +import { finalizeSandbox } from "./script-runner-quickjs.js"; + +describe("finalizeSandbox", () => { + it("always disposes the sandbox when pending-operation cancellation rejects", async () => { + const dispose = vi.fn(async () => undefined); + const abortError = new Error("stopPendingOperations failed"); + + await expect(finalizeSandbox({ dispose }, Promise.reject(abortError))).rejects.toBe(abortError); + + expect(dispose).toHaveBeenCalledOnce(); + }); +}); diff --git a/daemon/src/sandbox/script-runner-quickjs.ts b/daemon/src/sandbox/script-runner-quickjs.ts index 8d5140e6..ff5da7c7 100644 --- a/daemon/src/sandbox/script-runner-quickjs.ts +++ b/daemon/src/sandbox/script-runner-quickjs.ts @@ -6,12 +6,37 @@ interface ScriptOutput { onStderr: (data: string) => void; } +export async function finalizeSandbox( + sandbox: Pick, + abortPromise: Promise +): Promise { + let finalizationError: unknown; + try { + await abortPromise; + } catch (error) { + finalizationError = error; + } + + try { + await sandbox.dispose(); + } catch (error) { + finalizationError ??= error; + } + + if (finalizationError instanceof Error) { + throw finalizationError; + } + if (finalizationError !== undefined) { + throw new Error(String(finalizationError)); + } +} + export async function runScript( script: string, manager: BrowserManager, browserName: string, output: ScriptOutput, - options: { timeout?: number; memoryLimitBytes?: number } = {} + options: { timeout?: number; memoryLimitBytes?: number; signal?: AbortSignal } = {} ): Promise { const sandbox = new QuickJSSandbox({ manager, @@ -22,10 +47,25 @@ export async function runScript( timeoutMs: options.timeout, }); + let abortPromise = Promise.resolve(); + const onAbort = () => { + const reason = + options.signal?.reason instanceof Error + ? options.signal.reason + : new Error(String(options.signal?.reason ?? "Script aborted")); + abortPromise = sandbox.abort(reason); + }; + options.signal?.addEventListener("abort", onAbort, { once: true }); + try { + if (options.signal?.aborted) { + onAbort(); + throw options.signal.reason; + } await sandbox.initialize(); await sandbox.executeScript(`(async () => {\n${script}\n})()`); } finally { - await sandbox.dispose(); + options.signal?.removeEventListener("abort", onAbort); + await finalizeSandbox(sandbox, abortPromise); } } diff --git a/docs/dev/evidence/graphiti-adoption.json b/docs/dev/evidence/graphiti-adoption.json new file mode 100644 index 00000000..2132423c --- /dev/null +++ b/docs/dev/evidence/graphiti-adoption.json @@ -0,0 +1,14 @@ +{ + "groupId": "dev_browser_main", + "discoveryHealth": "healthy", + "initialFactCount": 0, + "preflightMatches": 0, + "jobId": "14dee3bb-960b-43d6-a535-ceaaff616aa8", + "status": "failed", + "error": "Processing failed with TimeoutError", + "episodeUuid": null, + "postFailureExactMatches": 0, + "persistenceVerified": false, + "retryAttempted": false, + "followUp": "Recheck job and exact metadata before any authorized retry; source artifact remains canonical." +} diff --git a/docs/dev/evidence/policy-selection-after.json b/docs/dev/evidence/policy-selection-after.json new file mode 100644 index 00000000..7aa7bd65 --- /dev/null +++ b/docs/dev/evidence/policy-selection-after.json @@ -0,0 +1,16 @@ +{ + "recommendation_mode": "patch-missing", + "memory_discovery": { + "policy_module": "graph-backed-memory-usage", + "policy_selected": true, + "rationale": "repo policy or guidance explicitly names a graph-backed memory workflow", + "repo_default": "use", + "repo_graph_memory_signals": true, + "task_decisions": [ + "use", + "skip", + "unavailable" + ] + }, + "validation_problems": [] +} diff --git a/docs/dev/evidence/policy-selection-before.json b/docs/dev/evidence/policy-selection-before.json new file mode 100644 index 00000000..2188fafb --- /dev/null +++ b/docs/dev/evidence/policy-selection-before.json @@ -0,0 +1,63 @@ +{ + "repo_purpose": "library-cli", + "recommended_profile": "standalone-library", + "recommended_modules": [ + "policy-management", + "policy-upgrade-management", + "policy-adoption-feedback-loop", + "graph-backed-memory-usage", + "planning-discipline", + "codegraph-usage", + "code-testing-discipline", + "git-worktree-hygiene", + "commit-history-discipline", + "branch-and-integration-strategy", + "commit-and-push-cadence", + "versioning-and-release", + "turn-closeout", + "validation-and-handoff", + "upstream-fork-maintenance" + ], + "memory_discovery": { + "policy_module": "graph-backed-memory-usage", + "policy_selected": true, + "rationale": "shared policy is selected, but repo evidence does not establish a concrete graph-memory workflow", + "repo_default": "task-conditional", + "repo_graph_memory_signals": false, + "task_decisions": [ + "use", + "skip", + "unavailable" + ] + }, + "existing_policy_surfaces": [ + { + "action": "merge", + "canonical": true, + "path": "/home/ecochran76/workspace.local/dev-browser/AGENTS.md", + "rationale": "inline AGENTS policy should be thinned and merged into canonical repo-local policy files", + "source_type": "agents-entrypoint" + } + ], + "repo_local_policy_findings": [ + { + "action": "keep", + "matched_modules": [], + "path": "/home/ecochran76/workspace.local/dev-browser/AGENTS.md", + "rationale": "section appears repo-specific and should remain in AGENTS.md", + "section_heading": "Tooling" + }, + { + "action": "merge", + "matched_modules": [ + "validation-and-handoff" + ], + "path": "/home/ecochran76/workspace.local/dev-browser/AGENTS.md", + "rationale": "section overlaps shared policy concepts and should be reviewed for merge into adopted local policy files", + "section_heading": "Validation" + } + ], + "existing_migration_surfaces": [], + "validation_problems": [], + "adoption_mode": "clean-adoption" +} diff --git a/docs/dev/evidence/policy-validation.json b/docs/dev/evidence/policy-validation.json new file mode 100644 index 00000000..6cc2c482 --- /dev/null +++ b/docs/dev/evidence/policy-validation.json @@ -0,0 +1,13 @@ +{ + "bundleVersion": "0.1.22", + "sourceCommit": "12a7f9fef466522e99be44d980c44a4ff056f540", + "copiedFilesVerified": 100, + "policiesWired": 16, + "fullPlanningAudit": "pass", + "activePlanningAudit": "pass", + "selectorValidationProblems": [], + "templateWorkflowYaml": "pass (PyYAML 6.0.2)", + "diffCheck": "pass", + "runtimeTests": "not run; policy/tooling-only change", + "githubEnforcement": "not yet published or configured" +} diff --git a/docs/dev/notes/0001-2026-09-22-policy-adoption.md b/docs/dev/notes/0001-2026-09-22-policy-adoption.md new file mode 100644 index 00000000..9c1f84a2 --- /dev/null +++ b/docs/dev/notes/0001-2026-09-22-policy-adoption.md @@ -0,0 +1,77 @@ +# Policy adoption and feedback — 2026-09-22 + +## Decision and provenance + +Adopt `standalone-library` plus `validation-and-handoff`, +`upstream-fork-maintenance`, and local `pull-request-and-issue-management`. +The active set is the 16 uniquely linked files in AGENTS.md. The selector bundle +is v0.1.22, source `12a7f9fef466522e99be44d980c44a4ff056f540`, installed from the +reviewed local bundle under `.agents/skills/repo-policy-selector/` with its +manifest. `.agents/policy-selector-install.json` preserves installation provenance; +absolute paths there describe the installation event, not portable commands. + +The library is installed source material. `docs/dev/policies/` is the runtime +policy authority. No live GitHub settings, issues, or PRs were changed. + +## Extraction and migration disposition + +- **Keep:** existing Node/pnpm/Cargo tooling, bundle-before-Cargo order, and + runtime validation commands in AGENTS.md. +- **Merge:** validation, graph-discovery, Git, and closeout expectations into + uniquely wired policy modules with local overrides. +- **Keep as historical evidence:** docs/maintenance-v0.2.9.md, + docs/wsl-browser-default.md, and docs/validation/wsl-stealth-browser-smoke.json. + These are completed receipts, not competing active plans. +- **Keep on its branch:** eval/v1-rc3's completed evaluation plan/report and + artifacts at d86af0c. Do not move or reopen that work under adoption. +- **Retire:** none. No active legacy plan migration is needed; new bounded + work uses docs/dev/plans and dated feedback uses docs/dev/notes. + +## Fit, overrides, and deferred modules + +Graphiti default is `use`, with per-task `use`/`skip`/`unavailable` decisions. +The initial repo-native selector could not see the operator-supplied Graphiti +instructions and returned task-conditional. The adopted explicit group +`dev_browser_main` corrects that; health and a bounded empty fact search were +verified. Adoption feedback is mirrored only through the verified policy 0004 +workflow, not arbitrary memory writes. + +The existing untracked `.codex` is a file. Installing into `.agents/` preserves +it; the install record was also moved out of the default `.codex/` location. +The upstream fork module was explicitly retained because selection inside a +linked worktree omitted it even though the main checkout detected it. + +Prefer merge-based maintenance, not the generic private-fork rebase suggestion. +Preserve the operator's ask-before-missing-index initialization rule. PR/issue +management has no dedicated module in this bundle, so policy 0016 and GitHub +templates are a local extension, a candidate for future upstream harvesting. +No upstream message is authorized by this adoption. + +Defer roadmap/runbook, goal-execution, subagent, and active-lane modules: this +is a bounded CLI policy adoption and the v1 evaluation is already complete. +Before new concurrent implementation lanes, adopt active-lane coordination. +The post-adoption heuristic recommends a broader product profile after reading +shared module text. That is selection feedback, not authority to expand the +retained profile. No active-lane or goal-only audit is claimed applicable. + +## Validation and enactment + +Run `python3 scripts/check-repo-policy.py` for full and active planning audits, +unique wiring, selector validation, and Graphiti routing. The GitHub policy +workflow runs the same command once published. Existing CI runtime checks remain. +Policy-only changes do not require rebuilding or running browser suites. + +This adoption exercises discovery, worktree isolation, a bounded plan, and +policy validation. GitHub issue/PR review, enforced branch protection, remote +publication, and runtime release behavior are **not yet evidenced**. +The bootstrap exception permits only a validated local fast-forward of this +policy commit. Retain the local adoption worktree pending remote custody. +Graphiti verification details are in `docs/dev/evidence/graphiti-adoption.json`. + +Graphiti feedback job `14dee3bb-960b-43d6-a535-ceaaff616aa8` failed with +`TimeoutError`; post-failure exact episode lookup returned zero matches. +No successful persistence is claimed and no retry was attempted. The dated +file remains canonical; recheck job and exact metadata before a later retry. +This bounded external-service failure does not block local policy adoption. +Full/active planning audits, unique wiring, pinned bundle byte comparison, +YAML checks (PyYAML 6.0.2), and staged diff checks passed. diff --git a/docs/dev/plans/0001-2026-09-22-policy-adoption.md b/docs/dev/plans/0001-2026-09-22-policy-adoption.md new file mode 100644 index 00000000..32f45fce --- /dev/null +++ b/docs/dev/plans/0001-2026-09-22-policy-adoption.md @@ -0,0 +1,53 @@ +# Plan 0001: Fork policy adoption + +State: CLOSED + +## Current State + +The selector bundle and all 16 active policies are complete and validated. +Graphiti read discovery passed; feedback ingestion timed out and is recorded +in docs/dev/evidence/graphiti-adoption.json without a persistence claim. +The adoption checkpoint is prepared for the explicit local fast-forward; +remote publication and worktree cleanup remain outside this plan. + +## Scope + +Adopt the standalone-library profile with fork maintenance, validation/handoff, +and a local PR/issue contract. Install a pinned selector, wire AGENTS.md, +provide GitHub templates, and preserve existing local work. + +## Non-goals + +No runtime changes, v1 integration, GitHub publication/protection changes, +issue creation, upstream messages, or branch/worktree deletion. + +## Acceptance Criteria + +- Relevant policies are uniquely wired with explicit read triggers. +- Graphiti group and verified write workflow are concrete. +- PR, issue, worktree, and upstream integration rules have local targets. +- Existing tooling and historical evidence remain intact. +- Full and active planning audits plus policy/template checks pass. +- The adoption commit is on local main; no remote publication is implied. + +## Execution + +Owner: current primary agent. Branch: chore/repo-policy-adoption. +Base: 6a6f9d6. Target: local main; method: fast-forward bootstrap adoption. +Write surfaces: AGENTS.md, .agents/, docs/dev/, GitHub templates, policy audit CI. +Inputs: installed catalog/modules and current repository evidence. +Serial critical path: inspect, install, adapt, audit, record, commit, integrate. +No delegated tracks; content and wiring share the same write surface. +Terminal condition: local adoption and evidence complete, or a concrete blocker. + +## Definition of Done + +All acceptance criteria have evidence, the plan is CLOSED, and closeout clearly +separates local integration from remote custody and runtime state. + +## Outcome evidence + +`docs/dev/evidence/policy-validation.json` records successful local checks. +`docs/dev/notes/0001-2026-09-22-policy-adoption.md` records fit, migration +classification, exceptions, and the Graphiti failure. Local Git ancestry at +closeout proves integration; GitHub enactment remains unverified. diff --git a/docs/dev/plans/0002-2026-09-22-publication-reconciliation.md b/docs/dev/plans/0002-2026-09-22-publication-reconciliation.md new file mode 100644 index 00000000..79bdc7ca --- /dev/null +++ b/docs/dev/plans/0002-2026-09-22-publication-reconciliation.md @@ -0,0 +1,50 @@ +# Plan 0002: Publish and reconcile fork custody + +State: CLOSED + +## Current State + +The maintenance, policy, and completed v1 evaluation checkpoints are published. +Issues #1/#2 and draft PRs #3/#4 track maintenance and policy integration. +Remote main remains unchanged; approvals and CI are separate integration gates. + +## Scope + +Refresh origin; publish existing commits through explicit topic refs; enable +fork issues; open tracking issues and draft PRs; reconcile the branch inventory +and local main tracking. Owner: primary agent for operator ecochran76. +Branch: chore/repo-policy-adoption. Target: origin/main through PR #4 after #3. + +## Non-goals + +No merge, main reset, force push, worktree deletion, branch-protection change, +Graphiti retry, release, installation, or v1 migration. + +## Acceptance Criteria + +- Published refs match exact local checkpoints. +- Issues and draft PRs exist with explicit dependencies and validation limits. +- Historical adoption claims remain dated; current custody is documented. +- Local policy audits and diff checks pass for the reconciliation. + +## Execution and evidence + +Serial critical path: fetch, inspect, push, remote readback, create tracking, +update inventory, validate, commit and publish this receipt. No parallel agents. +Inputs: Plan 0001, prior validation receipts, current refs, policy 0016. +Write surface: fork topic refs/issues/PRs and these repo-local tracking docs. + +Maintenance: 6a6f9d60ef61d7aa7780feff4001782cb17432d1, PR #3, issue #1. +Policy baseline: 3d3b071030904fc72555efc7da9c9e7638fbeaf9, PR #4, issue #2; +this receipt is its publication-reconciliation successor. +Evaluation: d86af0cc7e301cfaf501bd1cab4a87c9dfef264d, eval/v1-rc3; +retained as completed evaluation, not offered for migration integration. +Origin main at publication: e3a717426118fb9c07c1ba391421369d99ba94dc. +All three initial refs were verified by git ls-remote and tracking-ref readback. + +## Definition of Done + +Publication and tracking are verified; remaining review/CI/integration gates +are explicit. Plan closure does not claim either PR merged or authorize cleanup. +Policy and diff checks passed; remote readback of the final successor is part +of the executing turn's closeout. diff --git a/docs/dev/plans/0003-2026-09-22-review-and-merge.md b/docs/dev/plans/0003-2026-09-22-review-and-merge.md new file mode 100644 index 00000000..0aec2a13 --- /dev/null +++ b/docs/dev/plans/0003-2026-09-22-review-and-merge.md @@ -0,0 +1,52 @@ +# Plan 0003: Review and merge fork maintenance and policies + +State: CLOSED + +## Current State + +Operator requested review/merge of PRs #3 and #4. Review identified six Windows +fixture failures at the original maintenance head. Commit d26ad5c replaces +host-specific path assumptions and uses the existing native Windows TCP +transport in the agent-browser test. TypeScript and all 41 discovery tests +pass locally. The policy branch CI passed Linux/Windows daemon and Rust checks, +formatting, bundling, and policy audit at e12d24f. This closeout becomes +effective on integration through PR #4 after PR #3; current GitHub checks and +merge ancestry remain the authority, not this prospective closure commit. + +## Scope + +Owner: primary agent. Review fork carry and policy changes, fix accepted CI +blockers, validate exact heads, merge #3 then #4, and reconcile local main. +Write surfaces: discovery tests and review/custody docs. Serial critical path: +review, focused fix, CI, ordered merge, ancestry and issue readback. +Target: origin/main via merge commits. Operator's explicit review/merge request +is the authority for this manual merge; no independent GitHub approval is claimed. + +## Non-goals + +No v1 migration, runtime reinstall, Graphiti repair, history rewrite, branch +protection bypass, or worktree deletion. No broad unrelated refactoring. + +## Acceptance Criteria + +- Accepted Windows CI findings are fixed, with native CI proving behavior. +- Applicable checks pass on each current PR head; failures remain visible. +- Both PRs merge in order and target ancestry contains both exact heads. +- Local main fast-forwards and issues/custody reflect integrated outcomes. + +## Review findings + +Blocking: six failures in auto-connect.test.ts on Windows (PR #3 CI run +35739438821, also reproduced in #4). Fixtures compared POSIX literals with +native paths; custom profile fixtures omitted path resolution; Unix socket +setup failed on Windows. Fixed without runtime changes or skipped tests. +Validation: tsc --noEmit and focused Vitest (41/41) on Linux; Windows CI passed in PR #4 run 35740904433. + +No additional blocking findings in reviewed fork configuration, protocol +plumbing, permissions, policy wiring, or PR dependency arrangement. Existing +native Windows live-browser acceptance remains outside these fixture checks. + +## Definition of Done + +Applicable CI, GitHub merged state, ancestry, local status, and issue state are +verified. Installed runtime and completed v1 evaluation remain separate. diff --git a/docs/dev/policies/0001-policy-management.md b/docs/dev/policies/0001-policy-management.md new file mode 100644 index 00000000..505e9616 --- /dev/null +++ b/docs/dev/policies/0001-policy-management.md @@ -0,0 +1,39 @@ +# Policy | Policy Management + +## Policy + +- When a repo adopts shared policy, install the policy library before running selection or adoption workflows. +- Enumerate available profiles, modules, and catalog metadata deterministically from the installed policy library rather than relying on chat history or sibling checkout layout. +- Keep the adopted repo-local policy under `docs/dev/policies/`. +- Keep exactly one active repo-local policy file per shared module identity. + Treat the module id, not the ordinal filename, as the stable identity; a new + serial must not turn an upgrade into a second active copy. +- Keep `AGENTS.md` as the entrypoint that wires the adopted repo-local policy into the repo contract. +- Treat `AGENTS.md` as a policy-loading contract, not just a static pointer. +- Treat repo-local policy as one section of `AGENTS.md`, not the whole file. +- Keep repo-specific commands, environment prerequisites, and operating constraints in `AGENTS.md` or adjacent local docs even after shared policy is installed. +- Keep `AGENTS.md` thin relative to the full durable policy body; do not turn it into the full policy dump if the repo can keep policy files under `docs/dev/policies/`. +- Make each policy pointer name both the target and the condition that should + trigger reading it. Required policy behind a vague or stale pointer is not + reliably wired. +- Keep each rule in one authoritative location. Use `AGENTS.md` for routing and + repo-specific constraints, and use linked policy files for the durable body; + do not duplicate the same rule across both surfaces for emphasis. +- Re-read the relevant adopted policy files at the start of any non-trivial turn. +- Re-read the relevant adopted policy files when task scope changes mid-session. +- Treat policy installation, policy enumeration, and `AGENTS.md` wiring as deterministic setup work rather than ad hoc prose copying. +- Validate policy identity and wire-in uniqueness deterministically. Duplicate + identities must name every conflicting path and fail closed until a + maintainer reconciles them; tooling must not silently choose a winner. +- When the repo uses an installable selector bundle, ensure the selector ships with the policy library it depends on. +## Adoption Notes + +Use this module as the first adopted policy when a repo is managed through the shared policy selector workflow. + +## Fork-specific contract + +The pinned selector is `.agents/skills/repo-policy-selector/`; its install +record is `.agents/policy-selector-install.json`. The bundle is a source library; +only policies linked here from AGENTS.md govern this repo. Do not load all +vendored modules as active rules. This path avoids the pre-existing `.codex` +file. Use Python 3 to invoke the installed scripts. diff --git a/docs/dev/policies/0002-policy-upgrade-management.md b/docs/dev/policies/0002-policy-upgrade-management.md new file mode 100644 index 00000000..e672c863 --- /dev/null +++ b/docs/dev/policies/0002-policy-upgrade-management.md @@ -0,0 +1,54 @@ +# Policy | Policy Upgrade Management + +## Policy + +- Treat shared policy upgrades as intentional maintenance work, not accidental drift from copying files ad hoc. +- Check for policy-library updates through a deterministic source of truth, such as: + - tagged releases + - upstream commits + - a pinned selector bundle version + - a checked-out local policy repo or workspace path + - a known GitHub repository and branch or release channel +- Record what version, tag, commit, bundle ref, or local policy source the repo last reviewed or adopted when that information materially affects reproducibility. +- When upstream policy changes appear, decide explicitly whether to: + - adopt a new module + - upgrade an already adopted module + - retire a no-longer-useful local policy + - defer the change for a documented reason +- Review profile changes separately from module changes; a profile upgrade should not silently force a repo into every newly suggested module. +- When a local repo has customized policy, prefer merge review over blind overwrite. +- Retire superseded local policy files explicitly when a shared replacement makes them unnecessary. +- Resolve upgrades by module identity before allocating a new ordinal filename. + Replace or merge the existing adopted path when one identity exists; when + several paths claim the identity, stop and require explicit reconciliation. +- An upgrade is incomplete while `AGENTS.md` wires both a superseded and current + generation. Remove the retired pointer in the same transaction and verify + that exactly one retained path remains. +- Never infer the winner between divergent duplicates from filename recency, + modification time, or list order. Compare content and local overrides, retain + the intended semantics, and record the retirement decision. +- Scope upgrades against the repo's retained module set first; a broader profile recommendation should not automatically become the new local baseline when fit review says otherwise. +- When the policy library publishes release notes, changelog entries, or comparable upgrade summaries, use them to scope the upgrade review before patching local policy. +- If the repo follows upstream commits directly instead of releases, define how often to check and what level of change justifies adoption. +- Keep policy upgrade decisions durable in repo docs, plans, runbooks, or notes when the rationale would otherwise be lost. +- One dated policy adoption or upgrade artifact may serve as the canonical durable record for: + - the upgrade decision + - adoption feedback + - reusable continuity notes + when it records the version reviewed, decision taken, rationale, and notable fit or friction. +## Adoption Notes + +Use this module when the repo depends on an external or shared policy library and needs a durable contract for staying current without adopting every upstream change blindly. + +Repo-type guidance: +- `product-engineering`: usually wants deliberate upgrade review because planning, release, and operator policies can have cross-cutting effects +- `library-cli`: often benefits from checking policy upgrades near release or dependency-maintenance cycles +- `workspace-agent`: often benefits from reviewing upstream policy or selector updates regularly because skill and orchestration behavior can drift quickly +- `writing-project`: usually wants lighter upgrade cadence tied to major workflow or deliverable shifts rather than constant policy churn + +## Fork-specific contract + +Reviewed bundle: `v0.1.22`, source commit +`12a7f9fef466522e99be44d980c44a4ff056f540`. Review updates during release or +upstream-sync maintenance, using the installed manifest and explicit policy-root. +Preserve the local PR/issue contract and fork overrides during upgrades. diff --git a/docs/dev/policies/0003-policy-adoption-feedback-loop.md b/docs/dev/policies/0003-policy-adoption-feedback-loop.md new file mode 100644 index 00000000..f4c199df --- /dev/null +++ b/docs/dev/policies/0003-policy-adoption-feedback-loop.md @@ -0,0 +1,46 @@ +# Policy | Policy Adoption Feedback Loop + +## Policy + +- After first policy adoption, the first substantive execution under that + policy, a major policy upgrade, or meaningful policy friction, record a dated + feedback artifact in the adopting repo. +- The feedback artifact should identify at least: + - installed policy bundle version or immutable ref, or an explicit statement + that provenance is unknown and must be repaired + - selected profile + - modules adopted + - modules deferred, retired, or overridden locally + - what worked cleanly + - what created friction or ambiguity + - what should remain repo-local + - what may warrant an upstream module, profile, or selector change +- Distinguish installation, active wiring, and enacted behavior. Cite the + `AGENTS.md` entrypoint for wiring and a current plan, runbook entry, closeout, + audit receipt, or runtime readback for behavioral evidence. +- If no substantive execution has exercised the policy yet, record `not yet + evidenced` rather than calling adoption successful or ineffective. +- Prefer storing dated adoption feedback in the repo's normal durable continuity surface, such as: + - `docs/dev/notes/` + - `docs/dev/memories/` + - bounded plans plus matching runbook entries + - another documented local equivalent +- Do not leave important adoption lessons only in chat history, commit messages, or oral maintainer knowledge. +- When feedback appears reusable across repos, route it into the shared policy repo through a deterministic harvest path rather than treating it as one repo's private observation. +- If the repo uses a pinned installed selector bundle, tie feedback to that pinned version so later maintainers can interpret it correctly. +- When a repo adopts local overrides instead of the exact starter profile, record why; those reasons are often the best signal for future shared policy refinement. +- When a repo upgrades policy, compare the new experience to prior adoption notes so repeated friction becomes visible over time. +- Record stale local-policy prose, invalid local facts, and audit-contract + incompatibilities as adoption defects even when the underlying work outcome + was successful. +- When a repo has an explicit graph-memory group, mirror compact source-cited adoption feedback into that group after the dated feedback artifact exists, especially when it identifies reusable friction, missing modules, profile-fit issues, or selector behavior changes. +- Keep graph-memory feedback entries small and source-anchored. They should point future agents to the dated artifact or release note, not replace it. +- A single dated artifact may satisfy this module, `policy-upgrade-management`, and `notes-and-memories` when it captures both the upgrade or adoption decision and the resulting feedback clearly. +## Adoption Notes + +Use this module when repos adopt shared policy from an external source library and want a durable loop between downstream adoption experience and upstream policy improvement. + +This module complements `notes-and-memories` and `policy-harvest-loop`: +- `notes-and-memories` defines where continuity artifacts live +- `policy-harvest-loop` governs how a policy repo normalizes reusable rules +- `policy-adoption-feedback-loop` governs how adopting repos capture feedback that can later be harvested diff --git a/docs/dev/policies/0004-graph-backed-memory-usage.md b/docs/dev/policies/0004-graph-backed-memory-usage.md new file mode 100644 index 00000000..799dbbdd --- /dev/null +++ b/docs/dev/policies/0004-graph-backed-memory-usage.md @@ -0,0 +1,72 @@ +# Policy | Graph-Backed Memory Usage + +## Policy + +- Treat graph-backed memory as durable retrievable context, not as a scratchpad for every turn. +- Use graph-backed memory for compact, stable cross-turn facts such as: + - user preferences + - project decisions + - durable entity relationships + - recurring operational context that later turns should retrieve quickly +- Do not store ephemeral material in graph-backed memory, including: + - temporary debugging notes + - one-off command output + - transient errors unless they represent a durable incident worth tracking + - raw reasoning traces + - secrets, tokens, passwords, or credential material +- Before re-asking the user for likely durable context, prefer a bounded graph-memory read. +- At the start of non-trivial work, make one lightweight discovery decision: + - `use` when prior decisions, user preferences, runtime history, cross-repo + context, or avoided repeated investigation could materially affect the work + - `skip` when the task is trivial or self-contained and supplied content or + current authoritative sources are sufficient + - `unavailable` when the memory system or required retrieval surface is not + healthy; continue from repo-native evidence and state the fallback only + when it materially limits confidence +- For non-trivial planning, debugging, architecture, audit, adoption, upgrade, + harvest, or handoff work, default to `use` when prior context may exist. Do + not make performative memory calls when the decision is `skip`. +- Keep discovery bounded to the narrowest relevant group and one or two focused + reads before widening. Discovery is read-first and does not authorize a + memory write. +- Query the repo-named memory group first when repo policy names one. +- When the right memory group is unclear, or when the task crosses repos, tenants, or domains, query a reviewed atlas or routing layer first and inspect retrieval, privacy, export, and audience policy before descending into source groups. +- Prefer compact, factual, retrieval-friendly writes over conversational filler or repeated paraphrases of the same fact. +- Avoid memory spam: + - do not write the same preference or project fact every turn + - prefer one good durable memory over many near-duplicate entries + - if a durable fact changed, record the new durable state rather than narrating every intermediate thought +- Use partitions, groups, namespaces, or equivalent separation mechanisms deliberately so unrelated projects, tenants, or domains do not bleed into one another. +- Treat destructive memory-maintenance tools as explicit cleanup or repair operations, not casual day-to-day commands. +- Verify the memory system's availability or health before debugging against it or assuming it is available during normal work. +- Treat memory-derived claims as advisory until verified against repo files, artifacts, commits, tests, or cited episodes. +- Keep richer narrative rationale, long-form handoff, and human-readable change history in repo notes or memories; use graph-backed memory for compact retrieval-oriented facts and relationships. +## Adoption Notes + +Use this module when a repo regularly works with an installed graph-backed memory system and agents need explicit discipline for when to read, write, partition, or clean up memory. + +This module complements: +- `notes-and-memories`, which governs durable repo notes and long-form continuity artifacts +- `runtime-state-governance`, when the memory system is part of a user-scoped or operator-scoped runtime surface + +Keep product-specific tool names, partition-key semantics, and runtime assumptions repo-local unless they clearly generalize across multiple graph-memory systems. + +Repo-local policy should name the primary memory group when one exists and should identify the memory-discovery skill, tool, or command agents are expected to use. For Graphiti, prefer the `graphiti-discovery` skill and record the repo's primary `group_id`; if the repo does not use Graphiti, preserve the same `use` / `skip` / `unavailable` decision contract with its actual memory system. + +## Fork-specific contract + +Repo default: `use`; per-task decisions remain `use`, `skip`, or `unavailable`. +The primary Graphiti group is `dev_browser_main`. Use the installed +`graphiti-discovery` skill, run `graphiti-runtime doctor`, then one or two +focused `search_memory_facts` calls in that group. Never query another +project's group merely because this one has no results. Current files, refs, +and tests outrank recalled facts. Unavailability does not block local work. + +Write only compact source-backed decisions or completed adoption feedback, +not every commit. Repo adoption feedback is an authorized write workflow: +create the dated artifact first; use exact name/source-description preflight, +then `add_memory`, poll the job to completion, read the episode, verify grouped +visibility, and smoke search before claiming persistence. Do not retry an +uncertain write without checking its job and exact metadata. No secrets, +browser sessions, profile contents, raw private data, or full logs. Destructive +memory cleanup requires explicit task-specific authority. diff --git a/docs/dev/policies/0005-planning-discipline.md b/docs/dev/policies/0005-planning-discipline.md new file mode 100644 index 00000000..cd7ed58c --- /dev/null +++ b/docs/dev/policies/0005-planning-discipline.md @@ -0,0 +1,84 @@ +# Policy | Planning Discipline + +## Policy + +- Adopt bounded planning discipline in every repo, with ceremony proportional + to the work. Trivial one-step tasks do not need a plan artifact; substantive, + multi-file, multi-step, risky, or resumable work does. +- Use bounded plan artifacts under `docs/dev/plans/` or an equivalent plans directory, not ad hoc note files scattered through the repo. +- Plan filenames should use a deterministic serial-plus-date prefix such as `0001-YYYY-MM-DD-plan-slug.md`. +- If the repo uses a canonical long-range plan such as `ROADMAP.md`, treat it as the source of truth for priority. +- If the repo uses a canonical live execution log such as `RUNBOOK.md`, treat it as the source of truth for what happened turn by turn. +- When `RUNBOOK.md` is present, maintain it as a dated turn log with deterministic headings such as `Turn N | YYYY-MM-DD`. +- Treat planning migration for active repos as two phases: + - structural migration to establish canonical files, naming, and wiring + - semantic reconciliation to align plan text and lane status with the actual shipped state +- Configure deterministic planning audits to the repo's documented authority + paths. Do not assume `docs/dev/plans`, `ROADMAP.md`, or `RUNBOOK.md` when the + repo has an explicit equivalent such as `doc/dev/plans`. +- Each plan should carry an explicit deterministic state from a small fixed vocabulary, for example: + - `PLANNED` + - `OPEN` + - `CLOSED` + - `CANCELLED` +- Multi-track repositories may also use `BLOCKED`. Keep this outcome state separate from Git custody such as active worktree, paused ref, integration-ready, integrated, archived, or discard-approved. +- For any plan in an active state such as `OPEN`, require a short `Current State` section that says what already exists and what still remains. +- Use bounded plan artifacts with explicit scope, non-goals, acceptance criteria, and definition of done. +- A plan organizes execution; it does not grant, consume, or renew authority. + Once the user approves a goal, routine in-scope plan revisions, packets, and + successors proceed under that standing authority. Do not ask for approval + merely because the next step was not enumerated in advance. +- Keep plan altitude proportional to its horizon. A campaign or `/goal` plan may + remain high-level when it preserves the objective, milestones, dependencies, + gates, and outcome evidence; derive detailed implementation packets just in + time instead of pretending every future step is knowable up front. +- Separate the stable objective and milestone plan from mutable execution state. + Record material replanning as an explicit revision or successor plan rather + than silently rewriting the goal to fit current progress. +- Keep goal-level control state outside any one plan version. Successor plans, + packet retries, and reviewer replacement inherit standing authority, accepted + finding ledgers, review-discovery counts, and no-progress history; they do not + reset those controls merely by changing a filename or version number. +- When one reasonable next step is clearly implied and low risk, choose it and + keep moving. Ask the user to choose only when alternatives would materially + change the outcome, scope, cost, or safety envelope. +- Give each active execution packet one bounded outcome, owner, expected write + surface, required inputs, validation evidence, and terminal condition. +- When active work lives off the default branch, keep execution detail in the branch-local plan and publish only a compact active-lane projection to the default branch. Plan closure does not by itself authorize branch deletion or worktree removal. +- When a task is large enough to plan, explicitly separate: + - parallelizable low-conflict tracks + - critical-path serialized work +- Keep one critical-path owner visible even when subagents or parallel workers are used. +- Do not let one plan artifact accumulate endless follow-on polish; close it or open a new bounded slice. +- Reconcile plan state promptly when implementation lands, a successor + supersedes the plan, or a gate blocks integration. Stale `OPEN` labels are + continuity debt even when the implementation itself is sound; repair the + record, but do not treat the label alone as a new approval gate. +- Do not equate plan activity with progress. Require current evidence that a + slice advances an acceptance criterion or removes a verified blocker. +- Classify plan-only refinement, reviewer novelty, extra documentation, and + speculative hardening as hardening rather than outcome progress unless they + demonstrably remove a verified blocker or advance an acceptance criterion. +- If the repo adopts roadmap/runbook governance, keep plan wiring and plan state aligned with those canonical files. +- When the planning contract changes in a way that affects validation, update the deterministic audit helper in the same slice. +- Separate steady-state enforcement from legacy migration. A current/active + audit may ignore closed or unclassified historical artifacts only when the + report names what it excluded and the repo retains a bounded migration or + baseline decision for that debt. +- An active-only audit may accept exact repo-local findings from + `docs/dev/planning-audit-baseline.json` when the file records a rationale, + review condition, and exact finding strings. Keep accepted and unused + baseline entries visible in the report; do not apply the baseline to full or + forced audits, and do not let one accepted finding suppress a new one. +- Absence of a plans directory is not itself an active-scope defect. Continue + to require the configured directory during full or forced structural audits. +## Adoption Notes + +Use this module as a baseline in every starter profile. A lightweight repo may +use short bounded plans only for substantive work; baseline adoption does not +imply that every turn needs a plan. + +Use `roadmap-runbook-governance` as the stricter companion module when the repo keeps canonical `ROADMAP.md` and `RUNBOOK.md` authority. + +Use `goal-execution-governance` when the plan will drive autonomous work across +multiple slices, sessions, context windows, or gates. diff --git a/docs/dev/policies/0006-codegraph-usage.md b/docs/dev/policies/0006-codegraph-usage.md new file mode 100644 index 00000000..317422e4 --- /dev/null +++ b/docs/dev/policies/0006-codegraph-usage.md @@ -0,0 +1,37 @@ +# Policy | Codegraph Usage + +## Policy + +- When a repo has an available codegraph or indexed code-intelligence service, consult it before making non-trivial code changes, architecture claims, trace analysis, or refactor plans. +- Prefer codegraph context, trace, callers, callees, impact, or file-index tools for structural questions such as: + - where a symbol is defined + - what calls or depends on a function, class, route, or component + - how one behavior flows into another + - what a refactor is likely to affect + - which files make up an unfamiliar subsystem +- Use the repo's documented codegraph entrypoint when one exists, such as a sibling `../codegraph` checkout, local MCP tools, CLI wrapper, or indexed workspace service. +- Resolve the intended repository or worktree root and inspect current index status before relying on graph results. A sibling checkout's index is not proof that a fresh worktree or different branch is indexed. +- When a repo has already adopted, configured, or explicitly declared codegraph as an expected development surface, treat a missing index in a verified local worktree as routine derived-state maintenance: run the documented initialization workflow and verify the resulting index status. Do not require a fresh approval solely because the worktree is new. +- Do not assume automatic refresh applies to every project. The active checkout may have a live watcher while secondary projects, explicit-path queries, and fresh worktrees require explicit synchronization. +- When status or a staleness banner reports pending files, disabled auto-sync, an unwatched project, or a stale index, run the documented explicit sync once and re-check status. Do not wait repeatedly on a watcher that is absent or disabled. +- Treat the codegraph as a discovery and impact-analysis aid, not as proof that a change is correct. Verify behavior with source reads, targeted tests, type checks, linters, browser checks, or runtime smoke as appropriate. +- Prefer codegraph lookups over broad manual grep loops for symbol, flow, caller/callee, and architecture questions. Use text search or direct file reads to confirm details the index does not cover. +- After editing code, inspect the reported staleness or pending-sync state instead of guessing a delay. Use direct reads for specifically flagged files until synchronization is confirmed. +- Keep secrets, credentials, private logs, and unrelated runtime data out of indexed codegraph inputs or persisted analysis artifacts. +- Before initialization, confirm the target root and repo-local exclusions. Stop and ask when codegraph has not been established for the repo, the target or allowed input scope is ambiguous, repo policy reserves indexing for an operator, or initialization would create unexpected tracked-file changes. +- If initialization or one explicit sync still leaves codegraph unavailable or stale, proceed with ordinary repo inspection and report the exact failed status or staleness evidence in the handoff when it affects confidence. +## Adoption Notes + +Use this module when a repo contains code that agents edit, review, trace, or refactor and an indexed codegraph is available or expected in the working environment. + +Keep exact commands, MCP tool names, sibling checkout paths, service repair, and project-specific index exclusions repo-local. The reusable contract is initialize an expected missing index, explicitly sync when automatic refresh is absent, and verify current status. + +## Fork-specific contract + +Use available CodeGraph or codebase-memory-mcp structural tools before symbol, +call-flow, or impact exploration. Native search is appropriate for configs, +policy text, literal strings, and graph gaps. Respect the operator's existing +rule to ask before initializing a missing CodeGraph index; it overrides the +shared automatic-initialization suggestion. Query the intended worktree, not +the maintenance index for v1. Use SysRAG for semantic filesystem recall only +within already authorized roots; never register additional roots implicitly. diff --git a/docs/dev/policies/0007-code-testing-discipline.md b/docs/dev/policies/0007-code-testing-discipline.md new file mode 100644 index 00000000..36f37a86 --- /dev/null +++ b/docs/dev/policies/0007-code-testing-discipline.md @@ -0,0 +1,57 @@ +# Policy | Code Testing Discipline + +## Policy + +- Treat tests as maintained product assets with both protective value and lifecycle cost. Test count, assertion count, and raw coverage percentage are not success metrics by themselves. +- Name the invariant or failure risk before adding a test. Place it at the cheapest layer that can prove it reliably: prefer a focused unit or contract test, use a narrow integration test for boundary behavior, and reserve end-to-end, live, soak, and exhaustive tests for risks that cheaper layers cannot establish. +- Before adding a regression test, inspect existing coverage for the invariant. Demonstrate that the new or changed test detects the defect before the fix when practical, then passes after the fix. Consolidate overlapping cases instead of accumulating historical duplicates. +- Put a regression test at a stable seam that exercises the real failure + pattern. If no such seam exists, do not add a shallow or implementation- + coupled proxy merely to claim coverage; record the unprotected risk and the + architecture or testability gap, then route remediation as a separate bounded + decision. +- Keep each test independent, deterministic, order-agnostic, and hermetic by default. Declare inputs, isolate writable state, use explicit readiness signals instead of arbitrary sleeps, and keep network, provider, browser, large-data, and live-system tests out of the default local lane unless their exact risk requires them. +- Define repo-local execution tiers and concrete wall-clock plus compute/resource budgets. At minimum distinguish focused development checks, blocking presubmit checks, periodic comprehensive regression, and opt-in live/soak/provider checks. A long comprehensive lane may remain valuable without blocking every change. +- Use affected-test selection or explicit changed-surface manifests for fast feedback only when the dependency mapping is trustworthy. Unknown impact must widen to a documented safe fallback, and a periodic comprehensive run must detect selection drift. Never describe a selected subset as the full suite. +- Measure suite economics over time: selection size, collection/startup cost, p50 and p95 wall time, total compute, peak constrained resources where material, slowest tests, flake rate, retry rate, and failure yield. Optimize repeated setup and collection costs before merely adding workers. +- Parallelize or shard only after tests are isolated and reproducible. Balance shards by observed duration when practical, retain exact shard identity in resumable receipts, and lower concurrency when contention increases failures or total resource cost. +- Treat retries as diagnostic or infrastructure-recovery evidence, not as erasure. Preserve the first failure, classify a pass-on-retry as flaky, and do not report the lane clean until policy-defined flake disposition is satisfied. Reconcile uncertain external effects before retrying any test that can mutate shared or live state. +- Quarantine a flaky test only with an owner, reason, issue or locator, quarantine date, expiry or service-level target, and replacement blocking coverage when the risk requires it. Repair, redesign, or remove quarantined tests promptly; quarantine is not permanent storage. +- Review expensive, redundant, obsolete, and low-yield tests on a recurring cadence. Every retained expensive test should protect a distinct named risk. Consolidation or deletion requires a retained-risk mapping and validation that the surviving suite still proves the intended contract. +- Use coverage to locate consequential gaps, not to chase a universal percentage. Prefer behavior, branch-risk, contract, and selectively applied mutation evidence over copy-pasted tests that only increase coverage. +- When a suite exceeds its local budget, profile before changing the gate. Prefer cheaper seams, shared-fixture optimization without weakened isolation, case consolidation, tier correction, trustworthy selection, caching on declared inputs, or duration-aware sharding. Raising a budget requires an explicit risk/economics decision and a follow-up date. +- Record exactly which tier, selection, environment, retries, shards, and exclusions ran. Validation claims must distinguish `focused`, `presubmit`, `comprehensive`, and `live_or_soak`, and must report any budget breach, flake, quarantine, or unexecuted risk. +## Adoption Notes + +Each adopting repo should define a local test-suite contract with concrete values for: + +- `fast_feedback_target` +- `presubmit_blocking_budget` +- `presubmit_compute_budget` +- `comprehensive_lane_cadence` +- `unknown_impact_fallback` +- `flaky_test_disposition_sla` +- `retry_result_mode` + +Keep exact commands, marker names, CI job names, hardware assumptions, provider gates, and risk-specific test inventories repo-local. + +## Fork-specific contract + +Local targets (initial operating budgets, not measured performance claims): +- `fast_feedback_target`: 2 minutes for focused checks. +- `presubmit_blocking_budget`: 15 minutes wall time for the applicable suite. +- `presubmit_compute_budget`: one local test suite at a time, at most 4 workers, + and 8 GiB target peak memory; record breaches rather than hiding failures. +- `comprehensive_lane_cadence`: before release, upstream sync, and monthly while active. +- `unknown_impact_fallback`: full daemon and Rust validation from AGENTS.md; + build both embedded bundles before Cargo when daemon runtime changes. +- `flaky_test_disposition_sla`: record owner/reproducer within one working day, + repair or explicitly review quarantine within seven days. +- `retry_result_mode`: retain first failure; a retry pass is flaky, not clean. + +Policy/docs-only changes use planning audits, selector identity/wiring checks, +YAML parsing for changed templates/workflows, and diff checks. Runtime code +changes use the existing AGENTS.md commands plus relevant regression tests. +Browser tests use isolated profiles and a fresh OS process census afterward. +Live/provider tests are opt-in, scoped to the user's authorization. A breach +requires diagnosis and a documented budget decision, not automatic expansion. diff --git a/docs/dev/policies/0008-git-worktree-hygiene.md b/docs/dev/policies/0008-git-worktree-hygiene.md new file mode 100644 index 00000000..e48a36a8 --- /dev/null +++ b/docs/dev/policies/0008-git-worktree-hygiene.md @@ -0,0 +1,23 @@ +# Policy | Git / Worktree Hygiene + +## Policy + +- Start branch-sensitive work by checking `git status`. +- Inventory all registered worktrees with `git worktree list --porcelain` before creating, closing, pruning, or reassigning one; the current checkout alone is not the repository topology. +- Treat pre-existing dirty state as a real constraint. +- Keep one bounded branch or worktree scope per execution slice or roadmap lane, consistent with the repo's documented integration model. +- When parallel work is needed, prefer `git worktree` over a second full clone. +- Do not call work merge-ready while the intended changes are still uncommitted. +- Treat the worktree as a checkout, the branch or detached commit as local custody, and a verified remote or archive ref as shared custody. Removing a worktree does not preserve uncommitted changes and does not prove the commits remain discoverable. +- Before removing a worktree, require a clean status, a named branch or explicitly preserved detached commit, an exact checkpoint SHA, and verified durable custody on the intended remote ref or on matching local and remote archive refs. +- Normal closure uses `git worktree remove` without `--force`. Forced removal is exceptional recovery work: first inventory the exact path, preserve any recoverable diff and commit, establish a durable ref, record the reason, and verify the retained SHA. +- Do not delete an unmerged branch merely because its worktree is gone. Prove integration, archival, or explicit discard approval separately. +- If overlapping dirty work exists across branches or worktrees, open a reconciliation step rather than calling it a normal merge. +- Keep branch scope narrow and avoid mixing unrelated lanes unless the active slice requires it. +## Adoption Notes + +Use this module in repos where multiple lanes, multiple worktrees, or parallel agents regularly overlap. + +This module governs local git cleanliness and overlap handling. Use `branch-and-integration-strategy` to choose whether the repo prefers direct-to-`main`, short-lived feature branches, or another integration model. + +Use `active-lane-coordination` when several off-main lanes need default-branch discovery and custody reconciliation. diff --git a/docs/dev/policies/0009-commit-history-discipline.md b/docs/dev/policies/0009-commit-history-discipline.md new file mode 100644 index 00000000..3ba00ccb --- /dev/null +++ b/docs/dev/policies/0009-commit-history-discipline.md @@ -0,0 +1,29 @@ +# Policy | Commit History Discipline + +## Policy + +- Prefer commits that represent one coherent change or one tightly related slice of work. +- Do not mix unrelated fixes, refactors, and feature work in the same commit when they can be separated cleanly. +- Keep commit messages truthful about the actual change instead of describing aspirational intent. +- Write commit subjects that make sense in history on their own without relying on chat context. +- Include enough detail in the commit body when the reason, risk, migration effect, or operator impact would be unclear from the diff alone. +- Commit before risky history operations, broad rebases, or destructive cleanup so recoverable checkpoints exist. +- Also commit at material handoff, context-switch, branch-transfer, worktree-closure, and integration-preparation boundaries. A checkpoint is mandatory before an operation that could make owned work harder to recover. +- Review both staged and unstaged diffs before committing. A clean commit message cannot make a mixed or incomplete index truthful. +- Keep temporary work-in-progress commits on a clearly scoped private or feature branch. Before shared integration, preserve useful intermediate history or consolidate it according to the repository's documented merge model without hiding materially distinct changes. +- Do not create fake cleanliness by squashing materially different changes into one commit if that harms reviewability or future archaeology. +- Do not create noisy checkpoint spam on shared history when the repo expects a cleaner review-oriented log. +- Treat commit history as a durable engineering artifact, not just transport for the current turn. +## Adoption Notes + +Use this module in repos where git history is expected to support review, release notes, rollback, or later debugging. + +Repo-type guidance: +- `product-engineering`: usually wants reviewable feature/fix commits with bodies for migration or operator impact when needed +- `library-cli`: often benefits from commit history that maps cleanly to release notes and compatibility changes +- `workspace-agent`: usually benefits from explicit commit subjects because downstream maintainers often inspect history to understand selector, skill, or policy changes +- `writing-project`: can keep lighter history, but major structure changes, evidence updates, and submission-affecting edits should still be described truthfully + +Developer-preference guidance: +- squash-heavy teams may allow messy local checkpoint commits before merge, but should still require a truthful final shared history +- repos that value archaeology may prefer preserving a few well-scoped intermediate commits instead of aggressively flattening everything diff --git a/docs/dev/policies/0010-branch-and-integration-strategy.md b/docs/dev/policies/0010-branch-and-integration-strategy.md new file mode 100644 index 00000000..71b87842 --- /dev/null +++ b/docs/dev/policies/0010-branch-and-integration-strategy.md @@ -0,0 +1,62 @@ +# Policy | Branch And Integration Strategy + +## Policy + +- Document one primary integration model for the repo rather than switching casually between incompatible branch conventions. +- If a repo has both conservative maintenance work and more aggressive platform or architecture work, document how those tracks coexist instead of leaving branch choice to local habit. +- Be explicit about where normal work starts and where it lands: + - direct to `main` + - short-lived feature branches + - release or stabilization branches +- Prefer short-lived branches unless the repo has a documented reason for long-lived branch divergence. +- Prefer explicit track naming when different work classes coexist, for example maintenance-oriented branches versus architecture-oriented branches. +- State whether merge commits, rebased histories, or squash merges are preferred for shared history. +- State when rebasing is normal and when it is no longer appropriate because others may already depend on the branch. +- Once another lane, person, automation, or review surface depends on a published branch tip, do not rebase or otherwise rewrite it without explicit reconciliation and bounded lease protection. +- Treat branch protection, review gates, and release branching as part of the workflow contract rather than personal preference. +- Do not let local habit override the repo's documented integration model. +- When a repo supports parallel work, document whether reconciliation should happen by rebase, merge, or explicit integration branches. +- Keep branch lifecycle distinct from worktree lifecycle: a lane may remain active with a worktree, pause as a remotely preserved ref, become integration-ready, prove integration, remain temporarily cleanup-pending, archive, or receive explicit discard approval. +- Declare integration readiness only when the lane is clean, its tested checkpoint is published and matches the recorded SHA, dependencies and overlaps are reconciled, and the intended target and integration method are explicit. +- Prove merge integration by target ancestry. For squash or patch integration, preserve a durable receipt that identifies the source checkpoint and resulting target commit; do not infer integration from similar content or a closed pull request alone. +- Delete topic refs only after integration proof, verified archival, or exceptional discard approval. Routine branch cleanup must not use forced deletion to bypass missing evidence. +- Use disposable integration branches for cross-lane compatibility experiments. Do not make an exploratory integration branch a hidden source of truth for its component lanes. +- If current-behavior maintenance and future-architecture work can touch the same surface concurrently, document which class wins by default unless an approved migration slice says otherwise. +## Adoption Notes + +Use this module when the repo has more than one contributor, review checkpoints, CI gates, or multiple valid ways work could land. + +Repo-type guidance: +- `product-engineering`: usually benefits from explicit rules for feature branches, protected branches, and stabilization before release +- `library-cli`: often benefits from a simple default branch plus tagged releases, but may still need clear rules for release branches when compatibility is sensitive +- `workspace-agent`: often benefits from short-lived branches and explicit rebase expectations because selector, prompt, and policy changes can drift quickly +- `writing-project`: may keep a lighter branch model, but collaborative review repos still benefit from an explicit default integration path + +Developer-preference guidance: +- trunk-based teams may prefer direct-to-main or very short-lived feature branches with fast validation +- review-heavy teams may prefer feature branches plus squash or rebase merges +- release-sensitive teams may require temporary stabilization branches before tags or deploys + +Multi-track repo guidance: +- repos that act as both a maintenance surface and a development platform usually need: + - one stable integration line + - short-lived feature branches or worktrees for parallel tracks + - explicit rules for when maintenance preserves current behavior and when migration work may intentionally replace it + +## Fork-specific contract + +Canonical fork: `CochranResearchGroup/dev-browser`; `origin` targets the fork, +`upstream` targets `SawyerHood/dev-browser`. `main` is the v0.2.x maintenance +integration line. Start normal work from refreshed `origin/main`, first checking +for local unpublished maintenance commits; reconcile that difference explicitly. +Use `fix/`, `feat/`, `chore/`, or `eval/` topic branches and one owned worktree. +Open PRs against the fork's `main`, not upstream. Prefer merge commits to retain +fork ancestry. Rebase only unpublished, unshared topics; never rewrite `main`. + +`eval/v1-rc3` is a completed experimental evaluation, not migration approval. +Maintenance compatibility wins until a separately scoped migration is accepted. +See `docs/dev/workstreams.md` for the adoption-time custody inventory. +Subsequent integration follows the PR contract in policy 0016. This initial +local policy adoption alone may fast-forward local `main` from its recorded +base after validation; it grants no remote push, release, or branch-protection +change. Preserve all pre-existing commits and the untracked `.codex` file. diff --git a/docs/dev/policies/0011-commit-and-push-cadence.md b/docs/dev/policies/0011-commit-and-push-cadence.md new file mode 100644 index 00000000..1b8e2e83 --- /dev/null +++ b/docs/dev/policies/0011-commit-and-push-cadence.md @@ -0,0 +1,39 @@ +# Policy | Commit And Push Cadence + +## Policy + +- Commit at meaningful slice boundaries rather than waiting until a large body of work becomes hard to reason about or recover. +- Make an explicit local checkpoint before risky refactors, rebases, or cleanup that could discard work. +- Push when remote backup, collaboration, review, CI, or cross-machine continuity materially matters. +- Push a recoverable checkpoint before worktree closure, handoff to another owner, machine or environment transition, destructive cleanup, or any pause long enough that local custody could become ambiguous. +- Do not delay pushing important shared work so long that teammates or automation reason from stale branch state. +- Do not push half-understood or misleading commits to shared branches just to create activity. +- If the repo allows work-in-progress commits, keep them on private or clearly scoped branches unless the shared workflow says otherwise. +- Match push cadence to branch type: + - private branch: checkpoint for backup and continuity as needed + - shared feature branch: push whenever collaborators or CI need the current state + - protected branch: push only through the repo's documented integration path +- Name the source and destination explicitly for consequential pushes, for example `git push origin HEAD:refs/heads/`, then verify that the intended remote-tracking ref resolves to the pushed SHA. A successful process exit without ref readback is not sufficient custody evidence. +- Inspect ahead/behind or expected remote-tip state before pushing. Do not overwrite unexpected remote work. +- Prohibit plain forced pushes. A private-branch rewrite may use an exact expected-value `--force-with-lease=:` only when the repository permits rewriting, no dependent lane relies on the old history, and the replacement ref is verified afterward. +- Be explicit about whether end-of-day or end-of-slice pushing is expected for backup and handoff. +- In multi-track repos, do not let unpublished local `main` become a hidden holding area for architectural work once other maintainers depend on `main` for routine maintenance or operational continuity. +- Require handoff clarity about branch intent when different work classes coexist, for example whether a branch is maintenance-safe, migration-only, or still experimental. +## Adoption Notes + +Use this module when repos need a durable answer to "when should I commit?" and "when should I push?" across more than one maintainer or environment. + +Repo-type guidance: +- `product-engineering`: usually wants frequent local commits, timely shared-branch pushes, and explicit rules for when CI-ready state is required +- `library-cli`: often wants commits at coherent feature/fix boundaries and pushes aligned with review or release preparation +- `workspace-agent`: usually benefits from frequent private checkpoints because local experimentation and repo-local automation can move quickly +- `writing-project`: may prefer fewer but still meaningful commits around review checkpoints, major draft edits, and submission-affecting changes + +Developer-preference guidance: +- solo maintainers can tolerate lighter push cadence if local recovery is strong, but should still push before machine risk or context switching +- teams with active CI or review automation should push early enough for those systems to stay relevant +- repos that value clean shared history may allow messy local checkpoints but require cleanup before integration + +Multi-track repo guidance: +- when conservative maintenance and deeper platform work happen in parallel, maintenance-oriented branches should be pushed soon enough that operators are not forced to reason from stale assumptions +- architectural branches can tolerate more local iteration, but should be published once their state is coherent enough for another operator to interpret without private chat context diff --git a/docs/dev/policies/0012-versioning-and-release.md b/docs/dev/policies/0012-versioning-and-release.md new file mode 100644 index 00000000..57cef65a --- /dev/null +++ b/docs/dev/policies/0012-versioning-and-release.md @@ -0,0 +1,41 @@ +# Policy | Versioning And Release + +## Policy + +- Document one primary versioning scheme for the repo instead of mixing incompatible schemes opportunistically. +- Choose a versioning scheme that matches the consumer contract: + - use semantic versioning when downstream users depend on compatibility signals between released artifacts + - use date-based or milestone-based versioning when the repo primarily ships dated deliverables, internal deployment cuts, or review snapshots rather than reusable APIs +- Treat a version or release tag as an immutable cut that points to a reviewable repo state. +- Version and release consumer-visible changes, not every internal commit by default. +- Record what changed, who it affects, and any required migration, rollout, or operator action for each release. +- Keep the release process deterministic enough that two maintainers would cut the same release from the same validated state. +- Be explicit about release gating: + - what validation is required before a release cut + - whether release notes are required + - whether tags, packages, deploys, or deliverable bundles are the canonical release artifact +- Do not imply stronger compatibility guarantees than the repo can actually honor. +- If the repo supports multiple artifact types, document which artifact is authoritative for versioning and which are derived outputs. +## Adoption Notes + +Use this module when the repo ships named versions, release tags, package builds, deployable cuts, or formal deliverable revisions. + +Repo-type guidance: +- `product-engineering`: usually version consumer-facing API, app, or deployable changes; release notes should emphasize user-visible behavior, operator steps, and migration risk +- `library-cli`: usually prefer semantic versioning because external consumers often depend on compatibility signals from tags or packages +- `workspace-agent`: version installable skills, plugins, or policy bundles when downstream repos consume them as artifacts; if the repo is mostly internal, lighter tag-based releases may be enough +- `writing-project`: often prefer revision, milestone, or date-based release cuts tied to submission or review checkpoints rather than semantic versioning + +Developer-preference guidance: +- manual maintainers may prefer explicit human-reviewed release notes and manual tagging +- automation-heavy teams may prefer deterministic changelog generation, release scripts, and automated tagging once validation passes +- trunk-based repos may cut releases directly from `main`, while branch-heavy repos may require a documented release branch or stabilization step +- concise teams may keep short release summaries, while externally consumed repos usually need more explicit compatibility and upgrade notes + +## Fork-specific contract + +Keep the upstream-compatible semantic version plus an explicit fork revision +in release notes/artifact identity. Never overwrite upstream tags. Record the +exact source SHA, browser artifact, bundles, and validation for a fork release. +Publishing tags/packages or changing installed binaries is a separate action +from a merged PR and requires existing task authority for that action. diff --git a/docs/dev/policies/0013-turn-closeout.md b/docs/dev/policies/0013-turn-closeout.md new file mode 100644 index 00000000..a391860a --- /dev/null +++ b/docs/dev/policies/0013-turn-closeout.md @@ -0,0 +1,13 @@ +# Policy | Turn Closeout + +## Policy + +- End-of-turn closeout should default to a best recommendation. +- Alternate closeout modes should be explicit and limited, for example: + - plan or audit + - next slice details + - pause and review roadmap alignment +- Do not end with vague “what do you want next?” language when a best recommendation is available. +## Adoption Notes + +Use this module when the repo values consistent turn endings and explicit next-step guidance. diff --git a/docs/dev/policies/0014-validation-and-handoff.md b/docs/dev/policies/0014-validation-and-handoff.md new file mode 100644 index 00000000..29091fbb --- /dev/null +++ b/docs/dev/policies/0014-validation-and-handoff.md @@ -0,0 +1,64 @@ +# Policy | Validation And Handoff + +## Policy + +- Run the relevant validation for the touched surface before commit, handoff, or merge preparation. +- Prefer targeted verification that matches the changed area, and widen to broader suites when the impact is user-visible or cross-cutting. +- Include concrete pass/fail evidence in the handoff or closeout note. +- For off-main work, bind validation to the exact checkpoint SHA and report branch, remote custody, worktree status, target, integration method, and remaining cleanup or archival disposition. +- Keep handoff notes concise, explicit about remaining risk, and clear about the next recommended action. +- When live or manual smoke matters for the changed surface, record whether it was run and what it proved. +- Prefer validation receipts that bind the result to a durable commit, artifact, + installed version, endpoint response, or other current-state identifier. + Temporary paths alone are not durable handoff evidence; preserve or publish + the necessary artifact in a repo-approved location, or record why the proof + is intentionally ephemeral and how it can be reproduced. +- Distinguish validation run by the primary agent from validation reported by a subagent or delegated worker. +- If validation was delegated, record whether the primary agent independently verified the result or accepted the delegated evidence as-is. +- For failed, timed-out, incomplete, or unknown subagent statuses, state what was trusted, what was ignored, and what remains unverified. +- Use an independent evaluator when fresh judgment materially reduces risk or + uncertainty, or when an explicit acceptance contract requires it. Duration or + plan count alone does not make independent review mandatory for routine, + low-risk work. +- Treat evaluator output as candidate evidence, not an automatic veto. The + primary agent owns adjudication and records each candidate as `blocking`, + `nonblocking_backlog`, `rejected`, or `needs_evidence` against the frozen + objective, acceptance criteria, non-goals, and applicable safety controls. +- A reviewer is not an approver. Work may continue on unaffected in-scope units + while candidates are adjudicated, and only an accepted blocking finding may + block the action or criterion it actually affects. +- Require each candidate finding to state the criterion, evidence, consequence, + reproducer, confidence, and suggested disposition. A useful independent + review may return no findings; novelty and finding count are not quality + metrics. +- When both conformance and objective correctness matter, report them as + separate review axes: one for repository standards and one for the frozen + specification or acceptance contract. Do not let a pass on one axis mask a + failure on the other, and do not let the separation bypass primary-agent + evidence review and disposition. +- Separate review modes. Use at most one broad fresh-context `drift_discovery` + pass when observed drift, consequence, or uncertainty justifies it. After + adjudication, use `closed_world` remediation + verification limited to accepted blocking findings and critical regressions + introduced by their fixes. Do not reopen broad discovery merely because a + new evaluator performs final verification. +- Bound review and rework at the goal level, not only per plan version. Prefer + one consolidated candidate set and one bounded remediation pass; if accepted + blocking findings still fail verification, split, reframe, or block the unit + instead of continuing an open-ended evaluator/optimizer loop. Record + nonblocking concerns in backlog without silently expanding the active plan. +- A review or rework bound ending triggers primary-agent disposition, local + reframe, or a scoped block. It does not consume goal authority or require user + approval when another safe in-scope action remains. +- Validate the resulting outcome and current external state, not only the + transcript, diff shape, test count, or agent's narrative of progress. +- Distinguish `validated`, `integration-ready`, `integrated`, and `cleanup-complete`; none implies the next. Verify target ancestry or a squash/patch receipt before claiming integration, and verify retained refs before removing a worktree or branch. +- Treat fail-closed gates as successful policy execution when they prevent an + unsafe or disproven change from integrating. Report the blocked outcome and + evidence instead of grading effectiveness only by shipped changes. +## Adoption Notes + +Use this module when the repo: +- has multiple test or smoke surfaces with different scopes +- expects evidence-backed closeout notes +- needs clear verification and residual-risk communication before review or release diff --git a/docs/dev/policies/0015-upstream-fork-maintenance.md b/docs/dev/policies/0015-upstream-fork-maintenance.md new file mode 100644 index 00000000..af874fd9 --- /dev/null +++ b/docs/dev/policies/0015-upstream-fork-maintenance.md @@ -0,0 +1,56 @@ +# Policy | Upstream Fork Maintenance + +## Policy + +- Use a distinct upstream remote when the repo carries private or local features on top of a non-owned active upstream. +- Keep private feature work isolated from the branch used to mirror or track upstream state. +- Rebase private branches onto fresh upstream state when the goal is to keep a small, understandable delta over an active upstream. +- Prefer force-push only on branches that are explicitly private, unshared, or documented as rebase-managed. +- Do not rewrite shared branch history casually when other collaborators, CI systems, or deployments may already depend on it. +- Keep one branch or tag that records the last known clean upstream sync point before heavy private divergence. +- Before rewriting a downstream carry, preserve its exact prior tip and freeze + the downstream semantic invariants that must survive, such as excluded + features, promotion membership, authority boundaries, runtime behavior, or + publication scope. +- Record conflict-prone patches, local carry patches, or intentionally retained divergences somewhere durable when they are likely to recur across rebases. +- Resolve conflicts from each side's intent and primary sources. A favor option + or conflict-free operation is only a textual result; it is not proof that the + downstream semantics survived. +- After the operation, verify the frozen downstream invariants independently of + syntax, manifest, and test-runner checks. When semantic verification fails, + rebuild from the preserved tip and old/new upstream inputs or abort and + restart from the recovery point. +- Do not make merge or rebase completion mandatory. Abort or restart is the + safe disposition when intent is unavailable, the recovery point is + uncertain, or the proposed resolution cannot be validated without inventing + behavior. +- Keep source presence, promoted or enabled membership, local installation, + release publication, and remote publication as separate proof boundaries. +- Be explicit about whether downstream release tags are cut from rebased private branches, merge-based integration branches, or snapshots after upstream sync. +- If a private feature is becoming long-lived and hard to rebase, reconsider whether it should remain a fork-local patch set or become a maintained downstream branch line. +## Adoption Notes + +Use this module when the repo is a fork or downstream derivative of an actively changing upstream that the maintainers do not control. + +Repo-type guidance: +- `product-engineering`: useful for internal product forks of vendor or open-source systems where private deployable behavior rides on top of active upstream updates +- `library-cli`: useful for downstream maintained forks that publish their own releases while selectively ingesting upstream fixes +- `workspace-agent`: useful for private skill, prompt, or policy forks built on public upstream agent tooling +- `writing-project`: rarely needed unless the repo is effectively maintaining a downstream derivative of another canonical source tree + +Developer-preference guidance: +- rebase-oriented downstreams usually want small private deltas and frequent upstream sync +- audit-heavy downstreams may prefer merge-based integration branches that preserve explicit upstream incorporation points +- force-push is reasonable on truly private maintenance branches, but not as a default on shared collaboration branches + +## Fork-specific contract + +For this shared maintenance fork, merge a reviewed compatible upstream tag +on an integration topic; do not follow upstream main across the v1 rewrite. +Fetch `origin` and `upstream` explicitly without pruning as a normal read step. +Preserve WSL/Windows CDP discovery, authenticated agent-browser discovery, +custom port/profile hints, Codex installation, Unix permissions, and the +fail-closed native Linux executable selection. Freeze and validate these +invariants for each sync. See `docs/maintenance-v0.2.9.md` and +`docs/wsl-browser-default.md`. Installed runtime changes require task scope +that includes installation; source integration alone does not authorize them. diff --git a/docs/dev/policies/0016-pull-request-and-issue-management.md b/docs/dev/policies/0016-pull-request-and-issue-management.md new file mode 100644 index 00000000..7b1469ec --- /dev/null +++ b/docs/dev/policies/0016-pull-request-and-issue-management.md @@ -0,0 +1,50 @@ +# Policy | Pull requests and issues + +## Issues + +Use `CochranResearchGroup/dev-browser` for fork work. Search existing issues +before creating one. Substantive bugs, features, upstream syncs, and migration +work need a linked issue or a durable local plan awaiting publication. Trivial +edits may explain their scope directly in the PR. + +Record the problem, expected/current behavior, reproducible evidence, affected +version/platform/browser, acceptance criteria, non-goals, owner, and dependencies. +Use the supplied bug/task templates. States are triage, ready, in progress, +blocked, in review, and closed; use body fields when matching labels do not +exist. Do not invent applied labels or assigned owners. Plans own execution; +issues own shared backlog and priority. One workstream should have one issue +and one responsible owner; link related PRs and plans rather than duplicating +tracking. An evaluation finishing does not close the migration blocker it found. + +Close completed issues after the accepted outcome is integrated and evidenced. +Close duplicates with their canonical issue, or record explicit cancellation. +Use `Refs #N` for partial progress and `Fixes #N` only when the PR fully satisfies +acceptance. Inspect upstream issues read-only for context; cross-repo comments, +issue creation, or PR submission require scope covering that communication. + +## Pull requests + +Use topic branches and worktrees; target the fork's `main`. Name the problem +and changed behavior in the title/body. Include issue/plan, exact base/head, +validation commands/results, compatibility risks, upstream relationship, and +installed-runtime impact. Separate unrelated work. Use draft status while +acceptance, custody, or review remains incomplete. + +Before publication, fetch the fork, inspect the intended base and full diff, +and reconcile unexpected remote changes. A policy adoption is not blanket +permission to publish private/local work. When publication is authorized, push +an explicit branch/ref and verify the remote SHA before creating/updating a PR. +Record local-only status honestly when publication is outside task scope. + +Before merge, require applicable CI to pass on the current head, resolved +blocking findings, an approving maintainer review, and explicit authority for +the merge. New commits invalidate checks/reviews for changed behavior. Never +claim GitHub protection is enforced merely because this document requires it. +Prefer a merge commit; use squash only with a source-head to target receipt. +Do not force-push shared branches or auto-merge on an agent's self-review. + +After merge, verify target ancestry (or squash receipt), reconcile issue/plan +state, and only then consider cleanup. Preserve a verified remote or archive +checkpoint before removing a clean worktree; no forced removal. A closed PR +alone is not proof of integration. Installation and release publication remain +separate actions. diff --git a/docs/dev/workstreams.md b/docs/dev/workstreams.md new file mode 100644 index 00000000..702dd1ea --- /dev/null +++ b/docs/dev/workstreams.md @@ -0,0 +1,31 @@ +# Fork workstreams and custody + +Reconciled: 2026-09-22. Refresh origin and inspect worktrees before acting. +Git refs and GitHub review state outrank this dated projection. + +| Branch | Role | Published checkpoint / tracking | Disposition | +| --- | --- | --- | --- | +| main | Local v0.2.x maintenance plus bootstrap policy | Local 3d3b071, now tracks origin/main; origin/main remains e3a7174 | Local-ahead contents are preserved in review branches; do not push main around review or reset it | +| maintenance/v0.2.9-wsl | Maintenance integration | origin/maintenance/v0.2.9-wsl at 6a6f9d6; [issue #1](https://github.com/CochranResearchGroup/dev-browser/issues/1), [draft PR #3](https://github.com/CochranResearchGroup/dev-browser/pull/3) | Await CI and approving maintainer review before authorized merge | +| chore/repo-policy-adoption | Policy adoption and publication reconciliation | origin/chore/repo-policy-adoption; baseline 3d3b071 plus Plan 0002 receipt; [issue #2](https://github.com/CochranResearchGroup/dev-browser/issues/2), [draft PR #4](https://github.com/CochranResearchGroup/dev-browser/pull/4) | Depends on #3; both PRs target main, and #4's diff narrows after #3 merges | +| eval/v1-rc3 | Completed v1 evaluation | origin/eval/v1-rc3 at d86af0c; branch-local docs/v1-evaluation-report.md | Published custody verified; retained worktree, no migration or cleanup approval | + +Issues are enabled on the organization fork. Existing .codex remains untouched. +No worktree/ref was deleted. Publication is not integration: after approved +merges, fetch and fast-forward local main only if ancestry allows it, then +reconcile issue/plan state and cleanup against actual target ancestry. + +These are review/evaluation custody tracks, not simultaneous implementation +lanes. Adopt the shared active-lane-coordination catalog before opening new +concurrent implementation lanes; no machine-audited lane catalog is claimed +here. Plans live in docs/dev/plans and dated feedback in docs/dev/notes. + +## Ordered integration closeout + +Plan 0003 records the operator-requested review/merge and Windows fixture fix +`d26ad5c`. On integration of this closeout through PR #4, #3 is its required +merged predecessor and the policy adoption is integrated. Use the PR merge +commits and target ancestry for exact current SHAs rather than the earlier +publication snapshot above. Close issues #1/#2 only after their respective +merge is verified. Retain review and evaluation refs/worktrees; no cleanup +or runtime reinstall is included. Local main then fast-forwards to origin/main. diff --git a/docs/maintenance-v0.2.9.md b/docs/maintenance-v0.2.9.md new file mode 100644 index 00000000..29f0f5ba --- /dev/null +++ b/docs/maintenance-v0.2.9.md @@ -0,0 +1,46 @@ +# v0.2.9 fork maintenance merge + +Date: 2026-09-21 + +## Scope and plan + +Merge upstream tag `v0.2.9` (`edd6f44`) into fork `main` at `e3a7174`, +preserving published ancestry. Upstream `main` at `a25e767` contains the +incompatible 1.0 rewrite and is outside this maintenance merge. + +1. Merge the release tag without rebasing the four published fork commits. +2. Resolve overlaps while retaining WSL and agent-browser discovery, custom + CDP port/profile flags, Codex skill installation, and Unix permissions. +3. Rebuild both embedded bundles, run daemon and Rust checks, and inspect the + resulting fork delta against the release tag. +4. Commit the verified merge locally. Publication and installed-runtime + replacement are separate operations. + +## Resolution decisions + +- Keep upstream request execution and idle reaping. Pass custom discovery + hints together with its deadline and cancellation signal. +- Keep upstream atomic endpoint binding. Apply the fork's socket and PID + permissions after binding, without restoring the old unconditional unlink. +- Keep the local skill guidance and add upstream idle-cleanup documentation. + Use upstream's current Codex installer guidance instead of the obsolete + clone-and-copy README section. +- Retain the installer step that regenerates the sandbox-client bundle. +- Upstream already includes Codex installer support; its updated Rust tests + cover the shared behavior. + +## Validation results + +- `daemon`: frozen pnpm install, `npx tsc --noEmit`, both bundle commands, + and `pnpm format:check` passed. +- `daemon`: `pnpm vitest run` passed, 21 files and 161 tests. This includes + existing WSL, custom-port, agent-browser, idle-reaper, sandbox, CUA, and + request-execution coverage. Two added regression tests cover cancellation + during custom-port probing and deadline/cancellation with custom profiles. +- `cli`: `cargo fmt -- --check`, `cargo build`, and `cargo test` passed; + all 12 Rust tests passed. +- Built CLI help includes custom port/profile flags, idle timeout, and CUA APIs. +- `git diff --check` passed. Native Windows execution and attachment to a + real Windows Chrome session were not exercised; WSL discovery uses fixtures. + +The pre-existing untracked `.codex` entry is excluded from the merge. diff --git a/docs/validation/wsl-stealth-browser-smoke.json b/docs/validation/wsl-stealth-browser-smoke.json new file mode 100644 index 00000000..5e814d14 --- /dev/null +++ b/docs/validation/wsl-stealth-browser-smoke.json @@ -0,0 +1,25 @@ +{ + "success": true, + "receipts": [ + { + "mode": "headless", + "executablePath": "/home/ecochran76/workspace.local/chromium/artifacts/chromium-stealthcdp/150.0.7835.0+stealthcdp.3676a7503929/chrome-linux/chrome", + "version": "150.0.7835.0", + "title": "stealth-ok", + "webdriver": false, + "snapshot": { + "full": "- generic [ref=e1]:\n - textbox \"Name\" [ref=e2]: stealth-ok\n - button \"Apply\" [active] [ref=e3]" + } + }, + { + "mode": "headed", + "executablePath": "/home/ecochran76/workspace.local/chromium/artifacts/chromium-stealthcdp/150.0.7835.0+stealthcdp.3676a7503929/chrome-linux/chrome", + "version": "150.0.7835.0", + "title": "stealth-ok", + "webdriver": false, + "snapshot": { + "full": "- generic [ref=e1]:\n - textbox \"Name\" [ref=e2]: stealth-ok\n - button \"Apply\" [active] [ref=e3]" + } + } + ] +} \ No newline at end of file diff --git a/docs/wsl-browser-default.md b/docs/wsl-browser-default.md new file mode 100644 index 00000000..c94eeb64 --- /dev/null +++ b/docs/wsl-browser-default.md @@ -0,0 +1,57 @@ +# WSL browser default + +The local WSL configuration uses native Linux chromium-stealthcdp through +`executablePath` in `~/.dev-browser/config.json`: + +```text +/home/ecochran76/workspace.local/chromium/artifacts/chromium-stealthcdp/150.0.7835.0+stealthcdp.3676a7503929/chrome-linux/chrome +``` + +The executable SHA-256 matches the promoted artifact manifest: +`aebeac48273efa3a2767763cf0694cfa8f1be52c91b7fbafff0d4698a993ffce`. +The shared Chromium `current` alias still points to its Windows artifact. + +## Behavior + +New daemon-managed browsers read the configured absolute executable path. +Headed and headless launches use the same build and keep dev-browser's own +persistent profiles. Existing browser instances retain their original binary. +CDP attachment is unaffected. Invalid configuration or launch failure is an +error, without silently substituting bundled Chromium. Removing the setting +restores the bundled-browser default. + +## Validation + +- TypeScript, both embedded bundle builds, daemon formatting, Rust formatting, + Rust build, and all 12 Rust tests passed. +- All 173 daemon tests passed, including configured executable selection, + default fallback when unset, invalid settings, launch errors, CDP isolation, + and browser-status reporting. +- A real QuickJS/Playwright smoke passed in both headed (Xvfb) and headless + modes using temporary profiles. It checked the CDP-reported executable, + browser version, textbox fill, a normal locator click, resulting title, + ARIA snapshot, and `navigator.webdriver === false`. See + [the smoke receipt](validation/wsl-stealth-browser-smoke.json). +- The promoted 153.0.8003.0 build was not selected: normal locator clicks + timed out while waiting for element stability, reproduced without QuickJS + using the pinned Playwright 1.58.2, in headed and headless modes. Bringing + the page to the front and using navigation instead of setContent did not + resolve it. The 150 build passed the equivalent test. + +## Installed activation + +The previous CLI and the config-absence marker were saved under +`~/.dev-browser/backups/wsl-stealth-default-20260922T015934Z/`. +Activation completed after explicit approval to close the 10 previous sessions. +The installed CLI launched a fresh named browser without an executable override; +daemon status reported the configured Linux artifact, and the script passed a +real textbox fill, ordinary locator click, title check (`stealth-default-ok`), +ARIA snapshot, and `navigator.webdriver === false` check. Its user agent reported +HeadlessChrome/150.0.0.0. Installed daemon and sandbox bundle bytes match the +rebuilt repository bundles. + +The process census found an extra startup daemon and a verification process +tree that did not finish graceful shutdown. Those exact processes were removed; +the final daemon is PID 77326 with zero browsers, ready to launch the configured +default. The old PID 63226 and the temporary daemon/browser processes are gone. +The activation changed no Chromium artifact aliases. diff --git a/package-lock.json b/package-lock.json index 5a56976a..f34bee94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "prettier": "^3.7.4", "typescript": "^5" }, - "version": "0.2.6" + "version": "0.2.9" }, "node_modules/ansi-escapes": { "version": "7.2.0", @@ -475,5 +475,5 @@ } } }, - "version": "0.2.6" + "version": "0.2.9" } diff --git a/package.json b/package.json index fd510733..8fccd1ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dev-browser", - "version": "0.2.6", + "version": "0.2.9", "description": "CLI for controlling browsers with sandboxed JavaScript scripts", "type": "module", "bin": { diff --git a/scripts/check-repo-policy.py b/scripts/check-repo-policy.py new file mode 100644 index 00000000..1e2aa71d --- /dev/null +++ b/scripts/check-repo-policy.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Validate active policy wiring, identities, and the bounded planning contract.""" +import json +from pathlib import Path +import re +import subprocess +import sys + +root = Path(__file__).resolve().parents[1] +selector = root / '.agents/skills/repo-policy-selector' +policies = sorted((root / 'docs/dev/policies').glob('*.md')) +text = (root / 'AGENTS.md').read_text() +identities = [re.sub(r'^\d+-', '', p.stem) for p in policies] +assert len(identities) == len(set(identities)), 'Duplicate policy identity' +for policy in policies: + relative = policy.relative_to(root).as_posix() + assert text.count(relative) == 1, f'Policy must be wired exactly once: {relative}' +for pointer in re.findall(r'\]\((docs/dev/policies/[^)]+)\)', text): + assert (root / pointer).is_file(), f'Missing policy: {pointer}' +for options in [[], ['--active-only']]: + subprocess.run([sys.executable, str(selector / 'scripts/audit_planning_contract.py'), + '--repo-root', str(root), *options, '--json'], check=True) +selection = json.loads(subprocess.check_output([ + sys.executable, str(selector / 'scripts/select_policy.py'), '--repo-root', str(root), + '--policy-root', str(selector / 'policy-library'), '--json'], text=True)) +assert not selection.get('validation_problems'), selection.get('validation_problems') +assert selection['memory_discovery']['repo_default'] == 'use' +print(f'PASS: {len(policies)} uniquely wired policies; full/active planning audits; Graphiti routing') diff --git a/skills/dev-browser/SKILL.md b/skills/dev-browser/SKILL.md index 30dfe91d..1da5ab67 100644 --- a/skills/dev-browser/SKILL.md +++ b/skills/dev-browser/SKILL.md @@ -93,3 +93,5 @@ EOF - Use persistent named pages to avoid re-navigation across turns - Use `--connect` only when the user wants to work inside an existing Chrome session - For command details and API reference, run `dev-browser --help` + +Named daemon-launched browsers persist by default. For unattended work, `--idle-timeout 5m` closes each launched browser after inactivity while preserving its profile and login state. The setting never closes Chrome attached with `--connect`; use `--idle-timeout 0` to disable configured cleanup.