diff --git a/.dev-os/notepad.md b/.dev-os/notepad.md new file mode 100644 index 0000000..b93f38e --- /dev/null +++ b/.dev-os/notepad.md @@ -0,0 +1,8 @@ +## PRIORITY + + +## WORKING MEMORY + + +## MANUAL + diff --git a/.gitignore b/.gitignore index e9be04b..b1d8203 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ node_modules/ dist/ .DS_Store .no-mistakes/evidence/ +.dev-os/runtime/ +product/runtime/tool-artifacts/ diff --git a/AGENTS.md b/AGENTS.md index 1395f46..1135b29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,3 +104,302 @@ Keep this file for knowledge useful to almost every future agent session in this Do not repeat what the codebase already shows; point to the authoritative file or command instead. Prefer rewriting or pruning existing entries over appending new ones. When updating this file, preserve this bar for all agents and keep entries concise. + + + +## Project Context + +- **Project:** gh-axi +- **DevOS profile:** `unknown` + + + +## Skill Invocation Contract + +DevOS capabilities are **skills**, not executables, commands, or shell programs. Every +DevOS OS feature — including `map`, `capture`, `test`, and `devos-init` — is a +registry-routed skill. Read its `SKILL.md`, then execute that skill through the +active host's skill surface. Do not search for a binary, runner, wrapper, or +subcommand implementation. + +- A line such as `map --update` is a **skill invocation with arguments**, not a + request to run a shell command. Invoke the `map` skill with `--update`; do not + use `command -v map`, `which map`, `map --help` in a shell, or look for a + `runner.sh`, `run.sh`, or `map.sh`. +- To use skill `X`, read its `SKILL.md` (under `.claude/skills/X/SKILL.md`, or the + OS-specific skills directory) and follow the instructions. Do **not** look for or + execute a `runner.sh`, `run.sh`, or `X.sh` to "run the skill" — no such entrypoint + exists, and running one is not how a skill executes. +- Bare skill names (`gap-analysis`, `capture`, `knowledge-pull`, `architecture-creator`) + are **not shell commands**. `command -v gap-analysis` returning empty does **not** + mean the skill is missing — skills are registry-routed, not on `$PATH`. Resolve a + name against `docs/context/DEVOS_SKILLS_INDEX.json`; if it appears there, the skill + is available. Never report a mandatory gate (e.g. `gap-analysis --dalio --fix`) as + unavailable just because a bare name is not a shell command. +- Flags and arguments come from the skill's own `SKILL.md` (Decision Gate / + flag table) or a script's argument parser — never from inference. A flag + absent from that surface does not exist; do not invent one. Check whether + the primary argument is positional, and distinguish flags a skill ACCEPTS + from flags it PASSES DOWNSTREAM to another skill. +- Skill dispatch results surface only in the session that invoked them. + Spawned worker/secondary sessions do not receive skill output; run any + skill whose contract returns a handoff (worktree path, branch, SHA) from + the operator's own session, and treat a worker's empty result as this + transport property — not a retry candidate. +- When a skill surfaces an operator confirmation (e.g. "Start autonomous + execution now?"), present it to the human verbatim and stop. Autonomous + flags mean "run without per-step prompts", never "self-approve human + gates". +- If a skill or chain seems to need a guard the process lacks, capture the + gap (`capture`) or change the spec — do not bolt ad-hoc gates, locks, or + wrappers onto generated artifacts. +- When a `SKILL.md` says it "delegates to" a script (e.g. `scripts/lib/capture.sh`), + that script is a helper the skill tells you when and how to call. Read the `SKILL.md` + first for the gating and validation around that call — do not run the helper directly + as a substitute for processing the skill. +- The agent is already running inside a live active session. When the user says + "run the skill" or invokes `/skill:`, execute that skill in the current + session immediately. Do not treat the invocation as a request for instructions, + a shell command, or a handoff to another runtime. +- Helper scripts are not equivalent to full skill execution. Never substitute a + helper, runner, wrapper, or direct implementation for the requested skill. + Use a helper only when the skill's own instructions explicitly require it as + one step of the skill execution. +- If a requested skill genuinely cannot be dispatched, STOP and say so plainly, + naming the exact invocation attempted and the observed failure. Never improvise + a substitute path and never continue as if the skill had run. +- Never assert a harness, host, or runtime limitation without evidence from an + actual attempt. "I cannot invoke skills here" is a claim that requires a + reproducible failure, not an assumption. +- Completion claims MUST be evidence-shaped. Do not say a skill "ran", + "completed", or "passed" without showing the skill's own output or the + resulting artifact change. +- Never hand-edit generated stamp or provenance fields (for example a + `Generated:` header, version stamp, or anchor timestamp) to simulate a + regeneration. Those fields are written only by the owning generation path. +- **Run DevOS shell helpers under bash, never zsh.** When a skill has you source a + DevOS lib (`scripts/lib/*.sh`, `cli-context-regen.sh`, `profile-distribution.sh`, + …), invoke it as `bash -lc 'source "$DEVOS_DIR/scripts/lib/.sh" && '`. + These libs use `BASH_SOURCE[0]` and bash arrays that break when sourced directly + in zsh — sourcing them from zsh is a top cause of broken skill runs. +- In OMP interactive mode, invoke a discovered skill with + `/skill: [args]`. Example: + `/skill:gap-analysis --dalio --fix --area `. +- `skill://` is the read-only resource URL for inspecting a skill file; + reading it is not execution evidence. +- When giving instructions to Codex, GPT, or another model operating through + OMP, write `/skill: [args]` exactly. Do not substitute a bare shell + command or `invoke_skill(name, input)` unless the active host exposes that + exact API. +- The active host MUST resolve the canonical skill name against the registry + before invocation. `command -v ` is not an availability check. +- A caller MUST capture and validate the invoked skill's returned or emitted + contract before composing dependent output. +- If host-native dispatch fails, retry according to the skill contract, then + record the exact syntax, failure mode, and fallback. Never claim the skill + ran from a `SKILL.md` read alone. +- Every dispatched skill MUST produce a `skill-dispatch-result/v1` record in + `product/runtime/skill-dispatch-results.jsonl` (or the canonical runtime + equivalent), including skill, status, provider, session, summary, and evidence. +- A routing surface MUST NOT claim a dependency skill ran unless a matching + successful or partial dispatch-result record exists. + + + + +## Orca Control + +When Orca is the requested control plane, use the public `orca` CLI (`orca-ide` +on Linux) and the `orca-cli` / `computer-use` skills before falling back to ad +hoc desktop tools. + +- Codex and similar sandboxed sessions may be unable to reach Orca's runtime + socket under the user's application-support directory. If `orca status --json` + or `orca computer ... --json` reports `runtime_unavailable`, + `stale_bootstrap`, or a connection failure from inside the sandbox, retry the + same public `orca` / `orca-ide` command with the minimal approval/escalation + needed before concluding Orca is down. +- Use `orca open --json` (`orca-ide open --json` on Linux) when Orca is not + running. If the runtime points at a stale PID, prefer a graceful app + quit/reopen, then rerun `orca status --json`. +- For read-only checks, use `orca status --json`, + `orca computer capabilities --json`, `orca computer list-apps --json`, + `orca computer list-windows --app --json`, + `orca computer get-app-state --app --json`, `orca tab list --json`, + `orca snapshot --page --json`, and `orca terminal list/read --json`. +- Do not click, type, submit, send messages, delete data, change settings, or + expose sensitive app content unless the user explicitly requested that action. + + + +## Safety Rules + +- Before removing or overwriting config files, create a backup first +- Never bulk-delete files without explicit approval +- Do not commit secrets (`.env`, credentials, API keys) to git +- Before staging files for a commit, verify they are inside the git repository root +- Do not force-push to main/master +- Run tests before claiming a fix works + + + +## Development Conventions + +- Check project README and CLAUDE.md for stack-specific conventions + + + +## Key Directories + +- `src/` — source code +- `scripts/` — build and utility scripts +- `product/` — specs, planning, runtime (DevOS managed) +- `.dev-os/` — DevOS project configuration + + + +## DevOS Context + +Reference these context files in every session: + +- `docs/context/DEVOS_CONTEXT_BUNDLE.md` — project summary, profile, version +- `docs/context/DEVOS_CAPABILITIES_INDEX.md` — full capabilities inventory +- `docs/context/DEVOS_PUBLIC_SURFACE.md` — user-facing commands and entry points +- `docs/context/DEVOS_ARCHITECTURE.md` — system architecture and component relationships +- `docs/context/codebase-map.md` — file tree with role annotations +- `docs/context/DEVOS_OWNERSHIP_AUDIT.md` — who owns what across the codebase +- `docs/context/DEVOS_DEFERRED_TOOLS.md` — deferred HTTP MCP servers +- `docs/context/DEVOS_USER_FLOWS_STALENESS.md` — user flow freshness status +- `.dev-os/runtime/context-refresh-state.json` (logical path; resolve via `scripts/lib/runtime-state.sh`) + +Already indexed in managed blocks below (no need to read separately): +Skills index, Chains index, MCP index, Standards index, Workflows index + + + +## Compatibility Posture + +- Temporary pre-launch rule. Remove or revise when this project goes live. +- This project is not live yet and has no production customers. +- Breaking changes are acceptable if they simplify the product or close correctness gaps. +- Default to the best forward version, not backwards compatibility. +- Treat unfinished, unused, or dead code as unbuilt features. +- Prefer deletion or replacement over shims, adapters, compatibility layers, or legacy fallbacks. +- Do not add legacy shims, compatibility layers, migrations, or old-contract support unless explicitly requested. + + + +## Context Artifact Commit Policy + +The batchable artifact set is defined solely by the generated-artifact +classifier: `generated_artifact_classify` in `scripts/lib/generated-artifact-registry.sh`, +intent `context_commit_batch`. No surface may restate that set as a path list; +resolve it with `generated_artifact_partition_dirty` (batch and operator sets). + +Commit modes — the decisive question is whether a `session-end-context-commit` +Stop hook will fire in this workspace before handoff: + +- `--split` (default; interactive session staying in this worktree): commit the + operator set; leave batchables staged for the Stop hook, which flushes them as + `chore(context)` commits under a content-hash debounce. +- `--all` (no Stop hook will fire — CI, headless, push-and-close, worktree + teardown, or context regeneration as the purpose of the change): one commit + containing both sets; set `DEVOS_CONTEXT_COMMIT_MODE=all`, which the flow + records as a `Devos-Context-Commit-Mode: all` trailer. +- `--none`: stage only; no commit. For preflight or deferred decisions. + +The pre-commit hook rejects a staged set mixing batch and operator files unless +`DEVOS_CONTEXT_COMMIT_MODE` authorizes it. Env controls: `DEVOS_CONTEXT_COMMIT_FORCE=1` +flushes an active debounce immediately, `DEVOS_SKIP_CONTEXT_BATCH=1` disables the +hook batch for the session, `DEVOS_CONTEXT_BATCH_DEBOUNCE_SECONDS` overrides the +window. This block is generated from the registry — hand edits are lost on the +next refresh; change registry classes or commit mode instead. + + + +## Notepad Checkpoint Discipline + +You have a durable, file-based notepad at `.dev-os/notepad.md` (project-local) or +`~/.dev-os/notepad.md` (global fallback). Three sections: + +- `## PRIORITY` — always injected on session start (≤500 chars). Use for the + current focus and active blockers, not a journal. +- `## WORKING MEMORY` — append-only, auto-pruneable. Use for milestone + checkpoints and durable progress notes. +- `## MANUAL` — never auto-modified. Use for long-term observations. + +### When to write to WORKING MEMORY + +Call `notepad_write_working ""` at: + +1. **Task group completion** — after marking a task group `[x]` in `tasks.md`. +2. **Non-obvious decision** — after choosing between alternatives a future + session would re-litigate without the rationale. +3. **Spec, architecture, or ADR written** — after persisting any of these. + +Skip the write when the work was pure read-only research with no durable outcome. + +### When to promote WORKING → PRIORITY + +When a thought in WORKING MEMORY becomes a durable rule every future session +must know (an enforced convention, a project-wide constraint), call: + +```bash +notepad_promote_working +``` + +This atomically moves the entry to PRIORITY (prepended, semicolon-separated) +and removes it from WORKING MEMORY. The 500-char PRIORITY cap is enforced +write-time; an over-long promotion fails loud via `notepad_enforce_priority_cap` +and you must shorten or split the entry before retrying. + +### How to invoke + +```bash +bash -c 'source "${DEVOS_DIR:-$HOME/.dev-os}/scripts/lib/notepad.sh" && \ + notepad_write_working ""' +``` + +Inspect with `notepad_show_history "WORKING MEMORY" 7` (last week) or +`notepad_search ""` (case-insensitive across all sections). + +Do not hand-edit `.dev-os/notepad.md` — always go through the helpers to keep +the file layout and atomic-write guarantees intact. + + + + +## Framework Bundle Index + +Compact framework index for low-context providers. Read the referenced bundle files for full docs. + +**claude-code-core** vlatest (23.2KB) — claude code settings, permissions, CLAUDE.md, memory, CLI flags, slash commands, interactive mode, configuration +> Full docs: `~/.dev-os/bundles/tier0_platform/claude-code-core@latest.json` — read `compressed_docs` field + +**claude-code-extensions** vlatest (23.2KB) — hooks, skills, MCP, subagents, plugins, SKILL.md, hook events, MCP servers +> Full docs: `~/.dev-os/bundles/tier0_platform/claude-code-extensions@latest.json` — read `compressed_docs` field + +**claude-code-automation** vlatest (23.2KB) — headless mode, agent SDK, GitHub Actions, agent teams, CI/CD, automation, best practices, workflows +> Full docs: `~/.dev-os/bundles/tier0_platform/claude-code-automation@latest.json` — read `compressed_docs` field + +**claude-code-config** vlatest (23.2KB) — model config, sandboxing, checkpointing, keybindings, fast mode, status line, output styles, model aliases +> Full docs: `~/.dev-os/bundles/tier0_platform/claude-code-config@latest.json` — read `compressed_docs` field + +**github-rest-api** v2026-05 (4.9KB) — github-rest-api +> Full docs: `~/.dev-os/bundles/tier2_backend/github-rest-api@2026-05.json` — read `compressed_docs` field + +**vitest** vv4.1.5 (9.8KB) — Table of Contents +> Full docs: `~/.dev-os/bundles/tier2_testing/vitest@v4.1.5.json` — read `compressed_docs` field + + + + + +→ Codebase map not inlined here. Full map: docs/context/codebase-map.md + + + + +## Skills (266+ available) +Invoke via '/' or run /find-skills to discover. Full index disabled (token budget). + diff --git a/CLAUDE.md b/CLAUDE.md index a9d4d26..1b71a2a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,2 +1,310 @@ @AGENTS.md + + +SIMPLE COMMUNICATION MODE ACTIVE. + +Use simple, plain English. Start with the bottom line. Use short sections and bullets. Explain technical terms when they are necessary instead of assuming the user knows them. + +For explanations and progress reports, clearly separate: +- what works, +- what is missing, +- why it matters, +- what happens next. + +Recommend one clear next action. Keep implementation details out unless the user asks for them. Do not remove important warnings, evidence, or decisions in the name of simplicity. For direct questions that do not need all four sections, answer naturally and briefly rather than forcing a template. + + + + +## Project Context + +- **Project:** gh-axi +- **DevOS profile:** `unknown` + + + +## Skill Invocation Contract + +DevOS capabilities are **skills**, not executables, commands, or shell programs. Every +DevOS OS feature — including `map`, `capture`, `test`, and `devos-init` — is a +registry-routed skill. Read its `SKILL.md`, then execute that skill through the +active host's skill surface. Do not search for a binary, runner, wrapper, or +subcommand implementation. + +- A line such as `map --update` is a **skill invocation with arguments**, not a + request to run a shell command. Invoke the `map` skill with `--update`; do not + use `command -v map`, `which map`, `map --help` in a shell, or look for a + `runner.sh`, `run.sh`, or `map.sh`. +- To use skill `X`, read its `SKILL.md` (under `.claude/skills/X/SKILL.md`, or the + OS-specific skills directory) and follow the instructions. Do **not** look for or + execute a `runner.sh`, `run.sh`, or `X.sh` to "run the skill" — no such entrypoint + exists, and running one is not how a skill executes. +- Bare skill names (`gap-analysis`, `capture`, `knowledge-pull`, `architecture-creator`) + are **not shell commands**. `command -v gap-analysis` returning empty does **not** + mean the skill is missing — skills are registry-routed, not on `$PATH`. Resolve a + name against `docs/context/DEVOS_SKILLS_INDEX.json`; if it appears there, the skill + is available. Never report a mandatory gate (e.g. `gap-analysis --dalio --fix`) as + unavailable just because a bare name is not a shell command. +- Flags and arguments come from the skill's own `SKILL.md` (Decision Gate / + flag table) or a script's argument parser — never from inference. A flag + absent from that surface does not exist; do not invent one. Check whether + the primary argument is positional, and distinguish flags a skill ACCEPTS + from flags it PASSES DOWNSTREAM to another skill. +- Skill dispatch results surface only in the session that invoked them. + Spawned worker/secondary sessions do not receive skill output; run any + skill whose contract returns a handoff (worktree path, branch, SHA) from + the operator's own session, and treat a worker's empty result as this + transport property — not a retry candidate. +- When a skill surfaces an operator confirmation (e.g. "Start autonomous + execution now?"), present it to the human verbatim and stop. Autonomous + flags mean "run without per-step prompts", never "self-approve human + gates". +- If a skill or chain seems to need a guard the process lacks, capture the + gap (`capture`) or change the spec — do not bolt ad-hoc gates, locks, or + wrappers onto generated artifacts. +- When a `SKILL.md` says it "delegates to" a script (e.g. `scripts/lib/capture.sh`), + that script is a helper the skill tells you when and how to call. Read the `SKILL.md` + first for the gating and validation around that call — do not run the helper directly + as a substitute for processing the skill. +- The agent is already running inside a live active session. When the user says + "run the skill" or invokes `/skill:`, execute that skill in the current + session immediately. Do not treat the invocation as a request for instructions, + a shell command, or a handoff to another runtime. +- Helper scripts are not equivalent to full skill execution. Never substitute a + helper, runner, wrapper, or direct implementation for the requested skill. + Use a helper only when the skill's own instructions explicitly require it as + one step of the skill execution. +- If a requested skill genuinely cannot be dispatched, STOP and say so plainly, + naming the exact invocation attempted and the observed failure. Never improvise + a substitute path and never continue as if the skill had run. +- Never assert a harness, host, or runtime limitation without evidence from an + actual attempt. "I cannot invoke skills here" is a claim that requires a + reproducible failure, not an assumption. +- Completion claims MUST be evidence-shaped. Do not say a skill "ran", + "completed", or "passed" without showing the skill's own output or the + resulting artifact change. +- Never hand-edit generated stamp or provenance fields (for example a + `Generated:` header, version stamp, or anchor timestamp) to simulate a + regeneration. Those fields are written only by the owning generation path. +- **Run DevOS shell helpers under bash, never zsh.** When a skill has you source a + DevOS lib (`scripts/lib/*.sh`, `cli-context-regen.sh`, `profile-distribution.sh`, + …), invoke it as `bash -lc 'source "$DEVOS_DIR/scripts/lib/.sh" && '`. + These libs use `BASH_SOURCE[0]` and bash arrays that break when sourced directly + in zsh — sourcing them from zsh is a top cause of broken skill runs. +- In OMP interactive mode, invoke a discovered skill with + `/skill: [args]`. Example: + `/skill:gap-analysis --dalio --fix --area `. +- `skill://` is the read-only resource URL for inspecting a skill file; + reading it is not execution evidence. +- When giving instructions to Codex, GPT, or another model operating through + OMP, write `/skill: [args]` exactly. Do not substitute a bare shell + command or `invoke_skill(name, input)` unless the active host exposes that + exact API. +- The active host MUST resolve the canonical skill name against the registry + before invocation. `command -v ` is not an availability check. +- A caller MUST capture and validate the invoked skill's returned or emitted + contract before composing dependent output. +- If host-native dispatch fails, retry according to the skill contract, then + record the exact syntax, failure mode, and fallback. Never claim the skill + ran from a `SKILL.md` read alone. +- Every dispatched skill MUST produce a `skill-dispatch-result/v1` record in + `product/runtime/skill-dispatch-results.jsonl` (or the canonical runtime + equivalent), including skill, status, provider, session, summary, and evidence. +- A routing surface MUST NOT claim a dependency skill ran unless a matching + successful or partial dispatch-result record exists. + + + + +## Orca Control + +When Orca is the requested control plane, use the public `orca` CLI (`orca-ide` +on Linux) and the `orca-cli` / `computer-use` skills before falling back to ad +hoc desktop tools. + +- Codex and similar sandboxed sessions may be unable to reach Orca's runtime + socket under the user's application-support directory. If `orca status --json` + or `orca computer ... --json` reports `runtime_unavailable`, + `stale_bootstrap`, or a connection failure from inside the sandbox, retry the + same public `orca` / `orca-ide` command with the minimal approval/escalation + needed before concluding Orca is down. +- Use `orca open --json` (`orca-ide open --json` on Linux) when Orca is not + running. If the runtime points at a stale PID, prefer a graceful app + quit/reopen, then rerun `orca status --json`. +- For read-only checks, use `orca status --json`, + `orca computer capabilities --json`, `orca computer list-apps --json`, + `orca computer list-windows --app --json`, + `orca computer get-app-state --app --json`, `orca tab list --json`, + `orca snapshot --page --json`, and `orca terminal list/read --json`. +- Do not click, type, submit, send messages, delete data, change settings, or + expose sensitive app content unless the user explicitly requested that action. + + + +## Safety Rules + +- Before removing or overwriting config files, create a backup first +- Never bulk-delete files without explicit approval +- Do not commit secrets (`.env`, credentials, API keys) to git +- Before staging files for a commit, verify they are inside the git repository root +- Do not force-push to main/master +- Run tests before claiming a fix works + + + +## Development Conventions + +- Check project README and CLAUDE.md for stack-specific conventions + + + +## Key Directories + +- `src/` — source code +- `scripts/` — build and utility scripts +- `product/` — specs, planning, runtime (DevOS managed) +- `.dev-os/` — DevOS project configuration + + + +## DevOS Context + +Reference these context files in every session: + +- `docs/context/DEVOS_CONTEXT_BUNDLE.md` — project summary, profile, version +- `docs/context/DEVOS_CAPABILITIES_INDEX.md` — full capabilities inventory +- `docs/context/DEVOS_PUBLIC_SURFACE.md` — user-facing commands and entry points +- `docs/context/DEVOS_ARCHITECTURE.md` — system architecture and component relationships +- `docs/context/codebase-map.md` — file tree with role annotations +- `docs/context/DEVOS_OWNERSHIP_AUDIT.md` — who owns what across the codebase +- `docs/context/DEVOS_DEFERRED_TOOLS.md` — deferred HTTP MCP servers +- `docs/context/DEVOS_USER_FLOWS_STALENESS.md` — user flow freshness status +- `.dev-os/runtime/context-refresh-state.json` (logical path; resolve via `scripts/lib/runtime-state.sh`) + +Already indexed in managed blocks below (no need to read separately): +Skills index, Chains index, MCP index, Standards index, Workflows index + + + +## Compatibility Posture + +- Temporary pre-launch rule. Remove or revise when this project goes live. +- This project is not live yet and has no production customers. +- Breaking changes are acceptable if they simplify the product or close correctness gaps. +- Default to the best forward version, not backwards compatibility. +- Treat unfinished, unused, or dead code as unbuilt features. +- Prefer deletion or replacement over shims, adapters, compatibility layers, or legacy fallbacks. +- Do not add legacy shims, compatibility layers, migrations, or old-contract support unless explicitly requested. + + + +## Context Artifact Commit Policy + +The batchable artifact set is defined solely by the generated-artifact +classifier: `generated_artifact_classify` in `scripts/lib/generated-artifact-registry.sh`, +intent `context_commit_batch`. No surface may restate that set as a path list; +resolve it with `generated_artifact_partition_dirty` (batch and operator sets). + +Commit modes — the decisive question is whether a `session-end-context-commit` +Stop hook will fire in this workspace before handoff: + +- `--split` (default; interactive session staying in this worktree): commit the + operator set; leave batchables staged for the Stop hook, which flushes them as + `chore(context)` commits under a content-hash debounce. +- `--all` (no Stop hook will fire — CI, headless, push-and-close, worktree + teardown, or context regeneration as the purpose of the change): one commit + containing both sets; set `DEVOS_CONTEXT_COMMIT_MODE=all`, which the flow + records as a `Devos-Context-Commit-Mode: all` trailer. +- `--none`: stage only; no commit. For preflight or deferred decisions. + +The pre-commit hook rejects a staged set mixing batch and operator files unless +`DEVOS_CONTEXT_COMMIT_MODE` authorizes it. Env controls: `DEVOS_CONTEXT_COMMIT_FORCE=1` +flushes an active debounce immediately, `DEVOS_SKIP_CONTEXT_BATCH=1` disables the +hook batch for the session, `DEVOS_CONTEXT_BATCH_DEBOUNCE_SECONDS` overrides the +window. This block is generated from the registry — hand edits are lost on the +next refresh; change registry classes or commit mode instead. + + + +## Notepad Checkpoint Discipline + +You have a durable, file-based notepad at `.dev-os/notepad.md` (project-local) or +`~/.dev-os/notepad.md` (global fallback). Three sections: + +- `## PRIORITY` — always injected on session start (≤500 chars). Use for the + current focus and active blockers, not a journal. +- `## WORKING MEMORY` — append-only, auto-pruneable. Use for milestone + checkpoints and durable progress notes. +- `## MANUAL` — never auto-modified. Use for long-term observations. + +### When to write to WORKING MEMORY + +Call `notepad_write_working ""` at: + +1. **Task group completion** — after marking a task group `[x]` in `tasks.md`. +2. **Non-obvious decision** — after choosing between alternatives a future + session would re-litigate without the rationale. +3. **Spec, architecture, or ADR written** — after persisting any of these. + +Skip the write when the work was pure read-only research with no durable outcome. + +### When to promote WORKING → PRIORITY + +When a thought in WORKING MEMORY becomes a durable rule every future session +must know (an enforced convention, a project-wide constraint), call: + +```bash +notepad_promote_working +``` + +This atomically moves the entry to PRIORITY (prepended, semicolon-separated) +and removes it from WORKING MEMORY. The 500-char PRIORITY cap is enforced +write-time; an over-long promotion fails loud via `notepad_enforce_priority_cap` +and you must shorten or split the entry before retrying. + +### How to invoke + +```bash +bash -c 'source "${DEVOS_DIR:-$HOME/.dev-os}/scripts/lib/notepad.sh" && \ + notepad_write_working ""' +``` + +Inspect with `notepad_show_history "WORKING MEMORY" 7` (last week) or +`notepad_search ""` (case-insensitive across all sections). + +Do not hand-edit `.dev-os/notepad.md` — always go through the helpers to keep +the file layout and atomic-write guarantees intact. + + + + +## Framework Bundle Index + +Compact framework index for low-context providers. Read the referenced bundle files for full docs. + +**claude-code-core** vlatest (23.2KB) — claude code settings, permissions, CLAUDE.md, memory, CLI flags, slash commands, interactive mode, configuration +> Full docs: `~/.dev-os/bundles/tier0_platform/claude-code-core@latest.json` — read `compressed_docs` field + +**claude-code-extensions** vlatest (23.2KB) — hooks, skills, MCP, subagents, plugins, SKILL.md, hook events, MCP servers +> Full docs: `~/.dev-os/bundles/tier0_platform/claude-code-extensions@latest.json` — read `compressed_docs` field + +**claude-code-automation** vlatest (23.2KB) — headless mode, agent SDK, GitHub Actions, agent teams, CI/CD, automation, best practices, workflows +> Full docs: `~/.dev-os/bundles/tier0_platform/claude-code-automation@latest.json` — read `compressed_docs` field + +**claude-code-config** vlatest (23.2KB) — model config, sandboxing, checkpointing, keybindings, fast mode, status line, output styles, model aliases +> Full docs: `~/.dev-os/bundles/tier0_platform/claude-code-config@latest.json` — read `compressed_docs` field + +**github-rest-api** v2026-05 (4.9KB) — github-rest-api +> Full docs: `~/.dev-os/bundles/tier2_backend/github-rest-api@2026-05.json` — read `compressed_docs` field + +**vitest** vv4.1.5 (9.8KB) — Table of Contents +> Full docs: `~/.dev-os/bundles/tier2_testing/vitest@v4.1.5.json` — read `compressed_docs` field + + + + + +→ Codebase map not inlined here. Full map: docs/context/codebase-map.md + + diff --git a/README.md b/README.md index 1c6a65a..6eeb0c9 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,8 @@ gh-axi gist clone # clone a gist locally gh-axi setup hooks # install optional agent session hooks gh-axi update --check # check whether a newer release exists gh-axi update # upgrade a global install +gh-axi --fix-ignore-conflicts # dashboard: explicitly repair tracked ignored entries +gh-axi pr create --title "..." --fix-ignore-conflicts ``` For multi-line issue, PR, review, or comment text, write Markdown to a UTF-8 file and pass `--body-file ` on the relevant command. @@ -137,6 +139,8 @@ Cancelled, stale, timed-out, action-required, and startup-failure check runs cou `gh-axi stack` is a strict, non-interactive adapter over the official `github/gh-stack` extension. It supports `view`, `init`, `add`, `checkout`, `push`, `submit`, `sync`, `rebase`, `link`, `unstack`, `merge`, and branch navigation. It intentionally excludes the interactive `modify` and `switch` TUIs and the human-only `alias` and `feedback` utilities. Stack commands operate on local branches and `.git/gh-stack`, so run them from the target repository's working directory. They reject `-R`, `--repo`, and `GH_REPO` rather than pretending a remote repository is enough. `--hostname` remains available for authenticated GitHub Enterprise hosts. Agent-safe behavior is automatic: `stack view` requests JSON, `stack submit` adds `--auto`, and `stack merge` requires an explicit stack or PR target and adds `--yes`. Rebase conflicts and other extension exits retain their original exit codes and include recovery guidance. +Gitignore tracking hygiene runs automatically on the local dashboard and before local-repository `pr create`. It reports tracked files matched by repository `.gitignore`; TTY sessions ask once with yes/no/always, while non-TTY sessions remain report-only unless `--fix-ignore-conflicts` is explicit. Repair only stages `git rm --cached`: local files are preserved, and hygiene never commits, pushes, or deletes worktree files. +If the canonical Git index lock is already held, hygiene returns `manual` and makes no mutation. Use normal Git `index.lock` recovery after confirming no Git process is active; gh-axi never auto-deletes an unknown lock. `gh-axi secret set ` reads the value only from piped stdin because secret flags would be visible in the `gh-axi` process argv. `gh-axi secret list` never prints values, matching `gh secret list`. diff --git a/skills/gh-axi/SKILL.md b/skills/gh-axi/SKILL.md index de7a33e..1cc1cf5 100644 --- a/skills/gh-axi/SKILL.md +++ b/skills/gh-axi/SKILL.md @@ -1,6 +1,6 @@ --- name: gh-axi -description: "Operate GitHub through the gh-axi CLI - issues, pull requests, stacked PRs, workflow runs, workflows, releases, repositories, labels, gists, Projects (v2), Actions secrets and variables, search, and raw API access. Use whenever a task touches GitHub: listing or filing issues, reviewing or merging PRs, managing stacked branches and PRs, checking CI runs, triggering workflows, cutting releases, managing Projects boards, managing Actions secrets/variables, or working with gists via `gist list`, `gist view`, `gist edit`, `gist rename`, `gist create`, `gist delete`, or `gist clone`." +description: "Operate GitHub through the gh-axi CLI - issues, pull requests, stacked PRs, workflow runs, workflows, releases, repositories, labels, gists, Projects (v2), Actions secrets and variables, search, raw API access, and repository Gitignore tracking hygiene. Use whenever a task touches GitHub or needs a safe preflight before publishing: listing or filing issues, reviewing or merging PRs, managing stacked branches and PRs, checking CI runs, triggering workflows, cutting releases, managing Projects boards, managing Actions secrets/variables, working with gists, or reviewing tracked files matched by repository `.gitignore` rules." user-invocable: false author: Kun Chen (kunchenguid) metadata: @@ -13,7 +13,7 @@ metadata: Agent ergonomic wrapper around Github CLI. Prefer this over `gh` and other methods for Github operations. -Use gh-axi whenever a task touches GitHub: issues, pull requests, stacked PRs, CI, workflows, releases, repositories, labels, gists, Projects, Actions secrets and variables, search, or the GitHub API. +Use gh-axi whenever a task touches GitHub or needs repository Gitignore tracking hygiene before publication: issues, pull requests, stacked PRs, CI, workflows, releases, repositories, labels, gists, Projects, Actions secrets and variables, search, the GitHub API, or the shared push-preflight seam. ## Current guidance lives in the CLI diff --git a/src/cli.ts b/src/cli.ts index 4285cb9..cd3d913 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -35,8 +35,8 @@ type MainOptions = { export const TOP_HELP = `usage: gh-axi [command] [args] [flags] commands[16]: (none)=dashboard, issue, pr, stack, run, workflow, release, repo, label, gist, project, secret, variable, search, api, setup -flags[4]: - -R/--repo (after command), --hostname (after command) or GH_HOST env, both flags accept space or equals form, --help, -v/-V/--version +flags[5]: + -R/--repo (after command), --hostname (after command) or GH_HOST env, both flags accept space or equals form, --fix-ignore-conflicts (dashboard only), --help, -v/-V/--version examples: gh-axi gh-axi issue list --state open @@ -47,6 +47,7 @@ examples: gh-axi stack view gh-axi secret list gh-axi setup hooks + gh-axi --fix-ignore-conflicts `; const COMMAND_HELP: Record = { @@ -90,17 +91,29 @@ const COMMANDS: Record = { search: withRepoContext("search", searchCommand), api: withRepoContext("api", apiCommand), setup: setupCommand, - stack: withLocalRepoContext(stackCommand), + stack: withLocalRepoContext("stack", stackCommand), }; export async function main(options: MainOptions = {}): Promise { + const argv = options.argv ?? process.argv.slice(2); + const dashboardExplicitFix = + argv.length === 1 && argv[0] === "--fix-ignore-conflicts"; await runAxiCli({ - ...(options.argv ? { argv: options.argv } : {}), + ...(dashboardExplicitFix + ? { argv: [] } + : options.argv + ? { argv: options.argv } + : {}), description: DESCRIPTION, version: VERSION, topLevelHelp: TOP_HELP, ...(options.stdout ? { stdout: options.stdout } : {}), - home: withRepoContext(undefined, homeCommand), + home: withRepoContext(undefined, (args, ctx) => + homeCommand( + dashboardExplicitFix ? ["--fix-ignore-conflicts", ...args] : args, + ctx, + ), + ), commands: COMMANDS, getCommandHelp: (command) => COMMAND_HELP[command], formatError: (error) => { @@ -160,12 +173,15 @@ function withRepoContext( ); } -function withLocalRepoContext(handler: CommandFn): WrappedCommandFn { +function withLocalRepoContext( + name: string, + handler: CommandFn, +): WrappedCommandFn { return (args, ctx) => { - const parsed = parseRepoContextArgs("stack", args); + const parsed = parseRepoContextArgs(name, args); if (parsed.repoFlag !== undefined || process.env["GH_REPO"]) { throw new AxiError( - "stack commands operate on the repository in the current working directory and do not support -R, --repo, or GH_REPO", + `${name} commands operate on the repository in the current working directory and do not support -R, --repo, or GH_REPO`, "VALIDATION_ERROR", ); } diff --git a/src/commands/home.ts b/src/commands/home.ts index 40f56ea..5ae58f1 100644 --- a/src/commands/home.ts +++ b/src/commands/home.ts @@ -1,5 +1,9 @@ +import { encode } from "@toon-format/toon"; import type { RepoContext } from "../context.js"; import { ghJson } from "../gh.js"; +import { runGitignorePreflight } from "../gitignore-hygiene.js"; +import { isStdinTTY } from "../stdin.js"; +import { getSuggestions } from "../suggestions.js"; import { field, lower, @@ -10,18 +14,14 @@ import { renderOutput, type FieldDef, } from "../toon.js"; -import { getSuggestions } from "../suggestions.js"; -import { encode } from "@toon-format/toon"; export const HOME_HELP = ""; - const issueSchema: FieldDef[] = [ field("number"), field("title"), lower("state"), pluck("author", "login", "author"), ]; - const prSchema: FieldDef[] = [ field("number"), field("title"), @@ -39,15 +39,29 @@ const prSchema: FieldDef[] = [ ]; export async function homeCommand( - _args: string[], + args: string[], ctx?: RepoContext, ): Promise { - // Run queries in parallel - const [issues, prs] = await Promise.all([ + const explicitFix = args.includes("--fix-ignore-conflicts"); + const localRepo = !ctx || ctx.source === "git"; + const hygienePromise = localRepo + ? runGitignorePreflight({ + policy: explicitFix + ? "explicit-fix" + : isStdinTTY() + ? "interactive" + : "report", + }) + : Promise.resolve({ + findings: [], + gitAvailable: false, + action: "none" as const, + }); + const [issues, prs, hygiene] = await Promise.all([ ghJson[]>( ["issue", "list", "--json", "number,title,state,author", "--limit", "3"], ctx, - ).catch(() => [] as Record[]), + ).catch(() => []), ghJson[]>( [ "pr", @@ -58,32 +72,49 @@ export async function homeCommand( "3", ], ctx, - ).catch(() => [] as Record[]), + ).catch(() => []), + hygienePromise.catch(() => ({ + findings: [], + gitAvailable: false, + action: "none" as const, + })), ]); - const blocks: string[] = []; - - if (ctx) { - blocks.push(encode({ repo: ctx.nwo })); - } - + if (ctx) blocks.push(encode({ repo: ctx.nwo })); blocks.push( issues.length ? renderList("issues", issues, issueSchema) : "issues: 0 open", ); blocks.push(prs.length ? renderList("prs", prs, prSchema) : "prs: 0 open"); - + blocks.push( + encode({ + hygiene: { + action: !localRepo + ? "skipped" + : hygiene.gitAvailable + ? hygiene.findings.length > 0 + ? hygiene.action + : "none" + : "unavailable", + local_files: "preserved", + findings: hygiene.findings.length, + }, + }), + ); const hints: string[] = []; if (issues.length >= 3) hints.push("Run `gh-axi issue list` for full issue list"); if (prs.length >= 3) hints.push("Run `gh-axi pr list` for full PR list"); - const suggestions = getSuggestions({ - domain: "home", - action: "home", - repo: ctx, - }); - blocks.push(renderHelp([...hints, ...suggestions])); - + if (hygiene.findings.length > 0) + hints.push( + "Run `gh-axi --fix-ignore-conflicts` to repair tracked .gitignore conflicts", + ); + blocks.push( + renderHelp([ + ...hints, + ...getSuggestions({ domain: "home", action: "home", repo: ctx }), + ]), + ); return renderOutput(blocks); } diff --git a/src/commands/pr.ts b/src/commands/pr.ts index 3032757..75a7ccd 100644 --- a/src/commands/pr.ts +++ b/src/commands/pr.ts @@ -6,6 +6,8 @@ import { takeBody, truncateBody } from "../body.js"; import { formatCountLine } from "../format.js"; import { fetchListTotal, type ListFilter } from "../totals.js"; import { getSuggestions } from "../suggestions.js"; +import { isStdinTTY } from "../stdin.js"; +import { runGitignorePreflight } from "../gitignore-hygiene.js"; import { takeFlag, takeBoolFlag, @@ -276,6 +278,7 @@ const PR_FLAGS: Record = { "--label", "--milestone", "--project", + "--fix-ignore-conflicts", ], edit: [ "--title", @@ -327,7 +330,7 @@ flags{list}: flags{view}: --comments, --reviews (show review submissions and inline review comments), --full (show complete body without truncation) flags{create}: - --title (required), --body or --body-file , --base, --head, --draft, --assignee (repeatable), --reviewer (repeatable), --label (repeatable), --milestone, --project (repeatable) + --title (required), --body or --body-file , --base, --head, --draft, --assignee (repeatable), --reviewer (repeatable), --label (repeatable), --milestone, --project (repeatable), --fix-ignore-conflicts flags{edit}: --title , --body or --body-file , --add-label (repeatable), --remove-label (repeatable), --add-assignee (repeatable), --remove-assignee (repeatable), --add-reviewer (repeatable), --remove-reviewer (repeatable), --milestone flags{merge}: @@ -518,7 +521,12 @@ async function prView(args: string[], ctx?: RepoContext): Promise { return renderOutput([renderDetail("pull_request", pr, schema)]); } -async function prCreate(args: string[], ctx?: RepoContext): Promise { +async function prCreate( + args: string[], + ctx?: RepoContext, + preflight: typeof runGitignorePreflight = runGitignorePreflight, +): Promise { + const explicitFix = takeBoolFlag(args, "--fix-ignore-conflicts"); const title = takeFlag(args, "--title"); if (!title) throw new AxiError("--title is required", "VALIDATION_ERROR"); const body = takeBody(args); @@ -542,6 +550,17 @@ async function prCreate(args: string[], ctx?: RepoContext): Promise { if (milestone) ghArgs.push("--milestone", milestone); pushRepeated(ghArgs, "--project", projects); + const hygiene = + ctx?.source === "git" + ? await preflight({ + policy: explicitFix + ? "explicit-fix" + : isStdinTTY() + ? "interactive" + : "report", + }) + : { findings: [], gitAvailable: false, action: "none" as const }; + const stdout = await ghExec(ghArgs, ctx); // Parse PR number from the emitted URL: https:///OWNER/REPO/pull/123 const urlMatch = stdout.match(/\/pull\/(\d+)/); @@ -549,6 +568,13 @@ async function prCreate(args: string[], ctx?: RepoContext): Promise { const url = stdout.trim().split("\n").pop()?.trim() ?? ""; return renderOutput([ + encode({ + hygiene: { + action: hygiene.findings.length > 0 ? hygiene.action : "none", + local_files: "preserved", + findings: hygiene.findings.length, + }, + }), renderDetail("created", { number: num ?? url, url }, [ field("number"), field("url"), @@ -1026,13 +1052,10 @@ async function prRevert(args: string[], ctx?: RepoContext): Promise { ]); } -// --------------------------------------------------------------------------- -// Router -// --------------------------------------------------------------------------- - export async function prCommand( args: string[], ctx?: RepoContext, + preflight: typeof runGitignorePreflight = runGitignorePreflight, ): Promise { const sub = args[0]; const rest = args.slice(1); @@ -1046,7 +1069,7 @@ export async function prCommand( return prView(rest, ctx); case "create": rejectUnknownFlags(rest, PR_FLAGS.create, "pr", "create"); - return prCreate(rest, ctx); + return prCreate(rest, ctx, preflight); case "edit": rejectUnknownFlags(rest, PR_FLAGS.edit, "pr", "edit"); return prEdit(rest, ctx); diff --git a/src/gitignore-hygiene.ts b/src/gitignore-hygiene.ts new file mode 100644 index 0000000..71afa2d --- /dev/null +++ b/src/gitignore-hygiene.ts @@ -0,0 +1,295 @@ +import { execFile } from "node:child_process"; +import { + FileHandle, + open, + readFile, + rename, + stat, + unlink, + writeFile, +} from "node:fs/promises"; +import { createInterface } from "node:readline/promises"; +import { stdin, stdout } from "node:process"; +import { AxiError } from "./errors.js"; +import { isStdinTTY } from "./stdin.js"; +const ALWAYS_FIX_KEY = "gh-axi.ignoreConflicts"; +export type GitResult = { stdout: string; stderr: string; exitCode: number }; +export type GitRunner = ( + args: string[], + input?: string, + env?: NodeJS.ProcessEnv, +) => Promise; +export type HygieneFinding = { + path: string; + rule: string; + line: number; + source: string; + workingTree: "clean" | "modified" | "unavailable"; + index: "clean" | "staged-different" | "unavailable"; + classification: "policy-drift" | "manual"; + eligible: boolean; + reason?: string; +}; +export type HygieneReport = { + findings: HygieneFinding[]; + gitAvailable: boolean; +}; +export type PreflightPolicy = "report" | "interactive" | "explicit-fix"; +export type PreflightResult = HygieneReport & { + action: "none" | "reported" | "declined" | "fixed" | "manual"; +}; +const defaultRunner: GitRunner = (args, input, env) => + new Promise((resolve) => { + const child = execFile( + "git", + args, + { env: { ...process.env, ...env }, maxBuffer: 10 * 1024 * 1024 }, + (error, stdoutText, stderrText) => { + const code = error as (Error & { code?: number }) | null; + resolve({ + stdout: stdoutText ?? "", + stderr: stderrText ?? "", + exitCode: code ? Number(code.code) || 1 : 0, + }); + }, + ); + if (input !== undefined) child.stdin?.end(input); + }); +function nul(value: string): string[] { + return value.split("\0").filter(Boolean); +} +async function successful( + runner: GitRunner, + args: string[], + input?: string, + env?: NodeJS.ProcessEnv, +): Promise { + const result = await runner(args, input, env); + if (result.exitCode !== 0) + throw new AxiError(result.stderr || "git command failed", "UNKNOWN"); + return result; +} +export async function detectGitignoreConflicts( + runner: GitRunner = defaultRunner, + env?: NodeJS.ProcessEnv, +): Promise { + let tracked: string[]; + try { + tracked = nul( + ( + await successful( + runner, + ["ls-files", "-ci", "--exclude-per-directory=.gitignore", "-z"], + undefined, + env, + ) + ).stdout, + ); + } catch { + return { findings: [], gitAvailable: false }; + } + if (!tracked.length) return { findings: [], gitAvailable: true }; + const trackedSet = new Set(tracked); + const [stagedResult, workingResult, modesResult, sparseResult] = + await Promise.all([ + runner(["diff", "--name-only", "--cached", "-z"], undefined, env), + runner(["diff", "--name-only", "-z"], undefined, env), + runner(["ls-files", "--stage", "-z"], undefined, env), + runner(["ls-files", "-t", "-z"], undefined, env), + ]); + const probeFailed = [ + stagedResult, + workingResult, + modesResult, + sparseResult, + ].some((r) => r.exitCode !== 0); + const staged = new Set(nul(stagedResult.stdout)); + const working = new Set(nul(workingResult.stdout)); + const modes = new Map( + nul(modesResult.stdout).map((line) => { + const tab = line.indexOf("\t"); + return [line.slice(tab + 1), line.slice(0, tab).split(" ")[0]] as const; + }), + ); + const sparse = new Set( + nul(sparseResult.stdout) + .filter((line) => line.startsWith("S ")) + .map((line) => line.slice(2)), + ); + const evidence = await runner( + ["check-ignore", "-v", "--no-index", "--stdin", "-z"], + `${tracked.join("\0")}\0`, + env, + ); + if (evidence.exitCode !== 0 && evidence.exitCode !== 1) + return { findings: [], gitAvailable: false }; + const fields = nul(evidence.stdout); + const findings: HygieneFinding[] = []; + for (let i = 0; i + 3 < fields.length; i += 4) { + const [source, line, rule, path] = fields.slice(i, i + 4); + const lineNumber = Number(line); + if ( + !Number.isInteger(lineNumber) || + !/^(.+\/)?\.gitignore$/.test(source) || + !trackedSet.has(path) + ) + continue; + const reason = probeFailed + ? "Git safety probe unavailable" + : modes.get(path) === "160000" + ? "submodule gitlink" + : sparse.has(path) + ? "sparse-checkout path" + : staged.has(path) + ? "index differs from HEAD (staged content)" + : undefined; + findings.push({ + path, + rule, + line: lineNumber, + source, + workingTree: probeFailed + ? "unavailable" + : working.has(path) + ? "modified" + : "clean", + index: probeFailed + ? "unavailable" + : staged.has(path) + ? "staged-different" + : "clean", + classification: reason ? "manual" : "policy-drift", + eligible: !reason, + ...(reason ? { reason } : {}), + }); + } + return { findings, gitAvailable: true }; +} +export async function repairGitignoreConflicts( + findings: HygieneFinding[], + runner: GitRunner = defaultRunner, +): Promise { + const consented = new Set( + findings.filter((f) => f.eligible).map((f) => f.path), + ); + let lock: FileHandle | undefined; + let ownsLock = false; + let lockPath = ""; + let tempPath = ""; + let committed = false; + try { + const result = await successful(runner, [ + "rev-parse", + "--path-format=absolute", + "--git-path", + "index", + ]); + const indexPath = result.stdout.trim(); + lockPath = `${indexPath}.lock`; + const mode = (await stat(indexPath)).mode; + // Git's canonical index.lock is acquired before revalidation and held + // through atomic rename; competing writers fail and existing locks are + // never removed. + lock = await open(lockPath, "wx", mode); + ownsLock = true; + tempPath = `${indexPath}.tmp-${process.pid}-${Date.now()}`; + await writeFile(tempPath, await readFile(indexPath), { + mode: mode & 0o777, + }); + const latest = await detectGitignoreConflicts(runner, { + GIT_OPTIONAL_LOCKS: "0", + }); + const paths = latest.findings + .filter((f) => f.eligible && consented.has(f.path)) + .map((f) => f.path); + if (!paths.length) return 0; + const removed = await runner( + [ + "rm", + "--cached", + "--pathspec-from-file=-", + "--pathspec-file-nul", + "--ignore-unmatch", + ], + `${paths.join("\0")}\0`, + { GIT_INDEX_FILE: tempPath }, + ); + if (removed.exitCode !== 0) + throw new AxiError(removed.stderr || "git command failed", "UNKNOWN"); + await lock.writeFile(await readFile(tempPath)); + await lock.sync(); + await lock.close(); + lock = undefined; + await rename(lockPath, indexPath); + committed = true; + return paths.length; + } catch { + return 0; + } finally { + await lock?.close().catch(() => undefined); + if (tempPath) await unlink(tempPath).catch(() => undefined); + if (tempPath) await unlink(`${tempPath}.lock`).catch(() => undefined); + if (ownsLock && !committed && lockPath) + await unlink(lockPath).catch(() => undefined); + } +} +export type Prompt = (message: string) => Promise<"yes" | "no" | "always">; +export const terminalPrompt: Prompt = async (message) => { + const rl = createInterface({ input: stdin, output: stdout }); + try { + const answer = (await rl.question(message)).trim().toLowerCase(); + return answer === "a" || answer === "always" + ? "always" + : answer === "y" || answer === "yes" + ? "yes" + : "no"; + } finally { + rl.close(); + } +}; +async function configuredAlways(runner: GitRunner): Promise { + const result = await runner([ + "config", + "--local", + "--type=bool", + "--get", + ALWAYS_FIX_KEY, + ]); + return result.exitCode === 0 && result.stdout.trim() === "true"; +} +export async function runGitignorePreflight( + options: { + policy?: PreflightPolicy; + runner?: GitRunner; + prompt?: Prompt; + interactive?: boolean; + } = {}, +): Promise { + const runner = options.runner ?? defaultRunner; + const report = await detectGitignoreConflicts(runner); + if (!report.gitAvailable || !report.findings.length) + return { ...report, action: "none" }; + if (!report.findings.some((f) => f.eligible)) + return { ...report, action: "manual" }; + const explicit = options.policy === "explicit-fix"; + const tty = + options.interactive ?? (isStdinTTY() && options.policy === "interactive"); + const configured = tty && !explicit && (await configuredAlways(runner)); + if (!explicit && !tty && !configured) + return { ...report, action: "reported" }; + const decision = + explicit || configured + ? "yes" + : await (options.prompt ?? terminalPrompt)( + report.findings + .filter((f) => f.eligible) + .map((f) => `${f.path} - ${f.source}:${f.line} (${f.rule})`) + .join("\n") + + "\nLocal files are preserved. Fix all? [y]es/[n]o/[a] Always fix: ", + ); + if (decision === "no") return { ...report, action: "declined" }; + if (decision === "always") + await successful(runner, ["config", "--local", ALWAYS_FIX_KEY, "true"]); + const repaired = await repairGitignoreConflicts(report.findings, runner); + return { ...report, action: repaired > 0 ? "fixed" : "manual" }; +} diff --git a/src/skill.ts b/src/skill.ts index ebbe842..7fe6242 100644 --- a/src/skill.ts +++ b/src/skill.ts @@ -4,9 +4,11 @@ import { DESCRIPTION } from "./cli.js"; // Kept terse and outcome-focused so it fires on "needs GitHub" intents. export const SKILL_DESCRIPTION = "Operate GitHub through the gh-axi CLI - issues, pull requests, stacked PRs, workflow runs, workflows, " + - "releases, repositories, labels, gists, Projects (v2), Actions secrets and variables, search, and raw API access. " + - "Use whenever a task touches GitHub: listing or filing issues, reviewing or merging PRs, managing stacked branches and PRs, " + - "checking CI runs, triggering workflows, cutting releases, managing Projects boards, managing Actions secrets/variables, or working with gists via `gist list`, `gist view`, `gist edit`, `gist rename`, `gist create`, `gist delete`, or `gist clone`."; + "releases, repositories, labels, gists, Projects (v2), Actions secrets and variables, search, raw API access, " + + "and repository Gitignore tracking hygiene. " + + "Use whenever a task touches GitHub or needs a safe preflight before publishing: listing or filing issues, reviewing or merging PRs, managing stacked branches and PRs, " + + "checking CI runs, triggering workflows, cutting releases, managing Projects boards, managing Actions secrets/variables, working with gists, " + + "or reviewing tracked files matched by repository `.gitignore` rules."; export const SKILL_AUTHOR = "Kun Chen (kunchenguid)"; @@ -58,7 +60,7 @@ metadata: ${DESCRIPTION} -Use gh-axi whenever a task touches GitHub: issues, pull requests, stacked PRs, CI, workflows, releases, repositories, labels, gists, Projects, Actions secrets and variables, search, or the GitHub API. +Use gh-axi whenever a task touches GitHub or needs repository Gitignore tracking hygiene before publication: issues, pull requests, stacked PRs, CI, workflows, releases, repositories, labels, gists, Projects, Actions secrets and variables, search, the GitHub API, or the shared push-preflight seam. ## Current guidance lives in the CLI diff --git a/test/cli.test.ts b/test/cli.test.ts index 9b17415..7c826c7 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -113,7 +113,7 @@ describe("main CLI", () => { }); it("documents the top-level version flags in help output", () => { - expect(TOP_HELP).toContain("flags[4]:"); + expect(TOP_HELP).toContain("flags[5]:"); expect(TOP_HELP).toContain("-R/--repo (after command)"); expect(TOP_HELP).toContain( "--hostname (after command) or GH_HOST env", @@ -414,6 +414,19 @@ describe("main CLI", () => { expect(vi.mocked(homeCommand)).toHaveBeenCalledWith([], ctx); }); + it("routes the bare dashboard hygiene flag through the home handler", async () => { + await main({ argv: ["--fix-ignore-conflicts"] }); + + const options = vi.mocked(runAxiCli).mock.calls[0]?.[0]; + await options.home([], undefined); + + expect(vi.mocked(runAxiCli).mock.calls[0]?.[0]).toMatchObject({ argv: [] }); + expect(vi.mocked(homeCommand)).toHaveBeenCalledWith( + ["--fix-ignore-conflicts"], + undefined, + ); + }); + it("strips -R before invoking command handlers", async () => { await main(); diff --git a/test/commands/home.test.ts b/test/commands/home.test.ts index 0b3e746..ce4d3e6 100644 --- a/test/commands/home.test.ts +++ b/test/commands/home.test.ts @@ -4,16 +4,29 @@ vi.mock("../../src/gh.js", () => ({ ghExec: vi.fn(), ghRaw: vi.fn(), })); +vi.mock("../../src/stdin.js", () => ({ isStdinTTY: vi.fn(() => true) })); +vi.mock("../../src/gitignore-hygiene.js", () => ({ + runGitignorePreflight: vi.fn(async () => ({ + findings: [], + gitAvailable: true, + action: "none", + })), +})); import { ghJson } from "../../src/gh.js"; import { homeCommand } from "../../src/commands/home.js"; import type { RepoContext } from "../../src/context.js"; +import { runGitignorePreflight } from "../../src/gitignore-hygiene.js"; +import { isStdinTTY } from "../../src/stdin.js"; const mockedGhJson = vi.mocked(ghJson); +const mockedHygiene = vi.mocked(runGitignorePreflight); +const mockedTTY = vi.mocked(isStdinTTY); describe("homeCommand", () => { beforeEach(() => { vi.resetAllMocks(); + mockedTTY.mockReturnValue(true); }); afterEach(() => { @@ -98,6 +111,40 @@ describe("homeCommand", () => { expect(result).toContain("issues"); expect(result).toContain("prs"); }); + it("runs interactive hygiene preflight for the local dashboard", async () => { + mockedGhJson.mockResolvedValue([]); + await homeCommand([]); + expect(mockedHygiene).toHaveBeenCalledWith({ policy: "interactive" }); + }); + + it("uses explicit hygiene policy when requested", async () => { + mockedGhJson.mockResolvedValue([]); + await homeCommand(["--fix-ignore-conflicts"]); + expect(mockedHygiene).toHaveBeenCalledWith({ policy: "explicit-fix" }); + }); + + it("skips hygiene preflight for remote repository context", async () => { + mockedGhJson.mockResolvedValue([]); + await homeCommand([], { + owner: "o", + name: "r", + nwo: "o/r", + source: "flag", + }); + expect(mockedHygiene).not.toHaveBeenCalled(); + }); + + it("renders hygiene action and local file preservation", async () => { + mockedGhJson.mockResolvedValue([]); + mockedHygiene.mockResolvedValue({ + findings: [{ path: "ignored", eligible: true }], + gitAvailable: true, + action: "reported", + } as never); + const result = await homeCommand([]); + expect(result).toContain("reported"); + expect(result).toContain("preserved"); + }); it("works without repo context", async () => { mockedGhJson.mockResolvedValue([]); diff --git a/test/commands/pr.test.ts b/test/commands/pr.test.ts index c7d6265..fe8aa47 100644 --- a/test/commands/pr.test.ts +++ b/test/commands/pr.test.ts @@ -17,7 +17,6 @@ import type { RepoContext } from "../../src/context.js"; const mockedGhJson = vi.mocked(ghJson); const mockedGhExec = vi.mocked(ghExec); const mockedGhRaw = vi.mocked(ghRaw); - const ctx: RepoContext = { owner: "octo", name: "repo", @@ -1152,4 +1151,41 @@ describe("prCommand", () => { expect(result).not.toContain("original_length"); }); }); + it("injects hygiene preflight for explicit create fixes", async () => { + mockedGhExec.mockResolvedValue("https://github.com/octo/repo/pull/9\n"); + const hygiene = vi.fn(async () => ({ + findings: [{ path: "ignored", eligible: true }], + gitAvailable: true, + action: "fixed" as const, + local_files: "preserved", + })); + const result = await prCommand( + ["create", "--title", "T", "--fix-ignore-conflicts"], + { ...ctx, source: "git" }, + hygiene, + ); + expect(hygiene).toHaveBeenCalledWith({ policy: "explicit-fix" }); + expect(mockedGhExec.mock.calls[0][0]).not.toContain( + "--fix-ignore-conflicts", + ); + expect(result).toContain("pull/9"); + }); + + it("creates a PR after declined hygiene preflight", async () => { + mockedGhExec.mockResolvedValue("https://github.com/octo/repo/pull/10\n"); + const hygiene = vi.fn(async () => ({ + findings: [{ path: "ignored", eligible: true }], + gitAvailable: true, + action: "declined" as const, + local_files: "preserved", + })); + const result = await prCommand( + ["create", "--title", "T"], + { ...ctx, source: "git" }, + hygiene, + ); + expect(mockedGhExec).toHaveBeenCalled(); + expect(result).toContain("pull/10"); + expect(result).toContain("preserved"); + }); }); diff --git a/test/gitignore-hygiene.test.ts b/test/gitignore-hygiene.test.ts new file mode 100644 index 0000000..873d69a --- /dev/null +++ b/test/gitignore-hygiene.test.ts @@ -0,0 +1,391 @@ +import { execFile } from "node:child_process"; +import { + mkdir, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import { + detectGitignoreConflicts, + repairGitignoreConflicts, + runGitignorePreflight, + type GitResult, + type GitRunner, +} from "../src/gitignore-hygiene.js"; + +const repos: string[] = []; + +async function git( + repo: string, + args: string[], + input?: string, + env?: NodeJS.ProcessEnv, +): Promise { + return new Promise((resolve) => { + const child = execFile( + "git", + args, + { + cwd: repo, + env: { ...process.env, ...env }, + maxBuffer: 20 * 1024 * 1024, + }, + (error, stdout, stderr) => { + const result = error as { code?: number } | null; + resolve({ + stdout: stdout ?? "", + stderr: stderr ?? "", + exitCode: result ? Number(result.code) || 1 : 0, + }); + }, + ); + if (input !== undefined) child.stdin?.end(input); + }); +} + +async function repo(): Promise { + const path = await mkdtemp(join(tmpdir(), "gh-axi-hygiene-")); + repos.push(path); + expect((await git(path, ["init", "-q"])).exitCode).toBe(0); + expect( + (await git(path, ["config", "user.email", "test@example.com"])).exitCode, + ).toBe(0); + expect((await git(path, ["config", "user.name", "Test"])).exitCode).toBe(0); + return path; +} + +async function commit( + repoPath: string, + files: Record, +): Promise { + for (const [path, content] of Object.entries(files)) { + await mkdir(join(repoPath, path, ".."), { recursive: true }); + await writeFile(join(repoPath, path), content); + } + if (".gitignore" in files) + expect((await git(repoPath, ["add", ".gitignore"])).exitCode).toBe(0); + const trackedPaths = Object.keys(files).filter( + (path) => path !== ".gitignore", + ); + if (trackedPaths.length > 0) + expect( + (await git(repoPath, ["add", "-f", "--", ...trackedPaths])).exitCode, + ).toBe(0); + expect((await git(repoPath, ["commit", "-qm", "initial"])).exitCode).toBe(0); +} + +async function detected(repoPath: string) { + return detectGitignoreConflicts((args, input, env) => + git(repoPath, args, input, env), + ); +} + +afterEach(async () => { + await Promise.all( + repos.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); + vi.restoreAllMocks(); +}); +describe("gitignore hygiene", () => { + it("reports a modified tracked ignored file while preserving worktree and index", async () => { + const r = await repo(); + await commit(r, { ".gitignore": "ignored.txt\n", "ignored.txt": "one" }); + await writeFile(join(r, "ignored.txt"), "two"); + const report = await detected(r); + expect(report.findings).toEqual([ + expect.objectContaining({ + path: "ignored.txt", + workingTree: "modified", + index: "clean", + eligible: true, + }), + ]); + }); + + it("explicitly fixes by removing only the index entry and preserves exact bytes", async () => { + const r = await repo(); + const bytes = "zero\0one\n two"; + await commit(r, { ".gitignore": "ignored\n", ignored: bytes }); + const result = await runGitignorePreflight({ + policy: "explicit-fix", + runner: (a, i, e) => git(r, a, i, e), + }); + expect(result.action).toBe("fixed"); + expect(await readFile(join(r, "ignored"), "utf8")).toBe(bytes); + expect( + (await git(r, ["ls-files", "--error-unmatch", "ignored"])).exitCode, + ).not.toBe(0); + }); + + it("handles nested rules and spaces", async () => { + const r = await repo(); + await commit(r, { + ".gitignore": "other\n", + "nested/.gitignore": "file with spaces\n", + "nested/file with spaces": "x", + }); + const report = await detected(r); + expect(report.findings.map((f) => f.path)).toContain( + "nested/file with spaces", + ); + }); + + it("handles a pathname containing a newline", async () => { + const r = await repo(); + const path = "line\nbreak.txt"; + await commit(r, { ".gitignore": "*.txt\n", [path]: "x" }); + expect((await detected(r)).findings.map((f) => f.path)).toContain(path); + }); + + it("does not repair staged-different files", async () => { + const r = await repo(); + await commit(r, { ".gitignore": "ignored\n", ignored: "one" }); + await writeFile(join(r, "ignored"), "two"); + await git(r, ["add", "ignored"]); + const report = await detected(r); + const finding = report.findings.find((f) => f.path === "ignored")!; + expect(finding).toMatchObject({ + eligible: false, + classification: "manual", + reason: expect.stringContaining("staged"), + }); + await repairGitignoreConflicts([finding], (a, i, e) => git(r, a, i, e)); + expect( + (await git(r, ["ls-files", "--error-unmatch", "ignored"])).exitCode, + ).toBe(0); + }); + + it("revalidates findings before repair when a file becomes staged", async () => { + const r = await repo(); + await commit(r, { ".gitignore": "ignored\n", ignored: "one" }); + const findings = (await detected(r)).findings; + await writeFile(join(r, "ignored"), "two"); + await git(r, ["add", "ignored"]); + + await repairGitignoreConflicts(findings, (a, i, e) => git(r, a, i, e)); + + expect( + (await git(r, ["ls-files", "--error-unmatch", "ignored"])).exitCode, + ).toBe(0); + }); + + it("prompts once and declines with evidence", async () => { + const r = await repo(); + await commit(r, { ".gitignore": "ignored\n", ignored: "x" }); + const prompt = vi.fn(async (message: string) => { + expect(message).toContain("ignored"); + expect(message).toContain(".gitignore:1"); + expect(message).toContain("ignored"); + expect(message).toContain("Local files are preserved"); + return "no" as const; + }); + const result = await runGitignorePreflight({ + policy: "interactive", + interactive: true, + prompt, + runner: (a, i, e) => git(r, a, i, e), + }); + expect(result.action).toBe("declined"); + expect(prompt).toHaveBeenCalledTimes(1); + }); + + it("prompts once and fixes on yes", async () => { + const r = await repo(); + await commit(r, { ".gitignore": "ignored\n", ignored: "x" }); + const prompt = vi.fn(async () => "yes" as const); + const result = await runGitignorePreflight({ + policy: "interactive", + interactive: true, + prompt, + runner: (a, i, e) => git(r, a, i, e), + }); + expect(result.action).toBe("fixed"); + expect(prompt).toHaveBeenCalledTimes(1); + }); + + it("persists Always and fixes future interactive runs automatically", async () => { + const r = await repo(); + await commit(r, { ".gitignore": "ignored\n", ignored: "x" }); + const prompt = vi.fn(async () => "always" as const); + await runGitignorePreflight({ + policy: "interactive", + interactive: true, + prompt, + runner: (a, i, e) => git(r, a, i, e), + }); + await git(r, ["add", ".gitignore"]); + await writeFile(join(r, "ignored"), "y"); + await git(r, ["add", "-f", "--", "ignored"]); + await git(r, ["commit", "-qm", "restore ignored"]); + const second = await runGitignorePreflight({ + policy: "interactive", + interactive: true, + prompt, + runner: (a, i, e) => git(r, a, i, e), + }); + expect(second.action).toBe("fixed"); + expect(prompt).toHaveBeenCalledTimes(1); + }); + + it("honors Git's other true values for the local Always preference", async () => { + const r = await repo(); + await commit(r, { ".gitignore": "ignored\n", ignored: "x" }); + await git(r, ["config", "--local", "gh-axi.ignoreConflicts", "yes"]); + const prompt = vi.fn(async () => "no" as const); + const result = await runGitignorePreflight({ + policy: "interactive", + interactive: true, + prompt, + runner: (a, i, e) => git(r, a, i, e), + }); + expect(result.action).toBe("fixed"); + expect(prompt).not.toHaveBeenCalled(); + }); + + it("keeps saved Always disabled for report/noninteractive policy", async () => { + const r = await repo(); + await commit(r, { ".gitignore": "ignored\n", ignored: "x" }); + await git(r, ["config", "--local", "gh-axi.ignoreConflicts", "true"]); + const result = await runGitignorePreflight({ + policy: "report", + runner: (a, i, e) => git(r, a, i, e), + }); + expect(result.action).toBe("reported"); + expect( + (await git(r, ["ls-files", "--error-unmatch", "ignored"])).exitCode, + ).toBe(0); + }); + + it("returns manual and performs no mutation when a safety probe fails", async () => { + const r = await repo(); + await commit(r, { ".gitignore": "ignored\n", ignored: "x" }); + const baseRunner = (a: string[], i?: string, e?: NodeJS.ProcessEnv) => + git(r, a, i, e); + const runner: GitRunner = async (args, input, env) => + args[0] === "diff" + ? { stdout: "", stderr: "probe failed", exitCode: 1 } + : baseRunner(args, input, env); + const result = await runGitignorePreflight({ + policy: "explicit-fix", + runner, + }); + expect(result.action).toBe("manual"); + expect( + (await git(r, ["ls-files", "--error-unmatch", "ignored"])).exitCode, + ).toBe(0); + }); + + it("keeps all state when the index changes before batch repair", async () => { + const r = await repo(); + await commit(r, { ".gitignore": "a\nb\n", a: "a", b: "b" }); + const findings = (await detected(r)).findings; + await writeFile(join(r, "a"), "changed"); + expect((await git(r, ["add", "-f", "--", "a"])).exitCode).toBe(0); + await writeFile(join(r, ".git", "index.lock"), "locked"); + expect( + await repairGitignoreConflicts(findings, (a, i, e) => git(r, a, i, e)), + ).toBe(0); + expect((await git(r, ["ls-files", "--error-unmatch", "a"])).exitCode).toBe( + 0, + ); + expect((await git(r, ["ls-files", "--error-unmatch", "b"])).exitCode).toBe( + 0, + ); + expect(await readFile(join(r, "a"), "utf8")).toBe("changed"); + expect(await readFile(join(r, "b"), "utf8")).toBe("b"); + }); + + it("reports clean repositories without attempting mutation", async () => { + const r = await repo(); + await commit(r, { "tracked.txt": "x" }); + const runner = vi.fn((a: string[], i?: string, e?: NodeJS.ProcessEnv) => + git(r, a, i, e), + ); + const result = await runGitignorePreflight({ + policy: "explicit-fix", + runner, + }); + expect(result).toMatchObject({ action: "none", findings: [] }); + expect(runner).not.toHaveBeenCalledWith( + expect.arrayContaining(["rm"]), + expect.anything(), + ); + }); + it("blocks on an external lock without changing its bytes", async () => { + const r = await repo(); + await commit(r, { ".gitignore": "ignored\n", ignored: "x" }); + const lock = join(r, ".git", "index.lock"); + await writeFile(lock, "external"); + const result = await repairGitignoreConflicts( + (await detected(r)).findings, + (a, i, e) => git(r, a, i, e), + ); + expect(result).toBe(0); + expect(await readFile(lock, "utf8")).toBe("external"); + }); + + it("cleans owned lock and temp files when no paths remain", async () => { + const r = await repo(); + await commit(r, { ".gitignore": "ignored\n", ignored: "x" }); + const findings = (await detected(r)).findings.map((f) => ({ + ...f, + eligible: false, + })); + expect( + await repairGitignoreConflicts(findings, (a, i, e) => git(r, a, i, e)), + ).toBe(0); + expect( + (await git(r, ["ls-files", "--error-unmatch", "ignored"])).exitCode, + ).toBe(0); + expect( + (await readdir(join(r, ".git"))).filter( + (p) => p === "index.lock" || p.startsWith("index.tmp-"), + ), + ).toEqual([]); + }); + + it("cleans temporary state after temp-index rm failure", async () => { + const r = await repo(); + await commit(r, { ".gitignore": "ignored\n", ignored: "x" }); + const findings = (await detected(r)).findings; + const runner: GitRunner = async (args, input, env) => + args[0] === "rm" + ? { stdout: "", stderr: "injected failure", exitCode: 1 } + : git(r, args, input, env); + expect(await repairGitignoreConflicts(findings, runner)).toBe(0); + expect( + (await git(r, ["ls-files", "--error-unmatch", "ignored"])).exitCode, + ).toBe(0); + expect(await readFile(join(r, "ignored"), "utf8")).toBe("x"); + expect( + (await readdir(join(r, ".git"))).filter( + (p) => p === "index.lock" || p.startsWith("index.tmp-"), + ), + ).toEqual([]); + }); + it("blocks competing real git add while atomically repairing", async () => { + const r = await repo(); + await commit(r, { ".gitignore": "ignored\n", ignored: "before" }); + await writeFile(join(r, "ignored"), "modified bytes"); + const findings = (await detected(r)).findings; + let competingExit = -1; + const runner: GitRunner = async (args, input, env) => { + if (args[0] === "rm" && env?.GIT_INDEX_FILE) { + competingExit = (await git(r, ["add", "-f", "--", "ignored"])).exitCode; + } + return git(r, args, input, env); + }; + expect(await repairGitignoreConflicts(findings, runner)).toBe(1); + expect(competingExit).not.toBe(0); + expect( + (await git(r, ["ls-files", "--error-unmatch", "ignored"])).exitCode, + ).not.toBe(0); + expect(await readFile(join(r, "ignored"), "utf8")).toBe("modified bytes"); + }); +}); diff --git a/test/help-examples.test.ts b/test/help-examples.test.ts index fc48496..d16218e 100644 --- a/test/help-examples.test.ts +++ b/test/help-examples.test.ts @@ -12,7 +12,7 @@ import { VARIABLE_HELP } from "../src/commands/variable.js"; import { SEARCH_HELP } from "../src/commands/search.js"; import { API_HELP } from "../src/commands/api.js"; import { GIST_HELP } from "../src/commands/gist.js"; -import { TOP_HELP } from "../src/cli.js"; +import { main, TOP_HELP } from "../src/cli.js"; /** * Every HELP constant must contain an "examples:" section with at least 2 @@ -69,6 +69,19 @@ describe("--body-file discoverability", () => { }); }); +describe("dashboard hygiene discoverability", () => { + it("documents the supported explicit dashboard hygiene flag", async () => { + const chunks: string[] = []; + await main({ + argv: ["--help"], + stdout: { write: (chunk: string) => chunks.push(chunk) }, + }); + const output = chunks.join(""); + expect(output).toContain("gh-axi --fix-ignore-conflicts"); + expect(output).not.toContain("gh-axi hygiene"); + }); +}); + describe("GIST_HELP subcommands", () => { // Pin the subcommand count and names so a change that adds/removes gist // subcommands turns this into a visible test failure rather than a silent