ajhahn.de
← eeco commits

Commit

eeco

eeco v0.1.0 — provider-agnostic AI-cockpit generator

First public release of eeco as a provider-agnostic AI-cockpit generator: a single, local-first binary that generates and maintains the harness config a coding assistant needs to run AI well, plus a deterministic, no-AI-spend knowledge layer any assistant can read. Apache-2.0. See CHANGELOG.md.

ajhahnde · Jun 2026 · 2b22a650a2c853e1378e2348e2ce5d7b02c419cb · view on GitHub →

added .gitattributes
@@ -0,0 +1 @@
*.golden text eol=lf
added .github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,47 @@
---
name: Bug report
about: Report a defect in eeco
title: ""
labels: bug
assignees: ""
---
### Environment
- `eeco version` output:
- OS and architecture:
- Workspace state (output of `eeco doctor`, fenced):
### What happened
A clear description of the observed behaviour.
### What was expected
A clear description of the expected behaviour.
### Reproduction
Numbered steps starting from a fresh state (for example a scratch
`git init`):
1.
2.
3.
### Exit code
The exit code returned by the failing command, plus the exit-code
contract value it should have been (`0` clean, `1` finding/failure,
`2` blocked, `3` AI pass deferred).
### Logs and output
Paste relevant terminal output in a fenced block. Redact tokens,
file paths, and any project-private content.
### Additional context
Anything else that helps localise the defect (recent change to
`config.local`, a workspace with a non-default `automation` level,
a non-standard hook setup, etc.).
added .github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Security vulnerability
url: https://github.com/ajhahnde/eeco/security/advisories/new
about: Report a security issue privately. Do not file a public issue.
- name: Usage question
url: https://github.com/ajhahnde/eeco/discussions
about: Ask a how-to question or share a workflow idea.
added .github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,43 @@
---
name: Feature request
about: Propose a new behaviour, command, or workflow
title: ""
labels: enhancement
assignees: ""
---
### Problem
A clear description of the friction the proposed feature would
relieve. Concrete examples are stronger than abstract requirements.
### Proposed behaviour
What eeco would do after the change. If the proposal touches the
[public surface](../docs/ARCHITECTURE.md#public-surface) — commands,
flags, exit codes, config keys, queue or memory formats, builtin
workflow names — call that out explicitly.
### Constraint checklist
Confirm the proposal respects the non-negotiable constraints (mark
the box if respected, leave blank with a note if not):
- [ ] **Write-scope** — writes only inside the workspace.
- [ ] **No auto-commit / no auto-push** — does not invoke `git
commit` or `git push` on the user's behalf.
- [ ] **Brand-free** — no third-party product names in user-facing
output or docs.
- [ ] **Budget-capped AI** — any AI call routes through the existing
Gate (consent + budget + park-on-skip).
- [ ] **Queue-mediated** — decision-bearing output goes through the
queue, not directly to the tracked tree.
### Alternatives considered
Other approaches you weighed and why they were not chosen.
### Additional context
Workflow notes, a sketch of the UX, a draft of the CLI shape — any
detail that grounds the proposal.
added .github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,34 @@
### Summary
What changes and why. One paragraph is enough.
### Linked issue
Closes #
### Verification
- [ ] `make verify` is green locally.
- [ ] `make gates` exits 0 (`comment-hygiene` and `leak-guard`).
- [ ] New files are intent-added before running `leak-guard`
(`git add -N <path>`).
- [ ] `make bench` is under the 5-second budget (only if the change
touches scanner hot paths).
### Constraints checklist
- [ ] **Write-scope.** No new write target outside the workspace.
- [ ] **No tracked config.** No new file under the repo root that
eeco writes at runtime.
- [ ] **No AI attribution.** No AI-tool fingerprint or
`Co-Authored-By` trailer in any commit, file, or PR body.
- [ ] **Brand-free.** No personal or third-party product names in
shipped output, docs, or copy.
- [ ] **No silent breaking changes.** If the change touches the
[public surface](../docs/ARCHITECTURE.md#public-surface),
the PR body calls it out and the release notes will too.
### Notes for the reviewer
Anything that does not fit above: tricky edge case, deferred
follow-up, rationale for a non-obvious choice.
added .github/workflows/ci.yml
@@ -0,0 +1,91 @@
# CI: build, vet, test, eeco's own attribution + workspace-path gates,
# and the golangci-lint quality bar. Runs on every PR and on push to
# main. Read-only token by design.
name: ci
on:
pull_request:
push:
branches: [main]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
verify:
name: verify + gates (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
defaults:
run:
# Force Git Bash on windows-latest so the Makefile recipes and
# the smoke step share one shell with the linux job.
shell: bash
steps:
- name: checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: setup-go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: verify
run: make verify
- name: coverage
if: matrix.os == 'ubuntu-latest'
run: make cover-check
- name: codecov
if: matrix.os == 'ubuntu-latest'
uses: codecov/codecov-action@v5
with:
files: coverage.out
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: gates
run: make gates
- name: check version badge
if: matrix.os == 'ubuntu-latest'
run: bash scripts/check-version-badge.sh
- name: windows-smoke
if: matrix.os == 'windows-latest'
run: |
set -eu
mkdir -p tmp-smoke
cd tmp-smoke
git init -q
git config user.email smoke@local
git config user.name smoke
../eeco.exe init
../eeco.exe run leak-guard
../eeco.exe hooks pre-commit on
../eeco.exe hooks pre-commit off
../eeco.exe doctor
lint:
name: lint
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v5
- name: setup-go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
# golangci-lint v2.12.2 needs Go >= 1.25 to build; eeco's go.mod
# pins an older version and actions/setup-go exports
# GOTOOLCHAIN=local. Set GOTOOLCHAIN=auto inline so `go run`
# fetches the newer toolchain only to build the linter — eeco's
# own build, in the verify job, stays on the go.mod version.
- name: lint
run: GOTOOLCHAIN=auto make lint
added .github/workflows/release.yml
@@ -0,0 +1,130 @@
# Release: on `v*` tag push, cross-build the six-platform matrix,
# generate the package-manager manifests, sign the checksums (keyless),
# attest build provenance, and publish everything to the GitHub Release
# for that tag. Operator tags + pushes; CI never tags or pushes on its
# own. Keyless signing uses the workflow's OIDC identity — no secrets.
name: release
on:
push:
tags: ['v*']
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
# attest-build-provenance@v2 still bundles Node-20 sub-actions; opt them
# into Node-24 now (forced runner default from 2026-06-02 regardless).
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
permissions:
contents: write # create the release + upload assets
id-token: write # keyless cosign + provenance OIDC
attestations: write # build-provenance attestation
jobs:
publish:
name: build + sign + publish
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: setup-go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- name: derive build metadata
id: meta
run: |
TAG="${GITHUB_REF_NAME}"
BUILD_DATE="$(git log -1 --format=%cI "refs/tags/${TAG}")"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "build_date=${BUILD_DATE}" >> "$GITHUB_OUTPUT"
case "${TAG}" in
*-*) echo "prerelease=true" >> "$GITHUB_OUTPUT" ;;
*) echo "prerelease=false" >> "$GITHUB_OUTPUT" ;;
esac
- name: cross-build matrix
env:
VERSION: ${{ steps.meta.outputs.tag }}
BUILD_DATE: ${{ steps.meta.outputs.build_date }}
run: make release
- name: generate package manifests
env:
VERSION: ${{ steps.meta.outputs.tag }}
run: make packaging
- name: install cosign
uses: sigstore/cosign-installer@v3
- name: sign checksums (keyless)
run: |
cosign sign-blob --yes \
--output-signature dist/SHA256SUMS.sig \
--output-certificate dist/SHA256SUMS.pem \
dist/SHA256SUMS
- name: attest build provenance
uses: actions/attest-build-provenance@v2
with:
subject-path: 'dist/eeco_*.tar.gz, dist/eeco_*.zip'
- name: write release notes
run: |
cat > release-notes.md <<EOF
eeco ${{ steps.meta.outputs.tag }}
Pre-built binaries for darwin/linux/windows (amd64 + arm64),
SHA256SUMS, a keyless cosign signature, and the package
manifests (eeco.rb, eeco.json).
Verify the checksums signature (no key needed):
cosign verify-blob \\
--certificate dist/SHA256SUMS.pem \\
--certificate-identity-regexp \\
'^https://github.com/ajhahnde/eeco/\.github/workflows/release\.yml@refs/tags/v' \\
--certificate-oidc-issuer https://token.actions.githubusercontent.com \\
--signature dist/SHA256SUMS.sig \\
SHA256SUMS
Build provenance is attested; verify a downloaded archive with
'gh attestation verify <archive> --repo ajhahnde/eeco'.
Install routes and checksum verification: docs/USAGE.md §1.
EOF
- name: publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ steps.meta.outputs.tag }}"
ASSETS="dist/eeco_*.tar.gz dist/eeco_*.zip dist/SHA256SUMS \
dist/SHA256SUMS.sig dist/SHA256SUMS.pem dist/eeco.rb dist/eeco.json \
dist/eeco.1"
PRE_FLAG=""
if [ "${{ steps.meta.outputs.prerelease }}" = "true" ]; then
PRE_FLAG="--prerelease"
fi
if gh release view "${TAG}" >/dev/null 2>&1; then
gh release upload "${TAG}" ${ASSETS} --clobber
else
gh release create "${TAG}" ${ASSETS} \
--title "${TAG}" \
--notes-file release-notes.md \
${PRE_FLAG}
fi
- name: sync package-manager taps
# Stable releases only; taps never carry a prerelease. Skips
# cleanly when no PAT is configured, so the release still
# succeeds before the tap repos / token exist (Phase 1 setup).
if: steps.meta.outputs.prerelease == 'false'
env:
VERSION: ${{ steps.meta.outputs.tag }}
TAP_PUSH_TOKEN: ${{ secrets.TAP_PUSH_TOKEN }}
run: |
if [ -z "${TAP_PUSH_TOKEN}" ]; then
echo "TAP_PUSH_TOKEN unset; skipping tap sync" >&2
exit 0
fi
./scripts/sync-taps.sh
added .gitignore
@@ -0,0 +1,24 @@
# Build output
/dist/
*.exe
/eeco
# Go test/coverage
*.test
*.out
coverage.*
# Editor / OS
.DS_Store
.idea/
.vscode/
# Local-only idea mailbox + session-start hook (personal workflow)
.claude/hooks/
# Operator-personal continuation notes
ajhahnde/
# Local-only config
*.local
config.local.*
added .golangci.yml
@@ -0,0 +1,40 @@
# golangci-lint configuration for the eeco repository.
#
# Slice 1 of the CI quality-bar effort: errcheck, gosec, govet,
# staticcheck, and unused on top of the `go vet` minimum already run by
# `make verify`. Run locally with `make lint`; CI gates on the same
# target. The golangci-lint version is pinned in the Makefile
# (GOLANGCI_LINT_VERSION) so local and CI stay byte-identical.
version: "2"
linters:
default: none
enable:
- errcheck
- gosec
- govet
- staticcheck
- unused
settings:
gosec:
# eeco is a local, single-operator CLI. The rules below assume a
# network-facing service with untrusted input; they misfire on
# eeco's deliberate design and are excluded with that rationale.
excludes:
- G101 # "hardcoded credentials" — eeco's only matches are env-var NAMES (e.g. the ANTHROPIC_API_KEY default for ai_api_key_env); the secret value is read from the environment at call time, never stored in source or config
- G104 # unhandled errors — errcheck already owns error-checking; G104 is a redundant blanket re-report
- G122 # symlink TOCTOU in a WalkDir callback — eeco scans the operator's own checkout, not an untrusted tree
- G204 # subprocess with variable args — running git/cosign/gh/pagers/workflows is the product
- G301 # 0o755 workspace directories — deliberate, matches the git-tree convention, operator-readable
- G302 # 0o644 workspace files via OpenFile — deliberate, same rationale as G301
- G304 # file access via a computed path — eeco reads and writes its own workspace by path
- G306 # 0o644 WriteFile — deliberate, same rationale as G302
- G404 # weak RNG (math/rand) — the only use picks a cosmetic home-screen tip at random; tip selection is not security-sensitive and needs no crypto/rand
- G702 # command-injection taint analysis — the taint-analysis sibling of G204; eeco's subprocess args (git/cosign/gh/…) run with explicit argv and no shell, so an operator-supplied value reaching runGit as a commit message or git flag can never inject a command
- G703 # path-traversal taint analysis — same family as G304, same rationale
exclusions:
presets:
# Standard unchecked-error idioms: writes to stdout/stderr, Close,
# Flush, os.Remove — the error is unactionable at the call site.
- std-error-handling
added CHANGELOG.md
@@ -0,0 +1,133 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/eeco_logo_dark.png">
<img src="assets/eeco_logo_light.png" alt="eeco" width="280">
</picture>
<h1>Changelog</h1>
<p>
<a href="README.md"><b>README</b></a> ·
<a href="VISION.md"><b>Vision</b></a> ·
<a href="docs/COCKPIT.md"><b>Cockpit</b></a> ·
<a href="docs/USAGE.md"><b>Usage</b></a> ·
<a href="docs/ARCHITECTURE.md"><b>Architecture</b></a> ·
<a href="docs/PUBLIC_API.md"><b>Public API</b></a> ·
<a href="EXTENDING.md"><b>Extending</b></a> ·
<a href="CONTRIBUTING.md"><b>Contributing</b></a> ·
<a href="docs/UPGRADING.md"><b>Upgrading</b></a> ·
<a href="VERSIONING.md"><b>Versioning</b></a> ·
<b>Changelog</b> ·
<a href="SECURITY.md"><b>Security</b></a>
</p>
</div>
---
All notable changes to eeco are documented in this file.
The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). From v0.1.0
eeco follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
over the surface in [`docs/PUBLIC_API.md`](docs/PUBLIC_API.md), under the
pre-stability caveat of the [versioning policy](VERSIONING.md) §2.1.
## [v0.1.0] - 2026-06-06
First public release of eeco as a **provider-agnostic AI-cockpit
generator**: a single, local-first binary that generates and maintains
the config a coding assistant's harness needs to run AI well, plus a
deterministic, no-AI-spend knowledge layer any assistant can read.
eeco does not run AI as a product capability — it authors and maintains
the cockpit the harness runs. The AI lives in the harness eeco
configures; eeco is its author and mechanic. eeco is pre-stability on the
`v0.x` line (see [`VERSIONING.md`](VERSIONING.md) §2.1).
### Added
- **The cockpit generator (`eeco cockpit`) — the headline capability.**
eeco renders a neutral, reviewable playbook library
(`internal/playbooks`) into harness-specific AI config, reversibly and
behind a machine-checked safety invariant. `eeco cockpit generate |
verify | off | status | show` emit and manage the artifacts; `eeco
cockpit target list | add | rm` choose the harness targets (recorded in
`<username>/.eeco/cockpit.json`). Four targets ship: **Claude**
`SKILL.md` (enforced via `allowed-tools`) and the advisory **Cursor**
`.cursor/rules/*.mdc`, **`AGENTS.md`**, and **`GEMINI.md`** (each
carrying a loud `ADVISORY ONLY` banner and an honest fidelity line). The
**safety invariant is uniform across every target**: an emitted artifact
can never grant a write-capable git verb a playbook declares forbidden,
and generation *refuses* rather than silently drop one. Every emit is
reversible (a pre-existing foreign file is backed up and restored on
`off`), sha-gated (a hand-edited artifact is left untouched), and
byte-idempotent. The `cockpit-sync` workflow detects drift (and, at
`automation=auto`, regenerates in place); `eeco cockpit machinery on`
installs the auto-firing deterministic machinery into
`<username>/.claude/settings.json` — a PreToolUse git-write guard
(paired with `eeco authorize commit | tag`), a SessionStart orient/drift
brief, a Stop handover nudge, and a PostToolUse contract-watch — as one
reversible, ledgered unit. See [`docs/COCKPIT.md`](docs/COCKPIT.md).
- **Workspace model.** `eeco init` scaffolds a per-user workspace at
`<repo>/<username>/.eeco/`, detects the project type through a
four-layer pipeline (marker-file scan, conventional-dir scan,
interactive prompt, then an opt-in AI fallback constrained by a bundled
canonical-layouts catalog), and scaffolds project-type-aware knowledge
directories. `init` is the single verb permitted one initial commit and
one initial push, both restricted to the `.gitignore` line that scopes
the workspace out of the tracked tree. Flags: `--type`, `--ai`,
`--username`, `--no-commit`, `--no-push`, `--no-track`.
- **Knowledge layer for AI assistants.** `eeco go` prints a deterministic,
no-AI-spend project brief (with `--json`, `--brief`, `--write`,
`--copy`, and `--metrics`); `eeco ask "<question>"` answers a free-form
question with ranked `file:line` pointers; `eeco add fact` / `eeco add
task` / `eeco add note` write durable memory, queue items, and notes
back; `eeco stats` aggregates the AI-call ledger. The layer is
provider-agnostic — any assistant that can read a file or a clipboard is
briefable.
- **Workflow ecosystem.** Ten builtin workflows — `comment-hygiene`,
`leak-guard`, `version-sync`, `gate`, `bug-sweep`, `handover-refresh`,
`evolve`, `memory-drift`, `doc-drift`, `manifest-refresh` — plus the
pre-1.0 `cockpit-sync` machinery, `eeco new` to scaffold a project
workflow, and `eeco workflows <name> on|off` to toggle one. Findings
land in one Markdown decision queue, never a silent edit.
- **Git integration.** Opt-in, ledgered, byte-for-byte removable hooks —
`pre-commit`, `post-merge`, `session-start`, and `commit-msg` — plus the
cockpit's PreToolUse machinery. The session-start hook briefs an AI
assistant from project state.
- **Manifests and prompts.** `eeco refresh-manifest [<dir>]` regenerates
per-directory `.ai.json` manifests; `eeco show prompt [<name>]` lists or
prints the versioned prompt library.
- **Migration.** `eeco migrate v1 [--yes]` moves a legacy `<repo>/.eeco`
workspace under `<repo>/<username>/.eeco` idempotently — the `v1` names
the workspace-layout generation, not the product version (see
[`docs/UPGRADING.md`](docs/UPGRADING.md)).
- **Private workspace history.** An opt-out, local-only git repository
inside the gitignored workspace versions the knowledge layer over time
(`workspace_history`: `manual` default / `auto` / `off`); `eeco history`
shows, snapshots, and compacts it. It has no remote and never touches
the tracked tree.
- **The AI safety floor.** The one provider path that remains is a
brand-free CLI provider for eeco's own optional chores; `ai_provider` is
`{cli, none}`. Every AI pass is consent-gated, budget-capped, recorded
to `state/ai-calls.json`, and scanned for AI-attribution before anything
is written.
- **Supporting surface.** `eeco gc`, `eeco doctor`, `eeco docs
new|refresh|compact`, `eeco guide`, `eeco show adaptations`, `eeco
adaptations <name> on|off`, `eeco report-bug [--submit]`, `eeco
uninstall`, `eeco update` (cosign-signature + sha256 +
build-provenance verified), and the Bubble Tea control-center TUI.
- **Frozen public surface (pre-1.0).** The CLI commands and flags, config
keys, workflow contract, memory frontmatter, queue and ledger formats,
and builtin workflow names enumerated in
[`docs/PUBLIC_API.md`](docs/PUBLIC_API.md) are eeco's tracked surface. A
`v0.x` MINOR may change it with a migration note ([`VERSIONING.md`](VERSIONING.md)
§2.1); the cockpit surface is documented but not yet frozen
([`docs/COCKPIT.md`](docs/COCKPIT.md)).
[v0.1.0]: https://github.com/ajhahnde/eeco/releases/tag/v0.1.0
---
[← Prev: Versioning](VERSIONING.md) · [Next: Security →](SECURITY.md)
added CONTRIBUTING.md
@@ -0,0 +1,111 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/eeco_logo_dark.png">
<img src="assets/eeco_logo_light.png" alt="eeco" width="280">
</picture>
<h1>Contributing</h1>
<p>
<a href="README.md"><b>README</b></a> ·
<a href="VISION.md"><b>Vision</b></a> ·
<a href="docs/COCKPIT.md"><b>Cockpit</b></a> ·
<a href="docs/USAGE.md"><b>Usage</b></a> ·
<a href="docs/ARCHITECTURE.md"><b>Architecture</b></a> ·
<a href="docs/PUBLIC_API.md"><b>Public API</b></a> ·
<a href="EXTENDING.md"><b>Extending</b></a> ·
<b>Contributing</b> ·
<a href="docs/UPGRADING.md"><b>Upgrading</b></a> ·
<a href="VERSIONING.md"><b>Versioning</b></a> ·
<a href="CHANGELOG.md"><b>Changelog</b></a> ·
<a href="SECURITY.md"><b>Security</b></a>
</p>
</div>
---
## Contributing to eeco
eeco is a solo project with a deliberately small contribution surface. This is a
lightweight contract — how to build, which checks must pass, and the rules a
change holds to — not community-health boilerplate. For how to *add* something —
a workflow, a config key, a verb — see [EXTENDING.md](EXTENDING.md).
## Build and checks
eeco builds with the Go toolchain through a `Makefile`:
| Command | What it does |
| -------------------- | ---------------------------------------------------------------------------- |
| `make build` | Build the single `eeco` binary. |
| `make verify` | `go build ./...` + `go vet ./...` + `go test ./...`. |
| `make gates` | Run the `comment-hygiene`, `leak-guard`, and `version-sync` workflows. |
| `make lint` | `golangci-lint` — must report **0 issues**. |
| `make cover-check` | Fail if total statement coverage drops below the ratchet floor. |
| `make bench` | Scanner benchmarks; keep them under the five-second budget. |
| `make sync-guide` | Mirror `docs/USAGE.md` into the in-binary guide after editing it. |
## Definition of Done
A change is done when **all** of these hold:
- **`make verify` green** — build, vet, and tests pass.
- **`make gates` green** — `comment-hygiene`, `leak-guard`, `version-sync`.
- **`make lint` green** — `golangci-lint`, 0 issues.
- **`make cover-check` green** — total statement coverage does not regress.
- **Additive over the public surface** — no change to an item frozen in
[`docs/PUBLIC_API.md`](docs/PUBLIC_API.md) unless a semver bump is explicitly
declared; when one is, the README version badge is bumped **before** the tag.
- **No AI attribution** — no AI-tool fingerprint or `Co-Authored-By` trailer in
any commit, tag, file, or PR body.
- **CHANGELOG entry when user-facing** — a binary- or user-facing change gets a
[CHANGELOG.md](CHANGELOG.md) entry; infra-only changes (CI, scripts) do not.
## Public surface and versioning
From v0.1.0, eeco follows Semantic Versioning over the surface frozen in
[`docs/PUBLIC_API.md`](docs/PUBLIC_API.md): the CLI verbs and flags, the
`eeco go --json` keys, exit codes, config keys, memory frontmatter fields, the
queue / ledger / hook formats, and the builtin workflow names. Nothing outside
that list is covered. Adding to the surface is a MINOR bump; post-1.0, changing
or removing a frozen item is a MAJOR bump with the deprecation window described
in [`VERSIONING.md`](VERSIONING.md). While eeco is pre-1.0 (`v0.x`) a MINOR MAY
change or remove a frozen item with a CHANGELOG migration note — see
[`VERSIONING.md`](VERSIONING.md) §2.1. Keep changes additive by default.
## Trust boundaries
Three invariants are sacred and are pinned by the boundary suite
(`*_boundary_test.go`):
- **The engine never writes the tracked tree.** Only the command-layer
`initgit.go` / `historygit.go` write git, and only to the workspace's own
private history — never the host repository.
- **Hooks stay reversible.** Every hook records its install in a ledger and
removes byte-identically; disabling a hook restores the prior state exactly.
- **AI stays gated.** No AI call happens without consent and budget, and when
memory or input is malformed the AI path degrades instead of failing the
command.
A change that touches these areas keeps the boundary suite green. Treat a
boundary-test failure as a design error, not a test to update.
## Working habit
Big or design-forked changes get a short planning pass before code; small,
mechanical changes can be planned and built in one go. Either way: edit on a
branch, get the Definition of Done green, keep the change additive (or declare
the bump), add a one-line CHANGELOG entry when it is user-facing, and open a PR.
## Pull requests
Clone, branch, and make your change. Run `make verify`, `make gates`,
`make lint`, and `make cover-check` until they are green; intent-add any new
files (`git add -N <path>`) before `leak-guard` so it scans them. Open the PR
with the [template](.github/PULL_REQUEST_TEMPLATE.md) and link any
[issue](.github/ISSUE_TEMPLATE).
---
[← Prev: Extending](EXTENDING.md) · [Next: Upgrading →](docs/UPGRADING.md)
added EXTENDING.md
@@ -0,0 +1,162 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/eeco_logo_dark.png">
<img src="assets/eeco_logo_light.png" alt="eeco" width="280">
</picture>
<h1>Extending</h1>
<p>
<a href="README.md"><b>README</b></a> ·
<a href="VISION.md"><b>Vision</b></a> ·
<a href="docs/COCKPIT.md"><b>Cockpit</b></a> ·
<a href="docs/USAGE.md"><b>Usage</b></a> ·
<a href="docs/ARCHITECTURE.md"><b>Architecture</b></a> ·
<a href="docs/PUBLIC_API.md"><b>Public API</b></a> ·
<b>Extending</b> ·
<a href="CONTRIBUTING.md"><b>Contributing</b></a> ·
<a href="docs/UPGRADING.md"><b>Upgrading</b></a> ·
<a href="VERSIONING.md"><b>Versioning</b></a> ·
<a href="CHANGELOG.md"><b>Changelog</b></a> ·
<a href="SECURITY.md"><b>Security</b></a>
</p>
</div>
---
## What this is
This is the how-to companion to the **Extension seams** table in
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md#extension-seams). That table is the
authoritative map of where each kind of extension is registered; this guide
expands the two most common seams into step-by-step examples and gives a short
reference for the rest.
eeco is built so that most extensions have a **single registration point** plus
one new file. The verb dispatcher, the workflow registry, the config parser, and
the TUI command index each read from one list, and adding an entry there is the
whole job for the low-friction seams. One seam — a new hook type — is
deliberately higher friction; see [Hook types](#hook-types) below.
Before you start, read [CONTRIBUTING.md](CONTRIBUTING.md) for the build, the
gates, and the Definition of Done every change meets.
## Add a builtin workflow
A builtin workflow is a Go type that ships in the binary and runs via
`eeco run <name>`. The whole job is one new file plus one line in the registry.
**1. Implement the `Workflow` interface** (`internal/workflow/workflow.go`) in a
new file `internal/workflow/<name>.go`. The interface is three methods:
```go
func (myWorkflow) Name() string { return "my-workflow" }
func (myWorkflow) Summary() string { return "one line shown by eeco run." }
func (myWorkflow) Run(env Env) (Result, error) { /* ... */ }
```
Use an existing workflow as the model — `internal/workflow/leakguard.go` is a
good template for a read-only gate.
**2. Register it** by adding a zero value to the `DefaultRegistry` slice literal
in `internal/workflow/registry.go`:
```go
for _, w := range []Workflow{commentHygiene{}, leakGuard{}, /* ... */, myWorkflow{}} {
```
Lookup, listing, and tab-completion are all derived from this slice — there is
nothing else to wire.
**3. Test it** in `internal/workflow/<name>_test.go`.
**4. If it is user-facing**, the workflow name is part of the frozen surface: add
it to [`docs/PUBLIC_API.md`](docs/PUBLIC_API.md) (builtin workflow names),
document it in [`docs/USAGE.md`](docs/USAGE.md) and run `make sync-guide` to
mirror that into the binary, then add a [CHANGELOG.md](CHANGELOG.md) entry.
**Worked reference:** commit `575557b` added the `commit-guard` workflow exactly
this way — `internal/workflow/commitguard.go` plus the one registry line plus a
test — in the same change that added its companion hook type.
## Add a `config.local` key
`config.local` is the per-workspace settings file. A new key is a struct field
plus a parse case.
**1. Add the field** to the `Config` struct in `internal/config/config.go`, with
its JSON tag.
**2. Give it a default** if it carries one — a `Default…` const applied in `Load`
so an absent key resolves to a safe value (the unknown-to-default floor).
**3. Parse it** in `applyLocal`, the switch that maps each `config.local` key
onto its field. Validate or normalize there when the value is constrained: an
enum key falls back to its default on garbage input, never crashes.
**4. Test it** in `internal/config/config_test.go`, including the garbage-in
case.
**5. If it is user-facing**, config keys are frozen: add it to
[`docs/PUBLIC_API.md`](docs/PUBLIC_API.md) and document it in
[`docs/USAGE.md`](docs/USAGE.md) (then `make sync-guide`), with a CHANGELOG
entry.
**Mind the ripple.** The registration is small, but every read site is a touch.
Commit `5d5b2d9` added `workspace_history` — a single struct field and parse
case, yet roughly fifteen call sites across `cmd/eeco/` read it to decide whether
to auto-commit. Plan for where the value is consumed, not only where it is
declared.
## Other seams (reference)
Each entry below is the short form; the
[Extension seams table](docs/ARCHITECTURE.md#extension-seams) carries the
canonical list with the full touch-set.
- **CLI verb** *(low)* — register in the `run` dispatch switch in
`cmd/eeco/main.go` and add it to the `usage` const; put the runner in a new
`cmd/eeco/<verb>.go`, reusing the `loadInitedConfig` / `loadRepoConfig` /
`newFlagSet` guards in `cmd/eeco/helpers.go`.
- **Memory frontmatter field** *(medium)* — add the field to the `Fact` struct in
`internal/memory/fact.go`, then teach `frontmatter.go` to read it (`setField`)
and write it (`Serialize`); add a `Validate` rule if it is constrained.
- **AI provider** *(medium)* — add the provider type (`Name` / `Run`) and wire it
into the `Select` chooser in `internal/ai`, plus the `config.local` key that
selects it (see above).
- **TUI slash-command** *(low)* — add it to the `commandIndex` in
`internal/tui/commands.go` and handle it in the `dispatch` switch; completion
is derived automatically.
- **TUI chat tool** *(low)* — add it to the `chatTools` set in
`internal/tui/tools.go` and handle it in `chatExecutor`. The chat-tool registry
is read-only by construction, so a tool never gains a new write capability.
- **Hook type** *(HIGH)* — see below.
### Hook types
A new hook type is the one irreducibly multi-file seam, and the friction is the
price of the trust boundary. A hook is an opt-in, reversible escape from the
workspace, so every hook type carries a ledger field that records its install for
exact, byte-identical removal. Adding one touches the name const and the `Names`
list, the `ledger` struct, the enable/disable/refresh trio, and the `Status`
read-out in `internal/hooks/hooks.go`, plus the CLI dispatch in
`cmd/eeco/hooks.go` — and they stay in lock-step. Commit `575557b` is the worked
reference (it added the `commit-guard` hook). Prefer the single-registration
seams above; add a hook type only when the capability genuinely needs to leave
the workspace. See **Trust boundaries** in
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for why this coupling is
deliberate.
## Before you ship
Every change meets the Definition of Done in
[CONTRIBUTING.md](CONTRIBUTING.md): `make verify`, `make gates`, `make lint`, and
`make cover-check` green; additive over
[`docs/PUBLIC_API.md`](docs/PUBLIC_API.md) unless you declare a semver bump (see
[`VERSIONING.md`](VERSIONING.md)); no AI attribution; a CHANGELOG entry when the
change is user-facing.
---
[← Prev: Public API](docs/PUBLIC_API.md) · [Next: Contributing →](CONTRIBUTING.md)
added LICENSE
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but not
limited to compiled object code, generated documentation, and
conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work (an
example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the
original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or Derivative
Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work, excluding
those notices that do not pertain to any part of the Derivative
Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and do
not modify the License. You may add Your own attribution notices
within Derivative Works that You distribute, alongside or as an
addendum to the NOTICE text from the Work, provided that such
additional attribution notices cannot be construed as modifying
the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Anton Hahn
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
added Makefile
@@ -0,0 +1,87 @@
# eeco — build and release entry points.
# Default target builds a single local binary; `release` cross-builds
# the published matrix via scripts/build.sh.
VERSION ?= $(shell git describe --tags --dirty --always 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
# Windows host builds need the .exe suffix so the Makefile recipes
# (which invoke ./eeco directly) work under Git Bash.
ifeq ($(OS),Windows_NT)
EXE := .exe
else
EXE :=
endif
LDFLAGS = -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)
# golangci-lint version for `make lint`, pinned here as the single
# source of truth — CI runs the same `make lint`, so local and CI
# stay byte-identical.
GOLANGCI_LINT_VERSION ?= v2.12.2
.PHONY: all build release checksums packaging verify cover cover-check gates bench clean sync-guide lint manpage
all: build
# sync-guide refreshes the in-binary user manual mirror at
# internal/guide/usage.md from the canonical docs/USAGE.md. CI keeps
# the two byte-identical via a drift gate (TestUsageMirrorsDocs) — run
# this target whenever docs/USAGE.md changes.
sync-guide:
cp docs/USAGE.md internal/guide/usage.md
build:
go build -trimpath -ldflags '$(LDFLAGS)' -o eeco$(EXE) ./cmd/eeco
release: manpage
VERSION='$(VERSION)' COMMIT='$(COMMIT)' BUILD_DATE='$(BUILD_DATE)' ./scripts/build.sh
# manpage renders dist/eeco.1 from docs/USAGE.md via go-md2man. Run as a
# prerequisite of `release` so the man page is bundled into the
# darwin/linux archives and shipped as a standalone release asset.
manpage:
./scripts/gen-manpage.sh
checksums:
cd dist && shasum -a 256 eeco_$(VERSION)_*.tar.gz eeco_$(VERSION)_*.zip > SHA256SUMS
packaging:
VERSION='$(VERSION)' ./scripts/gen-packaging.sh
verify:
go build ./...
go vet ./...
go test ./...
# cover writes a coverage profile to coverage.out for the codecov
# upload in CI (and local inspection via `go tool cover`).
cover:
go test -coverprofile=coverage.out -covermode=atomic ./...
# cover-check enforces the coverage ratchet: total statement coverage must
# stay at or above the floor in scripts/check-coverage.sh (a regression
# guard, not a target). Depends on `cover` so coverage.out is fresh; CI
# gates on this and the codecov step reuses the same coverage.out.
cover-check: cover
bash scripts/check-coverage.sh
# lint runs the golangci-lint quality bar (errcheck, gosec, govet,
# staticcheck, unused) configured in .golangci.yml. Pinned via go run
# so no separate install step is needed.
lint:
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) run ./...
gates: build
./eeco$(EXE) run comment-hygiene
./eeco$(EXE) run leak-guard
./eeco$(EXE) run version-sync
bench: build
mkdir -p dist
go test -tags=bench -bench=. -benchtime=1x -timeout=300s -run=^$$ ./internal/workflow/...
rm -rf dist/bench-fixture
clean:
rm -rf dist eeco eeco.exe
added README.md
@@ -0,0 +1,165 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/eeco_logo_dark.png">
<img src="assets/eeco_logo_light.png" alt="eeco" width="420">
</picture>
<h3>Single-binary, terminal-integrated workflow ecosystem + no-AI-spend knowledge layer for any AI assistant.</h3>
<p>
<a href="https://github.com/ajhahnde/eeco/actions/workflows/ci.yml"><img src="https://github.com/ajhahnde/eeco/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
<a href="https://codecov.io/gh/ajhahnde/eeco"><img src="https://codecov.io/gh/ajhahnde/eeco/branch/main/graph/badge.svg" alt="Coverage"></a>
<a href="https://github.com/ajhahnde/eeco/releases/latest"><img src="https://img.shields.io/badge/version-v0.1.0-blue" alt="Version"></a>
<img src="https://img.shields.io/badge/license-Apache%202.0-green" alt="License">
<img src="https://img.shields.io/badge/go-1.24-orange" alt="Go 1.24">
<img src="https://img.shields.io/badge/binary-single--static-lightgrey" alt="single-static binary">
</p>
<p>
<b>README</b> ·
<a href="VISION.md"><b>Vision</b></a> ·
<a href="docs/COCKPIT.md"><b>Cockpit</b></a> ·
<a href="docs/USAGE.md"><b>Usage</b></a> ·
<a href="docs/ARCHITECTURE.md"><b>Architecture</b></a> ·
<a href="docs/PUBLIC_API.md"><b>Public API</b></a> ·
<a href="EXTENDING.md"><b>Extending</b></a> ·
<a href="CONTRIBUTING.md"><b>Contributing</b></a> ·
<a href="docs/UPGRADING.md"><b>Upgrading</b></a> ·
<a href="VERSIONING.md"><b>Versioning</b></a> ·
<a href="CHANGELOG.md"><b>Changelog</b></a> ·
<a href="SECURITY.md"><b>Security</b></a>
</p>
</div>
---
A single-binary, terminal-integrated, AI-assisted tool that gives any
coding project two things: a self-maintaining workflow ecosystem, and a
deterministic, no-AI-spend knowledge layer any AI assistant can plug
into. A control-center TUI, repeatable workflows that keep the project
hygienic and surface issues, a memory store with garbage collection, a
project brief and targeted Q&A that bring any assistant up to speed in
one cheap call, and an opt-in path to grow new workflows over time —
proposing larger changes for review rather than acting unilaterally.
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/demo-dark.gif">
<img src="assets/demo-light.gif" alt="eeco demo" width="780">
</picture>
</p>
## Design
- **Single static binary, zero runtime dependencies.** Trivial to
install, embed, and ship; nothing to provision on the host.
- **Local-first and private.** eeco reads anywhere in a target repo but
writes only inside that repo's gitignored workspace. The sole
exception is `eeco init`, which may make one initial commit and push
of the workspace `.gitignore` line; no other verb ever commits or
pushes.
- **Pluggable AI, opt-in by default.** A provider interface with a
generic CLI provider wired in; every AI pass is gated by explicit
consent (`--ai` or `automation=auto`) and a per-invocation budget cap.
- **Not intrusive.** Nothing runs unless invoked. A single queue is the
only channel that asks for a decision.
- **Reversible.** The two integrations that touch outside the workspace
(a local pre-commit hook, one entry in an AI CLI's user settings) are
opt-in and recorded in a ledger so they can be removed cleanly.
## What eeco gives you
- **Control-center TUI** (`eeco`). A home screen lists the slash
commands one line each; output streams above the input.
- **Ten builtin workflows.** `comment-hygiene`, `leak-guard`,
`version-sync`, `gate`, `memory-drift`, and `doc-drift` keep the
tracked tree hygienic and catch drift; `bug-sweep` keeps a triage
ledger; `handover-refresh` drafts dated handovers; `manifest-refresh`
keeps per-directory `.ai.json` manifests current; `evolve` proposes
new workflows from observed repetition. Run via `eeco run <name>`. A
pre-1.0 `cockpit-sync` workflow additionally keeps the generated AI
cockpit in step (see [`docs/COCKPIT.md`](docs/COCKPIT.md)).
- **Knowledge layer for AI assistants.** `eeco go` prints a
deterministic, no-AI-spend project brief (with `--json`, `--brief`,
`--write`, and `--copy` delivery axes); `eeco ask "<question>"`
answers a targeted question with ranked `path:line` pointers;
`eeco add fact` / `eeco add task` let an assistant record what it
learns back into memory and the queue. The delivery channels are
brand-free — point Claude Code at `CLAUDE.md`, Gemini CLI at
`GEMINI.md`, Codex at `AGENTS.md`, Cursor at `.cursorrules`, or
paste `eeco go --copy` into any chat-only assistant. One cheap
call brings any assistant — not only the strongest — up to speed.
- **Memory store with garbage collection** (`eeco gc`). One fact per
file with flat frontmatter and a regenerated index.
- **The queue.** Every decision-bearing finding lands in one
Markdown checklist under a presence lock — never email, never a
notification, never a silent edit.
- **Opt-in self-update** (`eeco update --apply`). Verifies the
release archive against the `SHA256SUMS` cosign signature, the
archive sha256, and the GitHub build-provenance attestation before
atomically replacing the running binary. Bare `eeco update` is
read-only.
- **Diagnostics** (`eeco doctor`), **clean removal** (`eeco uninstall`), and **friction capture** (`eeco report-bug`) for every
step before, during, and after.
## Install
Pick the route you prefer. See
[`docs/USAGE.md`](docs/USAGE.md#1-install) for the full platform matrix,
checksum verification, and the cosign signature + build-provenance
checks.
**Homebrew (macOS, Linux).**
```
brew install ajhahnde/eeco/eeco
```
**Scoop (Windows).**
```
scoop bucket add eeco https://github.com/ajhahnde/scoop-eeco
scoop install eeco
```
**Pre-built binary.** Download the archive for your platform from the
[releases page](https://github.com/ajhahnde/eeco/releases) and extract
the `eeco` binary onto your `PATH`. `SHA256SUMS` is cosign-signed and
the archives carry build provenance.
**From source.** Requires Go 1.24+.
```
git clone https://github.com/ajhahnde/eeco
cd eeco
make build # produces ./eeco with version metadata
```
**In-place upgrade.** Once eeco is installed, future releases
upgrade in place:
```
eeco update --apply
```
Verifies the cosign signature on `SHA256SUMS`, the archive sha256, and
the GitHub build-provenance attestation before swapping the binary.
Refuses on Homebrew- or Scoop-managed install roots in favour of the
package manager's upgrade verb.
`make verify` runs `go build ./... && go vet ./... && go test ./...`;
`make release` cross-builds the published matrix into `dist/`.
## License
Apache License, Version 2.0. See [`LICENSE`](LICENSE).
## See also
- [FlashOS](https://github.com/ajhahnde/FlashOS) — AArch64 bare-metal kernel for the Raspberry Pi 4 Model B.
- [the-way-out](https://github.com/ajhahnde/the-way-out) — top-down pixel-art escape-room shooter.
---
[Next: Vision →](VISION.md)
added SECURITY.md
@@ -0,0 +1,118 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/eeco_logo_dark.png">
<img src="assets/eeco_logo_light.png" alt="eeco" width="280">
</picture>
<h1>Security</h1>
<p>
<a href="README.md"><b>README</b></a> ·
<a href="VISION.md"><b>Vision</b></a> ·
<a href="docs/COCKPIT.md"><b>Cockpit</b></a> ·
<a href="docs/USAGE.md"><b>Usage</b></a> ·
<a href="docs/ARCHITECTURE.md"><b>Architecture</b></a> ·
<a href="docs/PUBLIC_API.md"><b>Public API</b></a> ·
<a href="EXTENDING.md"><b>Extending</b></a> ·
<a href="CONTRIBUTING.md"><b>Contributing</b></a> ·
<a href="docs/UPGRADING.md"><b>Upgrading</b></a> ·
<a href="VERSIONING.md"><b>Versioning</b></a> ·
<a href="CHANGELOG.md"><b>Changelog</b></a> ·
<b>Security</b>
</p>
</div>
---
eeco is a developer tool that runs inside a target repository. Its
safety model is documented in [`README.md`](README.md),
[`docs/USAGE.md`](docs/USAGE.md), and the architecture overview in
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). This file describes
how to report a vulnerability and the safety guarantees the project
considers in scope.
## Supported versions
Only the latest `v0.x` release receives security fixes; the pre-stability
line carries no support guarantee — see [`VERSIONING.md`](VERSIONING.md)
§2.1 and §8.1.
## Reporting a vulnerability
Use **GitHub Private Vulnerability Reporting** on this repository:
1. Go to <https://github.com/ajhahnde/eeco/security>.
2. Click **Report a vulnerability**.
3. Fill in the form. The report is private until disclosed.
Please include:
- The eeco version (`eeco version`) and your platform.
- A minimal reproduction or proof of concept.
- The expected vs observed behaviour and the impact you assess.
Acknowledgement is best-effort; eeco is maintained by a single
operator. A fix targets the next tagged release; a coordinated
disclosure timeline is negotiable on the advisory thread.
Please do not file a public issue for a security vulnerability.
## Safety guarantees in scope
These are the security-relevant invariants the project commits to.
A defect in any of them is a security report:
- **Write-scope.** eeco writes only inside the repo's gitignored
workspace (default `.eeco/`). A path guard refuses `..` traversal
and rejects any write target outside the workspace.
- **No auto-commit, no auto-push.** eeco never invokes `git commit`,
`git push`, or any other write-side git command on the user's
behalf, including on tracked-tree edits.
- **AI gating.** Every AI provider call passes through a single Gate
that enforces explicit consent (`--ai` or `automation=auto`) and a
per-invocation budget cap. A skip, over-budget, or provider error
parks the prompt under `<workspace>/state/parked/` and queues a
review item — there is no silent spend and no hard failure that
loses the prompt.
- **Reversible hooks.** The only two touches outside the workspace
are opt-in and reversible: a local `.git/hooks/pre-commit`
(installed only when no hook exists; removed only on byte-identical
match) and one namespaced entry in the AI CLI's user-global
settings file (atomic edit, workspace-side backup, validate, restore
on parse failure). Both are recorded in `state/hooks.json` so they
can be cleanly undone.
- **`leak-guard`.** The `leak-guard` builtin workflow blocks a commit
that would leak an AI-attribution string, a `Co-Authored-By`
trailer, or a workspace engine path into a tracked file. The
pre-commit hook (when enabled) refuses the commit; CI runs the
same gate on every PR and `main` push.
- **Trust artefacts.** Each release tag carries a cosign-signed
`SHA256SUMS` (keyless OIDC identity = the release workflow itself)
and GitHub build provenance on every archive. The verification
commands are in [`docs/USAGE.md`](docs/USAGE.md) §1.1.
## Out of scope
- A defect in a user's own scaffolded workflow script. The scaffold
enforces the contract; the script's contents are the user's code.
- A defect in a third-party AI CLI selected by the operator via
`ai_command`. eeco gates the call but does not audit the provider.
- A misconfiguration of `session_settings_path` that points outside
the user's own settings file. eeco refuses a relative path and
refuses to write a non-JSON file, but the destination itself is
operator-chosen.
- Cosmetic findings in copy that do not affect safety
(typos, link rot in docs, formatting).
## Telemetry
eeco emits no telemetry, ever. There is no analytics endpoint, no
crash reporter, and no opt-out switch because there is nothing to opt
out of. Network access is limited to the explicit AI provider call
(when consented and budgeted) and the read-only
`git ls-remote --tags` performed by `eeco update`.
---
[← Prev: Changelog](CHANGELOG.md) · [Back to start (README) ↺](README.md)
added VERSIONING.md
@@ -0,0 +1,368 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/eeco_logo_dark.png">
<img src="assets/eeco_logo_light.png" alt="eeco" width="280">
</picture>
<h1>Versioning Policy</h1>
<p>
<a href="README.md"><b>README</b></a> ·
<a href="VISION.md"><b>Vision</b></a> ·
<a href="docs/COCKPIT.md"><b>Cockpit</b></a> ·
<a href="docs/USAGE.md"><b>Usage</b></a> ·
<a href="docs/ARCHITECTURE.md"><b>Architecture</b></a> ·
<a href="docs/PUBLIC_API.md"><b>Public API</b></a> ·
<a href="EXTENDING.md"><b>Extending</b></a> ·
<a href="CONTRIBUTING.md"><b>Contributing</b></a> ·
<a href="docs/UPGRADING.md"><b>Upgrading</b></a> ·
<b>Versioning</b> ·
<a href="CHANGELOG.md"><b>Changelog</b></a> ·
<a href="SECURITY.md"><b>Security</b></a>
</p>
</div>
---
This document is the authoritative policy for how eeco is versioned,
released, supported, and retired. It is the contract every release of
eeco honours; a breach of any clause stated with **MUST** is a bug, not
a feature.
eeco is, at its first public release, **pre-stability**. It
re-launches publicly at `v0.1.0`; the most important thing this document does is mark that line
clearly and describe what changes — and what does not — when the
project crosses into stability at v1.0.0.
The keywords **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and
**MAY** in this document are to be interpreted as described in
[RFC 2119](https://www.rfc-editor.org/rfc/rfc2119).
## 1. Scope
This policy governs every artefact released under the
[`ajhahnde/eeco`](https://github.com/ajhahnde/eeco) GitHub repository
and the corresponding Homebrew and Scoop taps. It applies to every tag
of the form `vMAJOR.MINOR.PATCH`, including the current pre-stability
`v0.y.z` line, **with the explicit caveats** stated in §2.1 below.
## 2. Grammar
eeco follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html)
over the surface defined in §3. Until v1.0.0, the special pre-1.0 clause
of SemVer (§4) applies; see §2.1.
A release version is `vMAJOR.MINOR.PATCH` (with the leading `v`
preserved on every git tag, GitHub Release, and CHANGELOG section
header) optionally followed by a pre-release identifier as defined in
§7.
| Component | Trigger |
|---|---|
| **MAJOR** | A breaking change to any item enumerated in [`docs/PUBLIC_API.md`](docs/PUBLIC_API.md); a default-value change that can cause an existing workflow to produce a materially different result; the removal of a previously deprecated feature. *(Reserved pre-1.0: the first MAJOR is v1.0.0, the stability-freeze release — see §2.1.)* |
| **MINOR** | Under pre-1.0 (§2.1): any change, including a breaking one, with the migration path called out in the CHANGELOG. Under post-1.0 (§2.2): an additive change only — a new command, flag, config key, builtin workflow, output field, or exit-code value — after which an existing workflow MUST observe identical behaviour unless it opts in to the new surface. |
| **PATCH** | A bug fix, performance improvement, documentation change, dependency bump, or internal refactor that does not touch the public surface. PATCH is **always** backwards-compatible, including pre-1.0. |
A change that fits more than one bucket MUST take the most disruptive
applicable bucket (e.g., a bug fix that, in fixing the bug, also
renames a flag is a MAJOR).
### 2.1 Pre-v1.0.0 (current line)
Under SemVer 2.0.0 §4 a major version of zero is for initial
development and "anything MAY change at any time". eeco adopts the
literal reading of that clause for its `v0.y.z` line, which is where
v0.1.0 re-launches the project:
- A **MINOR** bump (`v0.y.z` → `v0.(y+1).0`) MAY include a breaking
change to the surface enumerated in §3. Any such breaking change MUST
be called out in the CHANGELOG entry (under `### Changed`, with the
migration path) per the project's no-silent-breaking-changes rule.
- A **PATCH** bump (`v0.y.z` → `v0.y.(z+1)`) MUST NOT include a breaking
change to the surface enumerated in §3. PATCH is backwards-compatible
even pre-1.0.
- **No support guarantee** is made for any pre-v1.0.0 release. Only the
latest pre-v1.0.0 tag receives further attention; once v1.0.0 ships,
the entire pre-1.0 line enters the **Archived** tier of §8
permanently.
### 2.2 Post-v1.0.0 (future)
From v1.0.0 onward the standard SemVer interpretation applies without
the §2.1 carve-out: a breaking change MUST take a MAJOR bump. At that
point the deprecation procedure (§9) and the support tiers (§8) become
enforceable — in particular §9.3 ("removal MUST happen in a MAJOR")
governs the post-1.0 line, which is what reconciles it with the §2.1
rule that lets a pre-1.0 MINOR remove surface. Until then, §8 and §9
describe the model that takes effect at the stability freeze, not the
current `v0.x` line.
## 3. Public surface
The **frozen public surface** is the union of the items enumerated in
[`docs/PUBLIC_API.md`](docs/PUBLIC_API.md). Nothing outside that
enumeration is part of the surface, and the policy MUST NOT be
interpreted to extend to anything else.
### 3.1 Stability classes
| Class | Discoverability | SemVer protection | Removal path |
|---|---|---|---|
| **GA** | Documented in [`docs/USAGE.md`](docs/USAGE.md) and [`docs/PUBLIC_API.md`](docs/PUBLIC_API.md). | Full. Breaking changes only in MAJOR with the deprecation window of §9. | Deprecate → window → remove in next MAJOR. |
| **Preview** | Documented as preview in [`docs/USAGE.md`](docs/USAGE.md); a runtime warning MUST be emitted on stderr the first time per process it is invoked. | None. A preview surface MAY change shape or be removed in any release, including a PATCH. | Drop without notice; CHANGELOG MUST record the removal. |
| **Internal** | Not documented in [`docs/PUBLIC_API.md`](docs/PUBLIC_API.md). The Go package surface under `internal/` is the canonical example. | None. | Drop without notice; not mentioned in the CHANGELOG. |
A surface MUST NOT be promoted from Preview to GA in a PATCH; the
promotion is a feature-add and goes in a MINOR.
### 3.2 Output stability
eeco is a CLI; its output channels carry different stability promises.
| Channel | Protection |
|---|---|
| **`--json` stdout** | Frozen top-level keys are enumerated in [`docs/PUBLIC_API.md`](docs/PUBLIC_API.md). Removing or renaming a frozen key is a MAJOR. Adding a frozen key is a MINOR. Nested object fields are **best-effort** and MAY gain keys in a MINOR; a frozen-nested-key contract is opt-in per command via [`docs/PUBLIC_API.md`](docs/PUBLIC_API.md). |
| **Human stdout** (default) | **Not** part of the public surface. Optimised for screen reading and SHALL change between MINORs whenever a presentation improvement is shipped. Scripts that parse the human form MUST switch to `--json`. |
| **Exit codes** | Documented in [`docs/PUBLIC_API.md`](docs/PUBLIC_API.md) §Workflow contract. Frozen. |
| **Stderr** | **Not** part of the public surface. Reserved for diagnostics, deprecation warnings, and operator hints. A consumer MUST NOT parse stderr. |
## 4. Release cadence
| Bump | Target cadence | Hard rule |
|---|---|---|
| **PATCH** | As-ready. A confirmed bug SHOULD reach a tagged PATCH within **7 days**. | Never blocked on a feature. |
| **MINOR** | Soft target of one per **~8 weeks**. No hard train — a MINOR ships when its surface is complete and the CHANGELOG entry is final. | Each MINOR MUST be additive over the previous MINOR within the same MAJOR (§2). |
| **MAJOR** | As-needed. A MAJOR MUST be announced under `## [Unreleased]` in [`CHANGELOG.md`](CHANGELOG.md) and on the GitHub Releases page **at least 90 days** before its tag. The announcement enumerates every breaking change. | An RC train (§7) MUST precede the GA tag of any MAJOR. |
## 5. Branching and tagging
- The default branch is `main`. Every release tag is reachable from
`main` at the moment of tagging.
- A MAJOR line that is still under any support tier (§8) MUST have a
`release-X` branch (e.g., `release-1`) where its PATCH fixes are
prepared. The branch is force-push-protected.
- A release tag MUST match the regex `^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$`.
- A release tag MUST be annotated (`git tag -a`) and signed with the
release workflow's keyless cosign identity. The signed
`SHA256SUMS` and the SLSA build provenance produced by the release
workflow are part of the release artefact set.
- A release tag MUST NOT be deleted, force-moved, or re-pointed. The
yank procedure of §10 is the only sanctioned recall path.
- The `v0.1.0` initial release was a **one-time pre-policy reset**: the
prototype-era tags that preceded it were removed during the public
re-launch, before this policy governed the line. That reset is not a
§10 yank and not a breach of the no-delete rule above, which binds
only tags cut under this policy (`v0.1.0` onward).
## 6. Versioning of generated artefacts
Each release ships, at minimum:
- `eeco_<version>_<os>_<arch>.tar.gz` (and `.zip` for Windows) — the
pre-built binary archive, one per OS×arch in the published support
matrix.
- `SHA256SUMS` — the sha256 of every archive in the release, signed by
cosign keyless OIDC (the release workflow itself).
- A GitHub-built SLSA provenance attestation per archive.
- A Homebrew formula (`eeco.rb`) and a Scoop manifest (`eeco.json`)
published to the respective taps.
## 7. Pre-releases
A pre-release tag carries the suffix `-rc.N` (release candidate; `N`
is a strictly increasing non-negative integer starting at `0`).
`-alpha` and `-beta` pre-release identifiers are **not** used.
- An RC MUST be used for every MAJOR. It SHOULD be used for any MINOR
that materially changes default behaviour.
- An RC MUST be published to GitHub Releases marked as **pre-release**
and MUST NOT be pushed to Homebrew or Scoop.
- The RC train ends when the corresponding GA tag is cut from the same
commit as the last RC, with no behaviour change between the two.
- A user installing an RC accepts that the surface MAY differ from the
eventual GA by the contents of any further `-rc.N+1` issued for the
same target version. An RC is documented in the CHANGELOG under the
GA section that supersedes it; no separate `## [vX.Y.Z-rc.N]` section
is written.
## 8. Support windows
eeco's support model is **Node.js-LTS-inspired** with a single
operator and a 12-month maintenance trailing window after each MAJOR.
| Tier | Receives | Applies to | Ends |
|---|---|---|---|
| **Active** | Every bug fix, every applicable feature, every security fix. | The **current MAJOR**. | When the next MAJOR's GA ships. |
| **Maintenance** | **Security fixes** and **critical bug fixes** (data loss, crash on cold start, write-scope violation, signing/verification regression). No feature backports. No cosmetic changes. | The **previous MAJOR**. | **12 months after the next MAJOR's GA tag.** |
| **Archived** | Nothing. | Every MAJOR older than Maintenance. | Permanent. |
### 8.1 Current support table
| MAJOR line | Tier | First tag | Tier ends |
|---|---|---|---|
| **v0.x** (pre-stability) | None — §2.1 (no support guarantee; only the latest `v0.x` tag is current) | `v0.1.0` | When v1.0.0 GA ships — the pre-1.0 line then enters **Archived**. |
The current support table MUST be updated in the same PR that tags a
new MAJOR. The new line enters **Active**, the line that was previously
Active enters **Maintenance** with the EOL date computed as
`<new-MAJOR GA date> + 12 months`, and the line that was previously
Maintenance enters **Archived**.
### 8.2 What "Active" delivers
While a MAJOR is in **Active** support, eeco MUST publish:
- Every confirmed bug fix in a PATCH within the §4 cadence.
- Every applicable feature in a MINOR.
- Every applicable security fix in a PATCH (or, if the fix is
inherently breaking, in the next MAJOR with the §13 embargo timing).
### 8.3 What "Maintenance" delivers
While a MAJOR is in **Maintenance** support, eeco MUST publish:
- Every security fix that applies to the maintenance-line surface.
- Every critical bug fix as defined in the table above.
Maintenance PATCHes MUST NOT introduce a new flag, command, config
key, output field, or exit code. They MUST NOT change a default value.
### 8.4 Skew between Active and Maintenance
A workspace created by an **Active**-tier release MUST read cleanly
under any **Maintenance**-tier release within the same major or the
immediately preceding one. The converse — workspaces from a
Maintenance line reading on Active — is **always** supported and is
the standard upgrade path.
## 9. Deprecation policy
A change that will eventually break a frozen-surface item MUST go
through deprecation. The procedure follows the Kubernetes deprecation
model adapted for a single-operator project.
### 9.1 Announce
- A `### Deprecated` section is added to the next release's CHANGELOG
entry, naming each deprecated item, the replacement (if any), and the
earliest version in which the item MAY be removed.
- The deprecated item, when invoked, MUST emit a one-line warning on
stderr beginning with `eeco: DEPRECATED: ` and naming the
replacement.
- [`docs/PUBLIC_API.md`](docs/PUBLIC_API.md) MUST mark the item with a
`*(deprecated since vX.Y.0; removed in vM.0.0 or later)*` annotation.
### 9.2 Wait
The minimum window between the deprecation MINOR and the removal
release MUST be the longer of:
- **2 MINOR releases**, and
- **6 months** of wall-clock time.
The window does **not** restart when a deprecation is re-announced; the
clock runs from the first announcement.
### 9.3 Remove
- Removal MUST happen in a MAJOR. A PATCH or MINOR MUST NOT remove a
deprecated frozen-surface item.
- The release that removes the item MUST list it in the `### Removed`
CHANGELOG section and in the §1 of [`docs/UPGRADING.md`](docs/UPGRADING.md)
release entry.
### 9.4 Surfacing active deprecations
Every release MUST surface its still-active deprecations through:
- The `eeco doctor` output, listing each deprecated item the workspace
is currently using.
- The output of `eeco --help` for any command or flag that is
deprecated.
## 10. Yank and recall
A release MAY be yanked only for one of:
- A defect that destroys or corrupts user state.
- A signing or verification regression that breaks the release-artefact
trust chain.
- A security defect for which a fix cannot be issued within the §13
timeline.
The yank procedure:
1. The GitHub Release for the yanked tag is edited: title is prefixed
`[YANKED]`, body opens with one paragraph naming the yank reason and
the recommended replacement version. The release is marked
pre-release so it is no longer the latest.
2. The Homebrew tap and the Scoop tap are reverted to point at the
previous stable release within 24 hours.
3. The CHANGELOG entry for the yanked release is amended (in a
follow-up commit on `main`) to prepend a `**YANKED on YYYY-MM-DD**`
notice.
4. The fix is shipped as a follow-up PATCH within 48 hours of the yank.
5. The yanked tag is **never** deleted or force-moved. It remains for
audit and for users who pinned to it.
## 11. Security release policy
Vulnerability reporting and the safety guarantees in scope are
documented in [`SECURITY.md`](SECURITY.md). The version-policy
implications of a security release:
- A security PATCH MUST be issued to every MAJOR line currently in
**Active** or **Maintenance** tier (§8) that carries the defect.
- The default embargo window between report and tagged fix is
**90 days**, in line with Project Zero industry practice.
A shorter window MAY be negotiated on the advisory thread when the
fix is ready earlier.
- The CHANGELOG entry for a security PATCH MUST link the advisory
(typically a GitHub Security Advisory; a CVE identifier when one is
assigned).
- If the fix is inherently breaking and cannot be made
backwards-compatible, it MUST be shipped in the next MAJOR with an
exception note in the embargo agreement; the policy MUST NOT silently
break a maintenance-line workspace to deliver a security fix.
## 12. Roadmap signalling
A breaking change MUST NOT be a surprise.
- Every breaking change planned for the next MAJOR MUST be listed under
`## [Unreleased]` in [`CHANGELOG.md`](CHANGELOG.md) **before** the
first RC of that MAJOR.
- The list is updated whenever a candidate breaking change is added or
removed; the diff itself is the public signal.
- The §4 ≥90-day announcement window starts from the date the
`[Unreleased]` block first contains the final list, not from when the
RC ships.
## 13. Governance
eeco is currently maintained by a single operator. A release MUST be
cut by:
1. A commit on `main` (or on a `release-X` branch for a Maintenance
PATCH).
2. Bumping the version anchors `version-sync` watches (see [`docs/USAGE.md`](docs/USAGE.md) §5).
3. Adding the release section to [`CHANGELOG.md`](CHANGELOG.md) and
the per-release entry to [`docs/UPGRADING.md`](docs/UPGRADING.md).
4. Tagging the commit `vX.Y.Z[-rc.N]` and pushing the tag, which
triggers the release workflow.
The release workflow MUST sign `SHA256SUMS` with cosign keyless OIDC
and produce the SLSA build provenance for every archive. A release
that fails the post-release `eeco update` self-verification on any
supported platform MUST be yanked (§10).
This policy MAY be amended; an amendment MUST itself follow the
versioning of this repository — a substantive change to the contract
is announced under `## [Unreleased]` and lands together with the next
release. A clarifying edit (typo, link rot, formatting) MAY land at
any time.
---
[← Prev: Upgrading](docs/UPGRADING.md) · [Next: Changelog →](CHANGELOG.md)
added VISION.md
@@ -0,0 +1,144 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="assets/eeco_logo_dark.png">
<img src="assets/eeco_logo_light.png" alt="eeco" width="280">
</picture>
<h1>Vision</h1>
<p>
<a href="README.md"><b>README</b></a> ·
<b>Vision</b> ·
<a href="docs/COCKPIT.md"><b>Cockpit</b></a> ·
<a href="docs/USAGE.md"><b>Usage</b></a> ·
<a href="docs/ARCHITECTURE.md"><b>Architecture</b></a> ·
<a href="docs/PUBLIC_API.md"><b>Public API</b></a> ·
<a href="EXTENDING.md"><b>Extending</b></a> ·
<a href="CONTRIBUTING.md"><b>Contributing</b></a> ·
<a href="docs/UPGRADING.md"><b>Upgrading</b></a> ·
<a href="VERSIONING.md"><b>Versioning</b></a> ·
<a href="CHANGELOG.md"><b>Changelog</b></a> ·
<a href="SECURITY.md"><b>Security</b></a>
</p>
</div>
---
eeco is the AI-agnostic ecosystem for autonomous project housekeeping
— an intelligent, system-level framework that sits as a meta-layer
above modern software development. Its purpose is to radically
automate project housekeeping so developers can focus entirely on
creative implementation and problem-solving.
This document states where eeco is headed. The README and USAGE
describe what it does today.
## The vision
eeco is not a reactive tool — it is a proactive background process
that continuously analyses, understands, and actively maintains a
project. Through pattern recognition, eeco identifies recurring
development and maintenance patterns and derives workflows from
them automatically — proactively, or on explicit user request.
## Core concepts
### Model-agnostic interoperability — the context compiler
eeco is a universal abstraction layer. It ensures that every AI —
from specialised small models to high-end LLMs like Gemini, Claude,
or GPT — receives exactly the project context it needs. eeco acts
as a **context compiler** that translates complex project
documentation into a form any AI can process, in a standardised
shape.
### Autonomous housekeeping
With built-in standard workflows — a bug finder for error analysis,
a hygiene keeper for code-standards enforcement, drift detectors
across memory and docs — eeco takes the administrative load. The
system learns from the developer workflow and evolves through
AI-assisted self-optimisation.
### Intelligent context steering — AI guardrails
eeco is a **guardrail** for AI agents. It delivers cross-model
binding rules — language policies, attribution rules, project
conventions — that minimise hallucinations and keep project
quality consistent without the user repeating explicit
instructions on every call.
## The goal
eeco aims to become the standard for AI-assisted project steering:
the interface where human software architecture and AI-driven
implementation meet. Through the handshake between eeco and any
AI model, a seamless environment emerges in which the AI acts not
just as a code generator, but as part of a self-organising,
highly efficient development system.
## Operating principles
The vision above is the destination. These principles hold on the
way there and are never traded away:
- **Human-in-the-loop.** eeco proposes; the operator decides. It
never commits to, pushes, or activates a workflow on your project's
tracked tree without explicit consent.
- **Init exception.** `eeco init` is the single verb permitted to make
one initial commit and one initial push to the HOST repo, both
restricted to the `.gitignore` line that scopes eeco's workspace out
of the tracked tree — plus the private workspace-history repo described
below, which is local-only and confined to the gitignored workspace.
Every other verb — `run`, `gc`, `hooks`, `gates`, `add`, etc. —
continues to never commit to or push the tracked tree, and never write
outside the workspace.
- **Workspace history (the AI's logbook).** eeco may keep a private,
**local** git repository inside its own gitignored workspace directory
(`<username>/`) to version its knowledge layer — memory, queue,
decisions, manifests — over time. This repository **never leaves the
machine** (no remote, no push by default) and **never touches the host
project's tracked tree**: it records only what eeco already writes
inside its own gitignored workspace. It is created opt-out at `eeco
init` (`workspace_history`: `manual` by default, `auto` to commit on
every change, `off` to disable) and removed at `eeco uninstall`. Inside
this private repo eeco has a free hand — commit, squash, gc — because
nothing there is shared or tracked by the host; every other verb still
never commits to the tracked tree, pushes, or writes outside the
workspace.
- **Opt-in AI spend.** Every AI call is consent-gated and
budget-capped. The default automation level is manual.
- **Local-first and private.** eeco reads anywhere in the repo,
writes only inside its gitignored workspace. No telemetry, no
hosted service.
- **Reversible.** Every escape from the workspace — the pre-commit
hook, the session-start integration, the commit-msg hook, the
commit-guard PreToolUse hook — is opt-in, ledgered, and removable
byte-for-byte.
## Non-goals
- Not a CI service, not a hosted product — it runs on your machine.
- Not tied to one AI vendor — the knowledge layer is
provider-agnostic.
- Not a replacement for operator judgment — eeco surfaces and
proposes; the operator approves.
## Roadmap
eeco follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
over the frozen surface in [`docs/PUBLIC_API.md`](docs/PUBLIC_API.md).
The next stretch deepens the context compiler and grows the
autonomous-housekeeping workflows. See [`CHANGELOG.md`](CHANGELOG.md)
for what has shipped.
## See also
- [README.md](README.md) — quick start and install
- [docs/USAGE.md](docs/USAGE.md) — full user reference
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — architecture overview
---
[← Prev: README](README.md) · [Next: Cockpit →](docs/COCKPIT.md)
added assets/demo-dark.gif
binary file — no preview
added assets/demo-light.gif
binary file — no preview
added assets/demo.tape
@@ -0,0 +1,77 @@
# eeco — animated README demo (canonical tape, dark variant).
# Regenerate with `scripts/regen-demo.sh` after building (`make build`).
# Renders assets/demo-dark.gif (OneDark = Atom One Dark). regen-demo.sh
# also derives the light variant (AtomOneLight -> assets/demo-light.gif);
# the README serves them via a prefers-color-scheme <picture> block.
Output assets/demo-dark.gif
Set FontSize 16
Set Width 1140
Set Height 710
Set Theme "OneDark"
Set Padding 20
Set TypingSpeed 50ms
Hide
Type "WORK=$(mktemp -d)/demo && mkdir -p $WORK && cd $WORK && git init -q && go mod init demo >/dev/null && mkdir -p cmd/demo internal && echo 'package main' > cmd/demo/main.go && clear"
Enter
Sleep 400ms
Show
Sleep 500ms
Type "eeco init --no-push"
Sleep 200ms
Enter
Sleep 900ms
Enter
Sleep 1700ms
Type "clear"
Enter
Sleep 200ms
Type "eeco cockpit generate --target claude"
Sleep 200ms
Enter
Sleep 2600ms
Type "clear"
Enter
Sleep 200ms
Type "eeco"
Sleep 200ms
Enter
Sleep 2200ms
Ctrl+C
Sleep 400ms
Type "clear"
Enter
Sleep 200ms
Type "eeco go | head -30"
Sleep 200ms
Enter
Sleep 3500ms
Type "clear"
Enter
Sleep 200ms
Type "eeco run leak-guard"
Sleep 200ms
Enter
Sleep 1200ms
Type "eeco doctor"
Sleep 250ms
Enter
Sleep 3500ms
Type "eeco update"
Sleep 200ms
Enter
Sleep 1500ms
added assets/eeco_logo_dark.png
binary file — no preview
added assets/eeco_logo_light.png
binary file — no preview
added cmd/eeco/adaptations.go
@@ -0,0 +1,172 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/memory"
)
const adaptationsUsage = `usage:
eeco adaptations <name> <on|off> toggle an AI adaptation fact on or off
An adaptation fact is a feedback- or user-typed memory fact recorded by
an AI assistant. Turning it off hides it from "eeco go" briefs and
"eeco ask" results without deleting it from disk; "on" restores it.
Use "eeco show adaptations" to list adaptations and their current state.`
// runAdaptations handles `eeco adaptations <name> on|off`. It loads the
// named memory fact, flips its Disabled flag, saves the file back to
// <workspace>/memory/, and regenerates the MEMORY.md index so the
// fact's on/off state is visible there at once. Writes stay inside the
// workspace (Constraint 1); nothing is staged or committed
// (Constraint 6).
func runAdaptations(args []string, stdout, stderr io.Writer) int {
if len(args) != 2 {
fmt.Fprintln(stderr, adaptationsUsage)
return 2
}
name, action := args[0], args[1]
var disabled bool
switch action {
case "on":
disabled = false
case "off":
disabled = true
default:
fmt.Fprintln(stderr, adaptationsUsage)
return 2
}
cfg, code := loadAdaptationsConfig(stderr, "eeco adaptations")
if code != 0 {
return code
}
store, err := memory.Open(cfg)
if err != nil {
fmt.Fprintln(stderr, "eeco adaptations:", err)
return 1
}
path := filepath.Join(store.MemoryDir, name+".md")
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
fmt.Fprintf(stderr, "eeco adaptations: no memory fact named %q.\n", name)
fmt.Fprintln(stderr, "hint: run `eeco show adaptations` to list known adaptations.")
return 2
}
fmt.Fprintln(stderr, "eeco adaptations:", err)
return 1
}
fact, err := memory.ParseFact(data)
if err != nil {
fmt.Fprintln(stderr, "eeco adaptations:", err)
return 1
}
if fact.Disabled == disabled {
// Regenerate the index even on a no-op: a fact disabled by an
// earlier eeco that did not refresh MEMORY.md on toggle leaves
// a stale index this call should still correct.
if code := refreshMemoryIndex(store, stderr); code != 0 {
return code
}
maybeAutoCommit(cfg.WorkspaceHistory.Auto(), cfg.UserDir, "adaptations "+name+" "+action, stderr)
fmt.Fprintf(stdout, "eeco adaptations: %s already %s\n", name, action)
return 0
}
fact.Disabled = disabled
if err := store.Save(fact); err != nil {
fmt.Fprintln(stderr, "eeco adaptations:", err)
return 1
}
if code := refreshMemoryIndex(store, stderr); code != 0 {
return code
}
maybeAutoCommit(cfg.WorkspaceHistory.Auto(), cfg.UserDir, "adaptations "+name+" "+action, stderr)
fmt.Fprintf(stdout, "eeco adaptations: %s %s\n", name, action)
return 0
}
// refreshMemoryIndex regenerates <workspace>/memory/MEMORY.md from the
// store so the index reflects the current on/off state of every fact.
// Returns 0 on success, or a non-zero exit code after reporting.
func refreshMemoryIndex(store *memory.Store, stderr io.Writer) int {
facts, err := store.LoadAll()
if err != nil {
fmt.Fprintln(stderr, "eeco adaptations:", err)
return 1
}
if err := store.WriteIndex(facts); err != nil {
fmt.Fprintln(stderr, "eeco adaptations:", err)
return 1
}
return 0
}
// runShowAdaptations lists every feedback- or user-typed memory fact —
// the AI-adaptation surface — newest-first by created date with each
// fact's on/off state. Other fact types are intentionally hidden:
// reference and finding facts are not adaptations to the operator.
func runShowAdaptations(args []string, stdout, stderr io.Writer) int {
if len(args) != 0 {
fmt.Fprintln(stderr, showUsage)
return 2
}
cfg, code := loadAdaptationsConfig(stderr, "eeco show adaptations")
if code != 0 {
return code
}
store, err := memory.Open(cfg)
if err != nil {
fmt.Fprintln(stderr, "eeco show adaptations:", err)
return 1
}
facts, err := store.LoadAll()
if err != nil {
fmt.Fprintln(stderr, "eeco show adaptations:", err)
return 1
}
var adaptations []*memory.Fact
for _, f := range facts {
if f.Type == memory.TypeFeedback || f.Type == memory.TypeUser {
adaptations = append(adaptations, f)
}
}
if len(adaptations) == 0 {
fmt.Fprintln(stdout, "no AI adaptations recorded yet — `eeco add fact --type=feedback ...` files one")
return 0
}
sort.SliceStable(adaptations, func(i, j int) bool {
if !adaptations[i].Created.Equal(adaptations[j].Created) {
return adaptations[i].Created.After(adaptations[j].Created)
}
return adaptations[i].Name < adaptations[j].Name
})
for _, f := range adaptations {
state := "on"
if f.Disabled {
state = "off"
}
fmt.Fprintf(stdout, "%s [%s] (%s) — %s\n", f.Name, state, f.Type, f.Description)
if f.Source != "" {
fmt.Fprintf(stdout, " source: %s\n", f.Source)
}
if f.Agent != "" {
fmt.Fprintf(stdout, " agent: %s\n", f.Agent)
}
}
return 0
}
// loadAdaptationsConfig resolves the workspace config for the
// adaptations CLI surface. A workspace is required: a memory store
// only exists inside an initialised workspace, and toggling an
// adaptation that has never been recorded would be meaningless.
func loadAdaptationsConfig(stderr io.Writer, prefix string) (*config.Config, int) {
return loadInitedConfig(stderr, prefix)
}
added cmd/eeco/adaptations_test.go
@@ -0,0 +1,220 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
// seedAdaptation writes one feedback fact through the CLI surface,
// honouring the same required-provenance contract real callers do.
// Returns the workspace root for follow-up assertions.
func seedAdaptation(t *testing.T, name, agent string) string {
t.Helper()
root := initRepo(t)
var out, errOut bytes.Buffer
code := runAdd([]string{
"fact",
"--type", "feedback",
"--name", name,
"--description", "preference noted by the assistant",
"--provenance", "user said no",
"--agent", agent,
"a body",
}, &out, &errOut)
if code != 0 {
t.Fatalf("seed fact exit=%d stderr=%s", code, errOut.String())
}
return root
}
func TestRunAdaptations_OffThenOnRoundTrip(t *testing.T) {
root := seedAdaptation(t, "terse-feedback", "claude-opus-4-7")
var out, errOut bytes.Buffer
if code := runAdaptations([]string{"terse-feedback", "off"}, &out, &errOut); code != 0 {
t.Fatalf("off exit=%d stderr=%s", code, errOut.String())
}
body, err := os.ReadFile(filepath.Join(root, "tester", ".eeco", "memory", "terse-feedback.md"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), "disabled: true") {
t.Errorf("disabled: true missing after off:\n%s", body)
}
out.Reset()
errOut.Reset()
if code := runAdaptations([]string{"terse-feedback", "on"}, &out, &errOut); code != 0 {
t.Fatalf("on exit=%d stderr=%s", code, errOut.String())
}
body, err = os.ReadFile(filepath.Join(root, "tester", ".eeco", "memory", "terse-feedback.md"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(body), "disabled: true") {
t.Errorf("disabled: true should be cleared after on:\n%s", body)
}
}
func TestRunAdaptations_RegeneratesIndex(t *testing.T) {
root := seedAdaptation(t, "terse-feedback", "claude-opus-4-7")
indexPath := filepath.Join(root, "tester", ".eeco", "memory", "MEMORY.md")
var out, errOut bytes.Buffer
if code := runAdaptations([]string{"terse-feedback", "off"}, &out, &errOut); code != 0 {
t.Fatalf("off exit=%d stderr=%s", code, errOut.String())
}
idx, err := os.ReadFile(indexPath)
if err != nil {
t.Fatal(err)
}
disHdr := strings.Index(string(idx), "## disabled")
fact := strings.Index(string(idx), "**terse-feedback**")
if disHdr < 0 || fact < 0 || disHdr >= fact {
t.Errorf("disabled fact not listed under ## disabled after off:\n%s", idx)
}
out.Reset()
errOut.Reset()
if code := runAdaptations([]string{"terse-feedback", "on"}, &out, &errOut); code != 0 {
t.Fatalf("on exit=%d stderr=%s", code, errOut.String())
}
idx, err = os.ReadFile(indexPath)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(idx), "## disabled") {
t.Errorf("MEMORY.md still has a ## disabled section after on:\n%s", idx)
}
}
func TestRunAdaptations_OnAlreadyOnIsNoop(t *testing.T) {
seedAdaptation(t, "already-on", "agent")
var out, errOut bytes.Buffer
code := runAdaptations([]string{"already-on", "on"}, &out, &errOut)
if code != 0 {
t.Fatalf("on on-already-on exit=%d stderr=%s", code, errOut.String())
}
if !strings.Contains(out.String(), "already on") {
t.Errorf("expected already-on note, got:\n%s", out.String())
}
}
func TestRunAdaptations_UnknownName(t *testing.T) {
initRepo(t)
var out, errOut bytes.Buffer
code := runAdaptations([]string{"no-such-fact", "off"}, &out, &errOut)
if code != 2 {
t.Fatalf("unknown name exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), "no memory fact named") {
t.Errorf("stderr missing not-found message:\n%s", errOut.String())
}
}
func TestRunAdaptations_BadAction(t *testing.T) {
initRepo(t)
var out, errOut bytes.Buffer
code := runAdaptations([]string{"name", "toggle"}, &out, &errOut)
if code != 2 {
t.Fatalf("bad action exit=%d, want 2", code)
}
}
func TestRunAdaptations_NeedsTwoArgs(t *testing.T) {
initRepo(t)
var out, errOut bytes.Buffer
code := runAdaptations([]string{"only-one"}, &out, &errOut)
if code != 2 {
t.Fatalf("single arg exit=%d, want 2", code)
}
}
func TestRunShowAdaptations_EmptyState(t *testing.T) {
initRepo(t)
var out, errOut bytes.Buffer
code := runShow([]string{"adaptations"}, &out, &errOut)
if code != 0 {
t.Fatalf("empty show exit=%d stderr=%s", code, errOut.String())
}
if !strings.Contains(out.String(), "no AI adaptations") {
t.Errorf("empty-state hint missing:\n%s", out.String())
}
}
func TestRunShowAdaptations_ListsFeedbackAndUser(t *testing.T) {
root := initRepo(t)
// One feedback fact, one user fact, one project fact (should NOT show).
var out, errOut bytes.Buffer
if code := runAdd([]string{
"fact", "--type", "feedback", "--name", "f1",
"--description", "feedback one", "--provenance", "snippet", "body",
}, &out, &errOut); code != 0 {
t.Fatalf("seed feedback exit=%d", code)
}
out.Reset()
errOut.Reset()
if code := runAdd([]string{
"fact", "--type", "user", "--name", "u1",
"--description", "user role", "--provenance", "snippet", "body",
}, &out, &errOut); code != 0 {
t.Fatalf("seed user exit=%d", code)
}
out.Reset()
errOut.Reset()
if code := runAdd([]string{
"fact", "--type", "project", "--name", "p1",
"--description", "project fact", "body",
}, &out, &errOut); code != 0 {
t.Fatalf("seed project exit=%d", code)
}
// Flip one off so the listing shows both on and off.
out.Reset()
errOut.Reset()
if code := runAdaptations([]string{"u1", "off"}, &out, &errOut); code != 0 {
t.Fatalf("off exit=%d", code)
}
out.Reset()
errOut.Reset()
if code := runShow([]string{"adaptations"}, &out, &errOut); code != 0 {
t.Fatalf("show exit=%d stderr=%s", code, errOut.String())
}
listing := out.String()
for _, want := range []string{"f1 [on]", "u1 [off]", "(feedback)", "(user)"} {
if !strings.Contains(listing, want) {
t.Errorf("listing missing %q:\n%s", want, listing)
}
}
if strings.Contains(listing, "p1") {
t.Errorf("project fact should not appear in adaptations listing:\n%s", listing)
}
// Make sure the on-disk state is consistent.
body, err := os.ReadFile(filepath.Join(root, "tester", ".eeco", "memory", "u1.md"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), "disabled: true") {
t.Errorf("u1 file should carry disabled: true:\n%s", body)
}
}
func TestRunShowAdaptations_NotInitialized(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
code := runShow([]string{"adaptations"}, &out, &errOut)
if code != 1 {
t.Fatalf("uninit show exit=%d, want 1", code)
}
}
added cmd/eeco/ask.go
@@ -0,0 +1,75 @@
package main
import (
"fmt"
"io"
"strings"
"github.com/ajhahnde/eeco/internal/ask"
)
const askUsage = `usage:
eeco ask [--limit N] [--json] "<question>"
Answer a free-form question about this project with a deterministic,
no-AI-spend, ranked set of pointers: the matching memory facts first
(eeco's curated topic→file map) and then the best-matching code
locations as path:line references. Where 'eeco go' gives a one-shot
project overview, 'eeco ask' is the interactive counterpart — a fast,
precise pointer into the codebase, beyond the static brief.
No AI is called; relevance is a word-overlap score over what eeco
already tracks. The answer is reproducible for a given question and
tree. ask reads anywhere in the repo and writes nothing.
ask works in any git repository; the memory section is simply empty
when the workspace is not initialised.
flags:
--limit N maximum number of code locations to return (default 10)
--json print the answer as a JSON object instead of Markdown`
// runAsk answers a free-form question about the project. It calls no AI
// provider: the answer is a deterministic word-overlap search over the
// memory store and the repository's tracked files. It writes nothing.
func runAsk(args []string, stdout, stderr io.Writer) int {
fs := newFlagSet("ask", stderr, askUsage)
limit := fs.Int("limit", ask.DefaultLimit, "maximum number of code locations to return")
asJSON := fs.Bool("json", false, "print the answer as a JSON object instead of Markdown")
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() != 1 {
fmt.Fprintln(stderr, askUsage)
return 2
}
question := strings.TrimSpace(fs.Arg(0))
if question == "" {
fmt.Fprintln(stderr, "eeco ask: a question is required.")
fmt.Fprintln(stderr, askUsage)
return 2
}
cfg, code := loadRepoConfig(stderr, "eeco ask")
if code != 0 {
return code
}
res, err := ask.Search(cfg, question, *limit)
if err != nil {
fmt.Fprintln(stderr, "eeco ask:", err)
return 1
}
if *asJSON {
text, err := ask.RenderJSON(res)
if err != nil {
fmt.Fprintln(stderr, "eeco ask:", err)
return 1
}
fmt.Fprint(stdout, text)
return 0
}
fmt.Fprint(stdout, ask.Render(res))
return 0
}
added cmd/eeco/ask_test.go
@@ -0,0 +1,104 @@
package main
import (
"bytes"
"encoding/json"
"strings"
"testing"
)
func TestRunAsk_AnswersQuestion(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "go.mod", "module sample\n\ngo 1.24\n")
writeFile(t, root, "boot.go", "// boot path setup\nfunc boot() {}\n")
var out, errOut bytes.Buffer
if code := runAsk([]string{"boot path"}, &out, &errOut); code != 0 {
t.Fatalf("runAsk exit=%d stderr=%s", code, errOut.String())
}
body := out.String()
if !strings.Contains(body, "eeco ask:") {
t.Errorf("missing header:\n%s", body)
}
if !strings.Contains(body, "boot.go") {
t.Errorf("expected boot.go in the answer:\n%s", body)
}
}
func TestRunAsk_NoMatch(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "go.mod", "module sample\n\ngo 1.24\n")
var out, errOut bytes.Buffer
if code := runAsk([]string{"xyzzy nonexistentterm"}, &out, &errOut); code != 0 {
t.Fatalf("runAsk exit=%d, want 0 (an empty answer is still success)", code)
}
if !strings.Contains(out.String(), "No matches") {
t.Errorf("expected the no-matches guidance:\n%s", out.String())
}
}
func TestRunAsk_RequiresQuestion(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
if code := runAsk(nil, &out, &errOut); code != 2 {
t.Fatalf("runAsk with no question exit=%d, want 2", code)
}
if code := runAsk([]string{" "}, &out, &errOut); code != 2 {
t.Fatalf("runAsk with a blank question exit=%d, want 2", code)
}
if code := runAsk([]string{"too", "many"}, &out, &errOut); code != 2 {
t.Fatalf("runAsk with two positionals exit=%d, want 2", code)
}
}
func TestRunAsk_NotInRepo(t *testing.T) {
dir := t.TempDir()
chdir(t, dir)
var out, errOut bytes.Buffer
if code := runAsk([]string{"anything"}, &out, &errOut); code != 1 {
t.Fatalf("runAsk outside a repo exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "not inside a git repository") {
t.Errorf("stderr missing not-in-repo message:\n%s", errOut.String())
}
}
func TestRunAsk_JSON(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "go.mod", "module sample\n\ngo 1.24\n")
writeFile(t, root, "queue.go", "// the decision queue\nfunc queue() {}\n")
var out, errOut bytes.Buffer
if code := runAsk([]string{"--json", "queue"}, &out, &errOut); code != 0 {
t.Fatalf("runAsk --json exit=%d stderr=%s", code, errOut.String())
}
if !json.Valid(out.Bytes()) {
t.Fatalf("runAsk --json did not emit valid JSON:\n%s", out.String())
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
t.Fatal(err)
}
for _, k := range []string{"question", "memory", "code"} {
if _, ok := raw[k]; !ok {
t.Errorf("JSON missing frozen key %q:\n%s", k, out.String())
}
}
}
func TestRunAsk_RejectsBadFlag(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
if code := runAsk([]string{"--bogus", "q"}, &out, &errOut); code != 2 {
t.Fatalf("runAsk with an unknown flag exit=%d, want 2", code)
}
}
added cmd/eeco/authorize.go
@@ -0,0 +1,46 @@
package main
import (
"fmt"
"io"
"os"
"path/filepath"
)
const authorizeUsage = `usage:
eeco authorize commit allow ONE git commit through the cockpit git-write guard
eeco authorize tag allow ONE git tag mutation through the guard
Writes a one-shot authorization sentinel (15-min TTL, consumed on the next
matching git op) the cockpit machinery's PreToolUse guard checks before it
lets a git commit / tag mutation run. This turns an accidental one-step
autonomous write into a deliberate two-step act.`
// runAuthorize backs `eeco authorize commit|tag`. It drops a one-shot
// sentinel under <workspace>/state that ScanGitWriteGuard honors for 15
// minutes, then consumes. It is cwd-independent (config.Load resolves the
// repo root, never trusting $PWD past the nested-<username>/ cwd hazard).
func runAuthorize(args []string, stdout, stderr io.Writer) int {
if len(args) != 1 || (args[0] != "commit" && args[0] != "tag") {
fmt.Fprintln(stderr, authorizeUsage)
return 2
}
kind := args[0]
cfg, code := loadRepoConfig(stderr, "eeco authorize")
if code != 0 {
return code
}
stateDir := filepath.Join(cfg.Workspace, "state")
if err := os.MkdirAll(stateDir, 0o755); err != nil {
fmt.Fprintln(stderr, "eeco authorize:", err)
return 1
}
// Truncating-write refreshes mtime, so re-authorizing resets the TTL.
path := filepath.Join(stateDir, "git-"+kind+"-authorized")
if err := os.WriteFile(path, nil, 0o600); err != nil {
fmt.Fprintln(stderr, "eeco authorize:", err)
return 1
}
fmt.Fprintf(stdout, "eeco authorize: one %s op authorized (expires in 15 min, consumed on the next matching git op)\n", kind)
return 0
}
added cmd/eeco/authorize_test.go
@@ -0,0 +1,41 @@
package main
import (
"bytes"
"os"
"strings"
"testing"
)
func TestRunAuthorize_WritesSentinel(t *testing.T) {
root := setupInited(t)
var out, errOut bytes.Buffer
if code := runAuthorize([]string{"commit"}, &out, &errOut); code != 0 {
t.Fatalf("runAuthorize commit = %d, stderr=%q", code, errOut.String())
}
if _, err := os.Stat(wsPath(root, "state", "git-commit-authorized")); err != nil {
t.Errorf("commit sentinel not written: %v", err)
}
if !strings.Contains(out.String(), "authorized") {
t.Errorf("output missing confirmation: %q", out.String())
}
if code := runAuthorize([]string{"tag"}, &out, &errOut); code != 0 {
t.Fatalf("runAuthorize tag = %d, stderr=%q", code, errOut.String())
}
if _, err := os.Stat(wsPath(root, "state", "git-tag-authorized")); err != nil {
t.Errorf("tag sentinel not written: %v", err)
}
}
func TestRunAuthorize_RejectsBadArg(t *testing.T) {
var out, errOut bytes.Buffer
for _, args := range [][]string{{}, {"push"}, {"commit", "extra"}} {
out.Reset()
errOut.Reset()
if code := runAuthorize(args, &out, &errOut); code != 2 {
t.Errorf("runAuthorize(%v) = %d, want 2", args, code)
}
}
}
added cmd/eeco/cockpit.go
@@ -0,0 +1,495 @@
package main
import (
"fmt"
"io"
"sort"
"strings"
"github.com/ajhahnde/eeco/internal/cockpit"
"github.com/ajhahnde/eeco/internal/hooks"
"github.com/ajhahnde/eeco/internal/playbooks"
)
const cockpitUsage = `usage:
eeco cockpit generate [--target T] [--playbook P] emit the active cockpit (or one target/playbook), reversibly
eeco cockpit verify [--target T] [--playbook P] [--parity <answer-key>]
check the emitted artifacts match + hold the safety invariant
eeco cockpit off [--target T] [--playbook P] remove eeco's emitted artifacts (sha-gated, reversible)
eeco cockpit status show emitted-cockpit state, one line per artifact
eeco cockpit show [--playbook P] print the neutral playbook source (JSON)
eeco cockpit target list|add <t>|rm <t> manage the active harness target set
eeco cockpit machinery on|off|status|refresh emit the auto-firing git-write guard as harness config
Targets: claude (enforced) · cursor, agents, gemini (advisory — not harness-enforced).
With no --target, generate/verify/off act on the active set (eeco cockpit target list).
Exit 0 clean, 1 on a failure (drift, safety violation, missing), 2 on usage error.`
// runCockpit dispatches `eeco cockpit` subcommands. The cockpit generates the
// provider-agnostic AI cockpit for a harness from eeco's neutral playbook
// library, reversibly and with the safety invariant preserved on every target.
func runCockpit(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, cockpitUsage)
return 2
}
switch args[0] {
case "generate":
return runCockpitGenerate(args[1:], stdout, stderr)
case "verify":
return runCockpitVerify(args[1:], stdout, stderr)
case "off":
return runCockpitOff(args[1:], stdout, stderr)
case "status":
return runCockpitStatus(args[1:], stdout, stderr)
case "show":
return runCockpitShow(args[1:], stdout, stderr)
case "target":
return runCockpitTarget(args[1:], stdout, stderr)
case "machinery":
return runCockpitMachinery(args[1:], stdout, stderr)
default:
fmt.Fprintln(stderr, cockpitUsage)
return 2
}
}
// runCockpitMachinery manages the auto-firing deterministic machinery — the
// git-write guard the cockpit emits as a PreToolUse hook into the per-project
// <username>/.claude/settings.json. It is explicit opt-in (NOT emitted by
// `cockpit generate`), reversible, and fidelity-honest: the guard is enforced
// only on Claude (the one target with a runtime hook channel).
func runCockpitMachinery(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, cockpitUsage)
return 2
}
cfg, code := loadInitedConfig(stderr, "eeco cockpit")
if code != 0 {
return code
}
if args[0] == "status" {
for _, line := range hooks.CockpitMachineryStatus(cfg) {
fmt.Fprintln(stdout, line)
}
printMachineryFidelity(stdout)
return 0
}
var (
msg string
herr error
)
switch args[0] {
case "on":
msg, herr = hooks.EnableCockpitMachinery(cfg)
case "off":
msg, herr = hooks.DisableCockpitMachinery(cfg)
case "refresh":
msg, herr = hooks.RefreshCockpitMachinery(cfg)
default:
fmt.Fprintln(stderr, cockpitUsage)
return 2
}
if herr != nil {
fmt.Fprintln(stderr, "eeco cockpit:", herr)
return 1
}
fmt.Fprintln(stdout, "eeco cockpit:", msg)
return 0
}
// printMachineryFidelity prints, per known target, whether the auto-firing
// machinery is harness-enforced or advisory-only — the honest fidelity gap.
// Only claude has a runtime hook channel; the other targets carry the
// no-autonomous-write policy as prose in their emitted config, never as a
// runtime guard. Keeps the cockpit's anti-"quietly-lies" principle.
func printMachineryFidelity(stdout io.Writer) {
fmt.Fprintln(stdout, "fidelity by target:")
for _, t := range cockpit.Targets() {
enf, ok := cockpit.MachineryFidelity(t)
if !ok {
continue
}
note := "advisory — no runtime hook channel (policy carried as prose only)"
if enf == cockpit.EnforcementEnforced {
note = "enforced — runtime PreToolUse/SessionStart/Stop/PostToolUse hooks"
}
fmt.Fprintf(stdout, " %s: %s\n", t, note)
}
}
// resolveTargets returns the targets a generate/verify/off acts on: just
// flagTarget when set (a one-off, validated against the known set, need not be
// active), otherwise the active set from the selection store.
func resolveTargets(flagTarget string, sel cockpit.Selection) ([]string, error) {
if flagTarget != "" {
if _, ok := cockpit.TargetFidelity(flagTarget); !ok {
return nil, fmt.Errorf("unknown target %q (known: %s)", flagTarget, strings.Join(cockpit.Targets(), ", "))
}
return []string{flagTarget}, nil
}
return sel.Targets, nil
}
// resolvePlaybooks returns the playbook set for a per-playbook target: just
// flagName when set, otherwise the selection's playbooks (all registered when
// the selection does not narrow them).
func resolvePlaybooks(flagName string, sel cockpit.Selection) ([]cockpit.Playbook, error) {
if flagName != "" {
pb, err := playbooks.Get(flagName)
if err != nil {
return nil, err
}
return []cockpit.Playbook{pb}, nil
}
if len(sel.Playbooks) > 0 {
var out []cockpit.Playbook
for _, n := range sel.Playbooks {
pb, err := playbooks.Get(n)
if err != nil {
return nil, err
}
out = append(out, pb)
}
return out, nil
}
return playbooks.All(), nil
}
func runCockpitGenerate(args []string, stdout, stderr io.Writer) int {
fs := newFlagSet("cockpit generate", stderr, cockpitUsage)
target := fs.String("target", "", "harness target to emit for (default: the active set)")
name := fs.String("playbook", "", "playbook to emit (default: all)")
if err := fs.Parse(args); err != nil {
return 2
}
cfg, code := loadInitedConfig(stderr, "eeco cockpit")
if code != 0 {
return code
}
sel := cockpit.LoadSelection(cfg)
targets, err := resolveTargets(*target, sel)
if err != nil {
fmt.Fprintln(stderr, "eeco cockpit:", err)
return 1
}
pbs, err := resolvePlaybooks(*name, sel)
if err != nil {
fmt.Fprintln(stderr, "eeco cockpit:", err)
return 1
}
aggSet, err := resolvePlaybooks("", sel) // aggregate targets always emit the whole set
if err != nil {
fmt.Fprintln(stderr, "eeco cockpit:", err)
return 1
}
rc := 0
changed := false
for _, tg := range targets {
if cockpit.IsAggregateTarget(tg) {
if *name != "" {
fmt.Fprintf(stderr, "eeco cockpit: note: %s is an aggregate target — ignoring --playbook, emitting the whole set\n", tg)
}
res, gerr := cockpit.GenerateAll(cfg, aggSet, tg)
if gerr != nil {
fmt.Fprintln(stderr, "eeco cockpit:", gerr)
rc = 1
continue
}
changed = true
fmt.Fprintln(stdout, "eeco cockpit:", res.Message())
continue
}
for _, pb := range pbs {
res, gerr := cockpit.Generate(cfg, pb, tg)
if gerr != nil {
fmt.Fprintln(stderr, "eeco cockpit:", gerr)
rc = 1
continue
}
changed = true
fmt.Fprintln(stdout, "eeco cockpit:", res.Message())
}
}
if changed {
maybeAutoCommit(cfg.WorkspaceHistory.Auto(), cfg.UserDir, "cockpit generate", stderr)
}
return rc
}
func runCockpitVerify(args []string, stdout, stderr io.Writer) int {
fs := newFlagSet("cockpit verify", stderr, cockpitUsage)
target := fs.String("target", "", "harness target to verify (default: the active set)")
name := fs.String("playbook", "", "playbook to verify (default: all)")
parity := fs.String("parity", "", "answer-key SKILL.md to structurally compare against (claude only)")
if err := fs.Parse(args); err != nil {
return 2
}
cfg, code := loadInitedConfig(stderr, "eeco cockpit")
if code != 0 {
return code
}
// No scoping flags → the whole-cockpit drift check, the same orphan-aware
// engine the cockpit-sync builtin runs (one drift definition). A scoped
// verify (--target / --playbook / --parity) keeps the per-artifact path
// below; bare --parity must stay scoped (Sync runs no parity check).
if *target == "" && *name == "" && *parity == "" {
report, serr := cockpit.Sync(cfg, playbooks.All())
if serr != nil {
fmt.Fprintln(stderr, "eeco cockpit:", serr)
return 1
}
return reportSync(report, stdout, stderr)
}
sel := cockpit.LoadSelection(cfg)
targets, err := resolveTargets(*target, sel)
if err != nil {
fmt.Fprintln(stderr, "eeco cockpit:", err)
return 1
}
pbs, err := resolvePlaybooks(*name, sel)
if err != nil {
fmt.Fprintln(stderr, "eeco cockpit:", err)
return 1
}
aggSet, err := resolvePlaybooks("", sel)
if err != nil {
fmt.Fprintln(stderr, "eeco cockpit:", err)
return 1
}
rc := 0
for _, tg := range targets {
if cockpit.IsAggregateTarget(tg) {
res, verr := cockpit.VerifyAll(cfg, aggSet, tg)
if verr != nil {
fmt.Fprintln(stderr, "eeco cockpit:", verr)
rc = 1
continue
}
rc = reportVerify(res, stdout, stderr, rc)
continue
}
for _, pb := range pbs {
res, verr := cockpit.Verify(cfg, pb, tg, *parity)
if verr != nil {
fmt.Fprintln(stderr, "eeco cockpit:", verr)
rc = 1
continue
}
rc = reportVerify(res, stdout, stderr, rc)
}
}
return rc
}
// reportSync prints a whole-cockpit drift report: a single clean line to
// stdout (exit 0), or one finding per line to stderr (exit 1). It is the
// no-flag `verify` path, sharing cockpit.Sync with the cockpit-sync builtin.
func reportSync(r cockpit.SyncReport, stdout, stderr io.Writer) int {
if r.Clean {
fmt.Fprintln(stdout, "eeco cockpit: clean — cockpit artifacts match their sources")
return 0
}
for _, f := range r.Findings {
fmt.Fprintln(stderr, "eeco cockpit:", f.Detail)
}
return 1
}
// reportVerify prints a verify outcome to the right stream and folds a failure
// into the running exit code.
func reportVerify(res cockpit.VerifyResult, stdout, stderr io.Writer, rc int) int {
if !res.Clean {
fmt.Fprintln(stderr, "eeco cockpit:", res.Detail)
return 1
}
fmt.Fprintln(stdout, "eeco cockpit:", res.Detail)
return rc
}
func runCockpitOff(args []string, stdout, stderr io.Writer) int {
fs := newFlagSet("cockpit off", stderr, cockpitUsage)
target := fs.String("target", "", "harness target to remove (default: the active set)")
name := fs.String("playbook", "", "playbook to remove (default: all)")
if err := fs.Parse(args); err != nil {
return 2
}
cfg, code := loadInitedConfig(stderr, "eeco cockpit")
if code != 0 {
return code
}
sel := cockpit.LoadSelection(cfg)
targets, err := resolveTargets(*target, sel)
if err != nil {
fmt.Fprintln(stderr, "eeco cockpit:", err)
return 1
}
pbs, err := resolvePlaybooks(*name, sel)
if err != nil {
fmt.Fprintln(stderr, "eeco cockpit:", err)
return 1
}
changed := false
for _, tg := range targets {
if cockpit.IsAggregateTarget(tg) {
res, oerr := cockpit.OffAll(cfg, tg)
if oerr != nil {
fmt.Fprintln(stderr, "eeco cockpit:", oerr)
return 1
}
changed = changed || res.Changed
fmt.Fprintln(stdout, "eeco cockpit:", res.Message)
continue
}
for _, pb := range pbs {
res, oerr := cockpit.Off(cfg, pb, tg)
if oerr != nil {
fmt.Fprintln(stderr, "eeco cockpit:", oerr)
return 1
}
changed = changed || res.Changed
fmt.Fprintln(stdout, "eeco cockpit:", res.Message)
}
}
if changed {
maybeAutoCommit(cfg.WorkspaceHistory.Auto(), cfg.UserDir, "cockpit off", stderr)
}
return 0
}
func runCockpitStatus(args []string, stdout, stderr io.Writer) int {
fs := newFlagSet("cockpit status", stderr, cockpitUsage)
if err := fs.Parse(args); err != nil {
return 2
}
cfg, code := loadInitedConfig(stderr, "eeco cockpit")
if code != 0 {
return code
}
for _, line := range cockpit.Status(cfg) {
fmt.Fprintln(stdout, line)
}
return 0
}
func runCockpitShow(args []string, stdout, stderr io.Writer) int {
fs := newFlagSet("cockpit show", stderr, cockpitUsage)
name := fs.String("playbook", "handover", "playbook source to print")
if err := fs.Parse(args); err != nil {
return 2
}
raw, err := playbooks.Raw(*name)
if err != nil {
fmt.Fprintln(stderr, "eeco cockpit:", err)
return 1
}
fmt.Fprint(stdout, raw)
return 0
}
// runCockpitTarget manages the active harness target set in the selection
// store (<username>/.eeco/cockpit.json): list the active + available targets,
// or add/remove one. `rm` deselects only — it never deletes emitted files (it
// hints `eeco cockpit off`), keeping removal an explicit, reversible step.
func runCockpitTarget(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, cockpitUsage)
return 2
}
cfg, code := loadInitedConfig(stderr, "eeco cockpit")
if code != 0 {
return code
}
sel := cockpit.LoadSelection(cfg)
sub := args[0]
rest := args[1:]
switch sub {
case "list":
printTargetList(sel, stdout)
return 0
case "add", "rm":
if len(rest) < 1 {
fmt.Fprintf(stderr, "eeco cockpit: target %s needs a target name (known: %s)\n", sub, strings.Join(cockpit.Targets(), ", "))
return 2
}
t := rest[0]
if _, ok := cockpit.TargetFidelity(t); !ok {
fmt.Fprintf(stderr, "eeco cockpit: unknown target %q (known: %s)\n", t, strings.Join(cockpit.Targets(), ", "))
return 1
}
if sub == "add" {
sel.Targets = appendUnique(sel.Targets, t)
} else {
sel.Targets = removeTarget(sel.Targets, t)
}
if err := cockpit.SaveSelection(cfg, sel); err != nil {
fmt.Fprintln(stderr, "eeco cockpit:", err)
return 1
}
if sub == "add" {
fmt.Fprintf(stdout, "eeco cockpit: target %s added — run `eeco cockpit generate` to emit it\n", t)
} else {
fmt.Fprintf(stdout, "eeco cockpit: target %s deselected — emitted files remain; run `eeco cockpit off --target %s` to remove them\n", t, t)
}
return 0
default:
fmt.Fprintln(stderr, cockpitUsage)
return 2
}
}
// printTargetList prints the active targets (with fidelity) and the
// available-but-inactive ones.
func printTargetList(sel cockpit.Selection, stdout io.Writer) {
active := make(map[string]bool, len(sel.Targets))
fmt.Fprintln(stdout, "active targets:")
for _, t := range sel.Targets {
active[t] = true
fmt.Fprintf(stdout, " %s (%s)\n", t, targetFidelityLabel(t))
}
var inactive []string
for _, t := range cockpit.Targets() {
if !active[t] {
inactive = append(inactive, t)
}
}
sort.Strings(inactive)
if len(inactive) > 0 {
fmt.Fprintln(stdout, "available (inactive):")
for _, t := range inactive {
fmt.Fprintf(stdout, " %s (%s)\n", t, targetFidelityLabel(t))
}
}
}
func targetFidelityLabel(t string) string {
enf, ok := cockpit.TargetFidelity(t)
if !ok {
return "unknown"
}
return enf.String()
}
func appendUnique(set []string, t string) []string {
for _, s := range set {
if s == t {
return set
}
}
return append(set, t)
}
func removeTarget(set []string, t string) []string {
out := set[:0:0]
for _, s := range set {
if s != t {
out = append(out, s)
}
}
return out
}
added cmd/eeco/cockpit_init.go
@@ -0,0 +1,94 @@
package main
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/ajhahnde/eeco/internal/cockpit"
"github.com/ajhahnde/eeco/internal/config"
)
// initCockpitSelection records which AI harness(es) the operator uses, so
// `eeco cockpit generate` knows what to emit (C2). It runs once — a re-init
// never clobbers an existing selection — and is non-interactive safe: on EOF
// (piped/test init) it takes the detected-or-default set without stalling. It
// degrades (a warning to stderr), never fails init.
func initCockpitSelection(cfg *config.Config, root string, stdin io.Reader, stderr io.Writer) {
if cockpit.HasSelection(cfg) {
return
}
targets := promptCockpitTargets(root, stdin, stderr)
if err := cockpit.SaveSelection(cfg, cockpit.Selection{Targets: targets}); err != nil {
fmt.Fprintln(stderr, "eeco init: could not record cockpit targets:", err)
return
}
fmt.Fprintf(stderr, "eeco init: cockpit targets: %s (change with `eeco cockpit target add|rm`)\n", strings.Join(targets, " "))
}
// promptCockpitTargets asks which harness(es) the operator uses, pre-filling
// the default from any harness config already present in the repo. An empty
// answer or EOF takes the default; an answer naming no known target also falls
// back to it.
func promptCockpitTargets(root string, stdin io.Reader, stderr io.Writer) []string {
def := detectHarnessTargets(root)
if len(def) == 0 {
def = []string{"claude"}
}
fmt.Fprintf(stderr, "eeco init: which AI harness(es) do you use? [%s] (default %s): ",
strings.Join(cockpit.Targets(), " "), strings.Join(def, " "))
sc := bufio.NewScanner(stdin)
if sc.Scan() {
if picked := parseTargets(sc.Text()); len(picked) > 0 {
return picked
}
}
return def
}
// detectHarnessTargets returns the targets whose harness config already exists
// in the repo root, as a sensible prompt default.
func detectHarnessTargets(root string) []string {
var out []string
if isDir(filepath.Join(root, ".claude")) {
out = append(out, "claude")
}
if isDir(filepath.Join(root, ".cursor")) {
out = append(out, "cursor")
}
if fileExists(filepath.Join(root, "AGENTS.md")) {
out = append(out, "agents")
}
if fileExists(filepath.Join(root, "GEMINI.md")) {
out = append(out, "gemini")
}
return out
}
// parseTargets splits an answer on spaces/commas and keeps the known,
// deduplicated targets in first-seen order. Unknown tokens are dropped.
func parseTargets(line string) []string {
fields := strings.FieldsFunc(line, func(r rune) bool { return r == ' ' || r == ',' || r == '\t' })
seen := make(map[string]bool, len(fields))
var out []string
for _, f := range fields {
f = strings.ToLower(strings.TrimSpace(f))
if f == "" || seen[f] {
continue
}
if _, ok := cockpit.TargetFidelity(f); !ok {
continue
}
seen[f] = true
out = append(out, f)
}
return out
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
added cmd/eeco/cockpit_test.go
@@ -0,0 +1,312 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
// cockpitSkillPath is where the emitted handover SKILL.md lands for a repo
// rooted at root: <root>/tester/.claude/skills/handover/SKILL.md (the
// EECO_USERNAME=tester pin scopes UserDir to <root>/tester).
func cockpitSkillPath(root string) string {
return filepath.Join(root, "tester", ".claude", "skills", "handover", "SKILL.md")
}
func setupInited(t *testing.T) string {
t.Helper()
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "go.mod", "module sample\n\ngo 1.24\n")
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
return root
}
func TestRunCockpit_GenerateVerifyOff(t *testing.T) {
root := setupInited(t)
skill := cockpitSkillPath(root)
ledger := wsPath(root, "state", "cockpit.json")
// generate
var out, errOut bytes.Buffer
if code := runCockpit([]string{"generate"}, &out, &errOut); code != 0 {
t.Fatalf("generate exit=%d stderr=%s", code, errOut.String())
}
b, err := os.ReadFile(skill)
if err != nil {
t.Fatalf("SKILL.md not written: %v", err)
}
body := string(b)
if !strings.Contains(body, "name: handover\n") || !strings.Contains(body, "allowed-tools: ") {
t.Errorf("emitted SKILL.md frontmatter off:\n%s", body)
}
if strings.Contains(body, "Bash(git commit") || strings.Contains(body, "Bash(git push") {
t.Error("emitted allowlist contains a write-git verb")
}
if _, err := os.Stat(ledger); err != nil {
t.Fatalf("ledger not written: %v", err)
}
// re-generate is byte-idempotent (no new backup)
if code := runCockpit([]string{"generate"}, &bytes.Buffer{}, &errOut); code != 0 {
t.Fatalf("re-generate exit=%d stderr=%s", code, errOut.String())
}
b2, _ := os.ReadFile(skill)
if string(b2) != body {
t.Error("SKILL.md changed on a no-op re-generate")
}
backups, _ := os.ReadDir(wsPath(root, "state", "backups"))
if len(backups) != 0 {
t.Errorf("re-generate created %d backup(s), want 0", len(backups))
}
// verify clean
if code := runCockpit([]string{"verify"}, &bytes.Buffer{}, &errOut); code != 0 {
t.Fatalf("verify exit=%d stderr=%s", code, errOut.String())
}
// hand-edit → verify drifts (exit 1)
if err := os.WriteFile(skill, []byte("edited\n"), 0o644); err != nil {
t.Fatal(err)
}
if code := runCockpit([]string{"verify"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 1 {
t.Errorf("verify on a drifted artifact exit=%d, want 1", code)
}
// off leaves the edited file (sha mismatch)
if code := runCockpit([]string{"off"}, &bytes.Buffer{}, &errOut); code != 0 {
t.Fatalf("off exit=%d stderr=%s", code, errOut.String())
}
if _, err := os.Stat(skill); err != nil {
t.Error("off removed a hand-edited artifact")
}
// re-generate clean, then off removes it
if err := os.Remove(skill); err != nil {
t.Fatal(err)
}
if code := runCockpit([]string{"generate"}, &bytes.Buffer{}, &errOut); code != 0 {
t.Fatalf("re-generate exit=%d stderr=%s", code, errOut.String())
}
if code := runCockpit([]string{"off"}, &bytes.Buffer{}, &errOut); code != 0 {
t.Fatalf("off exit=%d stderr=%s", code, errOut.String())
}
if _, err := os.Stat(skill); !os.IsNotExist(err) {
t.Error("clean off did not remove the artifact")
}
}
func TestRunCockpit_Status(t *testing.T) {
root := setupInited(t)
var out bytes.Buffer
if code := runCockpit([]string{"status"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("status exit=%d", code)
}
if !strings.Contains(out.String(), "claude/handover: not emitted") {
t.Errorf("status before generate = %q", out.String())
}
_ = root
}
func TestRunCockpit_Show(t *testing.T) {
root := setupInited(t)
var out bytes.Buffer
if code := runCockpit([]string{"show"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("show exit=%d", code)
}
if !strings.Contains(out.String(), "\"name\": \"handover\"") {
t.Errorf("show output missing handover JSON:\n%s", out.String())
}
_ = root
}
func TestRunCockpit_UsageErrors(t *testing.T) {
if code := runCockpit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 2 {
t.Errorf("no-arg cockpit exit=%d, want 2", code)
}
if code := runCockpit([]string{"bogus"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 2 {
t.Errorf("unknown subcommand exit=%d, want 2", code)
}
}
func TestRunCockpit_NotInited(t *testing.T) {
dir := t.TempDir()
chdir(t, dir)
var errOut bytes.Buffer
if code := runCockpit([]string{"generate"}, &bytes.Buffer{}, &errOut); code != 1 {
t.Errorf("generate outside a repo exit=%d, want 1", code)
}
}
// TestRunCockpit_InitWritesSelection: a non-interactive init records the
// default active set (claude), and `target list` reflects it.
func TestRunCockpit_InitWritesSelection(t *testing.T) {
root := setupInited(t)
if _, err := os.Stat(wsPath(root, "cockpit.json")); err != nil {
t.Fatalf("init did not write the selection store: %v", err)
}
var out bytes.Buffer
if code := runCockpit([]string{"target", "list"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("target list exit=%d", code)
}
s := out.String()
if !strings.Contains(s, "active targets:") || !strings.Contains(s, "claude (enforced)") {
t.Errorf("target list missing active claude:\n%s", s)
}
if !strings.Contains(s, "agents (advisory)") {
t.Errorf("target list missing inactive advisory targets:\n%s", s)
}
}
// TestRunCockpit_TargetAddRm: add a target, see it active; rm it, gone.
// rm never deletes files (it only deselects).
func TestRunCockpit_TargetAddRm(t *testing.T) {
setupInited(t)
if code := runCockpit([]string{"target", "add", "cursor"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("target add cursor failed")
}
var l1 bytes.Buffer
runCockpit([]string{"target", "list"}, &l1, &bytes.Buffer{})
if !strings.Contains(l1.String(), "cursor (advisory)") || !strings.Contains(l1.String(), "active targets:\n claude") {
t.Errorf("cursor not active after add:\n%s", l1.String())
}
if code := runCockpit([]string{"target", "rm", "cursor"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("target rm cursor failed")
}
var l2 bytes.Buffer
runCockpit([]string{"target", "list"}, &l2, &bytes.Buffer{})
// cursor now appears only under the inactive list, not the active one.
if strings.Contains(l2.String(), "active targets:\n claude (enforced)\n cursor") {
t.Errorf("cursor still active after rm:\n%s", l2.String())
}
// Unknown target is rejected.
if code := runCockpit([]string{"target", "add", "bogus"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 1 {
t.Error("expected exit 1 for an unknown target add")
}
}
// TestRunCockpit_GenerateActiveSet_WithCursor: with claude+cursor active,
// generate emits the Claude SKILL.md and the Cursor .mdc for every playbook.
func TestRunCockpit_GenerateActiveSet_WithCursor(t *testing.T) {
root := setupInited(t)
if code := runCockpit([]string{"target", "add", "cursor"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("target add cursor failed")
}
if code := runCockpit([]string{"generate"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("generate failed")
}
if _, err := os.Stat(cockpitSkillPath(root)); err != nil {
t.Errorf("claude SKILL.md missing: %v", err)
}
mdc := filepath.Join(root, "tester", ".cursor", "rules", "handover.mdc")
b, err := os.ReadFile(mdc)
if err != nil {
t.Fatalf("cursor .mdc missing: %v", err)
}
if !strings.Contains(string(b), "ADVISORY ONLY") {
t.Error("cursor .mdc missing the ADVISORY banner")
}
// Other playbooks emitted too (active-set generate is all-playbooks).
if _, err := os.Stat(filepath.Join(root, "tester", ".cursor", "rules", "commit.mdc")); err != nil {
t.Errorf("commit .mdc missing: %v", err)
}
}
// TestRunCockpit_AggregateAdvisory: an aggregate target emits one shared
// advisory file; off removes only it and the private tree survives.
func TestRunCockpit_AggregateAdvisory(t *testing.T) {
root := setupInited(t)
if code := runCockpit([]string{"target", "add", "agents"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("target add agents failed")
}
if code := runCockpit([]string{"generate", "--target", "agents"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("generate --target agents failed")
}
agents := filepath.Join(root, "tester", "AGENTS.md")
b, err := os.ReadFile(agents)
if err != nil {
t.Fatalf("AGENTS.md missing: %v", err)
}
if !strings.Contains(string(b), "ADVISORY ONLY") || !strings.Contains(string(b), "## Fidelity report") {
t.Error("AGENTS.md missing advisory banner / fidelity report")
}
if code := runCockpit([]string{"verify", "--target", "agents"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Error("verify --target agents should be clean")
}
if code := runCockpit([]string{"off", "--target", "agents"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("off --target agents failed")
}
if _, err := os.Stat(agents); !os.IsNotExist(err) {
t.Error("AGENTS.md should be removed")
}
if _, err := os.Stat(filepath.Join(root, "tester")); err != nil {
t.Error("private tree must survive aggregate off")
}
}
// TestRunCockpit_UnknownTargetFlag: an unknown --target is exit 1.
func TestRunCockpit_UnknownTargetFlag(t *testing.T) {
setupInited(t)
if code := runCockpit([]string{"generate", "--target", "bogus"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 1 {
t.Error("expected exit 1 for an unknown --target")
}
}
// TestRunCockpit_VerifyNoFlagCleanUnused: no-flag verify on a cockpit that
// was never generated is a silent clean (empty-ledger gate), exit 0.
func TestRunCockpit_VerifyNoFlagCleanUnused(t *testing.T) {
setupInited(t)
var out bytes.Buffer
if code := runCockpit([]string{"verify"}, &out, &bytes.Buffer{}); code != 0 {
t.Errorf("no-flag verify on an unused cockpit = %d, want 0", code)
}
if !strings.Contains(out.String(), "clean") {
t.Errorf("verify clean line missing: %s", out.String())
}
}
// TestRunCockpit_VerifyNoFlagMissing: activating a target without generating
// it makes the no-flag verify report it missing (exit 1).
func TestRunCockpit_VerifyNoFlagMissing(t *testing.T) {
setupInited(t)
if code := runCockpit([]string{"generate"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("generate failed")
}
if code := runCockpit([]string{"target", "add", "cursor"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("target add cursor failed")
}
var errOut bytes.Buffer
if code := runCockpit([]string{"verify"}, &bytes.Buffer{}, &errOut); code != 1 {
t.Errorf("no-flag verify after target add = %d, want 1", code)
}
if !strings.Contains(errOut.String(), "not emitted") {
t.Errorf("verify did not report missing: %s", errOut.String())
}
}
// TestRunCockpit_VerifyNoFlagOrphan: generating then deselecting a target
// leaves an orphan the no-flag verify reports exactly once (dedup by
// target), exit 1.
func TestRunCockpit_VerifyNoFlagOrphan(t *testing.T) {
setupInited(t)
if code := runCockpit([]string{"target", "add", "cursor"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("target add cursor failed")
}
if code := runCockpit([]string{"generate"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("generate failed")
}
if code := runCockpit([]string{"target", "rm", "cursor"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("target rm cursor failed")
}
var errOut bytes.Buffer
if code := runCockpit([]string{"verify"}, &bytes.Buffer{}, &errOut); code != 1 {
t.Errorf("no-flag verify with an orphan = %d, want 1", code)
}
out := errOut.String()
if n := strings.Count(out, "deselected but artifact remains"); n != 1 {
t.Errorf("orphan reported %d time(s), want exactly 1 (dedup by target):\n%s", n, out)
}
}
added cmd/eeco/docs.go
@@ -0,0 +1,408 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/docs"
)
const docsUsage = `usage:
eeco docs <verb> [args]
verbs:
new <target> scaffold a tracked-tree doc at the repository root
refresh <target> re-render the marker-wrapped project-derived block of a scaffolded doc
compact <path> move marked regions of <path> into a sibling archive file
targets:
vision seed a VISION.md describing what the project is for
readme seed a README.md introducing the project to a new reader
The verb is invoked explicitly by the operator. eeco writes the
tracked-tree file but never stages or commits it (Constraint 6).`
const docsNewUsage = `usage:
eeco docs new [--overwrite] <target>
targets:
vision seed a VISION.md describing what the project is for
readme seed a README.md introducing the project to a new reader
Writes one file at the repository root. Refuses if the target file
already exists; pass --overwrite to replace it. Nothing is staged or
committed (Constraint 6).`
// runDocs dispatches `eeco docs <verb>`. The version is threaded
// through so a scaffolded doc can record the eeco version it was seeded
// with and so refresh can stamp the same version on the regenerated
// block.
func runDocs(args []string, version string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, docsUsage)
return 2
}
switch args[0] {
case "new":
return runDocsNew(args[1:], version, stdout, stderr)
case "refresh":
return runDocsRefresh(args[1:], version, stdout, stderr)
case "compact":
return runDocsCompact(args[1:], stdout, stderr)
default:
fmt.Fprintf(stderr, "eeco docs: unknown verb %q\n", args[0])
fmt.Fprintln(stderr, docsUsage)
return 2
}
}
// runDocsNew handles `eeco docs new <target>`. The target is validated
// against the docs package's supported set, the repo root resolved via
// internal/config, then docs.Scaffold renders the template. The write
// target is the repository root; the precedent is `eeco init`'s
// .gitignore write — a one-shot tracked-tree write on explicit
// operator invocation.
func runDocsNew(args []string, version string, stdout, stderr io.Writer) int {
fs := newFlagSet("docs new", stderr, docsNewUsage)
overwrite := fs.Bool("overwrite", false, "replace the target file if it already exists")
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() != 1 {
fmt.Fprintln(stderr, docsNewUsage)
return 2
}
targetArg := fs.Arg(0)
var target docs.Target
matched := false
supported := docs.AllTargets()
for _, t := range supported {
if string(t) == targetArg {
target = t
matched = true
break
}
}
if !matched {
names := make([]string, len(supported))
for i, t := range supported {
names[i] = string(t)
}
fmt.Fprintf(stderr, "eeco docs new: unknown target %q (supported: %s)\n",
targetArg, strings.Join(names, ", "))
return 2
}
cfg, code := loadRepoConfig(stderr, "eeco docs new")
if code != 0 {
return code
}
params := docs.Params{
Project: filepath.Base(cfg.RepoRoot),
Version: version,
HasReadme: fileExistsAt(filepath.Join(cfg.RepoRoot, "README.md")),
HasUsage: fileExistsAt(filepath.Join(cfg.RepoRoot, "docs", "USAGE.md")),
HasArch: fileExistsAt(filepath.Join(cfg.RepoRoot, "docs", "ARCHITECTURE.md")),
}
written, err := docs.Scaffold(target, cfg.RepoRoot, *overwrite, params)
if err != nil {
fmt.Fprintln(stderr, "eeco docs new:", err)
return 1
}
fmt.Fprintf(stdout, "wrote %s\n", written)
fmt.Fprintln(stdout, "next: edit, then `git add` and commit when ready.")
return 0
}
// fileExistsAt reports whether p resolves to a regular file (or
// dangling symlink target) rather than a directory or a missing entry.
func fileExistsAt(p string) bool {
info, err := os.Stat(p)
return err == nil && !info.IsDir()
}
const docsRefreshUsage = `usage:
eeco docs refresh <target>
targets:
vision re-render VISION.md's marker-wrapped block
readme re-render README.md's marker-wrapped block
Re-renders the project-state-derived block delimited by
<!-- eeco:docs:start -->
... rendered content ...
<!-- eeco:docs:end -->
Operator prose outside the marker pair is left untouched. Markers
inside fenced code blocks are ignored. Unmatched, nested, or
out-of-order markers exit 1 with a parse error; the file is not
touched.
A legacy scaffold (no markers) is auto-initialised: the freshly
rendered block is appended at EOF, marker-wrapped, with a blank-line
separator. The operator removes the prior in-place block manually.
Refuses if the target file does not exist (run "eeco docs new
<target>" first). eeco writes the file but never stages or commits
it (Constraint 6).`
// runDocsRefresh handles `eeco docs refresh <target>`. The target is
// validated against the docs package's supported set, the repo root
// resolved via internal/config, then docs.Refresh re-renders the
// marker-wrapped block. The companion-doc presence (HasReadme /
// HasUsage / HasArch) is recomputed from the live tree so a freshly
// added docs/USAGE.md surfaces in the next refresh.
func runDocsRefresh(args []string, version string, stdout, stderr io.Writer) int {
fs := newFlagSet("docs refresh", stderr, docsRefreshUsage)
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() != 1 {
fmt.Fprintln(stderr, docsRefreshUsage)
return 2
}
targetArg := fs.Arg(0)
var target docs.Target
matched := false
supported := docs.AllTargets()
for _, t := range supported {
if string(t) == targetArg {
target = t
matched = true
break
}
}
if !matched {
names := make([]string, len(supported))
for i, t := range supported {
names[i] = string(t)
}
fmt.Fprintf(stderr, "eeco docs refresh: unknown target %q (supported: %s)\n",
targetArg, strings.Join(names, ", "))
return 2
}
cfg, code := loadRepoConfig(stderr, "eeco docs refresh")
if code != 0 {
return code
}
params := docs.Params{
Project: filepath.Base(cfg.RepoRoot),
Version: version,
HasReadme: fileExistsAt(filepath.Join(cfg.RepoRoot, "README.md")),
HasUsage: fileExistsAt(filepath.Join(cfg.RepoRoot, "docs", "USAGE.md")),
HasArch: fileExistsAt(filepath.Join(cfg.RepoRoot, "docs", "ARCHITECTURE.md")),
}
rep, err := docs.Refresh(target, cfg.RepoRoot, params)
if err != nil {
fmt.Fprintln(stderr, "eeco docs refresh:", err)
return 1
}
switch rep.Action {
case docs.RefreshReplaced:
fmt.Fprintf(stdout, "refreshed %s (replaced docs block)\n", rep.Path)
case docs.RefreshAppended:
fmt.Fprintf(stdout, "refreshed %s (no markers found — appended a fresh block at EOF; remove the prior in-place content manually)\n", rep.Path)
case docs.RefreshNoop:
fmt.Fprintf(stdout, "%s: docs block already current (no-op)\n", rep.Path)
}
if rep.Action != docs.RefreshNoop {
fmt.Fprintln(stdout, "next: review the result and `git add` / commit when ready.")
}
return 0
}
const docsCompactUsage = `usage:
eeco docs compact [--archive <path>] [--dry-run] <path>
eeco docs compact --keep-last <N> --heading <prefix> [--archive <path>] [--dry-run] <path>
Marker mode (default) moves every region of <path> delimited by
<!-- eeco:archive:start -->
... content ...
<!-- eeco:archive:end -->
into a sibling archive file, leaving a single-line pointer stub in
place at each cut.
Heading mode (--keep-last) discovers the regions by heading instead of
markers: it keeps the N most-recent sections that begin with --heading
(newest = topmost in the file) and archives everything older. A section
runs from its heading to the next heading of the same or a higher level,
so a live tail such as "## Next session" is never swallowed. The two
modes are mutually exclusive. In both modes, markers and headings inside
fenced code blocks are ignored.
flags:
--keep-last <N> keep the N most-recent --heading sections (newest on
top); archive the rest. Requires --heading.
--heading <prefix> heading-line prefix that delimits an archivable
section, e.g. "## Snapshot". Required with --keep-last.
--archive <path> repo-relative archive destination (default:
<basename>.archive<ext> next to <path>)
--dry-run print the regions that would move and exit;
write nothing
Re-running with nothing left to archive is an idempotent no-op. The
source path must resolve inside the repository. eeco writes the files
but never stages or commits them (Constraint 6).`
// runDocsCompact handles `eeco docs compact <path>`. Source + archive
// paths are repo-relative and validated against the repo root before
// the package-level Compact runs. Repo escape (absolute paths, `..`
// traversal) is rejected, mirroring `bug_report_dir` / `context_path`.
func runDocsCompact(args []string, stdout, stderr io.Writer) int {
fs := newFlagSet("docs compact", stderr, docsCompactUsage)
archiveFlag := fs.String("archive", "", "repo-relative archive destination (default: <basename>.archive<ext> next to source)")
dryRun := fs.Bool("dry-run", false, "print the regions that would move and exit; write nothing")
keepLast := fs.Int("keep-last", -1, "keep the N most-recent --heading sections (newest on top); archive the rest")
heading := fs.String("heading", "", "heading-line prefix that delimits an archivable section (required with --keep-last)")
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() != 1 {
fmt.Fprintln(stderr, docsCompactUsage)
return 2
}
sourceArg := fs.Arg(0)
// Heading mode is selected by --keep-last; -1 is the unset sentinel.
// The two flags are paired: each requires the other.
headingMode := *keepLast >= 0
if headingMode && *heading == "" {
fmt.Fprintln(stderr, "eeco docs compact: --keep-last requires --heading <prefix>")
return 1
}
if !headingMode && *heading != "" {
fmt.Fprintln(stderr, "eeco docs compact: --heading requires --keep-last <N>")
return 1
}
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintln(stderr, "eeco docs compact:", err)
return 1
}
cfg, err := config.Load(cwd, config.DefaultWorkspace)
if err != nil {
if errors.Is(err, config.ErrNotInRepo) {
fmt.Fprintln(stderr, "eeco docs compact: not inside a git repository.")
return 1
}
fmt.Fprintln(stderr, "eeco docs compact:", err)
return 1
}
sourceAbs, sourceRel, err := resolveRepoPath(cfg.RepoRoot, cwd, sourceArg)
if err != nil {
fmt.Fprintln(stderr, "eeco docs compact:", err)
return 1
}
info, err := os.Stat(sourceAbs)
if err != nil {
fmt.Fprintln(stderr, "eeco docs compact:", err)
return 1
}
if info.IsDir() {
fmt.Fprintf(stderr, "eeco docs compact: %s is a directory\n", sourceRel)
return 1
}
archiveArg := *archiveFlag
if archiveArg == "" {
archiveArg = defaultArchiveSibling(sourceRel)
}
archiveAbs, archiveRel, err := resolveRepoPath(cfg.RepoRoot, cwd, archiveArg)
if err != nil {
fmt.Fprintln(stderr, "eeco docs compact:", err)
return 1
}
if sourceAbs == archiveAbs {
fmt.Fprintln(stderr, "eeco docs compact: --archive must not equal the source path")
return 1
}
var rep docs.CompactReport
if headingMode {
rep, err = docs.CompactKeepLast(sourceAbs, archiveAbs, *dryRun, *heading, *keepLast)
} else {
rep, err = docs.Compact(sourceAbs, archiveAbs, *dryRun)
}
if err != nil {
fmt.Fprintln(stderr, "eeco docs compact:", err)
return 1
}
if len(rep.Regions) == 0 {
if headingMode {
fmt.Fprintf(stdout, "%s: nothing to archive — keeping the latest %d %q section(s) (no-op)\n", sourceRel, *keepLast, *heading)
} else {
fmt.Fprintf(stdout, "%s: no archive markers found (no-op)\n", sourceRel)
}
return 0
}
verb := "moved"
if *dryRun {
verb = "would move"
}
fmt.Fprintf(stdout, "%s %d region(s) from %s to %s\n", verb, len(rep.Regions), sourceRel, archiveRel)
for _, r := range rep.Regions {
fmt.Fprintf(stdout, " lines %d–%d\n", r.StartLine, r.EndLine)
}
if !*dryRun {
fmt.Fprintln(stdout, "next: review the result and `git add` / commit when ready.")
}
return 0
}
// resolveRepoPath validates rawArg as a repo-relative path, refusing
// absolute paths, `..` traversal that escapes the repo, and unix-style
// `/abs` prefixes on Windows. Returns the absolute path and the
// repo-relative path (the latter used in messages).
func resolveRepoPath(repoRoot, cwd, rawArg string) (absPath, relPath string, err error) {
if rawArg == "" {
return "", "", fmt.Errorf("path must not be empty")
}
if strings.HasPrefix(rawArg, "/") || strings.HasPrefix(rawArg, `\`) || filepath.IsAbs(rawArg) {
return "", "", fmt.Errorf("path must be repo-relative (got %q)", rawArg)
}
base := cwd
if base == "" {
base = repoRoot
}
full := filepath.Clean(filepath.Join(base, rawArg))
rel, err := filepath.Rel(repoRoot, full)
if err != nil {
return "", "", fmt.Errorf("path resolves outside the repo (%q)", rawArg)
}
rel = filepath.ToSlash(rel)
if rel == ".." || strings.HasPrefix(rel, "../") {
return "", "", fmt.Errorf("path escapes the repo (got %q)", rawArg)
}
return full, rel, nil
}
// defaultArchiveSibling derives `<basename>.archive<ext>` from a source
// path so the default destination lives next to the source.
func defaultArchiveSibling(sourceRel string) string {
dir := filepath.Dir(sourceRel)
ext := filepath.Ext(sourceRel)
stem := strings.TrimSuffix(filepath.Base(sourceRel), ext)
name := stem + ".archive" + ext
if dir == "" || dir == "." {
return name
}
return filepath.ToSlash(filepath.Join(dir, name))
}
added cmd/eeco/docs_test.go
@@ -0,0 +1,685 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestRunDocs_NoVerb(t *testing.T) {
var out, errOut bytes.Buffer
code := runDocs(nil, "v1.10.0", &out, &errOut)
if code != 2 {
t.Fatalf("runDocs with no verb exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), "verbs:") {
t.Errorf("stderr missing usage block:\n%s", errOut.String())
}
}
func TestRunDocs_UnknownVerb(t *testing.T) {
var out, errOut bytes.Buffer
code := runDocs([]string{"manifesto"}, "v1.10.0", &out, &errOut)
if code != 2 {
t.Fatalf("unknown verb exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), `unknown verb "manifesto"`) {
t.Errorf("stderr should name the unknown verb:\n%s", errOut.String())
}
}
func TestRunDocsRefresh_ReplacesReadmeBlock(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
code := runDocs([]string{"new", "readme"}, "v2.7.0", &out, &errOut)
if code != 0 {
t.Fatalf("scaffold exit=%d stderr=%s", code, errOut.String())
}
// Drop a docs/USAGE.md so a refresh has something new to surface.
if err := os.MkdirAll(filepath.Join(root, "docs"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "docs", "USAGE.md"), []byte("usage\n"), 0o644); err != nil {
t.Fatal(err)
}
out.Reset()
errOut.Reset()
code = runDocs([]string{"refresh", "readme"}, "v2.8.0", &out, &errOut)
if code != 0 {
t.Fatalf("refresh exit=%d stderr=%s", code, errOut.String())
}
if !strings.Contains(out.String(), "refreshed README.md") {
t.Errorf("stdout missing refresh confirmation:\n%s", out.String())
}
body, err := os.ReadFile(filepath.Join(root, "README.md"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), "[docs/USAGE.md](docs/USAGE.md)") {
t.Errorf("refresh did not pick up the new USAGE companion:\n%s", body)
}
}
func TestRunDocsRefresh_MissingFile(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
code := runDocs([]string{"refresh", "readme"}, "v2.8.0", &out, &errOut)
if code != 1 {
t.Fatalf("refresh on missing file exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "does not exist") {
t.Errorf("stderr missing not-exist message:\n%s", errOut.String())
}
}
func TestRunDocsRefresh_UnknownTarget(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
code := runDocs([]string{"refresh", "manifesto"}, "v2.8.0", &out, &errOut)
if code != 2 {
t.Fatalf("unknown target exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), `unknown target "manifesto"`) {
t.Errorf("stderr should name the unknown target:\n%s", errOut.String())
}
}
func TestRunDocsRefresh_NoTargetArg(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
code := runDocs([]string{"refresh"}, "v2.8.0", &out, &errOut)
if code != 2 {
t.Fatalf("missing target exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), "targets:") {
t.Errorf("stderr missing usage block:\n%s", errOut.String())
}
}
func TestRunDocs_UsageListsRefresh(t *testing.T) {
var out, errOut bytes.Buffer
code := runDocs(nil, "v2.8.0", &out, &errOut)
if code != 2 {
t.Fatalf("runDocs with no verb exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), "refresh <target>") {
t.Errorf("usage block missing refresh verb:\n%s", errOut.String())
}
}
func TestRunDocsNew_WritesVision(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
code := runDocs([]string{"new", "vision"}, "v1.10.0", &out, &errOut)
if code != 0 {
t.Fatalf("runDocs new vision exit=%d stderr=%s", code, errOut.String())
}
body, err := os.ReadFile(filepath.Join(root, "VISION.md"))
if err != nil {
t.Fatalf("VISION.md not written: %v", err)
}
repoName := filepath.Base(root)
if !strings.Contains(string(body), repoName+" — vision") {
t.Errorf("VISION.md missing project heading:\n%s", body)
}
if !strings.Contains(string(body), "eeco v1.10.0") {
t.Errorf("VISION.md missing version stamp:\n%s", body)
}
if !strings.Contains(out.String(), "wrote VISION.md") {
t.Errorf("stdout missing wrote confirmation:\n%s", out.String())
}
if !strings.Contains(out.String(), "git add") {
t.Errorf("stdout missing follow-up hint:\n%s", out.String())
}
}
func TestRunDocsNew_RefusesExisting(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "VISION.md", "operator wrote this\n")
var out, errOut bytes.Buffer
code := runDocs([]string{"new", "vision"}, "v1.10.0", &out, &errOut)
if code != 1 {
t.Fatalf("runDocs new vision on existing file exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "already exists") {
t.Errorf("stderr missing already-exists message:\n%s", errOut.String())
}
if !strings.Contains(errOut.String(), "--overwrite") {
t.Errorf("stderr should suggest --overwrite:\n%s", errOut.String())
}
body, err := os.ReadFile(filepath.Join(root, "VISION.md"))
if err != nil {
t.Fatal(err)
}
if string(body) != "operator wrote this\n" {
t.Errorf("existing file was mutated: %q", body)
}
}
func TestRunDocsNew_OverwriteReplaces(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "VISION.md", "stale\n")
var out, errOut bytes.Buffer
code := runDocs([]string{"new", "--overwrite", "vision"}, "v1.10.0", &out, &errOut)
if code != 0 {
t.Fatalf("runDocs new vision --overwrite exit=%d stderr=%s", code, errOut.String())
}
body, err := os.ReadFile(filepath.Join(root, "VISION.md"))
if err != nil {
t.Fatal(err)
}
repoName := filepath.Base(root)
if !strings.Contains(string(body), repoName+" — vision") {
t.Errorf("VISION.md not replaced by template:\n%s", body)
}
}
func TestRunDocsNew_WritesReadme(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
code := runDocs([]string{"new", "readme"}, "v2.6.0", &out, &errOut)
if code != 0 {
t.Fatalf("runDocs new readme exit=%d stderr=%s", code, errOut.String())
}
body, err := os.ReadFile(filepath.Join(root, "README.md"))
if err != nil {
t.Fatalf("README.md not written: %v", err)
}
repoName := filepath.Base(root)
if !strings.Contains(string(body), "# "+repoName) {
t.Errorf("README.md missing project heading:\n%s", body)
}
if !strings.Contains(string(body), "eeco v2.6.0") {
t.Errorf("README.md missing version stamp:\n%s", body)
}
if !strings.Contains(string(body), "## Quick start") {
t.Errorf("README.md missing Quick start heading:\n%s", body)
}
if !strings.Contains(out.String(), "wrote README.md") {
t.Errorf("stdout missing wrote confirmation:\n%s", out.String())
}
}
func TestRunDocs_ListsBothTargets(t *testing.T) {
var out, errOut bytes.Buffer
code := runDocs(nil, "v2.6.0", &out, &errOut)
if code != 2 {
t.Fatalf("runDocs with no verb exit=%d, want 2", code)
}
body := errOut.String()
for _, want := range []string{"vision", "readme"} {
if !strings.Contains(body, want) {
t.Errorf("usage block missing target %q:\n%s", want, body)
}
}
}
func TestRunDocsNew_ReadmeRefusesExisting(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "README.md", "operator wrote this\n")
var out, errOut bytes.Buffer
code := runDocs([]string{"new", "readme"}, "v2.6.0", &out, &errOut)
if code != 1 {
t.Fatalf("runDocs new readme on existing file exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "already exists") {
t.Errorf("stderr missing already-exists message:\n%s", errOut.String())
}
if !strings.Contains(errOut.String(), "--overwrite") {
t.Errorf("stderr should suggest --overwrite:\n%s", errOut.String())
}
body, err := os.ReadFile(filepath.Join(root, "README.md"))
if err != nil {
t.Fatal(err)
}
if string(body) != "operator wrote this\n" {
t.Errorf("existing file was mutated: %q", body)
}
}
func TestRunDocsNew_ReadmeOverwriteReplaces(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "README.md", "stale\n")
var out, errOut bytes.Buffer
code := runDocs([]string{"new", "--overwrite", "readme"}, "v2.6.0", &out, &errOut)
if code != 0 {
t.Fatalf("runDocs new readme --overwrite exit=%d stderr=%s", code, errOut.String())
}
body, err := os.ReadFile(filepath.Join(root, "README.md"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), "## Quick start") {
t.Errorf("README.md not replaced by template:\n%s", body)
}
}
func TestRunDocsNew_UnknownTarget(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
code := runDocs([]string{"new", "manifesto"}, "v1.10.0", &out, &errOut)
if code != 2 {
t.Fatalf("unknown target exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), `unknown target "manifesto"`) {
t.Errorf("stderr should name the unknown target:\n%s", errOut.String())
}
if !strings.Contains(errOut.String(), "vision") {
t.Errorf("stderr should list supported targets:\n%s", errOut.String())
}
if _, err := os.Stat(filepath.Join(root, "VISION.md")); !os.IsNotExist(err) {
t.Errorf("unknown target should not have written VISION.md (err=%v)", err)
}
}
func TestRunDocsNew_NoTargetArg(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
code := runDocs([]string{"new"}, "v1.10.0", &out, &errOut)
if code != 2 {
t.Fatalf("missing target exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), "targets:") {
t.Errorf("stderr missing usage block:\n%s", errOut.String())
}
}
func TestRunDocsNew_NotInRepo(t *testing.T) {
dir := t.TempDir()
chdir(t, dir)
var out, errOut bytes.Buffer
code := runDocs([]string{"new", "vision"}, "v1.10.0", &out, &errOut)
if code != 1 {
t.Fatalf("outside a git repo exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "not inside a git repository") {
t.Errorf("stderr missing not-in-repo message:\n%s", errOut.String())
}
}
func TestRunDocsNew_UnknownFlag(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
code := runDocs([]string{"new", "--bogus", "vision"}, "v1.10.0", &out, &errOut)
if code != 2 {
t.Fatalf("unknown flag exit=%d, want 2", code)
}
}
func TestRunDocsCompact_NoArgs(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
code := runDocs([]string{"compact"}, "v1.19.0", &out, &errOut)
if code != 2 {
t.Fatalf("missing path exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), "<!-- eeco:archive:start -->") {
t.Errorf("stderr missing usage block:\n%s", errOut.String())
}
}
func TestRunDocsCompact_MovesRegion(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "doc.md", "head\n<!-- eeco:archive:start -->\nold\n<!-- eeco:archive:end -->\ntail\n")
var out, errOut bytes.Buffer
code := runDocs([]string{"compact", "doc.md"}, "v1.19.0", &out, &errOut)
if code != 0 {
t.Fatalf("compact exit=%d stderr=%s", code, errOut.String())
}
if !strings.Contains(out.String(), "moved 1 region(s) from doc.md to doc.archive.md") {
t.Errorf("stdout missing summary:\n%s", out.String())
}
if !strings.Contains(out.String(), "lines 2") {
t.Errorf("stdout missing per-region line numbers:\n%s", out.String())
}
src, err := os.ReadFile(filepath.Join(root, "doc.md"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(src), "old") {
t.Errorf("source still carries archived body:\n%s", src)
}
if !strings.Contains(string(src), "> _archived to `doc.archive.md`") {
t.Errorf("source missing pointer stub:\n%s", src)
}
arch, err := os.ReadFile(filepath.Join(root, "doc.archive.md"))
if err != nil {
t.Fatalf("archive not created: %v", err)
}
if !strings.Contains(string(arch), "<!-- archived from doc.md -->") {
t.Errorf("archive missing provenance header:\n%s", arch)
}
}
func TestRunDocsCompact_DryRunWritesNothing(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
original := "<!-- eeco:archive:start -->\nold\n<!-- eeco:archive:end -->\n"
writeFile(t, root, "doc.md", original)
var out, errOut bytes.Buffer
code := runDocs([]string{"compact", "--dry-run", "doc.md"}, "v1.19.0", &out, &errOut)
if code != 0 {
t.Fatalf("dry-run exit=%d stderr=%s", code, errOut.String())
}
if !strings.Contains(out.String(), "would move 1 region(s)") {
t.Errorf("stdout missing dry-run verb:\n%s", out.String())
}
src, err := os.ReadFile(filepath.Join(root, "doc.md"))
if err != nil {
t.Fatal(err)
}
if string(src) != original {
t.Errorf("dry-run mutated source:\nwant: %q\ngot: %q", original, src)
}
if _, err := os.Stat(filepath.Join(root, "doc.archive.md")); !os.IsNotExist(err) {
t.Errorf("dry-run created archive file: err=%v", err)
}
}
func TestRunDocsCompact_NoMarkers(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "doc.md", "nothing marked\n")
var out, errOut bytes.Buffer
code := runDocs([]string{"compact", "doc.md"}, "v1.19.0", &out, &errOut)
if code != 0 {
t.Fatalf("no-markers exit=%d stderr=%s", code, errOut.String())
}
if !strings.Contains(out.String(), "no archive markers found") {
t.Errorf("stdout missing no-op message:\n%s", out.String())
}
}
func TestRunDocsCompact_UnmatchedMarker(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "doc.md", "<!-- eeco:archive:start -->\nbody\n")
var out, errOut bytes.Buffer
code := runDocs([]string{"compact", "doc.md"}, "v1.19.0", &out, &errOut)
if code != 1 {
t.Fatalf("unmatched marker exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "no matching end") {
t.Errorf("stderr missing parse-error detail:\n%s", errOut.String())
}
}
func TestRunDocsCompact_AbsolutePathRejected(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
code := runDocs([]string{"compact", "/etc/hosts"}, "v1.19.0", &out, &errOut)
if code != 1 {
t.Fatalf("absolute path exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "repo-relative") {
t.Errorf("stderr missing repo-relative reason:\n%s", errOut.String())
}
}
func TestRunDocsCompact_EscapingPathRejected(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
code := runDocs([]string{"compact", "../escape.md"}, "v1.19.0", &out, &errOut)
if code != 1 {
t.Fatalf("escaping path exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "escapes the repo") {
t.Errorf("stderr missing escape reason:\n%s", errOut.String())
}
}
func TestRunDocsCompact_NotInRepo(t *testing.T) {
dir := t.TempDir()
chdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "doc.md"), []byte("x\n"), 0o644); err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
code := runDocs([]string{"compact", "doc.md"}, "v1.19.0", &out, &errOut)
if code != 1 {
t.Fatalf("outside repo exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "not inside a git repository") {
t.Errorf("stderr missing not-in-repo message:\n%s", errOut.String())
}
}
func TestRunDocsCompact_CustomArchiveFlag(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "doc.md", "<!-- eeco:archive:start -->\nx\n<!-- eeco:archive:end -->\n")
var out, errOut bytes.Buffer
code := runDocs([]string{"compact", "--archive", "history.md", "doc.md"}, "v1.19.0", &out, &errOut)
if code != 0 {
t.Fatalf("compact --archive exit=%d stderr=%s", code, errOut.String())
}
if _, err := os.Stat(filepath.Join(root, "history.md")); err != nil {
t.Fatalf("custom archive not written: %v", err)
}
src, err := os.ReadFile(filepath.Join(root, "doc.md"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(src), "> _archived to `history.md`") {
t.Errorf("stub should reference custom archive:\n%s", src)
}
}
func TestRunDocsCompact_SameSourceAndArchive(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "doc.md", "head\n")
var out, errOut bytes.Buffer
code := runDocs([]string{"compact", "--archive", "doc.md", "doc.md"}, "v1.19.0", &out, &errOut)
if code != 1 {
t.Fatalf("same source/archive exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "must not equal the source") {
t.Errorf("stderr should reject same path:\n%s", errOut.String())
}
}
func TestRunDocsCompact_UnknownFlag(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
code := runDocs([]string{"compact", "--bogus", "doc.md"}, "v1.19.0", &out, &errOut)
if code != 2 {
t.Fatalf("unknown flag exit=%d, want 2", code)
}
}
// resumeShaped is a small RESUME-style fixture: newest snapshot on top,
// a live "## Next session" tail after the oldest.
const resumeShaped = "# RESUME\n\n" +
"## Snapshot — session 3\nc3\n\n" +
"## Snapshot — session 2\nc2\n\n" +
"## Snapshot — session 1\nc1\n\n" +
"## Next session\nlive tail\n"
func TestRunDocsCompact_KeepLastMovesOldest(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "RESUME.md", resumeShaped)
var out, errOut bytes.Buffer
code := runDocs([]string{"compact", "--keep-last", "2", "--heading", "## Snapshot", "RESUME.md"}, "v1.19.0", &out, &errOut)
if code != 0 {
t.Fatalf("keep-last exit=%d stderr=%s", code, errOut.String())
}
if !strings.Contains(out.String(), "moved 1 region(s) from RESUME.md to RESUME.archive.md") {
t.Errorf("stdout missing summary:\n%s", out.String())
}
src, err := os.ReadFile(filepath.Join(root, "RESUME.md"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(src), "## Snapshot — session 1") {
t.Errorf("source still carries the oldest snapshot:\n%s", src)
}
if !strings.Contains(string(src), "## Next session") || !strings.Contains(string(src), "## Snapshot — session 3") {
t.Errorf("source dropped a kept block:\n%s", src)
}
if !strings.Contains(string(src), "> _archived to `RESUME.archive.md`") {
t.Errorf("source missing pointer stub:\n%s", src)
}
arch, err := os.ReadFile(filepath.Join(root, "RESUME.archive.md"))
if err != nil {
t.Fatalf("archive not created: %v", err)
}
if !strings.Contains(string(arch), "## Snapshot — session 1") {
t.Errorf("archive missing the moved section:\n%s", arch)
}
}
func TestRunDocsCompact_KeepLastDryRun(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "RESUME.md", resumeShaped)
var out, errOut bytes.Buffer
code := runDocs([]string{"compact", "--keep-last", "2", "--heading", "## Snapshot", "--dry-run", "RESUME.md"}, "v1.19.0", &out, &errOut)
if code != 0 {
t.Fatalf("keep-last dry-run exit=%d stderr=%s", code, errOut.String())
}
if !strings.Contains(out.String(), "would move 1 region(s)") {
t.Errorf("stdout missing dry-run verb:\n%s", out.String())
}
src, err := os.ReadFile(filepath.Join(root, "RESUME.md"))
if err != nil {
t.Fatal(err)
}
if string(src) != resumeShaped {
t.Errorf("dry-run mutated source:\n%s", src)
}
if _, err := os.Stat(filepath.Join(root, "RESUME.archive.md")); !os.IsNotExist(err) {
t.Errorf("dry-run created archive: err=%v", err)
}
}
func TestRunDocsCompact_KeepLastNoOp(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "RESUME.md", resumeShaped)
var out, errOut bytes.Buffer
code := runDocs([]string{"compact", "--keep-last", "9", "--heading", "## Snapshot", "RESUME.md"}, "v1.19.0", &out, &errOut)
if code != 0 {
t.Fatalf("keep-last no-op exit=%d stderr=%s", code, errOut.String())
}
if !strings.Contains(out.String(), "nothing to archive") {
t.Errorf("stdout missing heading-mode no-op message:\n%s", out.String())
}
}
func TestRunDocsCompact_KeepLastRequiresHeading(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "RESUME.md", resumeShaped)
var out, errOut bytes.Buffer
code := runDocs([]string{"compact", "--keep-last", "2", "RESUME.md"}, "v1.19.0", &out, &errOut)
if code != 1 {
t.Fatalf("keep-last without heading exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "--keep-last requires --heading") {
t.Errorf("stderr missing pairing error:\n%s", errOut.String())
}
}
func TestRunDocsCompact_HeadingRequiresKeepLast(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "RESUME.md", resumeShaped)
var out, errOut bytes.Buffer
code := runDocs([]string{"compact", "--heading", "## Snapshot", "RESUME.md"}, "v1.19.0", &out, &errOut)
if code != 1 {
t.Fatalf("heading without keep-last exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "--heading requires --keep-last") {
t.Errorf("stderr missing pairing error:\n%s", errOut.String())
}
}
func TestRunDocsCompact_KeepLastMarkersConflict(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "RESUME.md", "head\n<!-- eeco:archive:start -->\nx\n<!-- eeco:archive:end -->\n## Snapshot — session 1\nc1\n")
var out, errOut bytes.Buffer
code := runDocs([]string{"compact", "--keep-last", "0", "--heading", "## Snapshot", "RESUME.md"}, "v1.19.0", &out, &errOut)
if code != 1 {
t.Fatalf("markers conflict exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "explicit archive markers") {
t.Errorf("stderr missing conflict message:\n%s", errOut.String())
}
if _, err := os.Stat(filepath.Join(root, "RESUME.archive.md")); !os.IsNotExist(err) {
t.Errorf("conflict run should not create archive: err=%v", err)
}
}
func TestRunDocsCompact_KeepLastEscapeRejected(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
code := runDocs([]string{"compact", "--keep-last", "2", "--heading", "## Snapshot", "../escape.md"}, "v1.19.0", &out, &errOut)
if code != 1 {
t.Fatalf("escaping path exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "escapes the repo") {
t.Errorf("stderr missing escape reason:\n%s", errOut.String())
}
}
added cmd/eeco/doctor.go
@@ -0,0 +1,262 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/hooks"
"github.com/ajhahnde/eeco/internal/queue"
"github.com/ajhahnde/eeco/internal/workflow"
)
const doctorUsage = `usage:
eeco doctor run diagnostic probes; exit 0 healthy, 1 with a failure`
// runDoctor walks a fixed set of probes against the resolved
// configuration and prints one line per probe. The exit code is 1 when
// at least one probe fails; warnings do not fail the run.
func runDoctor(args []string, stdout, stderr io.Writer) int {
if len(args) > 0 {
fmt.Fprintln(stderr, doctorUsage)
return 2
}
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintln(stderr, "eeco doctor:", err)
return 1
}
d := newDoctor(stdout)
// Not the shared loader: doctor reports a load failure as a probe line via
// d.fail, not the helper's stderr diagnostic; and doctor takes no flags.
cfg, lerr := config.Load(cwd, config.DefaultWorkspace)
if lerr != nil {
if errors.Is(lerr, config.ErrNotInRepo) {
d.fail("repo", "not inside a git repository; run `git init` to start one")
} else {
d.fail("repo", lerr.Error())
}
d.flush()
return 1
}
d.ok("repo", cfg.RepoRoot)
probeWorkspace(d, cfg)
probeProfile(d, cfg)
probeGate(d, cfg)
probeAICommand(d, cfg)
probeAIBudget(d, cfg)
probeAutomation(d, cfg)
probeScope(d, cfg)
probeHooks(d, cfg)
probeQueue(d, cfg)
probeMemory(d, cfg)
probeStaleDays(d, cfg)
probeSessionSettings(d, cfg)
probeAttributionPatterns(d, cfg)
d.flush()
if d.failures > 0 {
return 1
}
return 0
}
// doctor accumulates probe lines and counts failures. Output is
// buffered and flushed once so a probe ordering change cannot reorder
// the printed report mid-run.
type doctor struct {
out io.Writer
lines []string
failures int
warnings int
}
func newDoctor(out io.Writer) *doctor { return &doctor{out: out} }
const doctorLabelWidth = 22
func (d *doctor) record(prefix, name, detail string) {
pad := name
if len(pad) < doctorLabelWidth {
pad = pad + strings.Repeat(" ", doctorLabelWidth-len(pad))
}
d.lines = append(d.lines, fmt.Sprintf("%-4s %s %s", prefix, pad, detail))
}
func (d *doctor) ok(name, detail string) { d.record("ok", name, detail) }
func (d *doctor) info(name, detail string) { d.record("ok", name, detail) }
func (d *doctor) warn(name, detail string) {
d.warnings++
d.record("warn", name, detail)
}
func (d *doctor) fail(name, detail string) {
d.failures++
d.record("fail", name, detail)
}
func (d *doctor) flush() {
for _, ln := range d.lines {
fmt.Fprintln(d.out, ln)
}
}
func probeWorkspace(d *doctor, cfg *config.Config) {
if config.IsInitialized(cfg) {
d.ok("workspace", cfg.WorkspaceName+"/ initialised at "+cfg.Workspace)
return
}
d.warn("workspace", cfg.WorkspaceName+"/ missing; run `eeco init`")
}
func probeProfile(d *doctor, cfg *config.Config) {
d.info("profile", string(cfg.Profile))
}
func probeGate(d *doctor, cfg *config.Config) {
if len(cfg.Gate) == 0 {
d.ok("gate", "(none — generic profile)")
return
}
// A gate chain runs only if every step's command is on PATH; report
// the first missing one, the same step the `gate` workflow blocks on.
for _, step := range cfg.Gate {
if len(step) == 0 {
continue
}
if _, err := exec.LookPath(step[0]); err != nil {
d.warn("gate", fmt.Sprintf("`%s` not on PATH; gate runs will be blocked (exit 2)", step[0]))
return
}
}
d.ok("gate", strings.Join(config.GateSteps(cfg.Gate), " && "))
}
func probeAICommand(d *doctor, cfg *config.Config) {
if len(cfg.AICommand) == 0 {
d.ok("ai.command", "not configured (every AI pass parks by design)")
return
}
if _, err := exec.LookPath(cfg.AICommand[0]); err != nil {
d.warn("ai.command", fmt.Sprintf("`%s` not on PATH; AI passes will park silently", cfg.AICommand[0]))
return
}
d.ok("ai.command", strings.Join(cfg.AICommand, " "))
}
func probeAIBudget(d *doctor, cfg *config.Config) {
if cfg.AIBudget == 0 {
d.ok("ai.budget", "0 (AI disabled — every pass parks)")
return
}
d.info("ai.budget", fmt.Sprintf("%d provider call(s) per invocation", cfg.AIBudget))
}
func probeAutomation(d *doctor, cfg *config.Config) {
d.info("automation", string(cfg.Automation))
}
func probeScope(d *doctor, cfg *config.Config) {
rel, err := filepath.Rel(cfg.RepoRoot, cfg.Workspace)
if err != nil {
d.fail("scope", fmt.Sprintf("workspace %q is not relative to repo root: %v", cfg.Workspace, err))
return
}
if filepath.IsAbs(rel) || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
d.fail("scope", fmt.Sprintf("workspace escapes repo root (rel=%q); writes would leak", rel))
return
}
d.ok("scope", "workspace contained inside repo root")
}
func probeHooks(d *doctor, cfg *config.Config) {
lines := hooks.Status(cfg)
if len(lines) == 0 {
d.warn("hooks", "no status available")
return
}
d.ok("hooks", lines[0])
for _, extra := range lines[1:] {
d.lines = append(d.lines, strings.Repeat(" ", 4+2+doctorLabelWidth+2)+extra)
}
}
func probeQueue(d *doctor, cfg *config.Config) {
stateDir := filepath.Join(cfg.Workspace, "state")
n, err := queue.Count(stateDir)
if err != nil {
d.fail("queue", fmt.Sprintf("queue file unreadable: %v", err))
return
}
d.ok("queue", fmt.Sprintf("%d open item(s)", n))
}
func probeMemory(d *doctor, cfg *config.Config) {
dir := filepath.Join(cfg.Workspace, "memory")
ents, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
if config.IsInitialized(cfg) {
d.fail("memory", fmt.Sprintf("memory dir missing despite initialised workspace: %s", dir))
return
}
d.ok("memory", "0 fact(s) (workspace not initialised)")
return
}
d.fail("memory", err.Error())
return
}
n := 0
for _, e := range ents {
if e.IsDir() {
continue
}
name := e.Name()
if name == "MEMORY.md" || !strings.HasSuffix(name, ".md") {
continue
}
n++
}
d.ok("memory", fmt.Sprintf("%d fact(s)", n))
}
func probeStaleDays(d *doctor, cfg *config.Config) {
d.info("stale_days", fmt.Sprintf("%d day(s)", cfg.StaleDays))
}
func probeSessionSettings(d *doctor, cfg *config.Config) {
if cfg.SessionSettingsPath == "" {
d.ok("session_settings_path", "not configured (session-start hook unavailable)")
return
}
if _, err := os.Stat(cfg.SessionSettingsPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
d.warn("session_settings_path",
fmt.Sprintf("%s does not exist yet; create it or unset `session_settings_path`", cfg.SessionSettingsPath))
return
}
d.warn("session_settings_path", err.Error())
return
}
d.ok("session_settings_path", cfg.SessionSettingsPath)
}
func probeAttributionPatterns(d *doctor, cfg *config.Config) {
n := len(cfg.AttributionPatterns)
if n == 0 {
d.ok("attribution_patterns", "0 extra (built-in denylist only)")
return
}
if _, err := workflow.NewDetector(cfg.AttributionPatterns); err != nil {
d.fail("attribution_patterns", err.Error())
return
}
d.ok("attribution_patterns", fmt.Sprintf("%d extra pattern(s) compile clean", n))
}
added cmd/eeco/doctor_test.go
@@ -0,0 +1,133 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestRunDoctor_NotInRepo(t *testing.T) {
chdir(t, t.TempDir())
var out, errOut bytes.Buffer
code := runDoctor(nil, &out, &errOut)
if code != 1 {
t.Fatalf("expected exit 1, got %d\nstdout:\n%s\nstderr:\n%s", code, out.String(), errOut.String())
}
if !strings.Contains(out.String(), "fail") || !strings.Contains(out.String(), "repo") {
t.Errorf("missing fail-repo line:\n%s", out.String())
}
if !strings.Contains(out.String(), "git init") {
t.Errorf("missing remedy hint:\n%s", out.String())
}
}
func TestRunDoctor_FreshRepoNotInit(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out bytes.Buffer
code := runDoctor(nil, &out, &bytes.Buffer{})
if code != 0 {
t.Fatalf("expected exit 0 (warns do not fail), got %d\n%s", code, out.String())
}
if !strings.Contains(out.String(), "warn") {
t.Errorf("expected a warn line, got:\n%s", out.String())
}
if !strings.Contains(out.String(), "eeco init") {
t.Errorf("missing init remedy hint:\n%s", out.String())
}
}
func TestRunDoctor_HealthyInit(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("init failed")
}
var out bytes.Buffer
code := runDoctor(nil, &out, &bytes.Buffer{})
if code != 0 {
t.Fatalf("expected exit 0 on healthy init, got %d\n%s", code, out.String())
}
for _, want := range []string{
"repo",
"workspace",
"profile",
"gate",
"ai.command",
"ai.budget",
"automation",
"scope",
"hooks",
"queue",
"memory",
"stale_days",
"session_settings_path",
"attribution_patterns",
} {
if !strings.Contains(out.String(), want) {
t.Errorf("missing probe %q in:\n%s", want, out.String())
}
}
if strings.Contains(out.String(), "fail") {
t.Errorf("unexpected fail line:\n%s", out.String())
}
}
func TestRunDoctor_BadGate(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("init failed")
}
if err := os.WriteFile(
filepath.Join(root, "tester", ".eeco", "config.local"),
[]byte("gate=definitely-not-on-path-abc12345\n"),
0o644,
); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
code := runDoctor(nil, &out, &bytes.Buffer{})
if code != 0 {
t.Fatalf("expected exit 0 (warns), got %d\n%s", code, out.String())
}
if !strings.Contains(out.String(), "warn") || !strings.Contains(out.String(), "gate") {
t.Errorf("missing warn-gate line:\n%s", out.String())
}
if !strings.Contains(out.String(), "definitely-not-on-path") {
t.Errorf("missing offending argv in detail:\n%s", out.String())
}
}
func TestRunDoctor_BadAttributionPattern(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("init failed")
}
if err := os.WriteFile(
filepath.Join(root, "tester", ".eeco", "config.local"),
[]byte("attribution_pattern=((unclosed\n"),
0o644,
); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
code := runDoctor(nil, &out, &bytes.Buffer{})
if code != 1 {
t.Fatalf("expected exit 1 on bad regex, got %d\n%s", code, out.String())
}
if !strings.Contains(out.String(), "fail") || !strings.Contains(out.String(), "attribution_patterns") {
t.Errorf("missing fail-attribution_patterns line:\n%s", out.String())
}
}
func TestRunDoctor_RejectsExtraArgs(t *testing.T) {
chdir(t, t.TempDir())
code := runDoctor([]string{"oops"}, &bytes.Buffer{}, &bytes.Buffer{})
if code != 2 {
t.Errorf("expected exit 2 for extra args, got %d", code)
}
}
added cmd/eeco/engine_nowrite_boundary_test.go
@@ -0,0 +1,152 @@
package main
import (
"bytes"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
// Trust-boundary suite H1.6, invariant (a): the engine never writes the
// user's tracked git tree. `eeco go` / `go --write` / `run` may write only
// inside the gitignored eeco workspace and the private workspace-history
// repo; the host repo's git state must stay byte-identical. This extends the
// gitx-unit seed TestReadOnly_StateFingerprintUnchanged up to the cmd/engine
// level against a REAL host working tree. No production change.
// hostFingerprint snapshots the six read-only git state fields of a REAL host
// repo via the package's production runGit. Re-derived here (rather than
// imported) because the equivalent gitx_test.go helper is package gitx and
// cross-package test helpers are not importable. Fields, joined \x1f/\x1e:
// rev-parse HEAD · status --porcelain · tag --list · rev-list --all --count ·
// config --list --local · stash list.
func hostFingerprint(t *testing.T, root string) string {
t.Helper()
var b strings.Builder
for _, args := range [][]string{
{"rev-parse", "HEAD"},
{"status", "--porcelain"},
{"tag", "--list"},
{"rev-list", "--all", "--count"},
{"config", "--list", "--local"},
{"stash", "list"},
} {
out, err := runGit(root, args...)
if err != nil {
t.Fatalf("hostFingerprint %v: %s", args, gitErr(out, err))
}
b.WriteString(strings.Join(args, " "))
b.WriteString("\x1f")
b.WriteString(out)
b.WriteString("\x1e")
}
return b.String()
}
// realHostRepo stands up a real git working tree (init + repo-scoped identity
// + commit.gpgsign=false + one committed file) and chdirs into it. Unlike
// newGitRepo (which makes a bare .git directory) this is a fingerprintable
// working tree: `git rev-parse HEAD` resolves a real commit.
func realHostRepo(t *testing.T) string {
t.Helper()
root := t.TempDir()
for _, args := range [][]string{
{"init"},
{"config", "user.email", "[email protected]"},
{"config", "user.name", "Test Host"},
{"config", "commit.gpgsign", "false"},
} {
if out, err := runGit(root, args...); err != nil {
t.Fatalf("git %v: %s", args, gitErr(out, err))
}
}
writeFile(t, root, "app.go", "package app\n")
if out, err := runGit(root, "add", "-A"); err != nil {
t.Fatalf("git add: %s", gitErr(out, err))
}
if out, err := runGit(root, "commit", "-m", "seed"); err != nil {
t.Fatalf("git commit: %s", gitErr(out, err))
}
chdir(t, root)
return root
}
// TestBoundary_EngineNeverWritesHostTree is the H1.6 keystone: it drives the
// real engine verbs against a real host repo with the private-repo
// auto-committer armed, and asserts the host git fingerprint is byte-identical
// across them. Any smuggled host-tree write flips porcelain/HEAD and fails
// here. Init's one sanctioned .gitignore commit is excluded by snapshotting
// AFTER init and BEFORE the verbs.
func TestBoundary_EngineNeverWritesHostTree(t *testing.T) {
requireGit(t)
root := realHostRepo(t)
t.Cleanup(setTrackHistory(true)) // arm the private-repo init seam
var initOut, initErr bytes.Buffer
if code := runInit(nil, &initOut, &initErr); code != 0 {
t.Fatalf("runInit exit %d; stderr:\n%s", code, initErr.String())
}
// Arm the auto-committer so maybeAutoCommit runs a real private-repo
// commit after every mutating verb (precedent: workspace_history=auto via
// config.local). config.local lives inside the gitignored workspace.
writeFile(t, wsPath(root), "config.local", "workspace_history=auto\n")
// Load-bearing ordering: snapshot AFTER init's sanctioned host write,
// BEFORE the engine verbs, so only go/run are measured.
before := hostFingerprint(t, root)
verbs := []struct {
name string
args []string
run func([]string, io.Writer, io.Writer) int
}{
{"go", nil, runGo},
{"go --write", []string{"--write"}, runGo},
{"go --brief --write", []string{"--brief", "--write"}, runGo},
{"run comment-hygiene", []string{"comment-hygiene"}, runRun},
}
for _, v := range verbs {
var out, errb bytes.Buffer
if code := v.run(v.args, &out, &errb); code != 0 {
t.Fatalf("%s exit %d; stderr:\n%s", v.name, code, errb.String())
}
}
after := hostFingerprint(t, root)
if before != after {
t.Errorf("engine wrote the host tree: git state changed across go/run\nbefore:\n%q\nafter:\n%q", before, after)
}
// Positive controls: prove the measurement window was live, so a stable
// host fingerprint is a real read-only result and not vacuous.
if _, err := os.Stat(wsPath(root, "context.md")); err != nil {
t.Errorf("--write did not write the workspace context.md: %v", err)
}
// The private repo's .git lives at cfg.UserDir = <root>/tester (NOT the
// workspace <root>/tester/.eeco) — see config UserDir / historygit.
userDir := filepath.Join(root, "tester")
logOut, err := logPrivateRepo(userDir, 10)
if err != nil {
t.Fatalf("logPrivateRepo: %v", err)
}
if !strings.Contains(logOut, "workspace auto: go --write") {
t.Errorf("auto-committer did not commit the private repo during the window:\n%s", logOut)
}
}
// TestBoundary_HostFingerprintDetectsAWrite proves the keystone assertion is
// not vacuous: a single empty commit must flip the fingerprint.
func TestBoundary_HostFingerprintDetectsAWrite(t *testing.T) {
requireGit(t)
root := realHostRepo(t)
a := hostFingerprint(t, root)
if out, err := runGit(root, "commit", "--allow-empty", "-m", "probe"); err != nil {
t.Fatalf("git commit: %s", gitErr(out, err))
}
b := hostFingerprint(t, root)
if a == b {
t.Error("fingerprint did not change after a commit — the keystone assertion would be vacuous")
}
}
added cmd/eeco/facts.go
@@ -0,0 +1,141 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/ajhahnde/eeco/internal/memory"
)
const addFactUsage = `usage:
eeco add fact --description "<summary>" [--type <type>] [--name <slug>] [--ref <path>] [--pin] [--provenance <text>] [--agent <name>] "<body>"
Record a durable memory fact in the workspace store. eeco writes one
frontmatter file under <workspace>/memory/ and regenerates the MEMORY.md
index; nothing is staged or committed (Constraint 6).
flags:
--description <text> one-line summary; drives relevance matching (required)
--type <type> user, feedback, project, reference, or finding
(default: project)
--name <slug> lower-kebab-case filename stem; derived from
--description when omitted
--ref <path> repo-relative path the fact documents
--pin mark the fact pinned (never garbage-collected)
--provenance <text> short snippet (<=120 chars) of what triggered the
fact; required for --type feedback or user
--agent <name> assistant identity that recorded the fact
(e.g. claude-opus-4-7)`
// runAddFact handles `eeco add fact ... "<body>"`. It builds a memory
// fact from the flags and the body positional, writes it into the
// workspace memory store, and regenerates the MEMORY.md index. The
// write stays inside the workspace (Constraint 1) and nothing is staged
// or committed (Constraint 6).
func runAddFact(args []string, stdout, stderr io.Writer) int {
fs := newFlagSet("add fact", stderr, addFactUsage)
description := fs.String("description", "", "one-line summary (required)")
typeFlag := fs.String("type", string(memory.TypeProject), "fact type")
nameFlag := fs.String("name", "", "lower-kebab-case filename stem")
refFlag := fs.String("ref", "", "repo-relative path the fact documents")
pinFlag := fs.Bool("pin", false, "mark the fact pinned")
provenanceFlag := fs.String("provenance", "", "short snippet of what triggered the fact")
agentFlag := fs.String("agent", "", "assistant identity that recorded the fact")
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() != 1 {
fmt.Fprintln(stderr, addFactUsage)
return 2
}
body := fs.Arg(0)
desc := strings.TrimSpace(*description)
if desc == "" {
fmt.Fprintln(stderr, "eeco add fact: --description is required")
fmt.Fprintln(stderr, addFactUsage)
return 2
}
factType := memory.FactType(*typeFlag)
if !memory.ValidType(factType) {
fmt.Fprintf(stderr, "eeco add fact: invalid --type %q (want user, feedback, project, reference, finding)\n", *typeFlag)
return 2
}
name := strings.TrimSpace(*nameFlag)
if name == "" {
name = slugify(desc)
}
source := strings.TrimSpace(*provenanceFlag)
agent := strings.TrimSpace(*agentFlag)
if source == "" && (factType == memory.TypeFeedback || factType == memory.TypeUser) {
fmt.Fprintf(stderr, "eeco add fact: --provenance is required for --type %s\n", factType)
fmt.Fprintln(stderr, "hint: pass a short snippet of what triggered the fact (<=120 chars).")
return 2
}
now := time.Now()
fact := &memory.Fact{
Name: name,
Description: desc,
Type: factType,
Created: now,
LastUsed: now,
Ref: strings.TrimSpace(*refFlag),
Pin: *pinFlag,
Source: source,
Agent: agent,
Body: body,
}
// Validate up front so a bad --name or --ref is reported as a usage
// error (exit 2) rather than a store write failure (exit 1).
if err := fact.Validate(); err != nil {
fmt.Fprintln(stderr, "eeco add fact:", err)
return 2
}
cfg, code := loadInitedConfig(stderr, "eeco add fact")
if code != 0 {
return code
}
store, err := memory.Open(cfg)
if err != nil {
fmt.Fprintln(stderr, "eeco add fact:", err)
return 1
}
// Refuse to clobber an existing fact: memory.Store.Save overwrites
// unconditionally, so the no-clobber guard lives in the CLI layer.
path := filepath.Join(store.MemoryDir, fact.Name+".md")
if _, err := os.Stat(path); err == nil {
fmt.Fprintf(stderr, "eeco add fact: a fact named %q already exists.\n", fact.Name)
fmt.Fprintln(stderr, "hint: pass a different --name, or edit the existing file directly.")
return 1
} else if !errors.Is(err, os.ErrNotExist) {
fmt.Fprintln(stderr, "eeco add fact:", err)
return 1
}
if err := store.Save(fact); err != nil {
fmt.Fprintln(stderr, "eeco add fact:", err)
return 1
}
facts, err := store.LoadAll()
if err != nil {
fmt.Fprintln(stderr, "eeco add fact:", err)
return 1
}
if err := store.WriteIndex(facts); err != nil {
fmt.Fprintln(stderr, "eeco add fact:", err)
return 1
}
maybeAutoCommit(cfg.WorkspaceHistory.Auto(), cfg.UserDir, "add fact "+fact.Name, stderr)
fmt.Fprintf(stdout, "wrote %s\n", fact.Path)
return 0
}
added cmd/eeco/facts_test.go
@@ -0,0 +1,353 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
// initRepo returns a git repo with an initialised eeco workspace and
// makes it the working directory.
func initRepo(t *testing.T) string {
t.Helper()
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("runInit exit=%d", code)
}
return root
}
func TestRunAddFact_WritesIntoWorkspace(t *testing.T) {
root := initRepo(t)
var out, errOut bytes.Buffer
code := runAdd([]string{
"fact",
"--description", "boot path lives in cmd/eeco",
"--name", "boot-path",
"The dispatch switch is in main.go.",
}, &out, &errOut)
if code != 0 {
t.Fatalf("add fact exit=%d stderr=%s", code, errOut.String())
}
path := filepath.Join(root, "tester", ".eeco", "memory", "boot-path.md")
// Constraint 1: the write stays inside the workspace.
if !strings.HasPrefix(path, filepath.Join(root, "tester", ".eeco")+string(os.PathSeparator)) {
t.Errorf("fact written outside the workspace: %s", path)
}
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("fact file not written: %v", err)
}
got := string(body)
for _, want := range []string{
"name: boot-path",
"description: boot path lives in cmd/eeco",
"type: project",
"pin: false",
"The dispatch switch is in main.go.",
} {
if !strings.Contains(got, want) {
t.Errorf("fact file missing %q:\n%s", want, got)
}
}
if !strings.Contains(out.String(), "wrote ") {
t.Errorf("stdout missing wrote confirmation:\n%s", out.String())
}
// The index is regenerated and lists the new fact.
index, err := os.ReadFile(filepath.Join(root, "tester", ".eeco", "memory", "MEMORY.md"))
if err != nil {
t.Fatalf("MEMORY.md not written: %v", err)
}
if !strings.Contains(string(index), "boot-path") {
t.Errorf("MEMORY.md does not list the new fact:\n%s", string(index))
}
}
func TestRunAddFact_DerivedName(t *testing.T) {
root := initRepo(t)
var out, errOut bytes.Buffer
code := runAdd([]string{"fact", "--description", "boot path", "a body"}, &out, &errOut)
if code != 0 {
t.Fatalf("add fact exit=%d stderr=%s", code, errOut.String())
}
// Name derived from --description via slugify.
if _, err := os.Stat(filepath.Join(root, "tester", ".eeco", "memory", "boot-path.md")); err != nil {
t.Errorf("derived-name file not found: %v", err)
}
}
func TestRunAddFact_RefPinType(t *testing.T) {
root := initRepo(t)
var out, errOut bytes.Buffer
code := runAdd([]string{
"fact",
"--description", "the usage guide",
"--name", "usage-guide",
"--type", "reference",
"--ref", "docs/USAGE.md",
"--pin",
"points at the user manual",
}, &out, &errOut)
if code != 0 {
t.Fatalf("add fact exit=%d stderr=%s", code, errOut.String())
}
body, err := os.ReadFile(filepath.Join(root, "tester", ".eeco", "memory", "usage-guide.md"))
if err != nil {
t.Fatal(err)
}
got := string(body)
for _, want := range []string{"type: reference", "ref: docs/USAGE.md", "pin: true"} {
if !strings.Contains(got, want) {
t.Errorf("fact file missing %q:\n%s", want, got)
}
}
}
func TestRunAddFact_MissingDescription(t *testing.T) {
initRepo(t)
var out, errOut bytes.Buffer
code := runAdd([]string{"fact", "a body"}, &out, &errOut)
if code != 2 {
t.Fatalf("add fact with no --description exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), "--description is required") {
t.Errorf("stderr missing required-description message:\n%s", errOut.String())
}
}
func TestRunAddFact_MissingBody(t *testing.T) {
initRepo(t)
var out, errOut bytes.Buffer
code := runAdd([]string{"fact", "--description", "x"}, &out, &errOut)
if code != 2 {
t.Fatalf("add fact with no body exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), "usage:") {
t.Errorf("stderr missing usage:\n%s", errOut.String())
}
}
func TestRunAddFact_InvalidType(t *testing.T) {
initRepo(t)
var out, errOut bytes.Buffer
code := runAdd([]string{"fact", "--description", "x", "--type", "bogus", "body"}, &out, &errOut)
if code != 2 {
t.Fatalf("add fact with bad --type exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), "invalid --type") {
t.Errorf("stderr missing invalid-type message:\n%s", errOut.String())
}
}
func TestRunAddFact_InvalidName(t *testing.T) {
initRepo(t)
var out, errOut bytes.Buffer
code := runAdd([]string{"fact", "--description", "x", "--name", "Bad_Name", "body"}, &out, &errOut)
if code != 2 {
t.Fatalf("add fact with bad --name exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), "kebab-case") {
t.Errorf("stderr missing name-format message:\n%s", errOut.String())
}
}
func TestRunAddFact_Collision(t *testing.T) {
initRepo(t)
var out, errOut bytes.Buffer
if code := runAdd([]string{"fact", "--description", "x", "--name", "dup", "first"}, &out, &errOut); code != 0 {
t.Fatalf("first add fact exit=%d stderr=%s", code, errOut.String())
}
out.Reset()
errOut.Reset()
code := runAdd([]string{"fact", "--description", "y", "--name", "dup", "second"}, &out, &errOut)
if code != 1 {
t.Fatalf("colliding add fact exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "already exists") {
t.Errorf("stderr missing collision message:\n%s", errOut.String())
}
}
func TestRunAddFact_NotInRepo(t *testing.T) {
chdir(t, t.TempDir()) // no .git anywhere up the tree
var out, errOut bytes.Buffer
code := runAdd([]string{"fact", "--description", "x", "body"}, &out, &errOut)
if code != 1 {
t.Fatalf("add fact outside a repo exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "not inside a git repository") {
t.Errorf("stderr missing not-in-repo message:\n%s", errOut.String())
}
}
func TestRunAddFact_NotInitialized(t *testing.T) {
root := newGitRepo(t) // a git repo, but no `eeco init`
chdir(t, root)
var out, errOut bytes.Buffer
code := runAdd([]string{"fact", "--description", "x", "body"}, &out, &errOut)
if code != 1 {
t.Fatalf("add fact in uninitialised repo exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "not initialised") {
t.Errorf("stderr missing not-initialised message:\n%s", errOut.String())
}
}
func TestRunAddFact_FeedbackRequiresProvenance(t *testing.T) {
initRepo(t)
var out, errOut bytes.Buffer
code := runAdd([]string{
"fact",
"--type", "feedback",
"--description", "user wants terse responses",
"a body",
}, &out, &errOut)
if code != 2 {
t.Fatalf("feedback without --provenance exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), "--provenance is required") {
t.Errorf("stderr missing provenance-required message:\n%s", errOut.String())
}
}
func TestRunAddFact_UserRequiresProvenance(t *testing.T) {
initRepo(t)
var out, errOut bytes.Buffer
code := runAdd([]string{
"fact",
"--type", "user",
"--description", "user role",
"a body",
}, &out, &errOut)
if code != 2 {
t.Fatalf("user without --provenance exit=%d, want 2", code)
}
}
func TestRunAddFact_ProjectAllowsNoProvenance(t *testing.T) {
// project facts (the default) must not require --provenance:
// they are not assistant adaptations and the friction would
// hurt every fact eeco itself files.
initRepo(t)
var out, errOut bytes.Buffer
code := runAdd([]string{
"fact",
"--description", "project fact",
"a body",
}, &out, &errOut)
if code != 0 {
t.Fatalf("project fact exit=%d stderr=%s", code, errOut.String())
}
}
func TestRunAddFact_ProvenanceAndAgentRoundTrip(t *testing.T) {
root := initRepo(t)
var out, errOut bytes.Buffer
code := runAdd([]string{
"fact",
"--type", "feedback",
"--description", "user prefers terse",
"--name", "terse-feedback",
"--provenance", "stop summarizing what you just did",
"--agent", "claude-opus-4-7",
"a body",
}, &out, &errOut)
if code != 0 {
t.Fatalf("add fact exit=%d stderr=%s", code, errOut.String())
}
body, err := os.ReadFile(filepath.Join(root, "tester", ".eeco", "memory", "terse-feedback.md"))
if err != nil {
t.Fatal(err)
}
got := string(body)
for _, want := range []string{
"source: stop summarizing what you just did",
"agent: claude-opus-4-7",
} {
if !strings.Contains(got, want) {
t.Errorf("fact missing %q:\n%s", want, got)
}
}
// Disabled is false by default and must be omitted from the wire.
if strings.Contains(got, "disabled:") {
t.Errorf("default-disabled fact should not write disabled key:\n%s", got)
}
}
// TestRunAddFact_AutoCommit is the end-to-end auto path: a workspace with
// workspace_history=auto, real private repo (setTrackHistory on), and a real
// git. `eeco add fact` must record one extra workspace-history commit with
// the auto message.
func TestRunAddFact_AutoCommit(t *testing.T) {
requireGit(t)
defer setTrackHistory(true)()
root := newGitRepo(t)
chdir(t, root)
// init stands up the workspace AND the private repo (manual default).
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("setup init failed")
}
userDir := filepath.Join(root, "tester")
// Switch the workspace to auto so the next mutating verb commits.
writeFile(t, filepath.Join(root, "tester", ".eeco"), "config.local", "workspace_history=auto\n")
before, err := logPrivateRepo(userDir, 100)
if err != nil {
t.Fatalf("baseline log: %v", err)
}
beforeN := len(strings.Split(strings.TrimSpace(before), "\n"))
var out, errOut bytes.Buffer
code := runAdd([]string{"fact", "--description", "auto-committed fact", "--name", "auto-fact", "a body"}, &out, &errOut)
if code != 0 {
t.Fatalf("add fact exit=%d stderr=%s", code, errOut.String())
}
after, err := logPrivateRepo(userDir, 100)
if err != nil {
t.Fatalf("post-add log: %v", err)
}
afterN := len(strings.Split(strings.TrimSpace(after), "\n"))
if afterN != beforeN+1 {
t.Fatalf("log count = %d, want %d (one auto commit)\nbefore:\n%s\nafter:\n%s", afterN, beforeN+1, before, after)
}
if !strings.Contains(after, "workspace auto: add fact auto-fact") {
t.Errorf("auto-commit message missing from log:\n%s", after)
}
}
func TestRunAddFact_ProvenanceOversize(t *testing.T) {
initRepo(t)
long := strings.Repeat("x", 121)
var out, errOut bytes.Buffer
code := runAdd([]string{
"fact",
"--type", "feedback",
"--description", "oversize",
"--provenance", long,
"a body",
}, &out, &errOut)
if code != 2 {
t.Fatalf("oversize --provenance exit=%d, want 2", code)
}
}
added cmd/eeco/gates.go
@@ -0,0 +1,106 @@
package main
import (
"fmt"
"io"
"os"
"strings"
"github.com/ajhahnde/eeco/internal/gates"
)
const gatesUsage = `usage:
eeco gates check-attribution [flags]
scans the tracked tree and recent commit bodies for AI-attribution
fingerprints. Exit 0 clean, 1 on a hit, 2 on usage error.
flags:
--paths "<a b c>" override the default tracked-files filter (space-separated)
--commits N force the HEAD~N..HEAD commit-body range
--no-commits skip the commit-body scan
--no-files skip the file scan
--exclude <path> repeatable; additional repo-relative path to skip during the file scan`
// runGates dispatches eeco gates subcommands. Today there is only
// check-attribution; future gates land here as additional cases.
func runGates(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, gatesUsage)
return 2
}
switch args[0] {
case "check-attribution":
return runGatesCheckAttribution(args[1:], stdout, stderr)
default:
fmt.Fprintln(stderr, gatesUsage)
return 2
}
}
// gatesStringSlice collects a repeatable string flag (--exclude).
type gatesStringSlice []string
func (s *gatesStringSlice) String() string { return strings.Join(*s, ",") }
func (s *gatesStringSlice) Set(v string) error {
*s = append(*s, v)
return nil
}
func runGatesCheckAttribution(args []string, stdout, stderr io.Writer) int {
fs := newFlagSet("check-attribution", stderr, gatesUsage)
var (
pathsList string
commitsN int
noCommits bool
noFiles bool
excludeList gatesStringSlice
)
fs.StringVar(&pathsList, "paths", "", "override the tracked-files filter")
fs.IntVar(&commitsN, "commits", 0, "force HEAD~N..HEAD range")
fs.BoolVar(&noCommits, "no-commits", false, "skip commit-body scan")
fs.BoolVar(&noFiles, "no-files", false, "skip file scan")
fs.Var(&excludeList, "exclude", "additional repo-relative path to skip")
if err := fs.Parse(args); err != nil {
return 2
}
if noFiles && noCommits {
fmt.Fprintln(stderr, "eeco gates: --no-files and --no-commits together leave nothing to scan")
return 2
}
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintln(stderr, "eeco gates:", err)
return 1
}
opts := gates.Options{
ScanFiles: !noFiles,
ScanCommits: !noCommits,
Excludes: excludeList,
}
if pathsList != "" {
opts.Paths = strings.Fields(pathsList)
}
if commitsN > 0 {
opts.Range = fmt.Sprintf("HEAD~%d..HEAD", commitsN)
}
res, err := gates.CheckAttribution(cwd, opts)
if err != nil {
fmt.Fprintln(stderr, "eeco gates:", err)
return 1
}
for _, n := range res.Notices {
fmt.Fprintln(stderr, "eeco gates:", n)
}
if len(res.Findings) == 0 {
fmt.Fprintln(stdout, "eeco gates check-attribution: clean")
return 0
}
for _, f := range res.Findings {
if f.Commit != "" {
fmt.Fprintf(stderr, "commit %s: line %d: %s\n", f.Commit, f.Line, f.Excerpt)
continue
}
fmt.Fprintf(stderr, "%s:%d: %s\n", f.Path, f.Line, f.Excerpt)
}
fmt.Fprintf(stderr, "eeco gates check-attribution: %d finding(s)\n", len(res.Findings))
return 1
}
added cmd/eeco/gates_test.go
@@ -0,0 +1,116 @@
package main
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// newRealGitRepo initialises a real `git init` repo with one seed
// commit so gate scans have a HEAD to enumerate. Skips when git is
// not on PATH.
func newRealGitRepo(t *testing.T) string {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not on PATH")
}
dir := t.TempDir()
gitInit := exec.Command("git", "init", "-q", "-b", "main")
gitInit.Dir = dir
if out, err := gitInit.CombinedOutput(); err != nil {
t.Fatalf("git init: %v\n%s", err, out)
}
for _, args := range [][]string{
{"config", "user.email", "[email protected]"},
{"config", "user.name", "test"},
} {
c := exec.Command("git", args...)
c.Dir = dir
if out, err := c.CombinedOutput(); err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
}
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("# seed\n"), 0o644); err != nil {
t.Fatal(err)
}
for _, args := range [][]string{
{"add", "README.md"},
{"commit", "-q", "-m", "seed"},
} {
c := exec.Command("git", args...)
c.Dir = dir
if out, err := c.CombinedOutput(); err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
}
return dir
}
func TestRunGates_NoArgsShowsUsage(t *testing.T) {
var errOut bytes.Buffer
if code := runGates(nil, &bytes.Buffer{}, &errOut); code != 2 {
t.Fatalf("no args -> exit %d, want 2", code)
}
if !strings.Contains(errOut.String(), "usage:") {
t.Errorf("missing usage line:\n%s", errOut.String())
}
}
func TestRunGates_UnknownSubcommandShowsUsage(t *testing.T) {
var errOut bytes.Buffer
if code := runGates([]string{"unknown"}, &bytes.Buffer{}, &errOut); code != 2 {
t.Fatalf("unknown -> exit %d, want 2", code)
}
if !strings.Contains(errOut.String(), "usage:") {
t.Errorf("missing usage:\n%s", errOut.String())
}
}
func TestRunGates_CheckAttributionClean(t *testing.T) {
dir := newRealGitRepo(t)
chdir(t, dir)
var out bytes.Buffer
if code := runGates([]string{"check-attribution", "--commits", "1"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("clean -> exit %d\n%s", code, out.String())
}
if !strings.Contains(out.String(), "clean") {
t.Errorf("clean msg = %q, want 'clean'", out.String())
}
}
func TestRunGates_FlagConflictExits2(t *testing.T) {
dir := newRealGitRepo(t)
chdir(t, dir)
var errOut bytes.Buffer
if code := runGates([]string{"check-attribution", "--no-files", "--no-commits"}, &bytes.Buffer{}, &errOut); code != 2 {
t.Fatalf("flag conflict -> exit %d, want 2", code)
}
if !strings.Contains(errOut.String(), "nothing to scan") {
t.Errorf("missing conflict diagnostic:\n%s", errOut.String())
}
}
func TestRunGates_CheckAttributionFindsBodyLeak(t *testing.T) {
dir := newRealGitRepo(t)
chdir(t, dir)
// Append a commit whose body carries the forbidden trailer.
c := exec.Command("git", "commit", "--allow-empty", "-q", "-m",
"feat: x\n\n"+"Co-"+"Authored-"+"By: Claude <[email protected]>")
c.Dir = dir
if out, err := c.CombinedOutput(); err != nil {
t.Fatalf("git commit: %v\n%s", err, out)
}
var errOut bytes.Buffer
if code := runGates([]string{"check-attribution", "--no-files", "--commits", "1"}, &bytes.Buffer{}, &errOut); code != 1 {
t.Fatalf("body leak -> exit %d, want 1\nstderr:\n%s", code, errOut.String())
}
if !strings.Contains(errOut.String(), "commit ") {
t.Errorf("expected a 'commit <sha>' line in stderr:\n%s", errOut.String())
}
if !strings.Contains(errOut.String(), "1 finding") {
t.Errorf("expected '1 finding' summary:\n%s", errOut.String())
}
}
added cmd/eeco/gc.go
@@ -0,0 +1,45 @@
package main
import (
"fmt"
"io"
"github.com/ajhahnde/eeco/internal/memory"
)
func runGC(args []string, stdout, stderr io.Writer) int {
if len(args) > 0 {
fmt.Fprintln(stderr, "usage: eeco gc")
return 2
}
cfg, code := loadInitedConfig(stderr, "eeco gc")
if code != 0 {
return code
}
store, err := memory.Open(cfg)
if err != nil {
fmt.Fprintln(stderr, "eeco gc:", err)
return 1
}
res, err := store.GC()
if err != nil {
fmt.Fprintln(stderr, "eeco gc:", err)
return 1
}
maybeAutoCommit(cfg.WorkspaceHistory.Auto(), cfg.UserDir, "gc", stderr)
printGCReport(stdout, res)
return 0
}
func printGCReport(w io.Writer, r memory.GCResult) {
fmt.Fprintf(w, "eeco gc: archived %d · queued %d · kept %d\n",
r.Archived, r.Queued, r.Kept)
for _, a := range r.Actions {
if a.Action == "kept" {
continue
}
fmt.Fprintf(w, " %-9s %s (%s) — %s\n", a.Action, a.Name, a.Type, a.Reason)
}
}
added cmd/eeco/gc_test.go
@@ -0,0 +1,81 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestRunGC_OutsideRepo(t *testing.T) {
chdir(t, t.TempDir())
var errOut bytes.Buffer
code := runGC(nil, &bytes.Buffer{}, &errOut)
if code == 0 {
t.Fatal("expected non-zero exit outside repo")
}
if !strings.Contains(errOut.String(), "not inside a git repository") {
t.Errorf("missing hint:\n%s", errOut.String())
}
}
func TestRunGC_RequiresInit(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var errOut bytes.Buffer
code := runGC(nil, &bytes.Buffer{}, &errOut)
if code == 0 {
t.Fatal("expected non-zero exit before init")
}
if !strings.Contains(errOut.String(), "workspace not initialised") {
t.Errorf("missing hint:\n%s", errOut.String())
}
}
func TestRunGC_HappyPath(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("init failed")
}
// Plant a fact whose ref doesn't exist → reference type → archived.
factPath := filepath.Join(root, "tester", ".eeco", "memory", "doomed.md")
content := strings.Join([]string{
"---",
"name: doomed",
"description: missing ref doc",
"type: reference",
"created: 2026-05-19",
"last_used: 2026-05-19",
"ref: not/a/real/path.go",
"pin: false",
"---",
"detail",
"",
}, "\n")
if err := os.WriteFile(factPath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
code := runGC(nil, &out, &bytes.Buffer{})
if code != 0 {
t.Fatalf("runGC exit=%d, out:\n%s", code, out.String())
}
if !strings.Contains(out.String(), "archived 1") {
t.Errorf("missing summary:\n%s", out.String())
}
if _, err := os.Stat(filepath.Join(root, "tester", ".eeco", "memory", "attic", "doomed.md")); err != nil {
t.Errorf("fact not archived: %v", err)
}
}
func TestRunGC_RejectsExtraArgs(t *testing.T) {
chdir(t, t.TempDir())
code := runGC([]string{"oops"}, &bytes.Buffer{}, &bytes.Buffer{})
if code != 2 {
t.Errorf("expected exit 2 for extra args, got %d", code)
}
}
added cmd/eeco/go.go
@@ -0,0 +1,218 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"time"
"github.com/ajhahnde/eeco/internal/brief"
"github.com/ajhahnde/eeco/internal/clip"
"github.com/ajhahnde/eeco/internal/config"
)
const goUsage = `usage:
eeco go [--brief] [--metrics] [--write] [--json] [--copy]
Prints a deterministic project brief for an AI assistant: what eeco is,
the project shape, where to look for detail, what eeco already knows,
and the open decisions. No AI is called; the brief is assembled from
what eeco already tracks.
By default the brief is printed to stdout as Markdown. With --json it is
printed as a JSON object instead — the machine-readable form for a
downstream agent or script. With --write the Markdown brief is saved to
<workspace>/context.md instead (override the path with the context_path
key in config.local), so an assistant's instructions file can point at
a stable path rather than re-running the command. --write requires an
initialised workspace; --json and --write cannot be combined.
With --copy the brief is written to the host operating system's
clipboard instead of stdout — the one-shot paste path for a chat-only
assistant (Gemini web, AI Studio, a Gem, an LLM behind a chat box) that
has no terminal and no filesystem access. --copy composes with --brief
and --json (paste a smaller or a structured payload); --copy and
--write cannot be combined. The platform clipboard tool is pbcopy on
macOS, clip.exe on Windows, and wl-copy / xclip / xsel on Linux; when
none is reachable on PATH eeco exits 2 with an install hint.
When the context_budget config key is set, --write trims the saved
brief down a deterministic ladder (full, then the smaller --brief form
with progressively shorter lists) until it fits that byte budget.
With --brief the brief is reshaped for a tight context budget: the
"Working with eeco" preamble and "Recording back" outro drop out and
each per-section list is capped at five entries. --brief composes with
--json, --write, and --copy, so the size knob applies to every
delivery axis.
With --metrics eeco prints a one-line assembly readout to stderr after
the brief: how long the brief took to assemble, its size, and how much
of the project's knowledge layer it distils. Token figures are
estimates (the ~4-bytes-per-token heuristic, marked with a leading "≈");
byte counts are real. The readout always describes the Markdown brief,
even alongside --json or --copy, and never appears in --json stdout, so
the machine-readable surface is unchanged. --metrics composes with every
other flag.`
// runGo prints the AI-ready project brief, or with --write saves it to
// the workspace. It calls no AI provider: the brief is a deterministic
// read over the resolved config, the memory store, and the queue. The
// --write path is the only write, and it stays inside the workspace
// (Constraint 1).
func runGo(args []string, stdout, stderr io.Writer) int {
fs := newFlagSet("go", stderr, goUsage)
write := fs.Bool("write", false, "save the brief to the workspace instead of printing it")
asJSON := fs.Bool("json", false, "print the brief as a JSON object instead of Markdown")
asBrief := fs.Bool("brief", false, "render the smaller brief variant (no preamble/outro; capped lists)")
asCopy := fs.Bool("copy", false, "write the brief to the host clipboard instead of stdout")
metrics := fs.Bool("metrics", false, "print a one-line assembly-metrics readout to stderr")
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() > 0 {
fmt.Fprintln(stderr, goUsage)
return 2
}
if *write && *asJSON {
fmt.Fprintln(stderr, "eeco go: --json and --write cannot be combined.")
return 2
}
if *write && *asCopy {
fmt.Fprintln(stderr, "eeco go: --copy and --write cannot be combined.")
return 2
}
cfg, code := loadRepoConfig(stderr, "eeco go")
if code != 0 {
return code
}
// Markdown is the default; --json swaps in the structured renderer
// and --brief picks the trimmed variant of either. All four
// signatures match, so a function value selects between them.
render := brief.Render
switch {
case *asJSON && *asBrief:
render = brief.RenderJSONBrief
case *asJSON:
render = brief.RenderJSON
case *asBrief:
render = brief.RenderBrief
}
if *asCopy {
text, err := render(cfg)
if err != nil {
fmt.Fprintln(stderr, "eeco go:", err)
return 1
}
if err := clip.Copy(text); err != nil {
if errors.Is(err, clip.ErrNoClipboardTool) {
fmt.Fprintf(stderr, "eeco go --copy: no clipboard tool found on PATH — %s.\n", clip.InstallHint())
return 2
}
fmt.Fprintln(stderr, "eeco go --copy:", err)
return 1
}
fmt.Fprintf(stdout, "copied brief to clipboard (%d bytes)\n", len(text))
emitMetrics(*metrics, cfg, *asBrief, stderr)
return 0
}
if !*write {
text, err := render(cfg)
if err != nil {
fmt.Fprintln(stderr, "eeco go:", err)
return 1
}
fmt.Fprint(stdout, text)
emitMetrics(*metrics, cfg, *asBrief, stderr)
return 0
}
// --write: the file lives inside the workspace, so it must exist.
if !config.IsInitialized(cfg) {
fmt.Fprintln(stderr, "eeco go --write: workspace not initialised — run `eeco init` first.")
return 1
}
// Render the brief to persist. With the context_budget config key
// set, the brief is trimmed down a deterministic ladder until it
// fits that byte budget; otherwise the chosen full/--brief variant
// is written as-is.
var text string
var report brief.BudgetReport
var err error
budgeted := cfg.ContextBudget > 0
if budgeted {
text, report, err = brief.RenderWithinBudget(cfg, cfg.ContextBudget, *asBrief)
} else {
text, err = render(cfg)
}
if err != nil {
fmt.Fprintln(stderr, "eeco go:", err)
return 1
}
target := filepath.Join(cfg.Workspace, filepath.FromSlash(cfg.ContextPath))
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
fmt.Fprintln(stderr, "eeco go:", err)
return 1
}
if err := os.WriteFile(target, []byte(text), 0o644); err != nil {
fmt.Fprintln(stderr, "eeco go:", err)
return 1
}
rel := path.Join(cfg.WorkspaceName, cfg.ContextPath)
if !budgeted {
maybeAutoCommit(cfg.WorkspaceHistory.Auto(), cfg.UserDir, "go --write", stderr)
fmt.Fprintln(stdout, "wrote", rel)
emitMetrics(*metrics, cfg, *asBrief, stderr)
return 0
}
maybeAutoCommit(cfg.WorkspaceHistory.Auto(), cfg.UserDir, "go --write", stderr)
fmt.Fprintf(stdout, "wrote %s (%s, %d/%d bytes)\n", rel, report.Tier, report.Bytes, cfg.ContextBudget)
if !report.Met {
fmt.Fprintf(stderr,
"eeco go --write: context_budget=%d not met — wrote the smallest brief (%d bytes).\n",
cfg.ContextBudget, report.Bytes)
}
emitMetrics(*metrics, cfg, *asBrief, stderr)
return 0
}
// emitMetrics measures the Markdown brief for cfg and, when on is set,
// writes the one-line assembly readout to stderr. It is best-effort: the
// brief has already been produced on the calling path, so a measurement
// fault only suppresses the optional readout — it never fails the command.
func emitMetrics(on bool, cfg *config.Config, asBrief bool, stderr io.Writer) {
if !on {
return
}
_, m, err := brief.Measure(cfg, asBrief)
if err != nil {
return
}
printMetrics(stderr, m)
}
// printMetrics writes the one-line assembly-metrics readout — the single
// format site. Byte counts are real; the token figures and the
// compression percentage are estimates (bytes/4) and carry a "≈" prefix
// so they never read as exact. The percentage is clamped at 0: a brief is
// never reported as larger than the knowledge it distils.
func printMetrics(w io.Writer, m brief.AssemblyMetrics) {
briefTokens := brief.EstimateTokens(m.BriefBytes)
knowledgeTokens := brief.EstimateTokens(m.KnowledgeBytes)
savedPct := 0
if m.KnowledgeBytes > 0 {
savedPct = max(0, (m.KnowledgeBytes-m.BriefBytes)*100/m.KnowledgeBytes)
}
fmt.Fprintf(w,
"eeco go: assembled in %s · brief %d bytes (≈%d tokens) · distilled ≈%d tokens of project knowledge into ≈%d (≈%d%% smaller)\n",
m.Elapsed.Round(time.Microsecond), m.BriefBytes, briefTokens, knowledgeTokens, briefTokens, savedPct)
}
added cmd/eeco/go_test.go
@@ -0,0 +1,532 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/ajhahnde/eeco/internal/brief"
"github.com/ajhahnde/eeco/internal/config"
)
func TestRunGo_PrintsBrief(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "go.mod", "module sample\n\ngo 1.24\n")
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
var out, errOut bytes.Buffer
code := runGo(nil, &out, &errOut)
if code != 0 {
t.Fatalf("runGo exit=%d stderr=%s", code, errOut.String())
}
body := out.String()
for _, want := range []string{
"eeco project brief",
"## Working with eeco",
"## Project",
"## Where to look",
"## What eeco knows",
"## Open decisions",
"## Recording back",
} {
if !strings.Contains(body, want) {
t.Errorf("brief missing %q:\n%s", want, body)
}
}
}
func TestRunGo_NotInRepo(t *testing.T) {
dir := t.TempDir()
chdir(t, dir)
var out, errOut bytes.Buffer
code := runGo(nil, &out, &errOut)
if code != 1 {
t.Fatalf("runGo outside a repo exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "not inside a git repository") {
t.Errorf("stderr missing not-in-repo message:\n%s", errOut.String())
}
}
func TestRunGo_RejectsArgs(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
if code := runGo([]string{"extra"}, &out, &errOut); code != 2 {
t.Fatalf("runGo with a positional arg exit=%d, want 2", code)
}
if code := runGo([]string{"--bogus"}, &out, &errOut); code != 2 {
t.Fatalf("runGo with an unknown flag exit=%d, want 2", code)
}
}
func TestRunGo_Write(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "go.mod", "module sample\n\ngo 1.24\n")
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
var out, errOut bytes.Buffer
code := runGo([]string{"--write"}, &out, &errOut)
if code != 0 {
t.Fatalf("runGo --write exit=%d stderr=%s", code, errOut.String())
}
if !strings.Contains(out.String(), ".eeco/context.md") {
t.Errorf("stdout missing wrote-path confirmation:\n%s", out.String())
}
got, err := os.ReadFile(filepath.Join(root, "tester", ".eeco", "context.md"))
if err != nil {
t.Fatalf("read context.md: %v", err)
}
cfg, err := config.Load(root, "")
if err != nil {
t.Fatal(err)
}
want, err := brief.Render(cfg)
if err != nil {
t.Fatal(err)
}
if string(got) != want {
t.Errorf("context.md is not byte-identical to the brief\n--- file ---\n%s\n--- brief ---\n%s", got, want)
}
}
func TestRunGo_WriteNoWorkspace(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
code := runGo([]string{"--write"}, &out, &errOut)
if code != 1 {
t.Fatalf("runGo --write without a workspace exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "not initialised") {
t.Errorf("stderr missing not-initialised message:\n%s", errOut.String())
}
}
func TestRunGo_WriteCustomPath(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "go.mod", "module sample\n\ngo 1.24\n")
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
writeFile(t, filepath.Join(root, "tester", ".eeco"), "config.local", "context_path=brief/project.md\n")
var out, errOut bytes.Buffer
if code := runGo([]string{"--write"}, &out, &errOut); code != 0 {
t.Fatalf("runGo --write exit=%d stderr=%s", code, errOut.String())
}
if _, err := os.Stat(filepath.Join(root, "tester", ".eeco", "brief", "project.md")); err != nil {
t.Errorf("context_path override not honoured: %v", err)
}
}
func TestRunGo_JSON(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "go.mod", "module sample\n\ngo 1.24\n")
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
var out, errOut bytes.Buffer
code := runGo([]string{"--json"}, &out, &errOut)
if code != 0 {
t.Fatalf("runGo --json exit=%d stderr=%s", code, errOut.String())
}
if !json.Valid(out.Bytes()) {
t.Fatalf("runGo --json did not emit valid JSON:\n%s", out.String())
}
var d brief.Data
if err := json.Unmarshal(out.Bytes(), &d); err != nil {
t.Fatalf("unmarshal brief JSON: %v", err)
}
if !d.Initialized {
t.Error("brief JSON should report initialized=true after eeco init")
}
}
func TestRunGo_JSONWriteConflict(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
if code := runGo([]string{"--json", "--write"}, &out, &errOut); code != 2 {
t.Fatalf("runGo --json --write exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), "cannot be combined") {
t.Errorf("stderr missing the mutual-exclusion message:\n%s", errOut.String())
}
}
func TestRunGo_Brief(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "go.mod", "module sample\n\ngo 1.24\n")
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
var out, errOut bytes.Buffer
if code := runGo([]string{"--brief"}, &out, &errOut); code != 0 {
t.Fatalf("runGo --brief exit=%d stderr=%s", code, errOut.String())
}
body := out.String()
for _, dropped := range []string{"## Working with eeco", "## Recording back"} {
if strings.Contains(body, dropped) {
t.Errorf("--brief output should omit %q:\n%s", dropped, body)
}
}
for _, kept := range []string{"## Project", "## Where to look", "## What eeco knows", "## Open decisions"} {
if !strings.Contains(body, kept) {
t.Errorf("--brief output should keep %q:\n%s", kept, body)
}
}
}
func TestRunGo_BriefJSON(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "go.mod", "module sample\n\ngo 1.24\n")
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
var out, errOut bytes.Buffer
if code := runGo([]string{"--brief", "--json"}, &out, &errOut); code != 0 {
t.Fatalf("runGo --brief --json exit=%d stderr=%s", code, errOut.String())
}
if !json.Valid(out.Bytes()) {
t.Fatalf("--brief --json did not emit valid JSON:\n%s", out.String())
}
var d brief.Data
if err := json.Unmarshal(out.Bytes(), &d); err != nil {
t.Fatalf("unmarshal brief JSON: %v", err)
}
if !d.Initialized {
t.Error("brief JSON should report initialized=true after eeco init")
}
// The BriefMode flag is rendering metadata, not project state, and
// must not leak through the JSON brief.
if strings.Contains(out.String(), "BriefMode") || strings.Contains(out.String(), `"brief_mode"`) {
t.Errorf("brief JSON leaks the BriefMode flag:\n%s", out.String())
}
}
func TestRunGo_WriteContextBudgetTrims(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "go.mod", "module sample\n\ngo 1.24\n")
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
cfg, err := config.Load(root, "")
if err != nil {
t.Fatal(err)
}
full, err := brief.Render(cfg)
if err != nil {
t.Fatal(err)
}
briefText, err := brief.RenderBrief(cfg)
if err != nil {
t.Fatal(err)
}
if len(briefText) >= len(full) {
t.Fatalf("fixture not discriminating: brief %d not smaller than full %d", len(briefText), len(full))
}
// A budget below the full brief but above the brief form must trim
// the saved brief down to the brief form.
budget := len(full) - 1
writeFile(t, filepath.Join(root, "tester", ".eeco"), "config.local",
fmt.Sprintf("context_budget=%d\n", budget))
var out, errOut bytes.Buffer
if code := runGo([]string{"--write"}, &out, &errOut); code != 0 {
t.Fatalf("runGo --write exit=%d stderr=%s", code, errOut.String())
}
got, err := os.ReadFile(filepath.Join(root, "tester", ".eeco", "context.md"))
if err != nil {
t.Fatalf("read context.md: %v", err)
}
if len(got) > budget {
t.Errorf("written brief is %d bytes, over the %d budget", len(got), budget)
}
if string(got) != briefText {
t.Errorf("expected the brief form to be written\n--- file ---\n%s", got)
}
if !strings.Contains(out.String(), "(brief,") || !strings.Contains(out.String(), "bytes)") {
t.Errorf("wrote line should name the trimmed tier:\n%s", out.String())
}
}
func TestRunGo_WriteContextBudgetNotMet(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "go.mod", "module sample\n\ngo 1.24\n")
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
writeFile(t, filepath.Join(root, "tester", ".eeco"), "config.local", "context_budget=1\n")
// An impossible budget still writes the smallest brief and exits 0 —
// `eeco go` is a brief generator, not a gate.
var out, errOut bytes.Buffer
if code := runGo([]string{"--write"}, &out, &errOut); code != 0 {
t.Fatalf("runGo --write exit=%d stderr=%s", code, errOut.String())
}
if _, err := os.Stat(filepath.Join(root, "tester", ".eeco", "context.md")); err != nil {
t.Errorf("an over-budget brief should still be written: %v", err)
}
if !strings.Contains(errOut.String(), "not met") {
t.Errorf("stderr should warn the budget was not met:\n%s", errOut.String())
}
}
func TestRunGo_BriefWriteContextBudget(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "go.mod", "module sample\n\ngo 1.24\n")
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
cfg, err := config.Load(root, "")
if err != nil {
t.Fatal(err)
}
briefText, err := brief.RenderBrief(cfg)
if err != nil {
t.Fatal(err)
}
// --brief excludes the full tier from the budget ladder; a budget
// well above the brief form leaves the brief form untrimmed.
writeFile(t, filepath.Join(root, "tester", ".eeco"), "config.local",
fmt.Sprintf("context_budget=%d\n", len(briefText)+1024))
var out, errOut bytes.Buffer
if code := runGo([]string{"--brief", "--write"}, &out, &errOut); code != 0 {
t.Fatalf("runGo --brief --write exit=%d stderr=%s", code, errOut.String())
}
got, err := os.ReadFile(filepath.Join(root, "tester", ".eeco", "context.md"))
if err != nil {
t.Fatalf("read context.md: %v", err)
}
if string(got) != briefText {
t.Errorf("--brief --write under a roomy budget should write the brief form\n--- file ---\n%s", got)
}
}
func TestRunGo_CopyWriteConflict(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
if code := runGo([]string{"--copy", "--write"}, &out, &errOut); code != 2 {
t.Fatalf("runGo --copy --write exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), "--copy and --write cannot be combined") {
t.Errorf("stderr missing the copy-write conflict message:\n%s", errOut.String())
}
}
func TestRunGo_CopyMissingTool(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "go.mod", "module sample\n\ngo 1.24\n")
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
// Empty PATH so no platform clipboard tool resolves — the missing-
// tool exit-2 branch (workflow contract's "blocked") is reachable
// on every host.
t.Setenv("PATH", "")
var out, errOut bytes.Buffer
code := runGo([]string{"--copy"}, &out, &errOut)
if code != 2 {
t.Fatalf("runGo --copy with empty PATH exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), "no clipboard tool found") {
t.Errorf("stderr missing missing-tool message:\n%s", errOut.String())
}
}
func TestRunGo_Metrics(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "go.mod", "module sample\n\ngo 1.24\n")
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
var out, errOut bytes.Buffer
if code := runGo([]string{"--metrics"}, &out, &errOut); code != 0 {
t.Fatalf("runGo --metrics exit=%d stderr=%s", code, errOut.String())
}
// The brief still prints in full on stdout.
for _, want := range []string{
"## Working with eeco", "## Project", "## Where to look",
"## What eeco knows", "## Open decisions", "## Recording back",
} {
if !strings.Contains(out.String(), want) {
t.Errorf("stdout missing brief header %q:\n%s", want, out.String())
}
}
// The readout lands on stderr.
for _, want := range []string{"assembled in", "bytes (≈", "distilled ≈"} {
if !strings.Contains(errOut.String(), want) {
t.Errorf("stderr missing metrics fragment %q:\n%s", want, errOut.String())
}
}
// The readout must never leak into stdout.
for _, leak := range []string{"assembled in", "distilled", "smaller)"} {
if strings.Contains(out.String(), leak) {
t.Errorf("metrics fragment %q leaked into stdout:\n%s", leak, out.String())
}
}
}
func TestRunGo_MetricsStdoutByteIdentical(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "go.mod", "module sample\n\ngo 1.24\n")
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
var plainOut, plainErr bytes.Buffer
if code := runGo(nil, &plainOut, &plainErr); code != 0 {
t.Fatalf("runGo exit=%d", code)
}
var metricsOut, metricsErr bytes.Buffer
if code := runGo([]string{"--metrics"}, &metricsOut, &metricsErr); code != 0 {
t.Fatalf("runGo --metrics exit=%d", code)
}
if plainOut.String() != metricsOut.String() {
t.Errorf("--metrics changed stdout:\n--- plain ---\n%s\n--- metrics ---\n%s", plainOut.String(), metricsOut.String())
}
if metricsErr.Len() == 0 {
t.Error("--metrics produced no stderr readout")
}
}
func TestRunGo_MetricsJSON(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "go.mod", "module sample\n\ngo 1.24\n")
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
var out, errOut bytes.Buffer
if code := runGo([]string{"--metrics", "--json"}, &out, &errOut); code != 0 {
t.Fatalf("runGo --metrics --json exit=%d stderr=%s", code, errOut.String())
}
// --metrics + --json is accepted: stdout is the untouched JSON brief.
if !json.Valid(out.Bytes()) {
t.Fatalf("--metrics --json stdout is not valid JSON:\n%s", out.String())
}
var d brief.Data
if err := json.Unmarshal(out.Bytes(), &d); err != nil {
t.Fatalf("unmarshal brief JSON: %v", err)
}
if strings.Contains(out.String(), "assembled in") {
t.Errorf("metrics readout leaked into --json stdout:\n%s", out.String())
}
if !strings.Contains(errOut.String(), "assembled in") {
t.Errorf("stderr missing metrics readout:\n%s", errOut.String())
}
}
func TestRunGo_MetricsWrite(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "go.mod", "module sample\n\ngo 1.24\n")
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
var out, errOut bytes.Buffer
if code := runGo([]string{"--metrics", "--write"}, &out, &errOut); code != 0 {
t.Fatalf("runGo --metrics --write exit=%d stderr=%s", code, errOut.String())
}
if _, err := os.Stat(filepath.Join(root, "tester", ".eeco", "context.md")); err != nil {
t.Errorf("--write did not write the brief: %v", err)
}
if !strings.Contains(out.String(), ".eeco/context.md") {
t.Errorf("stdout missing wrote-path confirmation:\n%s", out.String())
}
if !strings.Contains(errOut.String(), "assembled in") {
t.Errorf("stderr missing metrics readout:\n%s", errOut.String())
}
}
func TestRunGo_MetricsBrief(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "go.mod", "module sample\n\ngo 1.24\n")
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
var out, errOut bytes.Buffer
if code := runGo([]string{"--metrics", "--brief"}, &out, &errOut); code != 0 {
t.Fatalf("runGo --metrics --brief exit=%d stderr=%s", code, errOut.String())
}
for _, dropped := range []string{"## Working with eeco", "## Recording back"} {
if strings.Contains(out.String(), dropped) {
t.Errorf("--brief stdout should omit %q:\n%s", dropped, out.String())
}
}
if !strings.Contains(errOut.String(), "assembled in") {
t.Errorf("stderr missing metrics readout:\n%s", errOut.String())
}
}
func TestRunGo_BriefWrite(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "go.mod", "module sample\n\ngo 1.24\n")
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
var out, errOut bytes.Buffer
if code := runGo([]string{"--brief", "--write"}, &out, &errOut); code != 0 {
t.Fatalf("runGo --brief --write exit=%d stderr=%s", code, errOut.String())
}
got, err := os.ReadFile(filepath.Join(root, "tester", ".eeco", "context.md"))
if err != nil {
t.Fatalf("read context.md: %v", err)
}
cfg, err := config.Load(root, "")
if err != nil {
t.Fatal(err)
}
want, err := brief.RenderBrief(cfg)
if err != nil {
t.Fatal(err)
}
if string(got) != want {
t.Errorf("context.md is not byte-identical to the brief\n--- file ---\n%s\n--- brief ---\n%s", got, want)
}
}
added cmd/eeco/guide.go
@@ -0,0 +1,84 @@
package main
import (
"fmt"
"io"
"os"
"os/exec"
"github.com/ajhahnde/eeco/internal/guide"
)
const guideUsage = `usage:
eeco guide
Pages the in-binary user manual (a verbatim mirror of docs/USAGE.md
at the tag the binary was built from) through the host pager. The
pager honours $PAGER, falls back to ` + "`less -R`" + ` when $PAGER is
unset, and dumps the manual to stdout when no pager is available or
stdout is not a terminal. At a terminal the manual is prettified
(box-drawing tables, styled headings); NO_COLOR drops the colour but
keeps the layout. Piped or redirected output is raw Markdown,
byte-identical to docs/USAGE.md.`
// runGuide pages the in-binary guide. Spawns a pager when stdout is
// a terminal so long output is browsable; falls through to a plain
// stdout dump otherwise (matches the TUI OneScreen() precedent).
func runGuide(args []string, stdout, stderr io.Writer) int {
fs := newFlagSet("guide", stderr, guideUsage)
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() > 0 {
fmt.Fprintln(stderr, guideUsage)
return 2
}
// Piped / non-TTY: dump raw Markdown, byte-identical to
// docs/USAGE.md, so machine consumers see no ANSI.
if !isStdoutTerminal(stdout) {
if err := guide.Dump(stdout); err != nil {
fmt.Fprintln(stderr, "eeco guide:", err)
return 1
}
return 0
}
// Interactive terminal: prettify (box-drawing tables, styled
// headings). NO_COLOR drops the ANSI but keeps the layout.
colour := os.Getenv("NO_COLOR") == ""
cmd := guide.PagerCommand(guide.Render(colour), os.Getenv, exec.LookPath)
if cmd == nil {
if err := guide.DumpRendered(stdout, colour); err != nil {
fmt.Fprintln(stderr, "eeco guide:", err)
return 1
}
return 0
}
cmd.Stdout = stdout
cmd.Stderr = stderr
if err := cmd.Run(); err != nil {
// Pager failed — fall back to a rendered dump so the user
// still sees the manual.
if err := guide.DumpRendered(stdout, colour); err != nil {
fmt.Fprintln(stderr, "eeco guide:", err)
return 1
}
}
return 0
}
// isStdoutTerminal reports whether the writer is a real character
// device — the binary was attached to a terminal. Mirrors the TUI
// detection in internal/tui/tui.go.
func isStdoutTerminal(w io.Writer) bool {
f, ok := w.(*os.File)
if !ok || f == nil {
return false
}
info, err := f.Stat()
if err != nil {
return false
}
return info.Mode()&os.ModeCharDevice != 0
}
added cmd/eeco/guide_test.go
@@ -0,0 +1,51 @@
package main
import (
"bytes"
"strings"
"testing"
"github.com/ajhahnde/eeco/internal/guide"
)
func TestRunGuide_NonTTYDumpsManual(t *testing.T) {
var out, errOut bytes.Buffer
code := runGuide(nil, &out, &errOut)
if code != 0 {
t.Fatalf("runGuide exit=%d stderr=%s", code, errOut.String())
}
if out.String() != guide.Text() {
t.Error("runGuide non-TTY output is not byte-identical to guide.Text()")
}
if errOut.Len() != 0 {
t.Errorf("stderr should be empty on the happy path; got %q", errOut.String())
}
}
func TestRunGuide_RejectsArgs(t *testing.T) {
var out, errOut bytes.Buffer
if code := runGuide([]string{"topic"}, &out, &errOut); code != 2 {
t.Errorf("runGuide with a positional arg exit=%d, want 2", code)
}
if code := runGuide([]string{"--bogus"}, &out, &errOut); code != 2 {
t.Errorf("runGuide with an unknown flag exit=%d, want 2", code)
}
}
func TestRunGuide_NonTTYContainsExpectedHeader(t *testing.T) {
var out, errOut bytes.Buffer
if code := runGuide(nil, &out, &errOut); code != 0 {
t.Fatalf("runGuide exit=%d", code)
}
if !strings.Contains(out.String(), "<h1>Usage</h1>") {
t.Errorf("guide output missing usage-guide header:\n%s", firstNLines(out.String(), 5))
}
}
func firstNLines(s string, n int) string {
lines := strings.SplitN(s, "\n", n+1)
if len(lines) > n {
lines = lines[:n]
}
return strings.Join(lines, "\n")
}
added cmd/eeco/helpers.go
@@ -0,0 +1,63 @@
package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"github.com/ajhahnde/eeco/internal/config"
)
// loadInitedConfig loads config for a verb that requires an initialised
// workspace. It returns (cfg, 0) on success, or (nil, 1) after printing a
// prefixed diagnostic. The repo-guard text lives in loadRepoConfig, which
// this delegates to, so a verb's "not inside a git repository" message is
// worded identically whether it needs init or not.
func loadInitedConfig(stderr io.Writer, prefix string) (*config.Config, int) {
cfg, code := loadRepoConfig(stderr, prefix)
if code != 0 {
return nil, code
}
if !config.IsInitialized(cfg) {
fmt.Fprintln(stderr, prefix+": workspace not initialised.")
fmt.Fprintln(stderr, "hint: run `eeco init` first.")
return nil, 1
}
return cfg, 0
}
// loadRepoConfig loads config for a verb that only needs to be inside a git
// repository (valid before `eeco init`). It returns (cfg, 0) on success, or
// (nil, 1) after printing a prefixed diagnostic. prefix is the verb's
// `eeco <verb>` message prefix (bare "eeco" for the no-arg control center).
func loadRepoConfig(stderr io.Writer, prefix string) (*config.Config, int) {
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintln(stderr, prefix+":", err)
return nil, 1
}
cfg, err := config.Load(cwd, config.DefaultWorkspace)
if err != nil {
if errors.Is(err, config.ErrNotInRepo) {
fmt.Fprintln(stderr, prefix+": not inside a git repository.")
fmt.Fprintln(stderr, "hint: cd into a repo, then run `eeco init`.")
return nil, 1
}
fmt.Fprintln(stderr, prefix+":", err)
return nil, 1
}
return cfg, 0
}
// newFlagSet builds a ContinueOnError flag set whose Usage prints usage to
// stderr. ContinueOnError lets the caller map a parse failure to exit 2
// rather than letting flag call os.Exit, and routing output to stderr keeps
// stdout clean for machine consumers.
func newFlagSet(name string, stderr io.Writer, usage string) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(stderr)
fs.Usage = func() { fmt.Fprintln(stderr, usage) }
return fs
}
added cmd/eeco/helpers_test.go
@@ -0,0 +1,84 @@
package main
import (
"bytes"
"flag"
"strings"
"testing"
)
func TestLoadRepoConfig_OutsideRepo(t *testing.T) {
chdir(t, t.TempDir())
var errOut bytes.Buffer
cfg, code := loadRepoConfig(&errOut, "eeco test")
if code != 1 || cfg != nil {
t.Fatalf("want (nil, 1) outside a repo, got (%v, %d)", cfg, code)
}
if !strings.Contains(errOut.String(), "not inside a git repository") {
t.Errorf("missing repo-guard text:\n%s", errOut.String())
}
if !strings.Contains(errOut.String(), "eeco test:") {
t.Errorf("missing prefix:\n%s", errOut.String())
}
}
func TestLoadRepoConfig_InRepoPreInit(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var errOut bytes.Buffer
cfg, code := loadRepoConfig(&errOut, "eeco test")
if code != 0 || cfg == nil {
t.Fatalf("want (cfg, 0) inside a repo before init, got (%v, %d); stderr:\n%s", cfg, code, errOut.String())
}
}
func TestLoadInitedConfig_OutsideRepo(t *testing.T) {
chdir(t, t.TempDir())
var errOut bytes.Buffer
cfg, code := loadInitedConfig(&errOut, "eeco test")
if code != 1 || cfg != nil {
t.Fatalf("want (nil, 1) outside a repo, got (%v, %d)", cfg, code)
}
if !strings.Contains(errOut.String(), "not inside a git repository") {
t.Errorf("missing repo-guard text:\n%s", errOut.String())
}
}
func TestLoadInitedConfig_InRepoUninitialised(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var errOut bytes.Buffer
cfg, code := loadInitedConfig(&errOut, "eeco test")
if code != 1 || cfg != nil {
t.Fatalf("want (nil, 1) before init, got (%v, %d)", cfg, code)
}
if !strings.Contains(errOut.String(), "workspace not initialised.") {
t.Errorf("missing init-guard text:\n%s", errOut.String())
}
if !strings.Contains(errOut.String(), "hint: run `eeco init` first.") {
t.Errorf("missing init hint:\n%s", errOut.String())
}
}
func TestLoadInitedConfig_Initialised(t *testing.T) {
root := newGitRepo(t)
initWorkspace(t, root)
chdir(t, root)
var errOut bytes.Buffer
cfg, code := loadInitedConfig(&errOut, "eeco test")
if code != 0 || cfg == nil {
t.Fatalf("want (cfg, 0) after init, got (%v, %d); stderr:\n%s", cfg, code, errOut.String())
}
}
func TestNewFlagSet(t *testing.T) {
var errOut bytes.Buffer
fs := newFlagSet("widget", &errOut, "usage: eeco widget")
if fs.ErrorHandling() != flag.ContinueOnError {
t.Errorf("want ContinueOnError, got %v", fs.ErrorHandling())
}
fs.Usage()
if !strings.Contains(errOut.String(), "usage: eeco widget") {
t.Errorf("Usage did not write the usage string:\n%s", errOut.String())
}
}
added cmd/eeco/history.go
@@ -0,0 +1,166 @@
package main
import (
"fmt"
"io"
"os"
"github.com/ajhahnde/eeco/internal/config"
)
const historyUsage = `usage:
eeco history show the workspace-history log (recent commits)
eeco history snapshot [-m <msg>] commit the current workspace state
eeco history compact [--dry-run] [--yes] squash the whole log into one commit (reflog-recoverable)
eeco keeps a private, local git repository inside its own gitignored
workspace directory to version the knowledge layer — memory, queue,
decisions, manifests — over time. It has no remote and is never pushed, and
it never touches the host project's tracked tree. Toggle it with the
workspace_history config key (off | manual | auto; manual is the default)
or the --no-track flag on eeco init.`
// runHistory dispatches `eeco history <verb>`. With no verb it prints the
// private repo's recent log. All private-repo git lives in the cmd layer
// (historygit.go); the engine never writes git.
func runHistory(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
return runHistoryLog(stdout, stderr)
}
switch args[0] {
case "snapshot":
return runHistorySnapshot(args[1:], stdout, stderr)
case "compact":
return runHistoryCompact(args[1:], stdout, stderr)
default:
fmt.Fprintf(stderr, "eeco history: unknown verb %q\n", args[0])
fmt.Fprintln(stderr, historyUsage)
return 2
}
}
// runHistoryLog prints the private repo's recent one-line log.
func runHistoryLog(stdout, stderr io.Writer) int {
cfg, code := historyConfig(stderr)
if cfg == nil {
return code
}
if code := historyRepoReady(cfg, stderr); code != 0 {
return code
}
log, err := logPrivateRepo(cfg.UserDir, privateLogDefault)
if err != nil {
fmt.Fprintln(stderr, "eeco history:", err)
return 1
}
if log == "" {
fmt.Fprintln(stdout, "(no commits yet)")
return 0
}
fmt.Fprintln(stdout, log)
return 0
}
// runHistorySnapshot commits the current workspace state (the manual
// cadence trigger). It is no-op-safe on a clean tree.
func runHistorySnapshot(args []string, stdout, stderr io.Writer) int {
fs := newFlagSet("history snapshot", stderr, historyUsage)
msg := fs.String("m", "", "commit message (default: a timestamped workspace snapshot)")
if err := fs.Parse(args); err != nil {
return 2
}
cfg, code := historyConfig(stderr)
if cfg == nil {
return code
}
if code := historyRepoReady(cfg, stderr); code != 0 {
return code
}
res, err := snapshotPrivateRepo(cfg.UserDir, *msg, stderr)
if err != nil {
fmt.Fprintln(stderr, "eeco history snapshot:", err)
return 1
}
if res.clean {
fmt.Fprintln(stdout, "nothing to snapshot — workspace unchanged since the last commit.")
return 0
}
fmt.Fprintln(stdout, "snapshot recorded.")
return 0
}
// historyCompactStdin is the reader `eeco history compact` uses for its
// confirmation prompt (mirrors initStdin / uninstallStdin); the test suite
// pins it as needed.
var historyCompactStdin io.Reader = os.Stdin
// runHistoryCompact squashes the private repo's entire history into one
// commit. The rewrite is destructive but reversible via the git reflog, so
// it confirms before applying unless --yes is given; --dry-run reports the
// count and writes nothing. Manual only — there is no auto-trigger (a
// destructive auto history rewrite would breach the safe-default-floor
// principle). On a one-commit (or empty) repo it is a no-op.
func runHistoryCompact(args []string, stdout, stderr io.Writer) int {
fs := newFlagSet("history compact", stderr, historyUsage)
dryRun := fs.Bool("dry-run", false, "report how many commits would be squashed and exit; write nothing")
yes := fs.Bool("yes", false, "skip the confirmation prompt")
if err := fs.Parse(args); err != nil {
return 2
}
cfg, code := historyConfig(stderr)
if cfg == nil {
return code
}
if code := historyRepoReady(cfg, stderr); code != 0 {
return code
}
n, err := commitCount(cfg.UserDir)
if err != nil {
fmt.Fprintln(stderr, "eeco history compact:", err)
return 1
}
if n <= 1 {
fmt.Fprintf(stdout, "workspace history already compact (%d commit).\n", n)
return 0
}
if *dryRun {
fmt.Fprintf(stdout, "would squash %d commits into 1.\n", n)
return 0
}
if !*yes {
prompt := fmt.Sprintf("squash %d commits of workspace history into one? the workspace files stay; only the commit log is collapsed (recoverable via git reflog). [y/N]: ", n)
if !confirm(historyCompactStdin, stderr, prompt) {
fmt.Fprintln(stdout, "aborted.")
return 0
}
}
if _, err := compactPrivateRepo(cfg.UserDir); err != nil {
fmt.Fprintln(stderr, "eeco history compact:", err)
return 1
}
fmt.Fprintf(stdout, "squashed %d commits into 1.\n", n)
return 0
}
// historyConfig loads the configuration for a history command, returning a
// nil *Config and an exit code on failure. A history command is repo-guarded
// only (it works before `eeco init`; historyRepoReady reports the absent
// private repo separately).
func historyConfig(stderr io.Writer) (*config.Config, int) {
return loadRepoConfig(stderr, "eeco history")
}
// historyRepoReady reports that workspace history is enabled and the
// private repo exists, printing the appropriate guidance and returning a
// non-zero exit code otherwise (0 means ready).
func historyRepoReady(cfg *config.Config, stderr io.Writer) int {
if !cfg.WorkspaceHistory.Enabled() {
fmt.Fprintln(stderr, "eeco history: workspace history is off (set workspace_history=manual to enable).")
return 1
}
if !privateRepoExists(cfg.UserDir) {
fmt.Fprintln(stderr, "eeco history: no workspace-history repository — run `eeco init` (or it was created with --no-track).")
return 1
}
return 0
}
added cmd/eeco/history_test.go
@@ -0,0 +1,192 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
func TestRunHistory_LogAndSnapshot(t *testing.T) {
requireGit(t)
defer setTrackHistory(true)()
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("setup init failed")
}
// `eeco history` shows the init commit.
var out bytes.Buffer
if code := runHistory(nil, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("history exit; out:\n%s", out.String())
}
if !strings.Contains(out.String(), privateInitCommitMsg) {
t.Errorf("history log missing init commit:\n%s", out.String())
}
// A clean tree → snapshot is a no-op.
var clean bytes.Buffer
if code := runHistory([]string{"snapshot"}, &clean, &bytes.Buffer{}); code != 0 {
t.Fatalf("clean snapshot exit; out:\n%s", clean.String())
}
if !strings.Contains(clean.String(), "nothing to snapshot") {
t.Errorf("clean snapshot output:\n%s", clean.String())
}
// Mutate the workspace, then snapshot with a custom message.
if code := runAdd([]string{"note", "hello history"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("add note failed")
}
var snap bytes.Buffer
if code := runHistory([]string{"snapshot", "-m", "after note"}, &snap, &bytes.Buffer{}); code != 0 {
t.Fatalf("snapshot exit; out:\n%s", snap.String())
}
if !strings.Contains(snap.String(), "snapshot recorded") {
t.Errorf("snapshot output:\n%s", snap.String())
}
// The log now carries the custom-message commit.
var out2 bytes.Buffer
if code := runHistory(nil, &out2, &bytes.Buffer{}); code != 0 {
t.Fatal("history (2) failed")
}
if !strings.Contains(out2.String(), "after note") {
t.Errorf("log missing snapshot commit:\n%s", out2.String())
}
}
// TestRunHistoryCompact drives `eeco history compact` end-to-end: dry-run
// previews without writing, a "n" confirm aborts, and --yes squashes the log
// to one commit while the workspace files stay on disk. A second compact is a
// no-op (already one commit).
func TestRunHistoryCompact(t *testing.T) {
requireGit(t)
defer setTrackHistory(true)()
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("setup init failed")
}
userDir := filepath.Join(root, "tester")
// Grow the log past one commit.
for i := range 2 {
if code := runAdd([]string{"note", "n" + strconv.Itoa(i)}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("add note failed")
}
if code := runHistory([]string{"snapshot", "-m", "snap" + strconv.Itoa(i)}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("snapshot failed")
}
}
before, err := commitCount(userDir)
if err != nil || before < 3 {
t.Fatalf("setup commit count = %d (err %v), want >=3", before, err)
}
// A tracked workspace file to prove the content survives the squash.
tracked, _ := runGit(userDir, "ls-files")
if strings.TrimSpace(tracked) == "" {
t.Fatal("no tracked workspace files in the private repo")
}
sampleFile := strings.SplitN(strings.TrimSpace(tracked), "\n", 2)[0]
// Dry-run previews and writes nothing.
var dry bytes.Buffer
if code := runHistory([]string{"compact", "--dry-run"}, &dry, &bytes.Buffer{}); code != 0 {
t.Fatalf("compact --dry-run exit; out:\n%s", dry.String())
}
if !strings.Contains(dry.String(), "would squash") {
t.Errorf("dry-run output:\n%s", dry.String())
}
if n, _ := commitCount(userDir); n != before {
t.Errorf("dry-run changed commit count: %d -> %d", before, n)
}
// Confirm-abort: feeding "n" leaves the log untouched.
prev := historyCompactStdin
historyCompactStdin = strings.NewReader("n\n")
var ab bytes.Buffer
code := runHistory([]string{"compact"}, &ab, &bytes.Buffer{})
historyCompactStdin = prev
if code != 0 {
t.Fatalf("compact abort exit; out:\n%s", ab.String())
}
if !strings.Contains(ab.String(), "aborted") {
t.Errorf("abort output:\n%s", ab.String())
}
if n, _ := commitCount(userDir); n != before {
t.Errorf("abort changed commit count: %d -> %d", before, n)
}
// Apply with --yes → one commit, workspace data intact.
var did bytes.Buffer
if code := runHistory([]string{"compact", "--yes"}, &did, &bytes.Buffer{}); code != 0 {
t.Fatalf("compact --yes exit; out:\n%s", did.String())
}
if !strings.Contains(did.String(), "squashed") {
t.Errorf("apply output:\n%s", did.String())
}
if n, _ := commitCount(userDir); n != 1 {
t.Errorf("after compact commit count = %d, want 1", n)
}
if _, err := os.Stat(filepath.Join(userDir, sampleFile)); err != nil {
t.Errorf("workspace data %q missing after compact: %v", sampleFile, err)
}
if out, _ := runGit(userDir, "status", "--porcelain"); strings.TrimSpace(out) != "" {
t.Errorf("workspace dirty after compact:\n%s", out)
}
// A second compact is a no-op (already one commit).
var noop bytes.Buffer
if code := runHistory([]string{"compact", "--yes"}, &noop, &bytes.Buffer{}); code != 0 {
t.Fatalf("second compact exit; out:\n%s", noop.String())
}
if !strings.Contains(noop.String(), "already compact") {
t.Errorf("second compact should be a no-op:\n%s", noop.String())
}
}
func TestRunHistory_NoRepoErrors(t *testing.T) {
defer setTrackHistory(true)()
root := newGitRepo(t)
chdir(t, root)
// Workspace exists but the private repo was opted out.
if code := runInit([]string{"--no-track"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("setup init failed")
}
var errb bytes.Buffer
if code := runHistory(nil, &bytes.Buffer{}, &errb); code == 0 {
t.Error("history succeeded with no private repo")
}
if !strings.Contains(errb.String(), "no workspace-history repository") {
t.Errorf("missing no-repo message:\n%s", errb.String())
}
}
func TestRunHistory_OffErrors(t *testing.T) {
defer setTrackHistory(true)()
root := newGitRepo(t)
chdir(t, root)
if code := runInit([]string{"--no-track"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("setup init failed")
}
writeFile(t, root+"/tester/.eeco", "config.local", "workspace_history=off\n")
var errb bytes.Buffer
if code := runHistory(nil, &bytes.Buffer{}, &errb); code == 0 {
t.Error("history succeeded with workspace_history=off")
}
if !strings.Contains(errb.String(), "workspace history is off") {
t.Errorf("missing off message:\n%s", errb.String())
}
}
func TestRunHistory_UnknownVerb(t *testing.T) {
var errb bytes.Buffer
if code := runHistory([]string{"bogus"}, &bytes.Buffer{}, &errb); code != 2 {
t.Errorf("unknown verb exit=%d, want 2", code)
}
if !strings.Contains(errb.String(), "unknown verb") {
t.Errorf("missing unknown-verb message:\n%s", errb.String())
}
}
added cmd/eeco/historygit.go
@@ -0,0 +1,319 @@
package main
import (
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/ajhahnde/eeco/internal/gitx"
"github.com/ajhahnde/eeco/internal/queue"
)
// This file holds the SECOND sanctioned git-write path in eeco, alongside
// initgit.go. Like initgit.go it lives in package main so no engine
// package (internal/*) can reach a git-write call: the engine stays
// strictly read-only with respect to git (PLAN.md Constraint 6, mirrored
// by internal/gitx's read-only-by-construction surface). Where initgit.go
// performs the one-shot `eeco init` .gitignore commit on the HOST repo,
// this file manages a SEPARATE, private, local git repository that eeco
// stands up INSIDE its own gitignored workspace directory
// (<repo>/<username>/) to version its knowledge layer over time. That
// private repo never has a remote and is never pushed, and it never
// touches the host project's tracked tree — it records only what eeco
// already writes inside its own gitignored workspace.
//
// THE NESTED-REPO HAZARD (the load-bearing safety point). The private
// repo's .git lives at <userDir>/.git, INSIDE the host worktree. Git
// searches upward for a repository, so any `git -C <userDir> <write>` run
// when <userDir>/.git does NOT exist would silently operate on the HOST
// repo above it — e.g. `git -C <userDir> add -A` would stage the host's
// own files. Two guards prevent that:
// 1. privateRepoExists: no private-repo git WRITE runs unless
// <userDir>/.git is a real directory. The sole exception is the
// `git init` that creates it; `git init` in a subdirectory of an
// existing repo correctly makes a new nested repo and does not touch
// the parent.
// 2. privateRepoRooted: before any add/commit, assert that git resolves
// the repository's top level to <userDir> itself, not an ancestor.
const (
// privateInitCommitMsg is the static, brand-free message for the
// initial commit a fresh workspace-history repo receives. Commit
// messages here are literal templates, never AI output, so they carry
// no attribution risk.
privateInitCommitMsg = "eeco workspace init"
// privateRepoUserName / privateRepoUserEmail are the repo-scoped
// (never --global) git identity eeco's own commits are made under.
// Brand-free, no AI fingerprint.
privateRepoUserName = "eeco"
privateRepoUserEmail = "eeco@localhost"
// privateLogDefault is how many recent commits `eeco history` prints.
privateLogDefault = 20
)
// privateRepoExists reports whether a private workspace-history repository
// has been initialized at userDir — i.e. <userDir>/.git is a directory.
// This is the primary nested-repo guard: no private-repo git write runs
// unless this returns true (except the `git init` that creates it).
func privateRepoExists(userDir string) bool {
if userDir == "" {
return false
}
info, err := os.Stat(filepath.Join(userDir, ".git"))
return err == nil && info.IsDir()
}
// privateRepoRooted asserts, as defense-in-depth before any write, that
// git run with userDir as its working directory resolves the repository
// top level to userDir itself — not an ancestor host repo. It returns
// false if git roots the worktree anywhere other than userDir, which is
// exactly the nested-repo hazard the caller must refuse to write into.
func privateRepoRooted(userDir string) bool {
out, err := runGit(userDir, "rev-parse", "--show-toplevel")
if err != nil {
return false
}
return samePath(strings.TrimSpace(out), userDir)
}
// samePath reports whether two paths denote the same location, resolving
// symlinks on both sides. git's `--show-toplevel` reports the physical
// path (on macOS a temp dir under /var resolves to /private/var), so a
// raw string compare against userDir would spuriously fail.
func samePath(a, b string) bool {
if ra, err := filepath.EvalSymlinks(a); err == nil {
a = ra
}
if rb, err := filepath.EvalSymlinks(b); err == nil {
b = rb
}
return filepath.Clean(a) == filepath.Clean(b)
}
// historyInitResult records what initPrivateRepo did, for an accurate
// init report.
type historyInitResult struct {
created bool // a fresh repo was initialized and given its first commit
already bool // a private repo already existed (idempotent skip)
}
// initPrivateRepo stands up the private workspace-history repository at
// userDir: `git init`, a repo-scoped brand-free identity, the repo's own
// .gitignore, and an initial commit of the scaffolded workspace. It is
// idempotent (an existing repo is left untouched) and degrades rather
// than fails: git missing or any step failing warns on stderr and returns
// a zero-value result, because the workspace is already on disk and a
// history hiccup must not break `eeco init`.
func initPrivateRepo(userDir string, stderr io.Writer) historyInitResult {
var res historyInitResult
if !gitx.Available() {
fmt.Fprintln(stderr, "eeco init: git not available — skipping workspace history (the workspace is set up).")
return res
}
if privateRepoExists(userDir) {
res.already = true
return res
}
if out, err := runGit(userDir, "init"); err != nil {
fmt.Fprintf(stderr, "eeco init: could not initialise workspace history (the workspace is set up): %s\n", gitErr(out, err))
return res
}
// Repo-scoped identity (never --global). commit.gpgsign=false so a
// user's global signing default cannot make eeco's non-interactive
// commits prompt or fail.
for _, kv := range [][2]string{
{"user.name", privateRepoUserName},
{"user.email", privateRepoUserEmail},
{"commit.gpgsign", "false"},
} {
if out, err := runGit(userDir, "config", kv[0], kv[1]); err != nil {
fmt.Fprintf(stderr, "eeco init: workspace history: could not set %s: %s\n", kv[0], gitErr(out, err))
}
}
writePrivateGitignore(userDir, stderr)
// Defense-in-depth before the first add/commit: confirm git roots the
// repo at userDir, not an ancestor. If not, refuse to stage anything.
if !privateRepoRooted(userDir) {
fmt.Fprintln(stderr, "eeco init: workspace history: repository did not root at the workspace; skipping initial commit.")
return res
}
if out, err := runGit(userDir, "add", "-A"); err != nil {
fmt.Fprintf(stderr, "eeco init: workspace history: could not stage initial state: %s\n", gitErr(out, err))
return res
}
if out, err := runGit(userDir, "commit", "-m", privateInitCommitMsg); err != nil {
fmt.Fprintf(stderr, "eeco init: workspace history: could not record initial commit: %s\n", gitErr(out, err))
return res
}
res.created = true
return res
}
// writePrivateGitignore writes the private repo's OWN .gitignore (distinct
// from the host's), excluding the transient files eeco creates inside the
// workspace: memory temp files (memory.Save's os.CreateTemp `.<name>.*.tmp`)
// and the queue lock file. A failure only warns — a noisier history is not
// worth failing init over.
func writePrivateGitignore(userDir string, stderr io.Writer) {
body := "# eeco workspace-history repository — local-only, never pushed.\n" +
"# Transient files eeco writes inside the workspace:\n" +
"*.tmp\n" + // memory atomic-write temps (.<name>.*.tmp)
".eeco-*\n" + // any eeco atomic-write temp that lands here (.eeco-session-*, …)
queue.LockName + "\n"
if err := os.WriteFile(filepath.Join(userDir, ".gitignore"), []byte(body), 0o644); err != nil {
fmt.Fprintf(stderr, "eeco init: workspace history: could not write .gitignore: %v\n", err)
}
}
// snapshotResult records what snapshotPrivateRepo did.
type snapshotResult struct {
committed bool // a commit was recorded
clean bool // nothing changed — no commit needed
}
// snapshotPrivateRepo stages and commits the current workspace state into
// the private repo (the manual cadence trigger behind `eeco history
// snapshot`). It is no-op-safe: with a clean tree it records nothing and
// reports clean. message, when empty, defaults to a timestamped template.
// It refuses to run unless the private repo exists AND roots at userDir,
// so it can never stage the host repo.
func snapshotPrivateRepo(userDir, message string, _ io.Writer) (snapshotResult, error) {
var res snapshotResult
if !privateRepoExists(userDir) {
return res, fmt.Errorf("no workspace-history repository at %s", filepath.Join(userDir, ".git"))
}
if !privateRepoRooted(userDir) {
return res, fmt.Errorf("workspace-history repository did not root at the workspace; refusing to touch the host repo")
}
out, err := runGit(userDir, "status", "--porcelain")
if err != nil {
return res, fmt.Errorf("git status: %s", gitErr(out, err))
}
if strings.TrimSpace(out) == "" {
res.clean = true
return res, nil
}
if out, err := runGit(userDir, "add", "-A"); err != nil {
return res, fmt.Errorf("git add: %s", gitErr(out, err))
}
if strings.TrimSpace(message) == "" {
message = "workspace snapshot " + time.Now().UTC().Format(time.RFC3339)
}
if out, err := runGit(userDir, "commit", "-m", message); err != nil {
return res, fmt.Errorf("git commit: %s", gitErr(out, err))
}
res.committed = true
return res, nil
}
// maybeAutoCommit records a workspace-history commit after a mutating verb
// when workspace_history=auto. It is the auto cadence trigger — the
// counterpart to the manual `eeco history snapshot`. Callers pass the
// resolved values directly (cfg.WorkspaceHistory.Auto(), cfg.UserDir), so
// this file stays free of an internal/config import, mirroring the
// primitive-parameter shape of snapshotPrivateRepo and initPrivateRepo.
//
// Degrade-never-fail. The default (manual) path pays nothing: the auto
// gate short-circuits first. When auto is set but no private repo exists
// (e.g. `init --no-track`, or `off` flipped to `auto` without a re-init)
// it no-ops silently rather than warning. Any snapshot error is reported
// on one stderr line and swallowed — a history hiccup must never change a
// verb's exit code. The gate order also means the nested-repo guards
// (privateRepoExists here, then snapshotPrivateRepo's own privateRepoRooted
// assert) both run before any git write, so the auto path can no more stage
// the host repo than the manual one can.
func maybeAutoCommit(auto bool, userDir, detail string, stderr io.Writer) {
if !auto {
return
}
if !privateRepoExists(userDir) {
return
}
if _, err := snapshotPrivateRepo(userDir, "workspace auto: "+detail, stderr); err != nil {
fmt.Fprintln(stderr, "eeco: workspace history not updated:", err)
}
}
// compactResult records what compactPrivateRepo did.
type compactResult struct {
old int // number of commits before the squash
}
// commitCount returns the number of commits reachable from HEAD in the
// private repo. It requires the private repo to exist; the read pins cwd to
// userDir so it cannot reach the host repo.
func commitCount(userDir string) (int, error) {
if !privateRepoExists(userDir) {
return 0, fmt.Errorf("no workspace-history repository at %s", filepath.Join(userDir, ".git"))
}
out, err := runGit(userDir, "rev-list", "--count", "HEAD")
if err != nil {
return 0, fmt.Errorf("git rev-list: %s", gitErr(out, err))
}
n, err := strconv.Atoi(strings.TrimSpace(out))
if err != nil {
return 0, fmt.Errorf("parse commit count %q: %w", strings.TrimSpace(out), err)
}
return n, nil
}
// compactPrivateRepo squashes the private repo's entire history into a
// single parentless commit whose tree is the current HEAD tree — the git
// pendant to the docs-compaction protocol. The workspace files are left
// untouched (the kept tree is byte-identical to the pre-compact HEAD tree),
// and the old commits stay reachable via the reflog as a recovery net; no
// gc/prune is run, so the squash is reversible. It refuses to run unless the
// private repo exists AND roots at userDir, so it can never rewrite the host
// repo. The branch is never named (the private repo's branch is unpinned —
// master or main depending on the user's git), so `reset --soft` moves
// whichever branch is current. `--soft` leaves the index and working tree
// alone, so a clean tree stays clean and any uncommitted change survives.
func compactPrivateRepo(userDir string) (compactResult, error) {
var res compactResult
if !privateRepoExists(userDir) {
return res, fmt.Errorf("no workspace-history repository at %s", filepath.Join(userDir, ".git"))
}
if !privateRepoRooted(userDir) {
return res, fmt.Errorf("workspace-history repository did not root at the workspace; refusing to touch the host repo")
}
n, err := commitCount(userDir)
if err != nil {
return res, err
}
res.old = n
tree, err := runGit(userDir, "rev-parse", "HEAD^{tree}")
if err != nil {
return res, fmt.Errorf("git rev-parse tree: %s", gitErr(tree, err))
}
msg := "workspace history (compacted " + time.Now().UTC().Format(time.RFC3339) + ")"
newCommit, err := runGit(userDir, "commit-tree", strings.TrimSpace(tree), "-m", msg)
if err != nil {
return res, fmt.Errorf("git commit-tree: %s", gitErr(newCommit, err))
}
if out, err := runGit(userDir, "reset", "--soft", strings.TrimSpace(newCommit)); err != nil {
return res, fmt.Errorf("git reset: %s", gitErr(out, err))
}
return res, nil
}
// logPrivateRepo returns the private repo's recent one-line log (read-only).
// It requires the private repo to exist; the read itself cannot reach the
// host repo because privateRepoExists gates it and the command pins cwd to
// userDir.
func logPrivateRepo(userDir string, n int) (string, error) {
if !privateRepoExists(userDir) {
return "", fmt.Errorf("no workspace-history repository at %s", filepath.Join(userDir, ".git"))
}
if n <= 0 {
n = privateLogDefault
}
out, err := runGit(userDir, "log", "--oneline", "-n", strconv.Itoa(n))
if err != nil {
return "", fmt.Errorf("git log: %s", gitErr(out, err))
}
return strings.TrimSpace(out), nil
}
added cmd/eeco/historygit_test.go
@@ -0,0 +1,403 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/ajhahnde/eeco/internal/gitx"
)
func requireGit(t *testing.T) {
t.Helper()
if !gitx.Available() {
t.Skip("git not available on PATH")
}
}
func TestPrivateRepoExists(t *testing.T) {
dir := t.TempDir()
if privateRepoExists(dir) {
t.Error("privateRepoExists true for a dir with no .git")
}
if privateRepoExists("") {
t.Error("privateRepoExists true for an empty path")
}
// A `.git` FILE (worktree-style pointer) is not a private-repo dir.
if err := os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: x\n"), 0o644); err != nil {
t.Fatal(err)
}
if privateRepoExists(dir) {
t.Error("privateRepoExists true for a .git file (want a dir)")
}
// A `.git` DIR is.
dir2 := t.TempDir()
if err := os.Mkdir(filepath.Join(dir2, ".git"), 0o755); err != nil {
t.Fatal(err)
}
if !privateRepoExists(dir2) {
t.Error("privateRepoExists false for a .git dir")
}
}
func TestInitPrivateRepo_CreatesRepoAndCommit(t *testing.T) {
requireGit(t)
userDir := t.TempDir()
// Simulate scaffolded workspace content so the initial commit is non-empty.
if err := os.WriteFile(filepath.Join(userDir, "README.md"), []byte("hi\n"), 0o644); err != nil {
t.Fatal(err)
}
var errb bytes.Buffer
res := initPrivateRepo(userDir, &errb)
if !res.created {
t.Fatalf("created=false; stderr:\n%s", errb.String())
}
if !privateRepoExists(userDir) {
t.Fatal(".git dir not created")
}
log, err := logPrivateRepo(userDir, 5)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(log, privateInitCommitMsg) {
t.Errorf("log missing init commit:\n%s", log)
}
if out, _ := runGit(userDir, "config", "user.name"); strings.TrimSpace(out) != privateRepoUserName {
t.Errorf("user.name = %q, want %q", strings.TrimSpace(out), privateRepoUserName)
}
if _, err := os.Stat(filepath.Join(userDir, ".gitignore")); err != nil {
t.Errorf("private .gitignore not written: %v", err)
}
if !privateRepoRooted(userDir) {
t.Error("privateRepoRooted false after init")
}
}
func TestInitPrivateRepo_Idempotent(t *testing.T) {
requireGit(t)
userDir := t.TempDir()
if err := os.WriteFile(filepath.Join(userDir, "f"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if res := initPrivateRepo(userDir, &bytes.Buffer{}); !res.created {
t.Fatal("first init not created")
}
res := initPrivateRepo(userDir, &bytes.Buffer{})
if res.created {
t.Error("second init reported created; want already")
}
if !res.already {
t.Error("second init not flagged already")
}
// Still exactly one commit (no re-commit on re-init).
log, _ := logPrivateRepo(userDir, 10)
if log == "" || strings.Contains(strings.TrimSpace(log), "\n") {
t.Errorf("expected exactly one commit after idempotent re-init, log:\n%s", log)
}
}
func TestInitPrivateRepo_DegradesWithoutGit(t *testing.T) {
userDir := t.TempDir()
t.Setenv("PATH", "") // hide git from exec.LookPath / gitx.Available
var errb bytes.Buffer
res := initPrivateRepo(userDir, &errb)
if res.created || res.already {
t.Error("init should be a no-op when git is unavailable")
}
if privateRepoExists(userDir) {
t.Error(".git created though git was unavailable")
}
if !strings.Contains(errb.String(), "git not available") {
t.Errorf("missing degrade warning:\n%s", errb.String())
}
}
// TestSnapshotPrivateRepo_RefusesWithoutPrivateRepo_NoHostStaging is the
// nested-repo-hazard regression: with a real HOST repo above userDir and
// NO private repo at userDir, a snapshot must refuse and must never stage
// the host repo's files.
func TestSnapshotPrivateRepo_RefusesWithoutPrivateRepo_NoHostStaging(t *testing.T) {
requireGit(t)
host := t.TempDir()
if out, err := runGit(host, "init"); err != nil {
t.Fatalf("host git init: %s", gitErr(out, err))
}
userDir := filepath.Join(host, "tester")
if err := os.MkdirAll(userDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(userDir, "secret.txt"), []byte("data\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := snapshotPrivateRepo(userDir, "x", &bytes.Buffer{}); err == nil {
t.Fatal("snapshot succeeded with no private repo; nested-repo guard failed")
}
// The host index must be empty — nothing was staged.
out, err := runGit(host, "diff", "--cached", "--name-only")
if err != nil {
t.Fatalf("host diff --cached: %s", gitErr(out, err))
}
if strings.TrimSpace(out) != "" {
t.Fatalf("host repo had staged files after a guarded snapshot:\n%s", out)
}
// The defense-in-depth check sees userDir rooting at the HOST repo → false.
if privateRepoRooted(userDir) {
t.Error("privateRepoRooted true though only the host repo exists above userDir")
}
}
func TestSnapshotPrivateRepo_CommitsAndNoOp(t *testing.T) {
requireGit(t)
userDir := t.TempDir()
if err := os.WriteFile(filepath.Join(userDir, "a"), []byte("1"), 0o644); err != nil {
t.Fatal(err)
}
if res := initPrivateRepo(userDir, &bytes.Buffer{}); !res.created {
t.Fatal("init failed")
}
// Clean tree → no-op.
res, err := snapshotPrivateRepo(userDir, "", &bytes.Buffer{})
if err != nil {
t.Fatal(err)
}
if !res.clean || res.committed {
t.Errorf("clean snapshot: got %+v, want clean", res)
}
// A change → commits, honoring the custom message.
if err := os.WriteFile(filepath.Join(userDir, "a"), []byte("2"), 0o644); err != nil {
t.Fatal(err)
}
res, err = snapshotPrivateRepo(userDir, "after change", &bytes.Buffer{})
if err != nil {
t.Fatal(err)
}
if !res.committed || res.clean {
t.Errorf("dirty snapshot: got %+v, want committed", res)
}
log, _ := logPrivateRepo(userDir, 10)
if !strings.Contains(log, "after change") {
t.Errorf("custom message missing from log:\n%s", log)
}
}
// TestCompactPrivateRepo_SquashesToOne: a multi-commit private repo squashes
// to exactly one commit whose tree is byte-identical to the pre-compact HEAD
// tree, leaving the working tree clean and the file content intact.
func TestCompactPrivateRepo_SquashesToOne(t *testing.T) {
requireGit(t)
userDir := t.TempDir()
if err := os.WriteFile(filepath.Join(userDir, "a"), []byte("0"), 0o644); err != nil {
t.Fatal(err)
}
if res := initPrivateRepo(userDir, &bytes.Buffer{}); !res.created {
t.Fatal("init failed")
}
// Three more snapshots → 4 commits total.
for i := 1; i <= 3; i++ {
if err := os.WriteFile(filepath.Join(userDir, "a"), []byte(strconv.Itoa(i)), 0o644); err != nil {
t.Fatal(err)
}
if _, err := snapshotPrivateRepo(userDir, "snap "+strconv.Itoa(i), &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
}
if n, _ := commitCount(userDir); n != 4 {
t.Fatalf("setup commit count = %d, want 4", n)
}
treeBefore, _ := runGit(userDir, "rev-parse", "HEAD^{tree}")
res, err := compactPrivateRepo(userDir)
if err != nil {
t.Fatal(err)
}
if res.old != 4 {
t.Errorf("res.old = %d, want 4", res.old)
}
if n, _ := commitCount(userDir); n != 1 {
t.Errorf("after compact commit count = %d, want 1", n)
}
treeAfter, _ := runGit(userDir, "rev-parse", "HEAD^{tree}")
if strings.TrimSpace(treeBefore) != strings.TrimSpace(treeAfter) {
t.Errorf("HEAD tree changed by compact: %q -> %q", treeBefore, treeAfter)
}
// Working tree stays clean (content preserved).
if out, _ := runGit(userDir, "status", "--porcelain"); strings.TrimSpace(out) != "" {
t.Errorf("working tree dirty after compact:\n%s", out)
}
if b, _ := os.ReadFile(filepath.Join(userDir, "a")); string(b) != "3" {
t.Errorf("workspace file content = %q, want 3", b)
}
}
// TestCompactPrivateRepo_NoOpOnSingleCommit: a fresh init has exactly one
// commit, so the command-layer no-op guard (commitCount <= 1) has nothing to
// squash.
func TestCompactPrivateRepo_NoOpOnSingleCommit(t *testing.T) {
requireGit(t)
userDir := t.TempDir()
if err := os.WriteFile(filepath.Join(userDir, "a"), []byte("1"), 0o644); err != nil {
t.Fatal(err)
}
if res := initPrivateRepo(userDir, &bytes.Buffer{}); !res.created {
t.Fatal("init failed")
}
if n, err := commitCount(userDir); err != nil || n != 1 {
t.Fatalf("fresh init commit count = %d (err %v), want 1", n, err)
}
}
// TestCompactPrivateRepo_RefusesWithoutPrivateRepo_NoHostStaging is the
// nested-repo-hazard regression for the squash path: with a real HOST repo
// above userDir and NO private repo at userDir, compact must refuse and must
// never stage or rewrite the host repo.
func TestCompactPrivateRepo_RefusesWithoutPrivateRepo_NoHostStaging(t *testing.T) {
requireGit(t)
host := t.TempDir()
if out, err := runGit(host, "init"); err != nil {
t.Fatalf("host git init: %s", gitErr(out, err))
}
userDir := filepath.Join(host, "tester")
if err := os.MkdirAll(userDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(userDir, "secret.txt"), []byte("data\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := compactPrivateRepo(userDir); err == nil {
t.Fatal("compact succeeded with no private repo; nested-repo guard failed")
}
// The host index must be empty — nothing was staged.
out, err := runGit(host, "diff", "--cached", "--name-only")
if err != nil {
t.Fatalf("host diff --cached: %s", gitErr(out, err))
}
if strings.TrimSpace(out) != "" {
t.Fatalf("host repo had staged files after a guarded compact:\n%s", out)
}
if privateRepoRooted(userDir) {
t.Error("privateRepoRooted true though only the host repo exists above userDir")
}
}
func TestLogPrivateRepo_ErrsWithoutRepo(t *testing.T) {
userDir := t.TempDir()
if _, err := logPrivateRepo(userDir, 5); err == nil {
t.Error("logPrivateRepo succeeded with no private repo")
}
}
// initDirtyPrivateRepo stands up a private repo at a fresh temp userDir and
// then dirties the tree, so a following maybeAutoCommit has something to
// commit. It returns the userDir.
func initDirtyPrivateRepo(t *testing.T) string {
t.Helper()
userDir := t.TempDir()
if err := os.WriteFile(filepath.Join(userDir, "a"), []byte("1"), 0o644); err != nil {
t.Fatal(err)
}
if res := initPrivateRepo(userDir, &bytes.Buffer{}); !res.created {
t.Fatal("initPrivateRepo failed in setup")
}
if err := os.WriteFile(filepath.Join(userDir, "a"), []byte("2"), 0o644); err != nil {
t.Fatal(err)
}
return userDir
}
func TestMaybeAutoCommit_AutoWithRepo_Commits(t *testing.T) {
requireGit(t)
userDir := initDirtyPrivateRepo(t)
var errb bytes.Buffer
maybeAutoCommit(true, userDir, "add fact foo", &errb)
if errb.Len() != 0 {
t.Errorf("unexpected stderr on the happy path: %s", errb.String())
}
log, err := logPrivateRepo(userDir, 10)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(log, "workspace auto: add fact foo") {
t.Errorf("auto-commit message missing from log:\n%s", log)
}
}
func TestMaybeAutoCommit_ManualMode_NoOp(t *testing.T) {
requireGit(t)
userDir := initDirtyPrivateRepo(t)
before, _ := logPrivateRepo(userDir, 10)
var errb bytes.Buffer
maybeAutoCommit(false, userDir, "add fact foo", &errb) // auto=false (manual/off)
after, _ := logPrivateRepo(userDir, 10)
if before != after {
t.Errorf("auto=false committed; before=%q after=%q", before, after)
}
if errb.Len() != 0 {
t.Errorf("auto=false should be silent, got: %s", errb.String())
}
}
// TestMaybeAutoCommit_AutoNoRepo_NoOp covers auto set but no private repo
// (init --no-track, or off flipped to auto without re-init): a silent no-op
// that never creates a repo and surfaces nothing.
func TestMaybeAutoCommit_AutoNoRepo_NoOp(t *testing.T) {
userDir := t.TempDir()
var errb bytes.Buffer
maybeAutoCommit(true, userDir, "add fact foo", &errb)
if errb.Len() != 0 {
t.Errorf("auto-but-no-repo should be silent, got: %s", errb.String())
}
if privateRepoExists(userDir) {
t.Error("maybeAutoCommit created a repo; it must never init")
}
}
// TestMaybeAutoCommit_GitFailure_Swallowed: auto + a real private repo, but
// git breaks before the snapshot. The error must surface on one stderr line
// and never panic or otherwise escape.
func TestMaybeAutoCommit_GitFailure_Swallowed(t *testing.T) {
requireGit(t)
userDir := initDirtyPrivateRepo(t)
t.Setenv("PATH", "") // hide git so the snapshot's git calls fail
var errb bytes.Buffer
maybeAutoCommit(true, userDir, "add fact foo", &errb) // must not panic
if !strings.Contains(errb.String(), "workspace history not updated") {
t.Errorf("expected a swallowed-error degrade line, got: %q", errb.String())
}
}
// TestMaybeAutoCommit_AutoHostRepoNoPrivateRepo_NoHostStaging is the
// nested-repo regression for the auto path: a real HOST repo above userDir
// and NO private repo at userDir. maybeAutoCommit must no-op at the
// privateRepoExists guard and never reach a git write, so the host index
// stays empty (mirror of TestSnapshotPrivateRepo_RefusesWithoutPrivateRepo).
func TestMaybeAutoCommit_AutoHostRepoNoPrivateRepo_NoHostStaging(t *testing.T) {
requireGit(t)
host := t.TempDir()
if out, err := runGit(host, "init"); err != nil {
t.Fatalf("host git init: %s", gitErr(out, err))
}
userDir := filepath.Join(host, "tester")
if err := os.MkdirAll(userDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(userDir, "secret.txt"), []byte("data\n"), 0o644); err != nil {
t.Fatal(err)
}
var errb bytes.Buffer
maybeAutoCommit(true, userDir, "add fact foo", &errb)
if errb.Len() != 0 {
t.Errorf("auto-but-no-repo should be silent, got: %s", errb.String())
}
out, err := runGit(host, "diff", "--cached", "--name-only")
if err != nil {
t.Fatalf("host diff --cached: %s", gitErr(out, err))
}
if strings.TrimSpace(out) != "" {
t.Fatalf("host repo had staged files after an auto no-op:\n%s", out)
}
}
added cmd/eeco/hooks.go
@@ -0,0 +1,492 @@
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/hooks"
"github.com/ajhahnde/eeco/internal/workflow"
)
const hooksUsage = `usage:
eeco hooks [status] show hook wiring state
eeco hooks <name> <on|off> toggle a hook (reversible)
eeco hooks session-start refresh re-render every session_files block
eeco hooks session-emit [--with-pinned-bodies] [--if-initialized]
emit the session-start payload (hidden; used by the installed hook)
--if-initialized stays silent outside an eeco workspace
eeco hooks commit-guard <on|off> deny git commits that carry AI attribution via a PreToolUse hook
(needs session_settings_path; default off)
eeco hooks commit-guard-check decide a pending git commit from PreToolUse JSON on stdin
(hidden; used by the installed hook)
eeco hooks git-write-guard-check decide a pending git commit/tag from PreToolUse JSON on stdin
(hidden; used by the cockpit machinery hook)
eeco hooks stop-nudge-check nudge a handover when work is undocumented, from Stop JSON on stdin
(hidden; used by the cockpit machinery hook)
eeco hooks contract-watch-check flag a cockpit-input edit, from PostToolUse JSON on stdin
(hidden; used by the cockpit machinery hook)
eeco hooks <name> refresh rewrite a hook (pre-commit/post-merge/commit-msg/commit-guard)
with the current eeco binary path
names: pre-commit, post-merge, session-start, commit-msg, commit-guard`
// runHooks backs `eeco hooks`. The hidden `session-emit` and
// `commit-msg-check` subcommands are what the installed hooks run;
// everything else inspects or toggles the opt-in, reversible
// integration points.
func runHooks(args []string, stdout, stderr io.Writer) int {
// session-emit must be silent and exit 0 in every situation: it runs
// at the AI CLI's session start and must never disrupt it.
if len(args) >= 1 && args[0] == "session-emit" {
return runSessionEmit(args[1:], stdout)
}
// commit-msg-check is invoked by the installed git commit-msg hook
// with the path to the staged commit message as args[1]. Exit 0
// accepts the commit; exit 1 rejects with a stderr message.
if len(args) == 2 && args[0] == "commit-msg-check" {
if err := hooks.CheckCommitMsg(args[1]); err != nil {
fmt.Fprintln(stderr, err)
return 1
}
return 0
}
// commit-guard-check is invoked by the installed Claude Code
// PreToolUse hook with the event JSON on stdin. It must always exit 0
// (a deny is carried in the JSON body, never the exit code) and
// degrade open, so it can never wedge a harness session.
if len(args) >= 1 && args[0] == "commit-guard-check" {
return runCommitGuardCheck(stdout)
}
// git-write-guard-check is the cockpit machinery's PreToolUse runner.
// Like commit-guard-check it always exits 0 (a deny is carried in the
// JSON body) and degrades open, so it can never wedge a session.
if len(args) >= 1 && args[0] == "git-write-guard-check" {
return runGitWriteGuardCheck(stdout)
}
// stop-nudge-check is the cockpit machinery's Stop runner: it surfaces a
// throttled handover nudge when work is undocumented. It honors
// stop_hook_active and always exits 0 (the nudge is carried in the JSON
// body), degrading open so it can never wedge a session.
if len(args) >= 1 && args[0] == "stop-nudge-check" {
return runStopNudgeCheck(stdout)
}
// contract-watch-check is the cockpit machinery's PostToolUse runner: it
// drops a drift flag when a cockpit input is edited. It never blocks and
// always exits 0.
if len(args) >= 1 && args[0] == "contract-watch-check" {
return runContractWatchCheck(stdout)
}
cfg, code := loadRepoConfig(stderr, "eeco hooks")
if code != 0 {
return code
}
if len(args) == 0 || (len(args) == 1 && args[0] == "status") {
for _, line := range hooks.Status(cfg) {
fmt.Fprintln(stdout, line)
}
return 0
}
if len(args) != 2 {
fmt.Fprintln(stderr, hooksUsage)
return 2
}
name, action := args[0], args[1]
var (
msg string
herr error
)
switch {
case name == hooks.PreCommit && action == "on":
if verr := validateWorkflowNames("pre_commit_workflows", cfg.PreCommitWorkflows); verr != nil {
fmt.Fprintln(stderr, "eeco hooks:", verr)
return 1
}
msg, herr = hooks.EnablePreCommit(cfg)
case name == hooks.PreCommit && action == "off":
msg, herr = hooks.DisablePreCommit(cfg)
case name == hooks.PreCommit && action == "refresh":
msg, herr = hooks.RefreshPreCommit(cfg)
case name == hooks.PostMerge && action == "on":
if verr := validateWorkflowNames("post_merge_workflows", cfg.PostMergeWorkflows); verr != nil {
fmt.Fprintln(stderr, "eeco hooks:", verr)
return 1
}
msg, herr = hooks.EnablePostMerge(cfg)
case name == hooks.PostMerge && action == "off":
msg, herr = hooks.DisablePostMerge(cfg)
case name == hooks.PostMerge && action == "refresh":
msg, herr = hooks.RefreshPostMerge(cfg)
case name == hooks.SessionStart && action == "on":
msg, herr = hooks.EnableSessionStart(cfg)
case name == hooks.SessionStart && action == "off":
msg, herr = hooks.DisableSessionStart(cfg)
case name == hooks.SessionStart && action == "refresh":
msg, herr = hooks.RefreshSessionStart(cfg)
case name == hooks.CommitMsg && action == "on":
msg, herr = hooks.EnableCommitMsg(cfg)
case name == hooks.CommitMsg && action == "off":
msg, herr = hooks.DisableCommitMsg(cfg)
case name == hooks.CommitMsg && action == "refresh":
msg, herr = hooks.RefreshCommitMsg(cfg)
case name == hooks.CommitGuard && action == "on":
msg, herr = hooks.EnableCommitGuard(cfg)
case name == hooks.CommitGuard && action == "off":
msg, herr = hooks.DisableCommitGuard(cfg)
case name == hooks.CommitGuard && action == "refresh":
msg, herr = hooks.RefreshCommitGuard(cfg)
default:
fmt.Fprintln(stderr, hooksUsage)
return 2
}
if herr != nil {
fmt.Fprintln(stderr, "eeco hooks:", herr)
return 1
}
maybeAutoCommit(cfg.WorkspaceHistory.Auto(), cfg.UserDir, "hooks "+name+" "+action, stderr)
fmt.Fprintln(stdout, "eeco hooks:", msg)
return 0
}
// validateWorkflowNames rejects entries that do not name a known builtin
// workflow. The config package cannot import the workflow registry
// without a cycle, so this check runs here, at hook-install time, where
// the registry is reachable. key is the config key name (for the error
// message) and names is the configured list (pre_commit_workflows or
// post_merge_workflows).
func validateWorkflowNames(key string, names []string) error {
r := workflow.DefaultRegistry()
var unknown []string
for _, name := range names {
if _, ok := r.Get(name); !ok {
unknown = append(unknown, name)
}
}
if len(unknown) == 0 {
return nil
}
return fmt.Errorf("%s includes unknown workflow(s): %s (builtins: %s)",
key,
strings.Join(unknown, ", "),
strings.Join(r.Names(), ", "))
}
// runSessionEmit composes the bundled session-start output: an
// auto-detected (or config-driven) reading routine, a mailbox warning
// when the configured mailbox file has unprocessed content, the legacy
// one-line queue reminder, and optionally a "pinned memories" block
// when --with-pinned-bodies is passed or the workspace config sets
// session_start_pinned_bodies. It loads config best-effort, never
// errors, never writes, and always exits 0 so it is safe to wire into
// a session-start hook.
func runSessionEmit(args []string, stdout io.Writer) int {
withPinnedBodies := false
ifInitialized := false
for _, a := range args {
// Only these flags are accepted; other args are tolerated and
// ignored so an upstream wrapper passing extra tokens never
// disrupts session-start.
if a == "--with-pinned-bodies" {
withPinnedBodies = true
}
if a == "--if-initialized" {
ifInitialized = true
}
}
cwd, err := os.Getwd()
if err != nil {
return 0
}
cfg, err := config.Load(cwd, config.DefaultWorkspace)
if err != nil {
return 0
}
// --if-initialized suppresses all output unless an eeco workspace is
// scaffolded in the cwd. config.Load is pre-init-safe (it succeeds
// without a workspace), so without this gate the brief would emit in
// any repo that merely has a README. Degrade-open is preserved: the
// silent path still exits 0.
if ifInitialized && !config.IsInitialized(cfg) {
return 0
}
if withPinnedBodies {
cfg.SessionStartPinnedBodies = true
}
// Security: clear any stray one-shot git-write authorization so no session
// inherits standing authorization from a prior one (pairs with the C4a
// git-write guard). This is a WRITE, so it lives here, not in the pure Emit.
hooks.ClearGitWriteSentinels(cfg)
hooks.Emit(cfg, stdout)
// Throttled doc/cockpit-drift nudge (a WRITE: advances a stamp, clears the
// contract-changed flag), printed after the pure orientation blocks.
if line, fire := hooks.DocDriftNudge(cfg, time.Now()); fire {
fmt.Fprintln(stdout, line)
}
return 0
}
// commitGuardStdin is the reader the hidden `commit-guard-check` runner
// reads the PreToolUse event JSON from (mirrors historyCompactStdin); the
// test suite pins it to a crafted event.
var commitGuardStdin io.Reader = os.Stdin
// preToolUseEvent is the subset of the Claude Code PreToolUse / PostToolUse
// hook JSON the guards read: the tool name, the Bash command string (PreToolUse
// guards), the edited file path (PostToolUse contract-watch), and the working
// directory the tool runs in.
type preToolUseEvent struct {
ToolName string `json:"tool_name"`
ToolInput struct {
Command string `json:"command"`
FilePath string `json:"file_path"`
NotebookPath string `json:"notebook_path"`
} `json:"tool_input"`
Cwd string `json:"cwd"`
}
// runCommitGuardCheck is the hidden runner the installed PreToolUse hook
// invokes. It reads the event from stdin, scans a pending `git commit`
// for AI attribution with eeco's shared detector, and prints the deny
// JSON only on a positive finding. It always exits 0: a deny is carried
// in the JSON body (permissionDecision), and any uncertainty degrades
// open (no output) so a harness session is never wedged.
func runCommitGuardCheck(stdout io.Writer) int {
raw, err := io.ReadAll(commitGuardStdin)
if err != nil {
return 0
}
var ev preToolUseEvent
if jerr := json.Unmarshal(raw, &ev); jerr != nil {
return 0
}
if ev.ToolInput.Command == "" {
return 0
}
cwd := ev.Cwd
if cwd == "" {
cwd, _ = os.Getwd()
}
det := commitGuardDetector(cwd)
if det == nil {
return 0
}
res := workflow.ScanCommitGuard(det, ev.ToolInput.Command, cwd)
if len(res.Findings) == 0 {
return 0
}
emitCommitGuardDeny(stdout, commitGuardReason(res.Findings))
return 0
}
// commitGuardDetector builds the attribution detector for the hook's cwd,
// picking up the foreign repo's operator-supplied attribution_pattern
// entries when its config loads. A bad operator pattern (or no config at
// all) must never disable the guard, so it falls back to the builtin
// denylist (mirrors internal/tui/model.go). Returns nil only if even the
// builtin detector fails to compile (impossible in practice).
func commitGuardDetector(cwd string) *workflow.Detector {
var patterns []string
if cfg, err := config.Load(cwd, config.DefaultWorkspace); err == nil {
patterns = cfg.AttributionPatterns
}
if det, err := workflow.NewDetector(patterns); err == nil {
return det
}
det, _ := workflow.NewDetector(nil)
return det
}
// commitGuardReason renders the deny reason from the findings: the unique
// detector descriptions plus the first location. The descriptions are the
// detector's own fragment-clean "what" strings, safe to echo back.
func commitGuardReason(findings []workflow.Finding) string {
seen := map[string]bool{}
var msgs []string
for _, f := range findings {
if seen[f.Msg] {
continue
}
seen[f.Msg] = true
msgs = append(msgs, f.Msg)
}
loc := ""
if len(findings) > 0 {
loc = " (" + findings[0].Path + ")"
}
return "eeco commit-guard: " + strings.Join(msgs, ", ") + loc +
". Remove the AI attribution before committing."
}
// emitCommitGuardDeny prints the PreToolUse deny decision. A marshal
// failure prints nothing (degrade open).
func emitCommitGuardDeny(stdout io.Writer, reason string) {
out := map[string]any{
"hookSpecificOutput": map[string]any{
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason,
},
}
b, err := json.Marshal(out)
if err != nil {
return
}
fmt.Fprintln(stdout, string(b))
}
// gitWriteGuardStdin is the reader the hidden `git-write-guard-check` runner
// reads the PreToolUse event JSON from; the test suite pins it.
var gitWriteGuardStdin io.Reader = os.Stdin
// runGitWriteGuardCheck is the hidden runner the cockpit machinery's
// PreToolUse hook invokes. It reads the event from stdin and asks
// ScanGitWriteGuard whether a pending `git commit` / `git tag` mutation is
// authorized (and, for an authorized commit, leak-free). It emits the deny
// JSON on a deny and consumes the one-shot sentinel(s) on an allow. It
// always exits 0: a deny is carried in the JSON body, and any
// infrastructure uncertainty (no event, no eeco workspace) degrades OPEN —
// only the write-verb classifier fails closed, inside ScanGitWriteGuard.
func runGitWriteGuardCheck(stdout io.Writer) int {
raw, err := io.ReadAll(gitWriteGuardStdin)
if err != nil {
return 0
}
var ev preToolUseEvent
if jerr := json.Unmarshal(raw, &ev); jerr != nil {
return 0
}
if ev.ToolInput.Command == "" {
return 0
}
cwd := ev.Cwd
if cwd == "" {
cwd, _ = os.Getwd()
}
stateDir, wsName, ok := gitWriteGuardState(cwd)
if !ok {
// No eeco workspace resolved → no sentinel mechanism here; degrade
// open rather than wedge a repo the guard cannot authorize in.
return 0
}
det := commitGuardDetector(cwd)
if det == nil {
return 0
}
res := workflow.ScanGitWriteGuard(det, ev.ToolInput.Command, cwd, stateDir, wsName)
if res.Decision == "deny" {
emitCommitGuardDeny(stdout, res.Reason)
return 0
}
for _, kind := range res.Consumed {
_ = os.Remove(filepath.Join(stateDir, "git-"+kind+"-authorized"))
}
return 0
}
// gitWriteGuardState resolves the sentinel directory (<workspace>/state) and
// the workspace name for the repo at cwd. ok is false when config cannot be
// loaded (degrade-open: the runner then allows).
func gitWriteGuardState(cwd string) (stateDir, workspaceName string, ok bool) {
cfg, err := config.Load(cwd, config.DefaultWorkspace)
if err != nil {
return "", "", false
}
return filepath.Join(cfg.Workspace, "state"), cfg.WorkspaceName, true
}
// stopNudgeStdin is the reader the hidden stop-nudge-check runner reads the
// Stop event JSON from; the test suite pins it.
var stopNudgeStdin io.Reader = os.Stdin
// stopEvent is the subset of the Claude Code Stop hook JSON the handover nudge
// reads: the loop-guard flag.
type stopEvent struct {
StopHookActive bool `json:"stop_hook_active"`
}
// runStopNudgeCheck is the hidden runner the cockpit machinery's Stop hook
// invokes. It honors stop_hook_active (never loops), and on undocumented work
// past the 6h throttle emits a {"decision":"block","reason":…} advisory. It
// always exits 0 and degrades open (no eeco workspace ⇒ silent), so it can
// never wedge a session.
func runStopNudgeCheck(stdout io.Writer) int {
raw, err := io.ReadAll(stopNudgeStdin)
if err != nil {
return 0
}
var ev stopEvent
if jerr := json.Unmarshal(raw, &ev); jerr != nil {
return 0
}
if ev.StopHookActive {
return 0
}
cwd, err := os.Getwd()
if err != nil {
return 0
}
cfg, err := config.Load(cwd, config.DefaultWorkspace)
if err != nil || !config.IsInitialized(cfg) {
return 0
}
reason, fire := hooks.StopNudge(cfg, time.Now())
if !fire {
return 0
}
emitStopBlock(stdout, reason)
return 0
}
// emitStopBlock prints the Stop hook block decision. A marshal failure prints
// nothing (degrade open).
func emitStopBlock(stdout io.Writer, reason string) {
out := map[string]any{"decision": "block", "reason": reason}
b, err := json.Marshal(out)
if err != nil {
return
}
fmt.Fprintln(stdout, string(b))
}
// contractWatchStdin is the reader the hidden contract-watch-check runner reads
// the PostToolUse event JSON from; the test suite pins it.
var contractWatchStdin io.Reader = os.Stdin
// runContractWatchCheck is the hidden runner the cockpit machinery's PostToolUse
// hook invokes. When the edited file is a cockpit input it drops a drift flag
// for the next SessionStart orient. It never blocks and always exits 0.
func runContractWatchCheck(_ io.Writer) int {
raw, err := io.ReadAll(contractWatchStdin)
if err != nil {
return 0
}
var ev preToolUseEvent
if jerr := json.Unmarshal(raw, &ev); jerr != nil {
return 0
}
fp := ev.ToolInput.FilePath
if fp == "" {
fp = ev.ToolInput.NotebookPath
}
if fp == "" {
return 0
}
cwd := ev.Cwd
if cwd == "" {
cwd, _ = os.Getwd()
}
cfg, err := config.Load(cwd, config.DefaultWorkspace)
if err != nil {
return 0
}
_ = hooks.ContractWatch(cfg, fp)
return 0
}
added cmd/eeco/hooks_c4b_test.go
@@ -0,0 +1,61 @@
package main
import (
"bytes"
"encoding/json"
"os"
"testing"
)
func TestRunHooks_StopNudgeHonorsLoopGuard(t *testing.T) {
setupInited(t)
prev := stopNudgeStdin
stopNudgeStdin = bytes.NewReader([]byte(`{"stop_hook_active":true}`))
defer func() { stopNudgeStdin = prev }()
var out bytes.Buffer
if code := runHooks([]string{"stop-nudge-check"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("exit %d, want 0", code)
}
if out.String() != "" {
t.Errorf("stop_hook_active=true must be silent, got:\n%s", out.String())
}
}
func TestRunHooks_ContractWatchFlagsSelection(t *testing.T) {
root := setupInited(t)
ev, err := json.Marshal(map[string]any{
"tool_name": "Write",
"tool_input": map[string]any{"file_path": wsPath(root, "cockpit.json")},
"cwd": root,
})
if err != nil {
t.Fatal(err)
}
prev := contractWatchStdin
contractWatchStdin = bytes.NewReader(ev)
defer func() { contractWatchStdin = prev }()
var out bytes.Buffer
if code := runHooks([]string{"contract-watch-check"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("exit %d, want 0", code)
}
if _, err := os.Stat(wsPath(root, "state", "contract-changed")); err != nil {
t.Errorf("contract-changed flag not dropped after editing the selection store: %v", err)
}
}
func TestRunHooks_SessionEmitClearsSentinels(t *testing.T) {
root := setupInited(t)
if code := runAuthorize([]string{"commit"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("authorize exit=%d", code)
}
sentinel := wsPath(root, "state", "git-commit-authorized")
if _, err := os.Stat(sentinel); err != nil {
t.Fatalf("sentinel not written: %v", err)
}
if code := runHooks([]string{"session-emit", "--if-initialized"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("session-emit exit=%d", code)
}
if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
t.Errorf("session-emit should clear the git-commit sentinel, stat err=%v", err)
}
}
added cmd/eeco/hooks_gitwriteguard_test.go
@@ -0,0 +1,112 @@
package main
import (
"bytes"
"encoding/json"
"os"
"strings"
"testing"
)
// gitWriteEvent marshals a PreToolUse Bash event for the given command and
// working directory.
func gitWriteEvent(t *testing.T, command, cwd string) []byte {
t.Helper()
raw, err := json.Marshal(map[string]any{
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": map[string]any{"command": command},
"cwd": cwd,
})
if err != nil {
t.Fatal(err)
}
return raw
}
func TestRunHooks_GitWriteGuardDeniesUnauthorizedCommit(t *testing.T) {
root := setupInited(t)
prev := gitWriteGuardStdin
gitWriteGuardStdin = bytes.NewReader(gitWriteEvent(t, `git commit -m "fix: x"`, root))
defer func() { gitWriteGuardStdin = prev }()
var out, errOut bytes.Buffer
if code := runHooks([]string{"git-write-guard-check"}, &out, &errOut); code != 0 {
t.Fatalf("git-write-guard-check -> exit %d, want 0 (deny is in the body)", code)
}
s := out.String()
if !strings.Contains(s, `"permissionDecision":"deny"`) {
t.Errorf("unauthorized commit: expected deny, got:\n%s", s)
}
if !strings.Contains(s, "git-write-guard") {
t.Errorf("deny reason missing eeco label:\n%s", s)
}
}
func TestRunHooks_GitWriteGuardDeniesUnauthorizedTagMutation(t *testing.T) {
root := setupInited(t)
prev := gitWriteGuardStdin
gitWriteGuardStdin = bytes.NewReader(gitWriteEvent(t, `git tag -a v1 -m x`, root))
defer func() { gitWriteGuardStdin = prev }()
var out bytes.Buffer
if code := runHooks([]string{"git-write-guard-check"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("exit %d, want 0", code)
}
if !strings.Contains(out.String(), `"permissionDecision":"deny"`) {
t.Errorf("unauthorized tag mutation: expected deny, got:\n%s", out.String())
}
}
func TestRunHooks_GitWriteGuardAllowsAuthorizedCommitOnceOnly(t *testing.T) {
root := setupInited(t)
// Authorize one commit.
if code := runAuthorize([]string{"commit"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("runAuthorize commit = %d", code)
}
sentinel := wsPath(root, "state", "git-commit-authorized")
if _, err := os.Stat(sentinel); err != nil {
t.Fatalf("sentinel not written: %v", err)
}
run := func() string {
prev := gitWriteGuardStdin
gitWriteGuardStdin = bytes.NewReader(gitWriteEvent(t, `git commit -m "fix: a real change"`, root))
defer func() { gitWriteGuardStdin = prev }()
var out bytes.Buffer
if code := runHooks([]string{"git-write-guard-check"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("exit %d, want 0", code)
}
return out.String()
}
// First authorized run: allow (empty stdout) + sentinel consumed.
if s := run(); s != "" {
t.Errorf("authorized commit must allow (empty stdout), got:\n%s", s)
}
if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
t.Errorf("one-shot sentinel not consumed, stat err=%v", err)
}
// Second run: one-shot is spent → deny again.
if s := run(); !strings.Contains(s, `"permissionDecision":"deny"`) {
t.Errorf("second commit after one-shot consumed: expected deny, got:\n%s", s)
}
}
func TestRunHooks_GitWriteGuardAllowsNonGit(t *testing.T) {
root := setupInited(t)
for _, cmd := range []string{`git status`, `ls -la`, `git tag -l`} {
prev := gitWriteGuardStdin
gitWriteGuardStdin = bytes.NewReader(gitWriteEvent(t, cmd, root))
var out bytes.Buffer
code := runHooks([]string{"git-write-guard-check"}, &out, &bytes.Buffer{})
gitWriteGuardStdin = prev
if code != 0 {
t.Fatalf("%q -> exit %d, want 0", cmd, code)
}
if out.String() != "" {
t.Errorf("%q must allow (empty stdout), got:\n%s", cmd, out.String())
}
}
}
added cmd/eeco/hooks_test.go
@@ -0,0 +1,516 @@
package main
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/ajhahnde/eeco/internal/queue"
)
func TestRunHooks_StatusOutsideRepo(t *testing.T) {
chdir(t, t.TempDir())
var errOut bytes.Buffer
if code := runHooks(nil, &bytes.Buffer{}, &errOut); code != 1 {
t.Fatalf("status outside repo -> exit %d, want 1", code)
}
if !strings.Contains(errOut.String(), "not inside a git repository") {
t.Errorf("missing hint:\n%s", errOut.String())
}
}
func TestRunHooks_StatusInRepo(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out bytes.Buffer
if code := runHooks([]string{"status"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("status -> exit %d, want 0\n%s", code, out.String())
}
s := out.String()
if !strings.Contains(s, "pre-commit:") || !strings.Contains(s, "post-merge:") || !strings.Contains(s, "session-start:") || !strings.Contains(s, "commit-msg:") {
t.Errorf("status missing a hook line:\n%s", s)
}
}
func TestRunHooks_PreCommitOnThenOff(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
hookPath := filepath.Join(root, ".git", "hooks", "pre-commit")
var out bytes.Buffer
if code := runHooks([]string{"pre-commit", "on"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("pre-commit on -> exit %d\n%s", code, out.String())
}
if _, err := os.Stat(hookPath); err != nil {
t.Fatalf("pre-commit hook not installed: %v", err)
}
body, _ := os.ReadFile(hookPath)
if !strings.Contains(string(body), "run leak-guard") || !strings.Contains(string(body), "run version-sync") {
t.Errorf("installed hook missing default workflows:\n%s", body)
}
out.Reset()
if code := runHooks([]string{"pre-commit", "off"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("pre-commit off -> exit %d\n%s", code, out.String())
}
if _, err := os.Stat(hookPath); !os.IsNotExist(err) {
t.Errorf("pre-commit hook still present after off (err=%v)", err)
}
}
func TestRunHooks_PreCommitRejectsUnknownWorkflow(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
// Seed config.local with an unknown workflow name.
wsDir := filepath.Join(root, "tester", ".eeco")
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(wsDir, "config.local"),
[]byte("pre_commit_workflows=does-not-exist\n"), 0o644); err != nil {
t.Fatal(err)
}
var errOut bytes.Buffer
if code := runHooks([]string{"pre-commit", "on"}, &bytes.Buffer{}, &errOut); code != 1 {
t.Fatalf("unknown workflow -> exit %d, want 1\n%s", code, errOut.String())
}
if !strings.Contains(errOut.String(), "does-not-exist") {
t.Errorf("error must name the unknown workflow:\n%s", errOut.String())
}
// Hook must not have been installed.
if _, err := os.Stat(filepath.Join(root, ".git", "hooks", "pre-commit")); !os.IsNotExist(err) {
t.Errorf("hook installed despite validation failure (err=%v)", err)
}
}
func TestRunHooks_PostMergeOnThenOff(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
hookPath := filepath.Join(root, ".git", "hooks", "post-merge")
var out bytes.Buffer
if code := runHooks([]string{"post-merge", "on"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("post-merge on -> exit %d\n%s", code, out.String())
}
if _, err := os.Stat(hookPath); err != nil {
t.Fatalf("post-merge hook not installed: %v", err)
}
body, _ := os.ReadFile(hookPath)
if !strings.Contains(string(body), "run memory-drift") || !strings.Contains(string(body), "run doc-drift") {
t.Errorf("installed hook missing default workflows:\n%s", body)
}
out.Reset()
if code := runHooks([]string{"post-merge", "off"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("post-merge off -> exit %d\n%s", code, out.String())
}
if _, err := os.Stat(hookPath); !os.IsNotExist(err) {
t.Errorf("post-merge hook still present after off (err=%v)", err)
}
}
func TestRunHooks_PostMergeRejectsUnknownWorkflow(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
wsDir := filepath.Join(root, "tester", ".eeco")
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(wsDir, "config.local"),
[]byte("post_merge_workflows=does-not-exist\n"), 0o644); err != nil {
t.Fatal(err)
}
var errOut bytes.Buffer
if code := runHooks([]string{"post-merge", "on"}, &bytes.Buffer{}, &errOut); code != 1 {
t.Fatalf("unknown workflow -> exit %d, want 1\n%s", code, errOut.String())
}
if !strings.Contains(errOut.String(), "does-not-exist") {
t.Errorf("error must name the unknown workflow:\n%s", errOut.String())
}
if _, err := os.Stat(filepath.Join(root, ".git", "hooks", "post-merge")); !os.IsNotExist(err) {
t.Errorf("hook installed despite validation failure (err=%v)", err)
}
}
func TestRunHooks_BadArgs(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var errOut bytes.Buffer
if code := runHooks([]string{"pre-commit", "sideways"}, &bytes.Buffer{}, &errOut); code != 2 {
t.Fatalf("bad action -> exit %d, want 2", code)
}
if !strings.Contains(errOut.String(), "usage:") {
t.Errorf("missing usage:\n%s", errOut.String())
}
}
func TestRunHooks_SessionStartNotConfigured(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
t.Setenv("EECO_SESSION_SETTINGS", "")
var errOut bytes.Buffer
if code := runHooks([]string{"session-start", "on"}, &bytes.Buffer{}, &errOut); code != 1 {
t.Fatalf("unconfigured session-start -> exit %d, want 1", code)
}
if !strings.Contains(errOut.String(), "not configured") {
t.Errorf("missing not-configured message:\n%s", errOut.String())
}
}
func TestRunHooks_SessionEmitSilentWhenEmpty(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out bytes.Buffer
if code := runHooks([]string{"session-emit"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("session-emit -> exit %d, want 0", code)
}
if out.String() != "" {
t.Errorf("session-emit must be silent with an empty queue, got:\n%s", out.String())
}
}
func TestRunHooks_SessionStartRefreshFiles(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
t.Setenv("EECO_SESSION_SETTINGS", "")
// Initialise a workspace and declare a session_files entry.
wsDir := filepath.Join(root, "tester", ".eeco")
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(wsDir, "config.local"),
[]byte("session_files=AGENTS.md\n"), 0o644); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
if code := runHooks([]string{"session-start", "on"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("session-start on -> exit %d\n%s", code, out.String())
}
if !strings.Contains(out.String(), "files") {
t.Errorf("on msg should mention files:\n%s", out.String())
}
// Add a discoverable doc; refresh should update the block.
if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("# x\n"), 0o644); err != nil {
t.Fatal(err)
}
out.Reset()
if code := runHooks([]string{"session-start", "refresh"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("session-start refresh -> exit %d\n%s", code, out.String())
}
if !strings.Contains(out.String(), "refreshed") {
t.Errorf("refresh msg = %q, want 'refreshed'", out.String())
}
b, _ := os.ReadFile(filepath.Join(root, "AGENTS.md"))
if !strings.Contains(string(b), "README.md") {
t.Errorf("refresh did not pick up README.md:\n%s", b)
}
// Disable cleans up the file (it was empty before enable).
out.Reset()
if code := runHooks([]string{"session-start", "off"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("session-start off -> exit %d\n%s", code, out.String())
}
if _, err := os.Stat(filepath.Join(root, "AGENTS.md")); !os.IsNotExist(err) {
t.Errorf("AGENTS.md still present after off (err=%v)", err)
}
}
func TestRunHooks_SessionStartRefreshUnconfigured(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
t.Setenv("EECO_SESSION_SETTINGS", "")
var errOut bytes.Buffer
if code := runHooks([]string{"session-start", "refresh"}, &bytes.Buffer{}, &errOut); code != 1 {
t.Fatalf("refresh unconfigured -> exit %d, want 1", code)
}
if !strings.Contains(errOut.String(), "not configured") {
t.Errorf("missing not-configured message:\n%s", errOut.String())
}
}
func TestRunHooks_CommitMsgOnThenOff(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
hookPath := filepath.Join(root, ".git", "hooks", "commit-msg")
var out bytes.Buffer
if code := runHooks([]string{"commit-msg", "on"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("commit-msg on -> exit %d\n%s", code, out.String())
}
if _, err := os.Stat(hookPath); err != nil {
t.Fatalf("commit-msg hook not installed: %v", err)
}
body, _ := os.ReadFile(hookPath)
if !strings.Contains(string(body), "hooks commit-msg-check") {
t.Errorf("installed hook does not exec commit-msg-check:\n%s", body)
}
out.Reset()
if code := runHooks([]string{"commit-msg", "off"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("commit-msg off -> exit %d\n%s", code, out.String())
}
if _, err := os.Stat(hookPath); !os.IsNotExist(err) {
t.Errorf("commit-msg hook still present after off (err=%v)", err)
}
}
func TestRunHooks_CommitMsgRefreshNoop(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
if code := runHooks([]string{"commit-msg", "on"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("commit-msg on -> exit %d", code)
}
var out bytes.Buffer
if code := runHooks([]string{"commit-msg", "refresh"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("commit-msg refresh -> exit %d\n%s", code, out.String())
}
if !strings.Contains(out.String(), "already current") {
t.Errorf("refresh msg = %q, want 'already current'", out.String())
}
}
func TestRunHooks_CommitMsgCheckAcceptsClean(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
msgPath := filepath.Join(root, "msg")
if err := os.WriteFile(msgPath, []byte("feat: x\n\nResolves #1\n"), 0o644); err != nil {
t.Fatal(err)
}
var out, errOut bytes.Buffer
if code := runHooks([]string{"commit-msg-check", msgPath}, &out, &errOut); code != 0 {
t.Fatalf("commit-msg-check clean -> exit %d, stderr=%s", code, errOut.String())
}
}
func TestRunHooks_CommitMsgCheckRejectsClaude(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
msgPath := filepath.Join(root, "msg")
body := "feat: x\n\nCo-Authored-By: Claude Opus 4.7 <[email protected]>\n"
if err := os.WriteFile(msgPath, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
var errOut bytes.Buffer
if code := runHooks([]string{"commit-msg-check", msgPath}, &bytes.Buffer{}, &errOut); code != 1 {
t.Fatalf("commit-msg-check on bad msg -> exit %d, want 1", code)
}
if !strings.Contains(errOut.String(), "AI-attribution") {
t.Errorf("stderr missing AI-attribution context:\n%s", errOut.String())
}
if !strings.Contains(errOut.String(), "--no-verify") {
t.Errorf("stderr missing --no-verify bypass note:\n%s", errOut.String())
}
}
func TestRunHooks_SessionEmitWithPinnedBodies(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
// Write a pinned memory fact into the workspace so the new fourth
// block has something to emit. Without --with-pinned-bodies the
// output is empty (no queue, no docs, no mailbox).
memDir := filepath.Join(root, "tester", ".eeco", "memory")
if err := os.MkdirAll(memDir, 0o755); err != nil {
t.Fatal(err)
}
factBody := "---\n" +
"name: policy-z\n" +
"description: pinned policy z\n" +
"type: feedback\n" +
"created: 2026-05-24\n" +
"last_used: 2026-05-24\n" +
"pin: true\n" +
"---\n" +
"the body of policy z\n"
if err := os.WriteFile(filepath.Join(memDir, "policy-z.md"), []byte(factBody), 0o644); err != nil {
t.Fatal(err)
}
// Without the flag: no pinned-memories block.
var out bytes.Buffer
if code := runHooks([]string{"session-emit"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("session-emit -> exit %d", code)
}
if strings.Contains(out.String(), "pinned memories") {
t.Errorf("default-off emitted pinned-memories block: %q", out.String())
}
// With the flag: the block surfaces with the body inline.
out.Reset()
if code := runHooks([]string{"session-emit", "--with-pinned-bodies"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("session-emit --with-pinned-bodies -> exit %d", code)
}
got := out.String()
if !strings.Contains(got, "pinned memories") {
t.Errorf("--with-pinned-bodies did not emit the block: %q", got)
}
if !strings.Contains(got, "the body of policy z") {
t.Errorf("pinned fact body missing: %q", got)
}
}
func TestRunHooks_SessionEmit_IfInitialized_SilentWhenNotInitialized(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
// A README makes the un-gated brief emit a reading routine — this is
// exactly the non-eeco repo the v0.2.0 plugin wrongly briefed in.
writeFile(t, root, "README.md", "# project\n")
// Backward compat: without the flag the brief still emits on docs.
var out bytes.Buffer
if code := runHooks([]string{"session-emit"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("session-emit -> exit %d, want 0", code)
}
if out.String() == "" {
t.Fatal("un-gated session-emit must still emit on a README (backward compat)")
}
// With --if-initialized and no eeco workspace: silent.
out.Reset()
if code := runHooks([]string{"session-emit", "--if-initialized"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("session-emit --if-initialized -> exit %d, want 0", code)
}
if out.String() != "" {
t.Errorf("--if-initialized must be silent outside an eeco workspace, got:\n%s", out.String())
}
}
func TestRunHooks_SessionEmit_IfInitialized_EmitsWhenInitialized(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "README.md", "# project\n")
// Scaffold the 5 canonical subdirs so config.IsInitialized is true.
for _, sub := range []string{"engine", "memory", "workflows", "state", "docs"} {
if err := os.MkdirAll(wsPath(root, sub), 0o755); err != nil {
t.Fatal(err)
}
}
var out bytes.Buffer
if code := runHooks([]string{"session-emit", "--if-initialized"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("session-emit --if-initialized -> exit %d, want 0", code)
}
if out.String() == "" {
t.Error("--if-initialized must emit inside an initialized eeco workspace")
}
}
// guardTrailer assembles an attribution trailer from fragments so this
// test source stays self-clean for eeco's own attribution scan.
func guardTrailer() string {
return "Co-" + "Authored-" + "By: " + "A Real Person <[email protected]>"
}
func TestRunHooks_CommitGuardOnThenOff(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
sp := filepath.Join(t.TempDir(), "settings.json")
t.Setenv("EECO_SESSION_SETTINGS", sp)
var out bytes.Buffer
if code := runHooks([]string{"commit-guard", "on"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("commit-guard on -> exit %d\n%s", code, out.String())
}
b, err := os.ReadFile(sp)
if err != nil {
t.Fatalf("settings not written: %v", err)
}
if !strings.Contains(string(b), "PreToolUse") || !strings.Contains(string(b), "commit-guard-check") {
t.Errorf("settings missing PreToolUse commit-guard group:\n%s", b)
}
out.Reset()
if code := runHooks([]string{"commit-guard", "off"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("commit-guard off -> exit %d\n%s", code, out.String())
}
b, _ = os.ReadFile(sp)
if strings.Contains(string(b), "commit-guard-check") {
t.Errorf("commit-guard group still present after off:\n%s", b)
}
}
func TestRunHooks_CommitGuardNotConfigured(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
t.Setenv("EECO_SESSION_SETTINGS", "")
var errOut bytes.Buffer
if code := runHooks([]string{"commit-guard", "on"}, &bytes.Buffer{}, &errOut); code != 1 {
t.Fatalf("unconfigured commit-guard -> exit %d, want 1", code)
}
if !strings.Contains(errOut.String(), "not configured") {
t.Errorf("missing not-configured message:\n%s", errOut.String())
}
}
func TestRunHooks_CommitGuardCheckDeniesViolatingCommit(t *testing.T) {
cwd := t.TempDir()
event := map[string]any{
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": map[string]any{"command": `git commit -m "fix: x" -m "` + guardTrailer() + `"`},
"cwd": cwd,
}
raw, _ := json.Marshal(event)
prev := commitGuardStdin
commitGuardStdin = bytes.NewReader(raw)
defer func() { commitGuardStdin = prev }()
var out, errOut bytes.Buffer
if code := runHooks([]string{"commit-guard-check"}, &out, &errOut); code != 0 {
t.Fatalf("commit-guard-check -> exit %d, want 0 (deny is in the body)", code)
}
s := out.String()
if !strings.Contains(s, `"permissionDecision":"deny"`) {
t.Errorf("expected a deny decision, got:\n%s", s)
}
if !strings.Contains(s, "commit-guard") {
t.Errorf("deny reason missing eeco label:\n%s", s)
}
}
func TestRunHooks_CommitGuardCheckAllowsClean(t *testing.T) {
for _, cmd := range []string{
`git commit -m "fix: a real change"`, // clean commit
`git status`, // not a commit
`echo "git commit -m bad"`, // not a commit (quoted)
} {
event := map[string]any{
"tool_name": "Bash",
"tool_input": map[string]any{"command": cmd},
"cwd": t.TempDir(),
}
raw, _ := json.Marshal(event)
prev := commitGuardStdin
commitGuardStdin = bytes.NewReader(raw)
var out bytes.Buffer
code := runHooks([]string{"commit-guard-check"}, &out, &bytes.Buffer{})
commitGuardStdin = prev
if code != 0 {
t.Fatalf("%q -> exit %d, want 0", cmd, code)
}
if out.String() != "" {
t.Errorf("%q must allow (empty stdout), got:\n%s", cmd, out.String())
}
}
}
func TestRunHooks_SessionEmitPrintsWhenQueued(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
stateDir := filepath.Join(root, "tester", ".eeco", "state")
if err := queue.Append(stateDir, queue.Item{
Kind: "evolve", Title: "decide me", Project: "p", Date: time.Now(),
}); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
if code := runHooks([]string{"session-emit"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("session-emit -> exit %d, want 0", code)
}
if !strings.Contains(out.String(), "awaiting a decision") {
t.Errorf("expected a queue reminder, got:\n%s", out.String())
}
}
added cmd/eeco/init.go
@@ -0,0 +1,342 @@
package main
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/ajhahnde/eeco/internal/ai"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/gitx"
"github.com/ajhahnde/eeco/internal/projecttype"
"github.com/ajhahnde/eeco/internal/workflow"
)
// initStdin is the reader `eeco init` uses for its interactive prompts
// (the project-type ambiguity prompt and the workspace-owner prompt). It
// defaults to the process stdin; the cmd test suite pins it to an empty
// reader in TestMain so init stays non-interactive under `go test` and
// degrades to the deterministic fallback at every prompt.
var initStdin io.Reader = os.Stdin
// initTrackHistory gates whether `eeco init` stands up the private
// workspace-history git repo (subject to config + the --no-track flag). It
// defaults to true (production); the cmd test suite pins it to false in
// TestMain so the many init-driven setup helpers do not each create a real
// nested git repo. The dedicated history tests flip it back on locally.
// This mirrors the initStdin seam above (keep init focused under `go test`).
var initTrackHistory = true
func runInit(args []string, stdout, stderr io.Writer) int {
// Not newFlagSet: this usage prints the flag table (fs.PrintDefaults())
// over seven flags, which the shared one-line helper would drop.
fs := flag.NewFlagSet("init", flag.ContinueOnError)
fs.SetOutput(stderr)
fs.Usage = func() {
fmt.Fprintln(stderr, "usage: eeco init [--workspace NAME] [--type CATEGORY] [--username NAME] [--ai] [--no-commit] [--no-push] [--no-track]")
fs.PrintDefaults()
}
workspace := fs.String("workspace", config.DefaultWorkspace, "engine workspace directory name")
typeFlag := fs.String("type", "", "force the project-type category (skips detection)")
username := fs.String("username", "", "workspace owner directory name (defaults to git user.name)")
aiFlag := fs.Bool("ai", false, "allow a gated AI pass to classify an ambiguous project")
noCommit := fs.Bool("no-commit", false, "do not auto-commit the .gitignore change")
noPush := fs.Bool("no-push", false, "do not auto-push after the init commit")
noTrack := fs.Bool("no-track", false, "do not create the private workspace-history git repo")
if err := fs.Parse(args); err != nil {
return 2
}
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintln(stderr, "eeco init:", err)
return 1
}
// Not the shared loader: init does a two-phase FindRepoRoot then Load — it
// resolves the workspace owner between them, and Load takes the --workspace
// flag value, not the default workspace.
root, err := config.FindRepoRoot(cwd)
if err != nil {
if errors.Is(err, config.ErrNotInRepo) {
fmt.Fprintln(stderr, "eeco init: not inside a git repository.")
fmt.Fprintln(stderr, "hint: run `git init` first, then re-run `eeco init`.")
return 1
}
fmt.Fprintln(stderr, "eeco init:", err)
return 1
}
// Resolve the workspace owner before Load (Load reads it from the
// environment). A --username flag wins; otherwise the prompt fires
// only when there is no env pin and no git identity to fall back on.
if u := resolveInitUsername(root, *username, initStdin, stderr); u != "" {
os.Setenv(config.UsernameEnv, u)
}
cfg, err := config.Load(cwd, *workspace)
if err != nil {
if errors.Is(err, config.ErrNotInRepo) {
fmt.Fprintln(stderr, "eeco init: not inside a git repository.")
fmt.Fprintln(stderr, "hint: run `git init` first, then re-run `eeco init`.")
return 1
}
fmt.Fprintln(stderr, "eeco init:", err)
return 1
}
// Migration awareness: a workspace at the legacy repo-root location
// (<repo>/.eeco) is the old layout. eeco homes the workspace under
// <repo>/<username>/. Scaffolding a second workspace alongside the old
// one would leave a split state, so init IS the migration entry point:
// it offers `eeco migrate v1` interactively, then falls through to finish
// init against the migrated workspace. Declining leaves the tree
// untouched.
legacy := filepath.Join(root, config.DefaultWorkspace)
if legacy != cfg.Workspace && isDir(legacy) {
fmt.Fprintf(stderr, "eeco init: a legacy workspace exists at %s.\n", legacy)
fmt.Fprintln(stderr, "eeco 1.0 homes the workspace under "+cfg.Username+"/.")
if !confirm(initStdin, stderr, "migrate it now (eeco migrate v1)? [y/N]: ") {
fmt.Fprintln(stderr, "no changes made — run `eeco migrate v1` later, or remove the legacy workspace to start fresh.")
return 1
}
// init already took consent, so skip migrateV1's own prompt.
if code, err := migrateV1(cfg, initStdin, stdout, stderr, true); err != nil {
fmt.Fprintln(stderr, "eeco init:", err)
return 1
} else if code != 0 {
return code
}
}
// Detection runs on a fresh init, or when the operator forces a type
// or opts into the AI pass. An idempotent re-run without flags skips
// it so a re-run never re-prompts for an ambiguous type.
var result projecttype.Result
var detected bool
if !config.IsInitialized(cfg) || *typeFlag != "" || *aiFlag {
res, code := detectType(cfg, *typeFlag, *aiFlag, stderr)
if code != 0 {
return code
}
result = res
detected = true
cfg.KnowledgeDirs = result.Dirs
}
rep, err := config.Init(cfg)
if err != nil {
fmt.Fprintln(stderr, "eeco init:", err)
return 1
}
// First-run cockpit target selection (C2): record which harness(es) the
// operator uses so `eeco cockpit generate` knows what to emit. Done before
// the private workspace-history repo is initialised below, so the
// selection file lands inside the init snapshot (a clean tree afterward).
// Once-only, non-interactive safe, degrades — never fails init.
initCockpitSelection(cfg, root, initStdin, stderr)
// The single sanctioned write to the HOST git history (VISION.md init
// exception): commit the .gitignore line, optionally push. No-op on a
// non-real repo; failures warn, never fail init.
var commit initCommitResult
if rep.GitignoreChanged {
commit = autoCommitPush(root, !*noCommit, !*noPush, stderr)
}
// The private workspace-history repo (VISION.md "Workspace history"
// principle): a separate, local, no-remote git repo INSIDE the
// gitignored <username>/ so eeco can version its own knowledge layer.
// Opt out per-run with --no-track or durably with
// workspace_history=off. All private-repo git lives in the cmd layer
// (historygit.go); the engine never writes git. Degrades, never fails.
var historyLine string
if initTrackHistory {
var history historyInitResult
if cfg.WorkspaceHistory.Enabled() && !*noTrack {
history = initPrivateRepo(cfg.UserDir, stderr)
}
historyLine = historyReportLine(cfg.WorkspaceHistory, history, *noTrack)
}
printInitReport(stdout, rep, result, detected, commit, historyLine)
return 0
}
// historyReportLine renders the one-line init-report status for the
// private workspace-history repo. An empty return is never produced here
// (the caller omits the line only when history tracking is disabled for
// the run via the test seam).
func historyReportLine(mode config.WorkspaceHistory, res historyInitResult, noTrack bool) string {
switch {
case noTrack:
return "history: off (--no-track)"
case !mode.Enabled():
return "history: off"
case res.already:
return "history: already initialised"
case res.created && mode.Auto():
return "history: initialised (auto — commits after each change)"
case res.created:
return "history: initialised (manual)"
default:
// Enabled and not opted out, but nothing was created: git is
// unavailable or a step degraded (a warning already went to stderr).
return "history: not initialised (git unavailable)"
}
}
// detectType runs the project-type detector and returns the result. A
// non-zero status code means init should abort with that code (only an
// unknown --type value does this); the caller has already printed the
// reason.
func detectType(cfg *config.Config, typeFlag string, aiFlag bool, stderr io.Writer) (projecttype.Result, int) {
cat, err := projecttype.LoadCatalog()
if err != nil {
// The catalog is embedded at build time, so this is effectively
// unreachable; degrade to engine-only scaffolding rather than
// failing init outright.
fmt.Fprintln(stderr, "eeco init: project-type catalog unavailable; scaffolding engine workspace only:", err)
return projecttype.Result{}, 0
}
det, err := workflow.NewDetector(cfg.AttributionPatterns)
if err != nil {
fmt.Fprintln(stderr, "eeco init:", err)
return projecttype.Result{}, 1
}
gate := ai.NewGate(cfg, aiFlag, det.ScanResponse)
aiBridge := func(ctx context.Context, prompt string) (string, error) {
out, rerr := gate.Run(ctx, ai.Request{Label: "project-type-detection", User: prompt})
if rerr != nil {
return "", rerr
}
if !out.Ran {
return "", errors.New(out.Reason)
}
return out.Text, nil
}
opt := projecttype.Options{
RepoRoot: cfg.RepoRoot,
Threshold: cfg.InitDetectionThreshold,
Forced: projecttype.Category(typeFlag),
ForceAI: aiFlag,
Prompter: projecttype.NewStdinPrompter(initStdin, stderr),
AI: aiBridge,
}
result, err := projecttype.Detect(context.Background(), cat, opt)
if err != nil {
fmt.Fprintln(stderr, "eeco init:", err)
fmt.Fprintln(stderr, "valid --type values:", joinCategories(cat.Categories()))
return projecttype.Result{}, 1
}
return result, 0
}
// resolveInitUsername decides the workspace-owner directory name for an
// init run. It returns the value to pin into EECO_USERNAME, or "" to
// leave the resolution to config.Load (which reads the env, then git
// user.name, then $USER, then a fallback). The interactive prompt fires
// only when there is no flag, no env pin, and no git identity, and it
// degrades silently on EOF so a piped, non-interactive init still
// completes.
func resolveInitUsername(root, flagVal string, stdin io.Reader, stderr io.Writer) string {
if strings.TrimSpace(flagVal) != "" {
return strings.TrimSpace(flagVal)
}
if os.Getenv(config.UsernameEnv) != "" {
return ""
}
if name, err := gitx.UserName(root); err == nil && strings.TrimSpace(name) != "" {
return ""
}
hint := firstNonEmpty(os.Getenv("USER"), os.Getenv("USERNAME"))
if hint != "" {
fmt.Fprintf(stderr, "eeco init: no git user.name set. workspace owner [%s]: ", hint)
} else {
fmt.Fprint(stderr, "eeco init: no git user.name set. workspace owner: ")
}
sc := bufio.NewScanner(stdin)
if sc.Scan() {
if v := strings.TrimSpace(sc.Text()); v != "" {
return v
}
}
return ""
}
func printInitReport(w io.Writer, r config.InitReport, result projecttype.Result, detected bool, commit initCommitResult, historyLine string) {
fmt.Fprintf(w, "eeco workspace: %s\n", r.Workspace)
fmt.Fprintf(w, "profile: %s\n", r.Profile)
if detected && result.Category != "" {
fmt.Fprintf(w, "project type: %s (%s)\n", result.Category, result.Source)
}
if len(r.Gate) == 0 {
fmt.Fprintln(w, "gate: (none — generic profile)")
} else {
fmt.Fprintf(w, "gate: %s\n", strings.Join(config.GateSteps(r.Gate), " && "))
}
if historyLine != "" {
fmt.Fprintln(w, historyLine)
}
noop := r.AlreadyInit && len(r.CreatedDirs) == 0 && len(r.CreatedKnowledgeDirs) == 0 &&
!r.WroteReadme && !r.GitignoreChanged
if noop {
fmt.Fprintln(w, "status: already initialised — nothing to do")
} else {
if len(r.CreatedDirs) > 0 {
fmt.Fprintf(w, "created: %s\n", strings.Join(r.CreatedDirs, ", "))
}
if len(r.CreatedKnowledgeDirs) > 0 {
fmt.Fprintf(w, "knowledge dirs: %s\n", strings.Join(r.CreatedKnowledgeDirs, ", "))
}
if r.WroteReadme {
fmt.Fprintln(w, "wrote: README.md")
}
if r.GitignoreChanged {
fmt.Fprintf(w, "updated: %s (added /%s/)\n", r.GitignorePath, r.Username)
switch {
case commit.pushed:
fmt.Fprintln(w, "committed: .gitignore (eeco init) — pushed")
case commit.committed:
fmt.Fprintln(w, "committed: .gitignore (eeco init)")
default:
fmt.Fprintln(w, "next: commit the .gitignore change so the workspace stays ignored.")
}
}
}
// First-run nudge toward the health check, printed on every successful
// init (fresh and idempotent re-run alike). Mirrors the `eeco status`
// fresh-workspace hint (internal/tui/digest.go) for house consistency.
fmt.Fprintln(w, "next: run `eeco doctor` for a workspace health check")
}
func isDir(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
return ""
}
func joinCategories(cats []projecttype.Category) string {
parts := make([]string, len(cats))
for i, c := range cats {
parts[i] = string(c)
}
return strings.Join(parts, ", ")
}
added cmd/eeco/init_redesign_test.go
@@ -0,0 +1,227 @@
package main
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// TestRunInit_DetectsFullstack proves the project-type detector is wired
// into init: frontend/ + backend/ sibling dirs clear the deterministic
// confidence threshold, so init scaffolds the fullstack knowledge dirs
// without prompting.
func TestRunInit_DetectsFullstack(t *testing.T) {
root := newGitRepo(t)
for _, d := range []string{"frontend", "backend"} {
if err := os.Mkdir(filepath.Join(root, d), 0o755); err != nil {
t.Fatal(err)
}
}
chdir(t, root)
var out, errOut bytes.Buffer
if code := runInit(nil, &out, &errOut); code != 0 {
t.Fatalf("runInit exit=%d. stderr:\n%s", code, errOut.String())
}
if !strings.Contains(out.String(), "project type: fullstack") {
t.Errorf("missing fullstack detection line:\n%s", out.String())
}
for _, d := range []string{"frontend", "backend", "database", "infra"} {
if info, err := os.Stat(filepath.Join(root, "tester", d)); err != nil || !info.IsDir() {
t.Errorf("knowledge dir %s not scaffolded in UserDir: %v", d, err)
}
}
if strings.Contains(errOut.String(), "ambiguous") {
t.Errorf("high-confidence detection should not prompt:\n%s", errOut.String())
}
}
// TestRunInit_TypeFlagForces proves --type short-circuits detection.
func TestRunInit_TypeFlagForces(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
if code := runInit([]string{"--type", "cli"}, &out, &errOut); code != 0 {
t.Fatalf("runInit exit=%d. stderr:\n%s", code, errOut.String())
}
if !strings.Contains(out.String(), "project type: cli (type-flag)") {
t.Errorf("missing forced-type line:\n%s", out.String())
}
for _, d := range []string{"commands", "internals", "docs"} {
if _, err := os.Stat(filepath.Join(root, "tester", d)); err != nil {
t.Errorf("cli knowledge dir %s not scaffolded: %v", d, err)
}
}
}
// TestRunInit_TypeFlagUnknown rejects an unknown category and lists the
// valid ones.
func TestRunInit_TypeFlagUnknown(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var errOut bytes.Buffer
code := runInit([]string{"--type", "bogus"}, &bytes.Buffer{}, &errOut)
if code == 0 {
t.Fatal("expected non-zero exit for an unknown --type")
}
if !strings.Contains(errOut.String(), "unknown project type") {
t.Errorf("missing unknown-type error:\n%s", errOut.String())
}
if !strings.Contains(errOut.String(), "valid --type values:") {
t.Errorf("missing valid-values hint:\n%s", errOut.String())
}
}
// TestRunInit_LegacyWorkspaceGuard proves init offers migration when a v2.x
// workspace sits at the repo root and, when the offer is declined (the empty
// TestMain stdin hits EOF), leaves the tree untouched rather than
// dual-scaffolding.
func TestRunInit_LegacyWorkspaceGuard(t *testing.T) {
root := newGitRepo(t)
if err := os.MkdirAll(filepath.Join(root, ".eeco", "memory"), 0o755); err != nil {
t.Fatal(err)
}
chdir(t, root)
var errOut bytes.Buffer
code := runInit(nil, &bytes.Buffer{}, &errOut)
if code == 0 {
t.Fatal("expected non-zero exit with a legacy workspace present")
}
if !strings.Contains(errOut.String(), "legacy workspace") {
t.Errorf("missing legacy-workspace notice:\n%s", errOut.String())
}
if !strings.Contains(errOut.String(), "eeco migrate v1") {
t.Errorf("notice should point at the migration verb:\n%s", errOut.String())
}
if _, err := os.Stat(filepath.Join(root, "tester", ".eeco")); err == nil {
t.Error("init must not scaffold a new workspace when a legacy one exists")
}
}
// TestRunInit_LegacyWorkspaceMigrateAccept proves that accepting the init-time
// migration offer relocates the legacy workspace and lets init finish against
// the new location in one pass.
func TestRunInit_LegacyWorkspaceMigrateAccept(t *testing.T) {
root := newGitRepo(t)
seedLegacyWorkspace(t, root)
chdir(t, root)
prev := initStdin
initStdin = strings.NewReader("y\n")
t.Cleanup(func() { initStdin = prev })
var out, errOut bytes.Buffer
code := runInit(nil, &out, &errOut)
if code != 0 {
t.Fatalf("exit = %d, want 0 (stderr: %s)", code, errOut.String())
}
if isDir(filepath.Join(root, ".eeco")) {
t.Fatal("legacy workspace should have been migrated away")
}
if !isDir(filepath.Join(root, "tester", ".eeco")) {
t.Fatal("migrated workspace missing at tester/.eeco")
}
gi, err := os.ReadFile(filepath.Join(root, ".gitignore"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(gi), "/.eeco/") || !strings.Contains(string(gi), "/tester/") {
t.Fatalf(".gitignore not migrated:\n%s", gi)
}
}
// TestRunInit_UsernameFlag proves --username overrides the workspace
// owner. t.Setenv contains the EECO_USERNAME mutation runInit makes.
func TestRunInit_UsernameFlag(t *testing.T) {
t.Setenv("EECO_USERNAME", "tester")
root := newGitRepo(t)
chdir(t, root)
if code := runInit([]string{"--type", "generic", "--username", "alice"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("runInit exit=%d", code)
}
if _, err := os.Stat(filepath.Join(root, "alice", ".eeco", "memory")); err != nil {
t.Errorf("workspace not scoped under alice/: %v", err)
}
b, _ := os.ReadFile(filepath.Join(root, ".gitignore"))
if !strings.Contains(string(b), "/alice/\n") {
t.Errorf(".gitignore should scope /alice/: %s", string(b))
}
}
// TestRunInit_AutoCommitRealRepo proves the init-only commit exception
// fires against a real git working tree.
func TestRunInit_AutoCommitRealRepo(t *testing.T) {
root := realGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
if code := runInit([]string{"--type", "generic", "--no-push"}, &out, &errOut); code != 0 {
t.Fatalf("runInit exit=%d. stderr:\n%s", code, errOut.String())
}
if !strings.Contains(out.String(), "committed: .gitignore (eeco init)") {
t.Errorf("missing commit line:\n%s\nstderr:\n%s", out.String(), errOut.String())
}
if subj := strings.TrimSpace(gitOut(t, root, "log", "-1", "--format=%s")); subj != "eeco init" {
t.Errorf("last commit subject = %q, want \"eeco init\"", subj)
}
files := gitOut(t, root, "show", "--name-only", "--format=", "HEAD")
if !strings.Contains(files, ".gitignore") {
t.Errorf("init commit did not include .gitignore:\n%s", files)
}
}
// TestRunInit_NoCommitFlag proves --no-commit leaves the .gitignore
// change uncommitted.
func TestRunInit_NoCommitFlag(t *testing.T) {
root := realGitRepo(t)
chdir(t, root)
if code := runInit([]string{"--type", "generic", "--no-commit"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("runInit exit=%d", code)
}
status := gitOut(t, root, "status", "--porcelain")
if !strings.Contains(status, ".gitignore") {
t.Errorf("with --no-commit, .gitignore should be uncommitted:\n%q", status)
}
}
// --- helpers ---
func realGitRepo(t *testing.T) string {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
root := t.TempDir()
for _, args := range [][]string{
{"init"},
{"config", "user.email", "[email protected]"},
{"config", "user.name", "tester"},
{"config", "commit.gpgsign", "false"},
} {
cmd := exec.Command("git", args...)
cmd.Dir = root
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
return root
}
func gitOut(t *testing.T, root string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = root
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return string(out)
}
added cmd/eeco/init_test.go
@@ -0,0 +1,286 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
// TestMain pins the workspace owner so config.Load resolves a
// deterministic username across machines (otherwise it would pick up the
// dev box's `git config user.name`). The workspace then lives at
// <root>/tester/.eeco — use wsPath to build paths into it.
func TestMain(m *testing.M) {
os.Setenv("EECO_USERNAME", "tester")
// Keep `eeco init` non-interactive under `go test`: an empty reader
// makes every init prompt (project-type ambiguity, workspace owner)
// hit EOF and take its deterministic fallback instead of blocking on
// the terminal.
initStdin = bytes.NewReader(nil)
// Default the uninstall de-init prompt to EOF (declines) so no test can
// hang on stdin; tests that exercise removal set --yes or pin a reader.
uninstallStdin = bytes.NewReader(nil)
// Default the history-compact confirm prompt to EOF (declines) so no
// test can hang on stdin; tests that apply pass --yes or pin a reader.
historyCompactStdin = bytes.NewReader(nil)
// Default the commit-guard runner's stdin to EOF so no test reading it
// without a pinned event can hang; commit-guard tests pin a reader.
commitGuardStdin = bytes.NewReader(nil)
// Keep the many init-driven setup helpers from each standing up a real
// nested private git repo. The dedicated workspace-history tests flip
// this back on via setTrackHistory.
initTrackHistory = false
os.Exit(m.Run())
}
// setTrackHistory flips the private-repo init seam on/off and returns a
// restore func, so a test can exercise the real repo-creation path without
// leaking the setting into the rest of the suite.
func setTrackHistory(v bool) func() {
prev := initTrackHistory
initTrackHistory = v
return func() { initTrackHistory = prev }
}
func TestRunInit_CreatesWorkspaceHistory(t *testing.T) {
requireGit(t)
defer setTrackHistory(true)()
root := newGitRepo(t)
chdir(t, root)
var out bytes.Buffer
if code := runInit(nil, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("runInit exit; out:\n%s", out.String())
}
userDir := filepath.Join(root, "tester")
if !privateRepoExists(userDir) {
t.Fatal("private workspace-history repo not created")
}
if !strings.Contains(out.String(), "history: initialised (manual)") {
t.Errorf("init report missing history line:\n%s", out.String())
}
// The init commit is in the private repo.
if log, err := logPrivateRepo(userDir, 5); err != nil || !strings.Contains(log, privateInitCommitMsg) {
t.Errorf("private repo log = %q (err %v), want the init commit", log, err)
}
// Idempotent re-init reports already-initialised for history.
var out2 bytes.Buffer
if code := runInit(nil, &out2, &bytes.Buffer{}); code != 0 {
t.Fatalf("re-init exit; out:\n%s", out2.String())
}
if !strings.Contains(out2.String(), "history: already initialised") {
t.Errorf("re-init report missing already-initialised history line:\n%s", out2.String())
}
}
func TestRunInit_NoTrackSkipsHistory(t *testing.T) {
defer setTrackHistory(true)()
root := newGitRepo(t)
chdir(t, root)
var out bytes.Buffer
if code := runInit([]string{"--no-track"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("runInit exit; out:\n%s", out.String())
}
if privateRepoExists(filepath.Join(root, "tester")) {
t.Error("private repo created despite --no-track")
}
if !strings.Contains(out.String(), "history: off (--no-track)") {
t.Errorf("report missing --no-track history line:\n%s", out.String())
}
}
func TestRunInit_WorkspaceHistoryOffSkips(t *testing.T) {
defer setTrackHistory(true)()
root := newGitRepo(t)
chdir(t, root)
// First scaffold the workspace without the repo so config.local exists.
if code := runInit([]string{"--no-track"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("setup init failed")
}
writeFile(t, filepath.Join(root, "tester", ".eeco"), "config.local", "workspace_history=off\n")
var out bytes.Buffer
if code := runInit(nil, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("second init exit; out:\n%s", out.String())
}
if privateRepoExists(filepath.Join(root, "tester")) {
t.Error("private repo created though workspace_history=off")
}
if !strings.Contains(out.String(), "history: off") {
t.Errorf("report missing history-off line:\n%s", out.String())
}
}
func TestRunInit_FreshRepo(t *testing.T) {
root := newGitRepo(t)
writeFile(t, root, "go.mod", "module example.com/x\n")
chdir(t, root)
var out, errOut bytes.Buffer
code := runInit(nil, &out, &errOut)
if code != 0 {
t.Fatalf("runInit exit=%d, want 0. stderr:\n%s", code, errOut.String())
}
if _, err := os.Stat(filepath.Join(root, "tester", ".eeco", "memory")); err != nil {
t.Errorf("workspace memory dir not created: %v", err)
}
if !strings.Contains(out.String(), "profile: go") {
t.Errorf("output missing profile line:\n%s", out.String())
}
if !strings.Contains(out.String(), "commit the .gitignore change") {
t.Errorf("output missing commit hint:\n%s", out.String())
}
}
func TestRunInit_NudgesTowardDoctor(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out bytes.Buffer
if code := runInit(nil, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("runInit exit=%d", code)
}
if !strings.Contains(out.String(), "run `eeco doctor` for a workspace health check") {
t.Errorf("fresh init should nudge toward eeco doctor:\n%s", out.String())
}
// The idempotent re-run also nudges (the noop branch reaches the line).
var out2 bytes.Buffer
if code := runInit(nil, &out2, &bytes.Buffer{}); code != 0 {
t.Fatalf("re-init exit=%d", code)
}
if !strings.Contains(out2.String(), "run `eeco doctor` for a workspace health check") {
t.Errorf("idempotent re-init should also nudge toward eeco doctor:\n%s", out2.String())
}
}
func TestRunInit_Idempotent(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("first runInit exit=%d", code)
}
var out bytes.Buffer
code := runInit(nil, &out, &bytes.Buffer{})
if code != 0 {
t.Fatalf("second runInit exit=%d", code)
}
if !strings.Contains(out.String(), "already initialised") {
t.Errorf("second init output missing already-initialised note:\n%s", out.String())
}
}
func TestRunInit_OutsideRepo(t *testing.T) {
dir := t.TempDir()
chdir(t, dir)
var errOut bytes.Buffer
code := runInit(nil, &bytes.Buffer{}, &errOut)
if code == 0 {
t.Fatal("expected non-zero exit outside a git repo")
}
if !strings.Contains(errOut.String(), "not inside a git repository") {
t.Errorf("missing helpful error:\n%s", errOut.String())
}
}
func TestRunInit_CustomWorkspace(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
code := runInit([]string{"--workspace", ".work"}, &bytes.Buffer{}, &bytes.Buffer{})
if code != 0 {
t.Fatalf("runInit exit=%d", code)
}
if _, err := os.Stat(filepath.Join(root, "tester", ".work", "memory")); err != nil {
t.Errorf("custom workspace not created: %v", err)
}
}
func TestRunStatus_FreshRepo(t *testing.T) {
root := newGitRepo(t)
writeFile(t, root, "go.mod", "module example.com/x\n")
chdir(t, root)
var out bytes.Buffer
code := runStatus(&out, &bytes.Buffer{})
if code != 0 {
t.Fatalf("runStatus exit=%d", code)
}
if !strings.Contains(out.String(), "missing — run `eeco init`") {
t.Errorf("status should report missing workspace:\n%s", out.String())
}
if !strings.Contains(out.String(), "profile go") {
t.Errorf("status missing profile line:\n%s", out.String())
}
}
func TestRunStatus_OutsideRepo(t *testing.T) {
dir := t.TempDir()
chdir(t, dir)
var errOut bytes.Buffer
code := runStatus(&bytes.Buffer{}, &errOut)
if code == 0 {
t.Fatal("expected non-zero exit outside a git repo")
}
if !strings.Contains(errOut.String(), "not inside a git repository") {
t.Errorf("missing helpful error:\n%s", errOut.String())
}
}
func TestRunStatus_InitialisedShowsInitialised(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup runInit exit=%d", code)
}
var out bytes.Buffer
code := runStatus(&out, &bytes.Buffer{})
if code != 0 {
t.Fatalf("runStatus exit=%d", code)
}
if !strings.Contains(out.String(), "(initialised)") {
t.Errorf("status should report initialised:\n%s", out.String())
}
}
// --- helpers ---
func newGitRepo(t *testing.T) string {
t.Helper()
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil {
t.Fatal(err)
}
return root
}
// wsPath builds a path into the engine workspace for a repo rooted at
// root. With EECO_USERNAME=tester pinned in TestMain, config.Load scopes
// the workspace under the per-user dir, so the parts are joined onto
// <root>/tester + the default workspace name.
func wsPath(root string, parts ...string) string {
return filepath.Join(append([]string{root, "tester", ".eeco"}, parts...)...)
}
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
full := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func chdir(t *testing.T, dir string) {
t.Helper()
prev, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = os.Chdir(prev)
})
}
added cmd/eeco/initgit.go
@@ -0,0 +1,100 @@
package main
import (
"fmt"
"io"
"os/exec"
"strings"
)
// This file holds the single sanctioned git-write path in eeco. The
// engine library (internal/gitx and everything that imports it) is
// deliberately read-only — it never commits or pushes, so the engine
// cannot mutate history even by mistake (PLAN.md Constraint 6). `eeco
// init` is the documented exception in VISION.md: it makes one initial
// commit and one optional push, both limited to the `.gitignore` line
// that scopes the workspace out of the tracked tree. Keeping this logic
// in the command package — not in an importable engine package — means
// no engine code can reach it.
// initCommitResult records what the init-only git step actually did, for
// an accurate report. When the repository is not a real git working tree
// (the case the tests exercise with a bare `.git` directory), every
// field stays false and no warning is emitted: there is simply nothing
// to commit to.
type initCommitResult struct {
attempted bool
committed bool
pushed bool
}
// autoCommitPush performs the init-only `.gitignore` commit and optional
// push. It is a no-op on anything that is not a real git working tree.
// Commit and push failures are reported as warnings on stderr and never
// fail init: the workspace is already scaffolded on disk, and a missing
// remote or absent credentials must not block local setup.
func autoCommitPush(root string, doCommit, doPush bool, stderr io.Writer) initCommitResult {
var res initCommitResult
if !doCommit {
return res
}
if !isRealGitRepo(root) {
// A scaffolded-but-not-real repo (or git missing): leave the
// commit to the operator. printInitReport keeps the manual hint.
return res
}
res.attempted = true
if out, err := runGit(root, "add", "--", ".gitignore"); err != nil {
fmt.Fprintf(stderr, "eeco init: could not stage .gitignore (workspace is set up; commit it yourself): %s\n", gitErr(out, err))
return res
}
if out, err := runGit(root, "commit", "-m", "eeco init"); err != nil {
fmt.Fprintf(stderr, "eeco init: could not commit .gitignore (workspace is set up; commit it yourself): %s\n", gitErr(out, err))
return res
}
res.committed = true
if !doPush {
return res
}
if out, err := runGit(root, "push"); err != nil {
fmt.Fprintf(stderr, "eeco init: committed locally but push failed (push when ready): %s\n", gitErr(out, err))
return res
}
res.pushed = true
return res
}
// isRealGitRepo reports whether root is inside a real git working tree.
// A bare or empty `.git` directory (as the tests fake) is not, so the
// commit step is skipped cleanly there.
func isRealGitRepo(root string) bool {
out, err := runGit(root, "rev-parse", "--is-inside-work-tree")
return err == nil && strings.TrimSpace(out) == "true"
}
// runGit runs one git command with root as the working directory and
// returns combined output. It is intentionally local to the command
// package (see the file header).
func runGit(root string, args ...string) (string, error) {
cmd := exec.Command("git", args...)
cmd.Dir = root
out, err := cmd.CombinedOutput()
return string(out), err
}
// gitErr renders a concise one-line reason from git's combined output,
// falling back to the exec error when git printed nothing.
func gitErr(out string, err error) string {
msg := strings.TrimSpace(out)
if msg == "" {
return err.Error()
}
// Collapse to the first line so a multi-line git message stays a
// single warning line.
if i := strings.IndexByte(msg, '\n'); i >= 0 {
msg = msg[:i]
}
return msg
}
added cmd/eeco/main.go
@@ -0,0 +1,164 @@
// Command eeco is the entry point for the eeco workflow-ecosystem
// tool. The binary dispatches a small set of subcommands and exits
// with documented status codes.
package main
import (
"fmt"
"io"
"os"
"runtime/debug"
)
// version, commit, and buildDate are overridden at release time via
// -ldflags. The default version "0.0.0-dev" is the marker for an
// untagged local build; commit and buildDate stay empty in that case
// and the enriched lines are suppressed in the version output.
var (
version = "0.0.0-dev"
commit = ""
buildDate = ""
)
const usage = `eeco — self-maintaining workflow ecosystem for coding projects
usage:
eeco open the control center (status digest if non-TTY)
eeco init scaffold the workspace in the current repo
eeco migrate v1 [--yes] move a legacy <repo>/.eeco workspace under <username>/
eeco run <workflow> run one workflow (--ai opts into a gated AI pass)
eeco new <workflow> scaffold a new workflow into the workspace
eeco gc run memory garbage collection
eeco queue print the workspace queue (resolve by editing it)
eeco stats print cumulative AI usage from the call ledger
eeco hooks show or toggle opt-in reversible hooks
eeco cockpit generate emit the active AI cockpit as harness config (reversible)
eeco cockpit verify check the emitted cockpit artifacts match + are safe
eeco cockpit off remove eeco's emitted cockpit artifacts (reversible)
eeco cockpit target manage the active harness target set (list|add|rm)
eeco cockpit machinery emit the auto-firing git-write guard as harness config (on|off|status)
eeco authorize commit|tag allow ONE git commit / tag mutation through the guard (one-shot)
eeco doctor run diagnostic probes against the workspace
eeco docs new [--overwrite] <target> scaffold a tracked-tree doc (target: vision)
eeco docs compact [--archive <p>] [--dry-run] <path> move marked regions into an archive sibling
eeco history [snapshot [-m <msg>]] show or commit the private workspace-history repo
eeco go [--write] print an AI-ready project brief; --write saves it to the workspace
eeco ask "<q>" answer a question with ranked file:line pointers (no AI)
eeco guide page the in-binary user manual through the host pager
eeco update [--apply] check for a newer release; --apply downloads + verifies it
eeco uninstall write a handoff summary and print the removal command
eeco report-bug file a structured bug report into the workspace
eeco add note "<text>" append a free-form note to the workspace
eeco add fact --description <s> <body> record a durable memory fact
eeco add task "<title>" append an item to the workspace queue
eeco adaptations <name> on|off toggle an AI-adaptation fact
eeco workflows [<name> on|off] list or toggle scaffolded workflows
eeco show notes list the workspace notes, newest first
eeco show adaptations list AI-adaptation facts with on/off state
eeco show prompt [name] list the prompt library or print one prompt body
eeco refresh-manifest [<dir>] rebuild the .ai.json manifests in the knowledge dirs
eeco version print version
eeco help print this message
See PLAN.md for the build roadmap and docs/USAGE.md for the full
command and TUI reference.`
func main() {
version = resolveVersion(version)
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
// resolveVersion recovers the real version for `go install [email protected]`
// builds. Those builds do not get the release -ldflags, so version
// stays the "0.0.0-dev" default — but the Go toolchain records the
// module version in the embedded build info. Prefer that when it is
// present and version-shaped; otherwise keep the dev marker. Release
// builds (ldflags-injected) and plain local builds are unchanged.
func resolveVersion(v string) string {
if v != "0.0.0-dev" {
return v
}
info, ok := debug.ReadBuildInfo()
if !ok {
return v
}
switch info.Main.Version {
case "", "(devel)":
return v
default:
return info.Main.Version
}
}
func run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
return runStatus(stdout, stderr)
}
switch args[0] {
case "init":
return runInit(args[1:], stdout, stderr)
case "migrate":
return runMigrate(args[1:], stdout, stderr)
case "run":
return runRun(args[1:], stdout, stderr)
case "new":
return runNew(args[1:], stdout, stderr)
case "gc":
return runGC(args[1:], stdout, stderr)
case "queue":
return runQueue(args[1:], stdout, stderr)
case "stats":
return runStats(args[1:], stdout, stderr)
case "hooks":
return runHooks(args[1:], stdout, stderr)
case "authorize":
return runAuthorize(args[1:], stdout, stderr)
case "gates":
return runGates(args[1:], stdout, stderr)
case "cockpit":
return runCockpit(args[1:], stdout, stderr)
case "doctor":
return runDoctor(args[1:], stdout, stderr)
case "docs":
return runDocs(args[1:], version, stdout, stderr)
case "history":
return runHistory(args[1:], stdout, stderr)
case "go":
return runGo(args[1:], stdout, stderr)
case "ask":
return runAsk(args[1:], stdout, stderr)
case "guide":
return runGuide(args[1:], stdout, stderr)
case "update":
return runUpdate(args[1:], stdout, stderr)
case "uninstall":
return runUninstall(args[1:], stdout, stderr)
case "report-bug":
return runReportBug(args[1:], version, stdout, stderr)
case "add":
return runAdd(args[1:], stdout, stderr)
case "show":
return runShow(args[1:], stdout, stderr)
case "refresh-manifest":
return runRefreshManifest(args[1:], stdout, stderr)
case "adaptations":
return runAdaptations(args[1:], stdout, stderr)
case "workflows":
return runWorkflows(args[1:], stdout, stderr)
case "version", "--version", "-v":
fmt.Fprintln(stdout, "eeco", version)
if commit != "" {
fmt.Fprintln(stdout, " commit:", commit)
}
if buildDate != "" {
fmt.Fprintln(stdout, " built: ", buildDate)
}
return 0
case "help", "--help", "-h":
fmt.Fprintln(stdout, usage)
return 0
default:
fmt.Fprintf(stderr, "eeco: %q is not implemented yet — see PLAN.md\n", args[0])
return 2
}
}
added cmd/eeco/main_test.go
@@ -0,0 +1,19 @@
package main
import "testing"
func TestResolveVersion(t *testing.T) {
// A release build carries an ldflags-injected version; resolveVersion
// must pass it through untouched even though build info is present.
if got := resolveVersion("v1.27.3"); got != "v1.27.3" {
t.Errorf("release version: got %q, want %q", got, "v1.27.3")
}
// The dev marker resolves against the test binary's build info. Under
// `go test` the main module version is "" or "(devel)", so the marker
// is preserved; a `go install pkg@vX` build would instead surface the
// recorded module version. Either way it must never be empty.
if got := resolveVersion("0.0.0-dev"); got == "" {
t.Error("dev version resolved to empty string")
}
}
added cmd/eeco/manifest.go
@@ -0,0 +1,87 @@
package main
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/ajhahnde/eeco/internal/manifest"
)
// runRefreshManifest handles `eeco refresh-manifest [<dir>]`. With no argument
// it rebuilds the .ai.json manifest of every knowledge dir; with a single dir
// argument it refreshes just that one. The manifests are deterministic
// skeletons (paths + kinds); descriptions are left to the opt-in AI pass. All
// writes land inside the gitignored per-user directory, never the tracked tree.
func runRefreshManifest(args []string, stdout, stderr io.Writer) int {
if len(args) > 1 {
fmt.Fprintln(stderr, "usage: eeco refresh-manifest [<dir>]")
return 2
}
cfg, code := loadInitedConfig(stderr, "eeco refresh-manifest")
if code != 0 {
return code
}
var dirs []string
var err error
if len(args) == 1 {
dir := args[0]
if !safeKnowledgeDir(dir) {
fmt.Fprintf(stderr, "eeco refresh-manifest: %q is not a valid knowledge dir name.\n", dir)
return 2
}
info, err := os.Stat(filepath.Join(cfg.UserDir, dir))
if err != nil || !info.IsDir() {
fmt.Fprintf(stderr, "eeco refresh-manifest: no knowledge dir %q under %s\n", dir, cfg.UserDir)
return 1
}
dirs, err = manifest.Subtree(cfg.UserDir, dir)
if err != nil {
fmt.Fprintln(stderr, "eeco refresh-manifest:", err)
return 1
}
} else {
dirs, err = manifest.KnowledgeDirs(cfg.UserDir, cfg.WorkspaceName)
if err != nil {
fmt.Fprintln(stderr, "eeco refresh-manifest:", err)
return 1
}
if len(dirs) == 0 {
fmt.Fprintln(stdout, "no knowledge dirs to refresh")
return 0
}
}
for _, d := range dirs {
m, err := manifest.Build(cfg.UserDir, d)
if err != nil {
fmt.Fprintln(stderr, "eeco refresh-manifest:", err)
return 1
}
if err := manifest.Write(cfg.UserDir, d, m); err != nil {
fmt.Fprintln(stderr, "eeco refresh-manifest:", err)
return 1
}
fmt.Fprintf(stdout, "refreshed %s\n", filepath.Join(d, manifest.FileName))
}
maybeAutoCommit(cfg.WorkspaceHistory.Auto(), cfg.UserDir, "refresh-manifest", stderr)
return 0
}
// safeKnowledgeDir reports whether name is usable as a single, non-escaping
// path component — rejecting empty names, absolute paths, multi-segment paths,
// and the "." / ".." specials so a bad argument can never resolve outside
// UserDir.
func safeKnowledgeDir(name string) bool {
if name == "" || name == "." || name == ".." {
return false
}
if name != filepath.Clean(name) {
return false
}
return !filepath.IsAbs(name) && !strings.ContainsAny(name, `/\`)
}
added cmd/eeco/manifest_test.go
@@ -0,0 +1,113 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
// initWorkspace scaffolds the minimal workspace structure so
// config.IsInitialized passes for a repo rooted at root (workspace under
// <root>/tester/.eeco given the EECO_USERNAME=tester pin in TestMain).
func initWorkspace(t *testing.T, root string) {
t.Helper()
for _, sub := range []string{"engine", "memory", "workflows", "state", "docs"} {
if err := os.MkdirAll(wsPath(root, sub), 0o755); err != nil {
t.Fatal(err)
}
}
}
func TestRunRefreshManifest_AllDirs(t *testing.T) {
root := newGitRepo(t)
initWorkspace(t, root)
writeFile(t, filepath.Join(root, "tester", "backend"), "main.go", "package main")
writeFile(t, filepath.Join(root, "tester", "frontend"), "App.tsx", "x")
// A nested knowledge dir must get its own manifest too.
writeFile(t, filepath.Join(root, "tester", "management", "knowledge"), "doc.md", "x")
chdir(t, root)
var out, errb bytes.Buffer
if code := runRefreshManifest(nil, &out, &errb); code != 0 {
t.Fatalf("exit = %d, want 0 (stderr: %s)", code, errb.String())
}
for _, d := range []string{
"backend",
"frontend",
"management",
filepath.Join("management", "knowledge"),
} {
if _, err := os.Stat(filepath.Join(root, "tester", d, ".ai.json")); err != nil {
t.Fatalf("manifest missing for %s: %v", d, err)
}
if !strings.Contains(out.String(), filepath.Join(d, ".ai.json")) {
t.Fatalf("output missing %s in:\n%s", d, out.String())
}
}
}
func TestRunRefreshManifest_SingleDir(t *testing.T) {
root := newGitRepo(t)
initWorkspace(t, root)
// management has nested subdirs; the single-dir form must refresh the
// whole subtree, not just the named dir.
writeFile(t, filepath.Join(root, "tester", "management", "knowledge"), "doc.md", "x")
writeFile(t, filepath.Join(root, "tester", "management", "roadmap"), "plan.md", "x")
writeFile(t, filepath.Join(root, "tester", "frontend"), "App.tsx", "x")
chdir(t, root)
var out, errb bytes.Buffer
if code := runRefreshManifest([]string{"management"}, &out, &errb); code != 0 {
t.Fatalf("exit = %d, want 0 (stderr: %s)", code, errb.String())
}
for _, d := range []string{
"management",
filepath.Join("management", "knowledge"),
filepath.Join("management", "roadmap"),
} {
if _, err := os.Stat(filepath.Join(root, "tester", d, ".ai.json")); err != nil {
t.Fatalf("manifest missing for %s: %v", d, err)
}
}
// frontend was not requested → no manifest.
if _, err := os.Stat(filepath.Join(root, "tester", "frontend", ".ai.json")); !os.IsNotExist(err) {
t.Fatalf("frontend should be untouched, stat err = %v", err)
}
}
func TestRunRefreshManifest_UnknownDir(t *testing.T) {
root := newGitRepo(t)
initWorkspace(t, root)
chdir(t, root)
var out, errb bytes.Buffer
if code := runRefreshManifest([]string{"nope"}, &out, &errb); code != 1 {
t.Fatalf("exit = %d, want 1", code)
}
}
func TestRunRefreshManifest_RejectsUnsafeArg(t *testing.T) {
root := newGitRepo(t)
initWorkspace(t, root)
chdir(t, root)
var out, errb bytes.Buffer
if code := runRefreshManifest([]string{"../escape"}, &out, &errb); code != 2 {
t.Fatalf("exit = %d, want 2", code)
}
}
func TestRunRefreshManifest_NotInitialised(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errb bytes.Buffer
if code := runRefreshManifest(nil, &out, &errb); code != 1 {
t.Fatalf("exit = %d, want 1", code)
}
if !strings.Contains(errb.String(), "not initialised") {
t.Fatalf("stderr missing hint:\n%s", errb.String())
}
}
added cmd/eeco/migrate.go
@@ -0,0 +1,238 @@
package main
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/hooks"
)
// runMigrate dispatches `eeco migrate v1 [--yes]`. v1 is the only supported
// target: it moves a legacy workspace (<repo>/.eeco) to the v1.0 per-user
// location (<repo>/<username>/.eeco). The grammar is tiny, so the two tokens
// (the `v1` target and the optional `--yes`/`-y`) are scanned by hand rather
// than through flag, which would stop parsing at the `v1` positional.
func runMigrate(args []string, stdout, stderr io.Writer) int {
const usage = "usage: eeco migrate v1 [--yes]"
assumeYes := false
sub := ""
for _, a := range args {
switch a {
case "--yes", "-y":
assumeYes = true
case "v1":
sub = a
default:
fmt.Fprintf(stderr, "eeco migrate: unexpected argument %q\n", a)
fmt.Fprintln(stderr, usage)
return 2
}
}
if sub != "v1" {
fmt.Fprintln(stderr, usage)
return 2
}
cfg, code := loadRepoConfig(stderr, "eeco migrate v1")
if code != 0 {
return code
}
code, err := migrateV1(cfg, initStdin, stdout, stderr, assumeYes)
if err != nil {
fmt.Fprintln(stderr, "eeco migrate v1:", err)
return 1
}
return code
}
// migrateV1 relocates a legacy workspace at <repo>/.eeco to the v1.0 per-user
// location cfg.Workspace (<repo>/<username>/.eeco), corrects the hook ledger's
// recorded in-workspace paths, and rewrites .gitignore (drop the legacy
// /.eeco/ entry, add /<username>/). It is idempotent: a tree already on the
// v1.0 layout, or one with no workspace at all, is a clean no-op.
//
// The git hook scripts and the session-start settings command are deliberately
// left untouched: each invokes the eeco binary and re-resolves the workspace
// from the repo + owner at run time, so moving the directory is enough to
// relocate every hook. The only absolute paths that point inside the workspace
// are the session-start backups recorded in the ledger; those are rewritten in
// place. Returns the process exit code; a non-nil error is an unexpected I/O
// failure mid-migration.
func migrateV1(cfg *config.Config, in io.Reader, out, errw io.Writer, assumeYes bool) (int, error) {
legacy := filepath.Join(cfg.RepoRoot, config.DefaultWorkspace)
target := cfg.Workspace
if legacy == target {
// Only possible if the per-user dir resolved to the repo root, which
// Load never does. Defensive: nothing to move.
fmt.Fprintln(out, "workspace is already at the v1.0 location — nothing to migrate.")
return 0, nil
}
legacyExists := isDir(legacy)
targetExists := isDir(target)
switch {
case !legacyExists && targetExists:
fmt.Fprintf(out, "already on the v1.0 layout — workspace at %s.\n", target)
return 0, nil
case !legacyExists && !targetExists:
fmt.Fprintf(out, "no legacy workspace at %s — nothing to migrate.\n", legacy)
return 0, nil
case legacyExists && targetExists:
fmt.Fprintf(errw, "both a legacy workspace (%s) and a v1.0 workspace (%s) exist.\n", legacy, target)
fmt.Fprintln(errw, "resolve this by hand (keep one, remove the other) before migrating.")
return 1, nil
}
fmt.Fprintln(out, "eeco migrate v1 will:")
fmt.Fprintf(out, " move %s\n -> %s\n", legacy, target)
fmt.Fprintf(out, " ignore add /%s/ and drop /%s/ in .gitignore\n", cfg.Username, config.DefaultWorkspace)
fmt.Fprintln(out, " relink correct recorded workspace paths in the hook ledger")
fmt.Fprintln(out, " rebake rewrite enabled hook scripts with the current eeco binary path")
if !assumeYes && !confirm(in, out, "proceed? [y/N]: ") {
fmt.Fprintln(out, "aborted — no changes made.")
return 1, nil
}
if err := os.MkdirAll(cfg.UserDir, 0o755); err != nil {
return 1, fmt.Errorf("create %s: %w", cfg.UserDir, err)
}
if err := os.Rename(legacy, target); err != nil {
return 1, fmt.Errorf("move workspace: %w", err)
}
if err := rewriteLedgerPaths(target, legacy); err != nil {
return 1, fmt.Errorf("fix hook ledger: %w", err)
}
if err := migrateGitignore(cfg.RepoRoot, config.DefaultWorkspace, cfg.Username); err != nil {
return 1, fmt.Errorf("update .gitignore: %w", err)
}
rebakeHooks(cfg, out, errw)
fmt.Fprintf(out, "migrated workspace to %s\n", target)
return 0, nil
}
// rebakeHooks re-renders every enabled hook script so its embedded eeco
// binary path matches what the running binary resolves today. The hook
// scripts bake an absolute binary path at install time, which goes stale
// after a `brew upgrade` (a moved cellar dir) or any other relocation;
// the workspace move is the natural moment to heal it. Each Refresh is a
// no-op when the hook is not enabled or already current. A refresh
// failure is a warning, never a migration failure — the move itself has
// already succeeded.
func rebakeHooks(cfg *config.Config, out, errw io.Writer) {
steps := []struct {
name string
fn func(*config.Config) (string, error)
}{
{"commit-msg", hooks.RefreshCommitMsg},
{"pre-commit", hooks.RefreshPreCommit},
{"post-merge", hooks.RefreshPostMerge},
{"session-start", hooks.RefreshSessionStart},
}
for _, s := range steps {
msg, err := s.fn(cfg)
if err != nil {
fmt.Fprintf(errw, "warning: refresh %s: %v\n", s.name, err)
continue
}
if msg != "" {
fmt.Fprintf(out, " %s\n", msg)
}
}
}
// confirm prints prompt and returns true only on an explicit y/yes. EOF or any
// other input is a no, so a piped, non-interactive caller never migrates by
// accident.
func confirm(in io.Reader, out io.Writer, prompt string) bool {
fmt.Fprint(out, prompt)
s := bufio.NewScanner(in)
if !s.Scan() {
return false
}
switch strings.ToLower(strings.TrimSpace(s.Text())) {
case "y", "yes":
return true
default:
return false
}
}
// rewriteLedgerPaths corrects the hook ledger after the workspace move. The
// ledger lives at <newWorkspace>/state/hooks.json; the only paths it records
// that point inside the workspace are the session-start backups, so replacing
// the old workspace prefix with the new one fixes them while leaving the
// repo-scoped .git/hooks/* paths and the external settings path untouched. A
// missing ledger (no hooks were ever wired) is a no-op.
func rewriteLedgerPaths(newWorkspace, oldWorkspace string) error {
// hooks.json / state are mirrored from internal/hooks (ledgerName); a
// migration tool intentionally hard-codes them rather than importing the
// hooks package just for two constants.
ledger := filepath.Join(newWorkspace, "state", "hooks.json")
b, err := os.ReadFile(ledger)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
fixed := strings.ReplaceAll(string(b), oldWorkspace, newWorkspace)
if fixed == string(b) {
return nil
}
return os.WriteFile(ledger, []byte(fixed), 0o644)
}
// migrateGitignore drops every line equivalent to the legacy workspace ignore
// (`.eeco`, `.eeco/`, `/.eeco`, `/.eeco/`) and ensures `/<username>/` is
// present. Comments and unrelated entries are preserved; the file keeps a
// single trailing newline. A missing .gitignore is created with just the new
// entry.
func migrateGitignore(repoRoot, legacyName, username string) error {
path := filepath.Join(repoRoot, ".gitignore")
b, err := os.ReadFile(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
legacyEquiv := map[string]bool{
legacyName: true, legacyName + "/": true,
"/" + legacyName: true, "/" + legacyName + "/": true,
}
want := "/" + username + "/"
wantEquiv := map[string]bool{
username: true, username + "/": true,
"/" + username: true, want: true,
}
var kept []string
hasWant := false
for _, raw := range strings.Split(string(b), "\n") {
line := strings.TrimSpace(raw)
if legacyEquiv[line] {
continue
}
if wantEquiv[line] {
hasWant = true
}
kept = append(kept, raw)
}
text := strings.TrimRight(strings.Join(kept, "\n"), "\n")
if !hasWant {
if text != "" {
text += "\n"
}
text += want
}
text += "\n"
return os.WriteFile(path, []byte(text), 0o644)
}
added cmd/eeco/migrate_test.go
@@ -0,0 +1,230 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/ajhahnde/eeco/internal/config"
)
// seedLegacyWorkspace builds a v2.x-shaped workspace at <root>/.eeco with the
// canonical subdirs, a .gitignore that ignores /.eeco/, and a hook ledger whose
// session-start backup path points inside the legacy workspace (the one path
// the move must rewrite).
func seedLegacyWorkspace(t *testing.T, root string) {
t.Helper()
legacy := filepath.Join(root, ".eeco")
for _, sub := range []string{"engine", "memory", "workflows", "state", "docs"} {
if err := os.MkdirAll(filepath.Join(legacy, sub), 0o755); err != nil {
t.Fatal(err)
}
}
backup := filepath.Join(legacy, "state", "backups", "settings.json.bak")
ledger := `{
"session_start": {
"installed": true,
"path": "/home/dev/.claude/settings.json",
"backup": "` + backup + `"
}
}`
writeFile(t, filepath.Join(legacy, "state"), "hooks.json", ledger)
writeFile(t, root, ".gitignore", "/.eeco/\nnode_modules/\n")
}
func loadCfg(t *testing.T, root string) *config.Config {
t.Helper()
cfg, err := config.Load(root, "")
if err != nil {
t.Fatal(err)
}
return cfg
}
func TestMigrateV1_MovesWorkspaceAndFixesState(t *testing.T) {
root := newGitRepo(t)
seedLegacyWorkspace(t, root)
cfg := loadCfg(t, root)
var out, errb bytes.Buffer
code, err := migrateV1(cfg, strings.NewReader("y\n"), &out, &errb, false)
if err != nil {
t.Fatalf("migrateV1: %v (stderr: %s)", err, errb.String())
}
if code != 0 {
t.Fatalf("code = %d, want 0 (stderr: %s)", code, errb.String())
}
// Workspace moved.
if isDir(filepath.Join(root, ".eeco")) {
t.Fatal("legacy workspace should be gone")
}
target := filepath.Join(root, "tester", ".eeco")
if !isDir(target) {
t.Fatalf("target workspace missing at %s", target)
}
// .gitignore: legacy entry dropped, per-user dir added, unrelated kept.
gi, err := os.ReadFile(filepath.Join(root, ".gitignore"))
if err != nil {
t.Fatal(err)
}
got := string(gi)
if strings.Contains(got, "/.eeco/") {
t.Fatalf(".gitignore still ignores the legacy workspace:\n%s", got)
}
if !strings.Contains(got, "/tester/") {
t.Fatalf(".gitignore missing /tester/:\n%s", got)
}
if !strings.Contains(got, "node_modules/") {
t.Fatalf(".gitignore dropped an unrelated entry:\n%s", got)
}
// Ledger backup path rewritten to the new workspace location.
lb, err := os.ReadFile(filepath.Join(target, "state", "hooks.json"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(lb), filepath.Join(root, ".eeco")+"/") {
t.Fatalf("ledger still references the old workspace path:\n%s", lb)
}
if !strings.Contains(string(lb), filepath.Join(target, "state", "backups")) {
t.Fatalf("ledger backup not rewritten to new location:\n%s", lb)
}
}
func TestMigrateV1_RebakesStaleHookBinaryPath(t *testing.T) {
root := newGitRepo(t)
seedLegacyWorkspace(t, root)
// A pre-commit hook installed by an older eeco bakes an absolute binary
// path; a later `brew upgrade` moves the cellar dir out from under it,
// leaving the hook pointing at a vanished binary. migrate is the moment
// to heal it — the hook lives in .git/hooks and does not move.
stale := "#!/bin/sh\n# eeco-managed-pre-commit-v1\nset -e\n" +
"EECO=\"/opt/homebrew/Cellar/eeco/2.0.0/bin/eeco\"\n\"$EECO\" run leak-guard\n"
writeFile(t, filepath.Join(root, ".git", "hooks"), "pre-commit", stale)
cfg := loadCfg(t, root)
var out, errb bytes.Buffer
code, err := migrateV1(cfg, strings.NewReader("y\n"), &out, &errb, false)
if err != nil || code != 0 {
t.Fatalf("migrateV1: code=%d err=%v (stderr: %s)", code, err, errb.String())
}
hook, err := os.ReadFile(filepath.Join(root, ".git", "hooks", "pre-commit"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(hook), "Cellar/eeco/2.0.0") {
t.Fatalf("migrate left the stale binary path in the pre-commit hook:\n%s", hook)
}
if !strings.Contains(out.String(), "pre-commit refreshed") {
t.Fatalf("migrate did not report the pre-commit re-bake:\n%s", out.String())
}
}
func TestMigrateV1_Idempotent(t *testing.T) {
root := newGitRepo(t)
seedLegacyWorkspace(t, root)
cfg := loadCfg(t, root)
var out, errb bytes.Buffer
if code, err := migrateV1(cfg, strings.NewReader("y\n"), &out, &errb, false); err != nil || code != 0 {
t.Fatalf("first migrate: code=%d err=%v", code, err)
}
// Second run: already on the v1.0 layout → clean no-op, no prompt needed.
out.Reset()
errb.Reset()
code, err := migrateV1(loadCfg(t, root), strings.NewReader(""), &out, &errb, false)
if err != nil || code != 0 {
t.Fatalf("second migrate: code=%d err=%v", code, err)
}
if !strings.Contains(out.String(), "already on the v1.0 layout") {
t.Fatalf("expected already-migrated message, got:\n%s", out.String())
}
}
func TestMigrateV1_RefusesOnConflict(t *testing.T) {
root := newGitRepo(t)
seedLegacyWorkspace(t, root)
// Also create a v1.0 workspace → both exist → refuse.
if err := os.MkdirAll(filepath.Join(root, "tester", ".eeco", "state"), 0o755); err != nil {
t.Fatal(err)
}
cfg := loadCfg(t, root)
var out, errb bytes.Buffer
code, err := migrateV1(cfg, strings.NewReader("y\n"), &out, &errb, true)
if err != nil {
t.Fatal(err)
}
if code != 1 {
t.Fatalf("code = %d, want 1", code)
}
if !strings.Contains(errb.String(), "both") {
t.Fatalf("stderr missing conflict message:\n%s", errb.String())
}
// Legacy must be left in place untouched.
if !isDir(filepath.Join(root, ".eeco")) {
t.Fatal("legacy workspace should be untouched on conflict")
}
}
func TestMigrateV1_NoLegacyIsNoop(t *testing.T) {
root := newGitRepo(t)
cfg := loadCfg(t, root)
var out, errb bytes.Buffer
code, err := migrateV1(cfg, strings.NewReader(""), &out, &errb, false)
if err != nil || code != 0 {
t.Fatalf("code=%d err=%v", code, err)
}
if !strings.Contains(out.String(), "nothing to migrate") {
t.Fatalf("expected nothing-to-migrate message, got:\n%s", out.String())
}
}
func TestMigrateV1_AbortLeavesTreeUntouched(t *testing.T) {
root := newGitRepo(t)
seedLegacyWorkspace(t, root)
cfg := loadCfg(t, root)
var out, errb bytes.Buffer
code, err := migrateV1(cfg, strings.NewReader("n\n"), &out, &errb, false)
if err != nil {
t.Fatal(err)
}
if code != 1 {
t.Fatalf("code = %d, want 1 on abort", code)
}
if !isDir(filepath.Join(root, ".eeco")) {
t.Fatal("declining must leave the legacy workspace in place")
}
if isDir(filepath.Join(root, "tester", ".eeco")) {
t.Fatal("declining must not create the target workspace")
}
}
func TestRunMigrate_RejectsUnknownTarget(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errb bytes.Buffer
if code := runMigrate([]string{"v2"}, &out, &errb); code != 2 {
t.Fatalf("code = %d, want 2 for unknown target", code)
}
}
func TestMigrateGitignore_CreatesWhenAbsent(t *testing.T) {
root := t.TempDir()
if err := migrateGitignore(root, ".eeco", "tester"); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(root, ".gitignore"))
if err != nil {
t.Fatal(err)
}
if string(b) != "/tester/\n" {
t.Fatalf("unexpected .gitignore:\n%q", string(b))
}
}
added cmd/eeco/new.go
@@ -0,0 +1,32 @@
package main
import (
"fmt"
"io"
"github.com/ajhahnde/eeco/internal/workflow"
)
func runNew(args []string, stdout, stderr io.Writer) int {
if len(args) != 1 {
fmt.Fprintln(stderr, "usage: eeco new <workflow>")
return 2
}
name := args[0]
cfg, code := loadInitedConfig(stderr, "eeco new")
if code != 0 {
return code
}
dir, err := workflow.Scaffold(cfg, name)
if err != nil {
fmt.Fprintln(stderr, "eeco new:", err)
return 1
}
maybeAutoCommit(cfg.WorkspaceHistory.Auto(), cfg.UserDir, "new "+name, stderr)
fmt.Fprintf(stdout, "eeco new: scaffolded workflow %q\n", name)
fmt.Fprintf(stdout, " %s\n", dir)
fmt.Fprintln(stdout, "next: edit run to implement the check, then `eeco run "+name+"`.")
return 0
}
added cmd/eeco/notes.go
@@ -0,0 +1,135 @@
package main
import (
"fmt"
"io"
"path/filepath"
"time"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/notes"
)
const addUsage = `usage:
eeco add note "<text>" append a free-form note to the workspace
eeco add fact ... record a durable memory fact in the store
eeco add task ... append an item to the workspace queue
A note is saved as one plain Markdown file under <workspace>/notes/; a
fact is a frontmatter file under <workspace>/memory/; a task is an item
in <workspace>/state/queue.md. Nothing is staged or committed
(Constraint 6). Run "eeco add fact" or "eeco add task" with no arguments
for the full flag list.`
const showUsage = `usage:
eeco show notes list the workspace notes, newest first
eeco show adaptations list the AI adaptation facts and their on/off state
eeco show prompt [name] list the canonical prompt library, or print one prompt body`
// runAdd dispatches `eeco add <object>`. Objects are `note` (a
// free-form workspace note), `fact` (a durable memory fact), and `task`
// (a workspace queue item); the verb is kept open so future
// `add <object>` forms can join without burning a flat top-level token.
func runAdd(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, addUsage)
return 2
}
switch args[0] {
case "note":
return runAddNote(args[1:], stdout, stderr)
case "fact":
return runAddFact(args[1:], stdout, stderr)
case "task":
return runAddTask(args[1:], stdout, stderr)
default:
fmt.Fprintf(stderr, "eeco add: unknown object %q\n", args[0])
fmt.Fprintln(stderr, addUsage)
return 2
}
}
// runShow dispatches `eeco show <object>`. Today the only object is
// `notes`.
func runShow(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, showUsage)
return 2
}
switch args[0] {
case "notes":
return runShowNotes(args[1:], stdout, stderr)
case "adaptations":
return runShowAdaptations(args[1:], stdout, stderr)
case "prompt":
return runShowPrompt(args[1:], stdout, stderr)
default:
fmt.Fprintf(stderr, "eeco show: unknown object %q\n", args[0])
fmt.Fprintln(stderr, showUsage)
return 2
}
}
// runAddNote handles `eeco add note "<text>"`. It resolves the
// workspace, creates <workspace>/notes/ lazily, and writes the note.
// The write target stays inside the workspace (Constraint 1).
func runAddNote(args []string, stdout, stderr io.Writer) int {
if len(args) != 1 {
fmt.Fprintln(stderr, addUsage)
return 2
}
cfg, notesDir, code := resolveNotesDir(stderr, "eeco add note")
if code != 0 {
return code
}
path, err := notes.Add(notesDir, args[0], time.Now())
if err != nil {
fmt.Fprintln(stderr, "eeco add note:", err)
return 1
}
maybeAutoCommit(cfg.WorkspaceHistory.Auto(), cfg.UserDir, "add note", stderr)
fmt.Fprintf(stdout, "wrote %s\n", path)
return 0
}
// runShowNotes handles `eeco show notes`, listing notes newest first.
func runShowNotes(args []string, stdout, stderr io.Writer) int {
if len(args) != 0 {
fmt.Fprintln(stderr, showUsage)
return 2
}
_, notesDir, code := resolveNotesDir(stderr, "eeco show notes")
if code != 0 {
return code
}
items, err := notes.List(notesDir)
if err != nil {
fmt.Fprintln(stderr, "eeco show notes:", err)
return 1
}
if len(items) == 0 {
fmt.Fprintln(stdout, "no notes yet — add one with `eeco add note \"...\"`")
return 0
}
for _, n := range items {
fmt.Fprintf(stdout, "%s %s\n", n.When.Local().Format("2006-01-02 15:04"), n.Summary)
}
return 0
}
// resolveNotesDir locates <workspace>/notes/ for the current repo. It
// returns the resolved config, the notes dir, and a zero code on success;
// otherwise it prints a prefixed error and returns the exit code the caller
// should propagate. The config is returned so a mutating caller
// (runAddNote) can drive maybeAutoCommit; a read-only caller (runShowNotes)
// discards it.
func resolveNotesDir(stderr io.Writer, prefix string) (*config.Config, string, int) {
// Repo-guard only — notes work before `eeco init`, so this must use
// loadRepoConfig, never loadInitedConfig (an init-guard here would
// regress `add note` / `show notes` in an uninitialised repo).
cfg, code := loadRepoConfig(stderr, prefix)
if code != 0 {
return nil, "", code
}
return cfg, filepath.Join(cfg.Workspace, "notes"), 0
}
added cmd/eeco/notes_test.go
@@ -0,0 +1,141 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestRunAddNote_WritesIntoWorkspace(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
code := runAdd([]string{"note", "scratch this idea"}, &out, &errOut)
if code != 0 {
t.Fatalf("add note exit=%d stderr=%s", code, errOut.String())
}
notesDir := filepath.Join(root, "tester", ".eeco", "notes")
entries, err := os.ReadDir(notesDir)
if err != nil {
t.Fatalf("notes dir not created: %v", err)
}
if len(entries) != 1 {
t.Fatalf("got %d note files, want 1", len(entries))
}
// Constraint 1: the write stays inside the workspace.
written := filepath.Join(notesDir, entries[0].Name())
if !strings.HasPrefix(written, filepath.Join(root, "tester", ".eeco")+string(os.PathSeparator)) {
t.Errorf("note written outside the workspace: %s", written)
}
body, err := os.ReadFile(written)
if err != nil {
t.Fatal(err)
}
if string(body) != "scratch this idea" {
t.Errorf("body = %q, want verbatim text", string(body))
}
if !strings.Contains(out.String(), "wrote ") {
t.Errorf("stdout missing wrote confirmation:\n%s", out.String())
}
}
func TestRunAddNote_MissingText(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
code := runAdd([]string{"note"}, &out, &errOut)
if code != 2 {
t.Fatalf("add note with no text exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), "usage:") {
t.Errorf("stderr missing usage:\n%s", errOut.String())
}
}
func TestRunAdd_UnknownObject(t *testing.T) {
var out, errOut bytes.Buffer
code := runAdd([]string{"widget"}, &out, &errOut)
if code != 2 {
t.Fatalf("add widget exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), `unknown object "widget"`) {
t.Errorf("stderr should name the unknown object:\n%s", errOut.String())
}
}
func TestRunAdd_NoObject(t *testing.T) {
var out, errOut bytes.Buffer
code := runAdd(nil, &out, &errOut)
if code != 2 {
t.Fatalf("add with no object exit=%d, want 2", code)
}
}
func TestRunAddNote_NotInRepo(t *testing.T) {
chdir(t, t.TempDir()) // no .git anywhere up the tree
var out, errOut bytes.Buffer
code := runAdd([]string{"note", "x"}, &out, &errOut)
if code != 1 {
t.Fatalf("add note outside a repo exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "not inside a git repository") {
t.Errorf("stderr missing not-in-repo message:\n%s", errOut.String())
}
}
func TestRunShowNotes_NewestFirst(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
// Two notes with explicit stamped filenames so ordering is
// deterministic regardless of write speed.
notesDir := filepath.Join(root, "tester", ".eeco", "notes")
if err := os.MkdirAll(notesDir, 0o755); err != nil {
t.Fatal(err)
}
writeFile(t, notesDir, "2026-05-21-091100-older.md", "older note")
writeFile(t, notesDir, "2026-05-22-140300-newer.md", "newer note")
var out, errOut bytes.Buffer
code := runShow([]string{"notes"}, &out, &errOut)
if code != 0 {
t.Fatalf("show notes exit=%d stderr=%s", code, errOut.String())
}
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
if len(lines) != 2 {
t.Fatalf("got %d lines, want 2:\n%s", len(lines), out.String())
}
if !strings.Contains(lines[0], "newer note") || !strings.Contains(lines[1], "older note") {
t.Errorf("not newest-first:\n%s", out.String())
}
}
func TestRunShowNotes_Empty(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
code := runShow([]string{"notes"}, &out, &errOut)
if code != 0 {
t.Fatalf("show notes (empty) exit=%d, want 0", code)
}
if !strings.Contains(out.String(), "no notes yet") {
t.Errorf("stdout missing empty-state line:\n%s", out.String())
}
}
func TestRunShow_UnknownObject(t *testing.T) {
var out, errOut bytes.Buffer
code := runShow([]string{"widgets"}, &out, &errOut)
if code != 2 {
t.Fatalf("show widgets exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), `unknown object "widgets"`) {
t.Errorf("stderr should name the unknown object:\n%s", errOut.String())
}
}
added cmd/eeco/queue.go
@@ -0,0 +1,55 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/ajhahnde/eeco/internal/queue"
)
// runQueue backs `eeco queue`: it prints the workspace queue — eeco's
// single decision channel — at the CLI, mirroring the TUI's `/queue`
// command for non-TTY use. It is strictly read-only: the queue file is
// owned by the user, so resolving an item is a hand-edit of the
// checklist (tick `[ ]` → `[x]`, or delete the row). The closing hint
// names that path so a first-time user is not left guessing.
func runQueue(args []string, stdout, stderr io.Writer) int {
if len(args) > 0 {
fmt.Fprintln(stderr, "usage: eeco queue")
fmt.Fprintln(stderr, "eeco queue takes no arguments; it prints the workspace queue.")
return 2
}
cfg, code := loadInitedConfig(stderr, "eeco queue")
if code != 0 {
return code
}
stateDir := filepath.Join(cfg.Workspace, "state")
path := filepath.Join(stateDir, queue.Filename)
b, err := os.ReadFile(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
fmt.Fprintln(stderr, "eeco queue:", err)
return 1
}
if errors.Is(err, os.ErrNotExist) || len(strings.TrimSpace(string(b))) == 0 {
fmt.Fprintln(stdout, "queue: empty — nothing needs a decision")
return 0
}
n, err := queue.Count(stateDir)
if err != nil {
fmt.Fprintln(stderr, "eeco queue:", err)
return 1
}
fmt.Fprintf(stdout, "queue: %d open\n", n)
for _, ln := range strings.Split(strings.TrimRight(string(b), "\n"), "\n") {
fmt.Fprintln(stdout, " "+ln)
}
fmt.Fprintf(stdout, "resolve: tick `[ ]` → `[x]` (or delete the row) in %s\n", path)
return 0
}
added cmd/eeco/queue_test.go
@@ -0,0 +1,91 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestRunQueue_Empty(t *testing.T) {
root := initRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
code := runQueue(nil, &out, &errOut)
if code != 0 {
t.Fatalf("queue (empty) exit=%d stderr=%s", code, errOut.String())
}
if !strings.Contains(out.String(), "queue: empty") {
t.Errorf("stdout missing empty-queue message:\n%s", out.String())
}
}
func TestRunQueue_ListsItemsAndResolveHint(t *testing.T) {
root := initRepo(t)
chdir(t, root)
// Seed the queue through the supported write path.
var seedOut, seedErr bytes.Buffer
if code := runAdd([]string{"task", "ship the man page"}, &seedOut, &seedErr); code != 0 {
t.Fatalf("seed add task exit=%d stderr=%s", code, seedErr.String())
}
var out, errOut bytes.Buffer
code := runQueue(nil, &out, &errOut)
if code != 0 {
t.Fatalf("queue exit=%d stderr=%s", code, errOut.String())
}
got := out.String()
if !strings.Contains(got, "queue: 1 open") {
t.Errorf("stdout missing open count:\n%s", got)
}
if !strings.Contains(got, "ship the man page") {
t.Errorf("stdout missing the queued item:\n%s", got)
}
// The resolve affordance is a hand-edit of the user-owned file; the
// command must name that path since there is no resolve verb.
if !strings.Contains(got, "resolve:") || !strings.Contains(got, "queue.md") {
t.Errorf("stdout missing the resolve hint pointing at queue.md:\n%s", got)
}
}
func TestRunQueue_RejectsArgs(t *testing.T) {
root := initRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
code := runQueue([]string{"resolve"}, &out, &errOut)
if code != 2 {
t.Fatalf("queue with an argument exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), "usage:") {
t.Errorf("stderr missing usage:\n%s", errOut.String())
}
}
func TestRunQueue_NotInRepo(t *testing.T) {
chdir(t, t.TempDir()) // no .git anywhere up the tree
var out, errOut bytes.Buffer
code := runQueue(nil, &out, &errOut)
if code != 1 {
t.Fatalf("queue outside a repo exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "not inside a git repository") {
t.Errorf("stderr missing not-in-repo message:\n%s", errOut.String())
}
}
func TestRunQueue_NotInitialized(t *testing.T) {
root := newGitRepo(t) // a git repo, but no `eeco init`
chdir(t, root)
var out, errOut bytes.Buffer
code := runQueue(nil, &out, &errOut)
if code != 1 {
t.Fatalf("queue in uninitialised repo exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "not initialised") {
t.Errorf("stderr missing not-initialised message:\n%s", errOut.String())
}
}
added cmd/eeco/report_bug.go
@@ -0,0 +1,393 @@
package main
import (
"errors"
"fmt"
"io"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/ajhahnde/eeco/internal/config"
)
const reportBugUsage = `usage:
eeco report-bug [--note "<text>"] [--cmd "<eeco invocation>"] [--submit]
--note a short description of what surfaced the friction (optional)
--cmd the eeco invocation that triggered it, verbatim (optional)
--submit open the pre-filled issue URL in your browser (you still
review and click Submit — nothing is sent automatically)
Writes a single Markdown record for the current friction. When run
inside an initialised eeco workspace the record lands in
<workspace>/<bug_report_dir>/ (default bug-reports/); otherwise it lands
in ~/.eeco/bug-reports/ so the command works even on a fresh install.
Prints a pre-filled GitHub Issues URL on stdout so anyone can file the
report upstream in one click. Nothing is sent automatically.`
// fileTimestampLayout is a filesystem-safe variant of RFC 3339 (UTC,
// no colons). It sorts correctly when listed by name.
const fileTimestampLayout = "20060102T150405Z"
// homeReportSubpath is the per-user bug-report directory used when no
// in-repo workspace is available. It mirrors the in-repo workspace
// name (DefaultWorkspace, ".eeco") so the two forms are recognisably
// the same shape.
const homeReportSubpath = ".eeco/bug-reports"
// issuesNewBase is the upstream new-issue endpoint. Pre-filling
// `title` and `body` opens the GitHub issue form with everything ready
// so the filer only has to click Submit.
const issuesNewBase = "https://github.com/ajhahnde/eeco/issues/new"
// issueBodyURLCap leaves headroom for a long title and the rest of the
// URL underneath the GitHub server-side request limit (8 KB-ish). A
// record longer than this is filed locally in full and truncated in
// the URL with a pointer back to the on-disk path.
const issueBodyURLCap = 6000
// runReportBug captures friction into a Markdown record. The
// destination is the in-repo workspace bug-reports directory when one
// exists; otherwise the per-user ~/.eeco/bug-reports/ fallback so a
// fresh-install user with no eeco init can still file a bug. The
// version is passed in by main so the main-package var stays
// unshared.
func runReportBug(args []string, version string, stdout, stderr io.Writer) int {
fs := newFlagSet("report-bug", stderr, reportBugUsage)
noteArg := fs.String("note", "", "a short description of the friction")
cmdArg := fs.String("cmd", "", "the eeco invocation that surfaced it")
submit := fs.Bool("submit", false, "open the pre-filled issue URL in your browser (you still review and click Submit)")
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() > 0 {
fmt.Fprintln(stderr, reportBugUsage)
return 2
}
dest, err := resolveReportDestination()
if err != nil {
fmt.Fprintln(stderr, "eeco report-bug:", err)
return 1
}
if err := os.MkdirAll(dest.dir, 0o755); err != nil {
fmt.Fprintln(stderr, "eeco report-bug:", err)
return 1
}
now := time.Now().UTC()
slug := slugify(*noteArg)
base := fmt.Sprintf("%s-%s.md", now.Format(fileTimestampLayout), slug)
path := uniqueRecordPath(filepath.Join(dest.dir, base))
body := buildBugRecord(dest, version, *noteArg, *cmdArg, bugEnvSnapshot(), now)
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
fmt.Fprintln(stderr, "eeco report-bug: write record:", err)
return 1
}
// dest.cfg is non-nil only for the in-workspace flavour; the per-user
// fallback writes outside any workspace, so there is no private repo to
// commit to (and cfg.UserDir would be unavailable).
if dest.cfg != nil {
maybeAutoCommit(dest.cfg.WorkspaceHistory.Auto(), dest.cfg.UserDir, "report-bug", stderr)
}
displayPath := path
if dest.cfg != nil {
if r, rerr := filepath.Rel(dest.cfg.RepoRoot, path); rerr == nil {
displayPath = filepath.ToSlash(r)
}
} else if home, herr := os.UserHomeDir(); herr == nil {
if r, rerr := filepath.Rel(home, path); rerr == nil {
displayPath = "~/" + filepath.ToSlash(r)
}
}
shareURL := buildIssueURL(*noteArg, body)
fmt.Fprintf(stdout, "wrote: %s\n", displayPath)
if !dest.inWorkspace {
fmt.Fprintln(stdout, " (no eeco workspace here — used the per-user fallback)")
}
fmt.Fprintf(stdout, "share: open this URL to file the record as an issue:\n %s\n", shareURL)
fmt.Fprintln(stdout, " (or attach the file by hand if the URL is too long for your browser)")
if *submit {
if err := submitURL(shareURL); err != nil {
fmt.Fprintln(stdout, "submit: couldn't open a browser automatically — open the URL above by hand.")
} else {
fmt.Fprintln(stdout, "opened: your browser at the pre-filled issue — review and click Submit there.")
}
}
// Opening a pre-filled *form* is assisted-manual, not auto-send, so
// this reassurance stays literally true even with --submit.
fmt.Fprintln(stdout, "sent: nothing has been sent automatically.")
return 0
}
// submitURL opens a URL in the user's browser. It is a package-var seam
// so tests can substitute a fake without launching a real browser
// (mirrors internal/clip's runner seam and init.go's initTrackHistory).
var submitURL = openBrowser
// openBrowser is the production submitURL: a thin best-effort shell-out
// to the platform's URL opener. A non-empty $BROWSER wins (it also lets
// a manual gate point the opener at `echo`); otherwise the OS default
// handler is used. Failure is reported to the caller, which degrades to
// printing the URL — `eeco report-bug` never fails because a browser
// could not be launched.
func openBrowser(rawURL string) error {
if b := strings.TrimSpace(os.Getenv("BROWSER")); b != "" {
// #nosec G204 — b is an operator-set browser command and rawURL is
// a program-built GitHub Issues URL; neither is attacker-controlled
// and no shell is involved (explicit argv).
return exec.Command(b, rawURL).Run()
}
switch runtime.GOOS {
case "darwin":
return exec.Command("open", rawURL).Run()
case "windows":
return exec.Command("cmd", "/c", "start", "", rawURL).Run()
default: // linux, *bsd
return exec.Command("xdg-open", rawURL).Run()
}
}
// reportDestination resolves where a record goes for the current call:
// in a workspace, or the per-user fallback. cfg is non-nil only when
// the writer is the in-repo workspace flavour.
type reportDestination struct {
dir string
inWorkspace bool
cfg *config.Config
}
// resolveReportDestination picks the in-workspace path when one is
// available and falls back to the per-user ~/.eeco/bug-reports/
// directory otherwise. Either form is a valid eeco-owned location;
// neither writes into a target repo's tracked tree.
func resolveReportDestination() (reportDestination, error) {
cwd, err := os.Getwd()
if err != nil {
return reportDestination{}, err
}
cfg, lerr := config.Load(cwd, config.DefaultWorkspace)
if lerr == nil && config.IsInitialized(cfg) {
return reportDestination{
dir: filepath.Join(cfg.Workspace, filepath.FromSlash(cfg.BugReportDir)),
inWorkspace: true,
cfg: cfg,
}, nil
}
if lerr != nil && !errors.Is(lerr, config.ErrNotInRepo) {
return reportDestination{}, lerr
}
home, err := os.UserHomeDir()
if err != nil {
return reportDestination{}, fmt.Errorf("locate home dir: %w", err)
}
return reportDestination{
dir: filepath.Join(home, filepath.FromSlash(homeReportSubpath)),
inWorkspace: false,
}, nil
}
// uniqueRecordPath returns path, or path with a numeric suffix when
// path already exists, so a prior record is never overwritten.
func uniqueRecordPath(path string) string {
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
return path
}
dir, base := filepath.Split(path)
ext := filepath.Ext(base)
stem := strings.TrimSuffix(base, ext)
for i := 2; ; i++ {
cand := filepath.Join(dir, fmt.Sprintf("%s-%d%s", stem, i, ext))
if _, err := os.Stat(cand); errors.Is(err, os.ErrNotExist) {
return cand
}
}
}
// buildBugRecord renders the Markdown body of one bug report. dest
// supplies the workspace/profile context when it exists; for the
// per-user fallback those rows are labelled "(none — no workspace)".
func buildBugRecord(dest reportDestination, version, note, invokedCmd string, env map[string]string, now time.Time) string {
var b strings.Builder
fmt.Fprintf(&b, "# eeco bug report — %s\n\n", now.Format(time.RFC3339))
fmt.Fprintf(&b, "**eeco version:** %s\n", version)
if dest.cfg != nil {
relWorkspace := dest.cfg.WorkspaceName
if r, err := filepath.Rel(dest.cfg.RepoRoot, dest.cfg.Workspace); err == nil {
relWorkspace = filepath.ToSlash(r)
}
fmt.Fprintf(&b, "**profile:** %s\n", dest.cfg.Profile)
fmt.Fprintf(&b, "**workspace:** %s\n\n", relWorkspace)
} else {
b.WriteString("**profile:** (none — no workspace)\n")
b.WriteString("**workspace:** (none — no workspace)\n\n")
}
b.WriteString("## What happened\n\n")
if strings.TrimSpace(note) == "" {
b.WriteString("(none provided)\n\n")
} else {
b.WriteString(note)
if !strings.HasSuffix(note, "\n") {
b.WriteString("\n")
}
b.WriteString("\n")
}
b.WriteString("## Invoking command\n\n")
if strings.TrimSpace(invokedCmd) == "" {
b.WriteString("(not provided)\n\n")
} else {
fmt.Fprintf(&b, " %s\n\n", invokedCmd)
}
b.WriteString("## Environment\n\n")
keys := make([]string, 0, len(env))
for k := range env {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(&b, "- %s: %s\n", k, env[k])
}
b.WriteString("\n")
b.WriteString("## How to share this\n\n")
b.WriteString("This record lives only on your machine until you file it.\n")
b.WriteString("`eeco report-bug` printed a pre-filled GitHub Issues URL when\n")
b.WriteString("it wrote this file; open it in a browser and submit, or paste\n")
b.WriteString("this body into a new issue at\n")
b.WriteString("https://github.com/ajhahnde/eeco/issues by hand.\n")
b.WriteString("Nothing has been sent automatically.\n")
return b.String()
}
// bugEnvSnapshot captures a whitelisted environment slice for the
// record. A full os.Environ() dump would risk leaking secrets into a
// file that will travel with the bug report; this whitelist stays
// narrow on purpose.
func bugEnvSnapshot() map[string]string {
out := map[string]string{
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"go": runtime.Version(),
}
for _, k := range []string{"SHELL", "TERM"} {
if v := os.Getenv(k); v != "" {
out[k] = v
}
}
for _, raw := range os.Environ() {
k, v, ok := strings.Cut(raw, "=")
if !ok || !strings.HasPrefix(k, "EECO_") {
continue
}
out[k] = v
}
return out
}
// slugify reduces a free-form note to a short filename-safe stem. The
// result is at most slugMaxRunes runes; runs of non-`[a-z0-9]` collapse
// to `-`; leading and trailing `-` are trimmed. An empty or all-
// punctuation note yields the fallback "report" so the filename stays
// well-formed.
func slugify(note string) string {
const slugMaxRunes = 30
var b strings.Builder
dash := false
count := 0
for _, r := range note {
if count >= slugMaxRunes {
break
}
lr := unicode.ToLower(r)
if (lr >= 'a' && lr <= 'z') || (lr >= '0' && lr <= '9') {
b.WriteRune(lr)
dash = false
count++
continue
}
if !dash && b.Len() > 0 {
b.WriteRune('-')
dash = true
count++
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
return "report"
}
return out
}
// buildIssueURL constructs a pre-filled GitHub Issues new-issue URL so
// the filer reaches the eeco issue form with title and body ready. The
// body is truncated past issueBodyURLCap with a pointer back to the
// on-disk record so an over-long URL never breaks the click-through.
func buildIssueURL(note, body string) string {
title := issueTitle(note)
urlBody := body
if utf8.RuneCountInString(urlBody) > issueBodyURLCap {
// Trim by runes to avoid splitting a multi-byte sequence.
var b strings.Builder
count := 0
for _, r := range urlBody {
if count >= issueBodyURLCap {
break
}
b.WriteRune(r)
count++
}
urlBody = b.String() + "\n\n... (truncated for URL; full record is in your local file)\n"
}
u, err := url.Parse(issuesNewBase)
if err != nil {
return issuesNewBase
}
q := u.Query()
q.Set("title", title)
q.Set("body", urlBody)
u.RawQuery = q.Encode()
return u.String()
}
// issueTitle picks a short, sane title for the pre-filled URL. If the
// operator provided a note, the first non-empty line becomes the title
// (capped to issueTitleMaxRunes runes with an ellipsis); otherwise we
// use a generic "eeco report-bug" placeholder so the form is never
// untitled.
func issueTitle(note string) string {
const issueTitleMaxRunes = 80
for line := range strings.SplitSeq(strings.TrimSpace(note), "\n") {
t := strings.TrimSpace(line)
if t == "" {
continue
}
if utf8.RuneCountInString(t) <= issueTitleMaxRunes {
return t
}
var b strings.Builder
count := 0
for _, r := range t {
if count >= issueTitleMaxRunes-3 {
break
}
b.WriteRune(r)
count++
}
return b.String() + "..."
}
return "eeco report-bug"
}
added cmd/eeco/report_bug_test.go
@@ -0,0 +1,456 @@
package main
import (
"bytes"
"errors"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
const testVersion = "1.2.0-test"
// isolateHome points os.UserHomeDir at a per-test temp directory so the
// per-user fallback path never collides with the real user's
// ~/.eeco/bug-reports/. Call it from every test in this file.
func isolateHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home) // Windows
return home
}
func TestSlugify(t *testing.T) {
cases := []struct {
in string
want string
}{
{"", "report"},
{" ", "report"},
{"!!! ???", "report"},
{"Hello World", "hello-world"},
{" leading and trailing ", "leading-and-trailing"},
{"Mixed/Case & Punct!", "mixed-case-punct"},
{"abcdefghijklmnopqrstuvwxyz12345678", "abcdefghijklmnopqrstuvwxyz1234"},
{"python3 -m compileall -q .", "python3-m-compileall-q"},
}
for _, tc := range cases {
got := slugify(tc.in)
if got != tc.want {
t.Errorf("slugify(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestIssueTitle(t *testing.T) {
cases := []struct {
in string
want string
}{
{"", "eeco report-bug"},
{" \n\n ", "eeco report-bug"},
{"short title", "short title"},
{"first line\nsecond line", "first line"},
{strings.Repeat("a", 100), strings.Repeat("a", 77) + "..."},
}
for _, tc := range cases {
got := issueTitle(tc.in)
if got != tc.want {
t.Errorf("issueTitle(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestRunReportBug_HappyPath_InWorkspace(t *testing.T) {
isolateHome(t)
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
var out, errOut bytes.Buffer
args := []string{"--note", "leak-guard tripped on an unrelated tracked file", "--cmd", "eeco run leak-guard"}
code := runReportBug(args, testVersion, &out, &errOut)
if code != 0 {
t.Fatalf("runReportBug exit=%d stderr=%s", code, errOut.String())
}
entries, err := os.ReadDir(wsPath(root, "bug-reports"))
if err != nil {
t.Fatalf("bug-reports dir missing: %v", err)
}
if len(entries) != 1 {
t.Fatalf("expected 1 record, got %d", len(entries))
}
body, err := os.ReadFile(wsPath(root, "bug-reports", entries[0].Name()))
if err != nil {
t.Fatal(err)
}
bodyStr := string(body)
for _, want := range []string{
"# eeco bug report —",
"**eeco version:** " + testVersion,
"**profile:** ",
"## What happened",
"leak-guard tripped on an unrelated tracked file",
"## Invoking command",
" eeco run leak-guard",
"## Environment",
"- os: ",
"- arch: ",
"- go: ",
"## How to share this",
"https://github.com/ajhahnde/eeco/issues",
"Nothing has been sent automatically.",
} {
if !strings.Contains(bodyStr, want) {
t.Errorf("record missing %q:\n%s", want, bodyStr)
}
}
outStr := out.String()
if !strings.Contains(outStr, "wrote:") {
t.Errorf("stdout missing wrote line:\n%s", outStr)
}
if !strings.Contains(outStr, "https://github.com/ajhahnde/eeco/issues/new?") {
t.Errorf("stdout should print a pre-filled GitHub Issues URL:\n%s", outStr)
}
if !strings.Contains(outStr, "nothing has been sent") {
t.Errorf("stdout missing no-send reassurance:\n%s", outStr)
}
if strings.Contains(outStr, "no eeco workspace here") {
t.Errorf("workspace flavour should not flag the per-user fallback:\n%s", outStr)
}
}
func TestRunReportBug_HappyPath_NoRepoFallback(t *testing.T) {
home := isolateHome(t)
dir := t.TempDir()
chdir(t, dir)
var out, errOut bytes.Buffer
code := runReportBug([]string{"--note", "fresh install"}, testVersion, &out, &errOut)
if code != 0 {
t.Fatalf("runReportBug exit=%d stderr=%s", code, errOut.String())
}
fallback := filepath.Join(home, ".eeco", "bug-reports")
entries, err := os.ReadDir(fallback)
if err != nil {
t.Fatalf("home fallback dir missing: %v", err)
}
if len(entries) != 1 {
t.Fatalf("expected 1 record in home fallback, got %d", len(entries))
}
outStr := out.String()
if !strings.Contains(outStr, "no eeco workspace here") {
t.Errorf("stdout should flag the per-user fallback:\n%s", outStr)
}
if !strings.Contains(outStr, "https://github.com/ajhahnde/eeco/issues/new?") {
t.Errorf("stdout should print a pre-filled GitHub Issues URL:\n%s", outStr)
}
body, _ := os.ReadFile(filepath.Join(fallback, entries[0].Name()))
if !strings.Contains(string(body), "(none — no workspace)") {
t.Errorf("record should mark profile/workspace as absent:\n%s", body)
}
}
func TestRunReportBug_HappyPath_NoWorkspaceFallback(t *testing.T) {
// Inside a git repo but `eeco init` was never run. The command
// should still produce a record — in the home fallback, not the
// repo tree (Constraint 1).
home := isolateHome(t)
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
code := runReportBug(nil, testVersion, &out, &errOut)
if code != 0 {
t.Fatalf("runReportBug exit=%d stderr=%s", code, errOut.String())
}
entries, err := os.ReadDir(filepath.Join(home, ".eeco", "bug-reports"))
if err != nil {
t.Fatalf("home fallback dir missing: %v", err)
}
if len(entries) != 1 {
t.Fatalf("expected 1 record in home fallback, got %d", len(entries))
}
if _, err := os.Stat(filepath.Join(root, "tester", ".eeco")); err == nil {
t.Errorf(".eeco/ must not appear in the repo tree when no workspace exists yet")
}
if !strings.Contains(out.String(), "no eeco workspace here") {
t.Errorf("stdout should flag the per-user fallback:\n%s", out.String())
}
}
func TestRunReportBug_EmptyNoteAndCmd(t *testing.T) {
isolateHome(t)
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
var out bytes.Buffer
code := runReportBug(nil, testVersion, &out, &bytes.Buffer{})
if code != 0 {
t.Fatalf("runReportBug exit=%d", code)
}
entries, err := os.ReadDir(wsPath(root, "bug-reports"))
if err != nil || len(entries) != 1 {
t.Fatalf("expected exactly one record, got %d entries (err=%v)", len(entries), err)
}
body, _ := os.ReadFile(wsPath(root, "bug-reports", entries[0].Name()))
bodyStr := string(body)
if !strings.Contains(bodyStr, "(none provided)") {
t.Errorf("empty note should show (none provided):\n%s", bodyStr)
}
if !strings.Contains(bodyStr, "(not provided)") {
t.Errorf("empty cmd should show (not provided):\n%s", bodyStr)
}
if !strings.HasSuffix(entries[0].Name(), "-report.md") {
t.Errorf("empty note should fall back to slug 'report'; got filename %q", entries[0].Name())
}
}
func TestRunReportBug_UnknownFlag(t *testing.T) {
isolateHome(t)
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
var errOut bytes.Buffer
code := runReportBug([]string{"--bogus", "x"}, testVersion, &bytes.Buffer{}, &errOut)
if code != 2 {
t.Fatalf("expected exit 2 for bad flag, got %d", code)
}
}
func TestRunReportBug_DoesNotOverwriteExistingRecord(t *testing.T) {
isolateHome(t)
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
args := []string{"--note", "same note"}
if code := runReportBug(args, testVersion, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("first invocation failed")
}
if code := runReportBug(args, testVersion, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("second invocation failed")
}
entries, err := os.ReadDir(wsPath(root, "bug-reports"))
if err != nil {
t.Fatal(err)
}
if len(entries) != 2 {
t.Fatalf("expected 2 records (no overwrite), got %d", len(entries))
}
}
func TestRunReportBug_CustomBugReportDir(t *testing.T) {
isolateHome(t)
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
writeFile(t, root, filepath.Join("tester", ".eeco", "config.local"), "bug_report_dir=my-bugs\n")
var out bytes.Buffer
code := runReportBug([]string{"--note", "custom dir"}, testVersion, &out, &bytes.Buffer{})
if code != 0 {
t.Fatalf("runReportBug exit=%d", code)
}
if _, err := os.Stat(wsPath(root, "my-bugs")); err != nil {
t.Fatalf("custom bug-report dir not created: %v", err)
}
entries, _ := os.ReadDir(wsPath(root, "my-bugs"))
if len(entries) != 1 {
t.Fatalf("expected 1 record in custom dir, got %d", len(entries))
}
if _, err := os.Stat(wsPath(root, "bug-reports")); !os.IsNotExist(err) {
t.Errorf("default bug-reports dir should not have been created when override is set")
}
}
func TestBuildIssueURL_PrefillsTitleAndBody(t *testing.T) {
got := buildIssueURL("first line\nsecond line", "## body\nhello\n")
u, err := url.Parse(got)
if err != nil {
t.Fatalf("buildIssueURL produced unparseable URL %q: %v", got, err)
}
if u.Host != "github.com" || u.Path != "/ajhahnde/eeco/issues/new" {
t.Errorf("unexpected URL host/path: %s", got)
}
q := u.Query()
if q.Get("title") != "first line" {
t.Errorf("title = %q, want %q", q.Get("title"), "first line")
}
if !strings.Contains(q.Get("body"), "## body") {
t.Errorf("body should round-trip the record, got: %q", q.Get("body"))
}
}
func TestBuildIssueURL_TruncatesOversizeBody(t *testing.T) {
huge := strings.Repeat("x", issueBodyURLCap+500)
u, err := url.Parse(buildIssueURL("note", huge))
if err != nil {
t.Fatal(err)
}
body := u.Query().Get("body")
if len(body) > issueBodyURLCap+200 {
t.Errorf("oversize body should be truncated; got len=%d", len(body))
}
if !strings.Contains(body, "(truncated for URL") {
t.Errorf("truncation should be flagged in the URL body")
}
}
func TestRunReportBug_StaysOutsideRepoTree(t *testing.T) {
// Constraint §1 — even in the home-fallback case, the command
// must never write into the target repo's tracked tree.
home := isolateHome(t)
root := newGitRepo(t)
chdir(t, root)
if code := runReportBug([]string{"--note", "no init here"}, testVersion, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("runReportBug failed")
}
if entries, _ := os.ReadDir(root); len(entries) != 1 {
t.Errorf("repo root should only contain .git/ after a no-workspace report, got %d entries", len(entries))
}
if _, err := os.Stat(filepath.Join(home, ".eeco", "bug-reports")); err != nil {
t.Errorf("home fallback should hold the record: %v", err)
}
}
// fakeSubmit replaces the submitURL browser-open seam with a recorder
// for the duration of one test, restoring it via t.Cleanup (mirrors
// internal/clip's runner seam).
type fakeSubmit struct {
calls int
url string
err error
}
func (f *fakeSubmit) install(t *testing.T) {
t.Helper()
prev := submitURL
submitURL = func(u string) error {
f.calls++
f.url = u
return f.err
}
t.Cleanup(func() { submitURL = prev })
}
func TestRunReportBug_Submit_OpensBrowser(t *testing.T) {
isolateHome(t)
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
var fake fakeSubmit
fake.install(t)
var out, errOut bytes.Buffer
code := runReportBug([]string{"--note", "x", "--submit"}, testVersion, &out, &errOut)
if code != 0 {
t.Fatalf("runReportBug exit=%d stderr=%s", code, errOut.String())
}
if fake.calls != 1 {
t.Fatalf("submit seam called %d times, want 1", fake.calls)
}
if !strings.HasPrefix(fake.url, "https://github.com/ajhahnde/eeco/issues/new?") {
t.Errorf("seam got URL %q, want the pre-filled issues URL", fake.url)
}
outStr := out.String()
// The seam receives the exact URL printed on the share line.
if !strings.Contains(outStr, fake.url) {
t.Errorf("seam URL %q not the one printed to the user:\n%s", fake.url, outStr)
}
if !strings.Contains(outStr, "opened:") {
t.Errorf("stdout missing opened line:\n%s", outStr)
}
if !strings.Contains(outStr, "sent: nothing has been sent automatically.") {
t.Errorf("stdout must keep the verbatim no-send reassurance:\n%s", outStr)
}
}
func TestRunReportBug_Submit_OpenFailureStillExitsZero(t *testing.T) {
isolateHome(t)
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
fake := fakeSubmit{err: errors.New("no browser")}
fake.install(t)
var out bytes.Buffer
code := runReportBug([]string{"--note", "x", "--submit"}, testVersion, &out, &bytes.Buffer{})
if code != 0 {
t.Fatalf("open failure must degrade, got exit=%d", code)
}
outStr := out.String()
if !strings.Contains(outStr, "couldn't open a browser automatically") {
t.Errorf("stdout missing soft failure note:\n%s", outStr)
}
if !strings.Contains(outStr, "https://github.com/ajhahnde/eeco/issues/new?") {
t.Errorf("URL must still be present on open failure:\n%s", outStr)
}
if strings.Contains(outStr, "opened:") {
t.Errorf("must not claim opened on failure:\n%s", outStr)
}
}
func TestRunReportBug_NoSubmit_SeamNotCalled(t *testing.T) {
isolateHome(t)
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
var fake fakeSubmit
fake.install(t)
var out bytes.Buffer
code := runReportBug([]string{"--note", "x"}, testVersion, &out, &bytes.Buffer{})
if code != 0 {
t.Fatalf("runReportBug exit=%d", code)
}
if fake.calls != 0 {
t.Errorf("submit seam called %d times without --submit, want 0", fake.calls)
}
outStr := out.String()
if strings.Contains(outStr, "opened:") || strings.Contains(outStr, "submit:") {
t.Errorf("no-submit output must not contain submit lines:\n%s", outStr)
}
}
func TestBuildBugRecord_DeclaresNoNetwork(t *testing.T) {
isolateHome(t)
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
if code := runReportBug([]string{"--note", "x"}, testVersion, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("runReportBug failed")
}
entries, _ := os.ReadDir(wsPath(root, "bug-reports"))
body, _ := os.ReadFile(wsPath(root, "bug-reports", entries[0].Name()))
if !strings.Contains(string(body), "Nothing has been sent automatically.") {
t.Errorf("record body must declare local-only behaviour:\n%s", body)
}
}
added cmd/eeco/run.go
@@ -0,0 +1,71 @@
package main
import (
"flag"
"fmt"
"io"
"github.com/ajhahnde/eeco/internal/ai"
"github.com/ajhahnde/eeco/internal/workflow"
)
func runRun(args []string, stdout, stderr io.Writer) int {
// Not newFlagSet: this usage prints the flag table via fs.PrintDefaults(),
// which the shared one-line helper would drop.
fs := flag.NewFlagSet("run", flag.ContinueOnError)
fs.SetOutput(stderr)
fs.Usage = func() {
fmt.Fprintln(stderr, "usage: eeco run [--ai] <workflow>")
fs.PrintDefaults()
}
aiFlag := fs.Bool("ai", false, "allow this run's gated, budget-capped AI pass")
if err := fs.Parse(args); err != nil {
return workflow.CodeBlocked
}
if fs.NArg() != 1 {
fmt.Fprintln(stderr, "usage: eeco run [--ai] <workflow>")
reg := workflow.DefaultRegistry()
fmt.Fprintf(stderr, "builtin workflows: %v\n", reg.Names())
return workflow.CodeBlocked
}
name := fs.Arg(0)
cfg, code := loadRepoConfig(stderr, "eeco run")
if code != 0 {
return workflow.CodeFinding
}
det, err := workflow.NewDetector(cfg.AttributionPatterns)
if err != nil {
fmt.Fprintln(stderr, "eeco run:", err)
return workflow.CodeFinding
}
gate := ai.NewGate(cfg, *aiFlag, det.ScanResponse)
env := workflow.Env{Config: cfg, AI: gate.Consent, Gate: gate, Out: stderr}
reg := workflow.DefaultRegistry()
var res workflow.Result
if w, ok := reg.Get(name); ok {
res, err = workflow.Run(w, env)
} else {
res, err = workflow.ScriptRun(name, env)
}
if err != nil {
fmt.Fprintln(stderr, "eeco run:", err)
return workflow.CodeFinding
}
maybeAutoCommit(cfg.WorkspaceHistory.Auto(), cfg.UserDir, "run "+name, stderr)
printRunReport(stdout, name, res)
return res.Code
}
func printRunReport(w io.Writer, name string, r workflow.Result) {
fmt.Fprintf(w, "eeco run %s: %s\n", name, r.Summary)
for _, f := range r.Findings {
if f.Line > 0 {
fmt.Fprintf(w, " %s:%d: %s\n", f.Path, f.Line, f.Msg)
} else {
fmt.Fprintf(w, " %s: %s\n", f.Path, f.Msg)
}
}
}
added cmd/eeco/run_test.go
@@ -0,0 +1,97 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestRunRun_NoArgsBlocked(t *testing.T) {
chdir(t, t.TempDir())
var errOut bytes.Buffer
if code := runRun(nil, &bytes.Buffer{}, &errOut); code != 2 {
t.Fatalf("no workflow -> exit %d, want 2", code)
}
if !strings.Contains(errOut.String(), "usage: eeco run") {
t.Errorf("missing usage:\n%s", errOut.String())
}
}
func TestRunRun_OutsideRepo(t *testing.T) {
chdir(t, t.TempDir())
var errOut bytes.Buffer
if code := runRun([]string{"comment-hygiene"}, &bytes.Buffer{}, &errOut); code == 0 {
t.Fatal("expected non-zero outside repo")
}
if !strings.Contains(errOut.String(), "not inside a git repository") {
t.Errorf("missing hint:\n%s", errOut.String())
}
}
func TestRunRun_CommentHygieneCleanThenFinding(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
writeFile(t, root, "main.go", "package main\nfunc main(){}\n")
var out bytes.Buffer
if code := runRun([]string{"comment-hygiene"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("clean tree -> exit %d\n%s", code, out.String())
}
// Plant a fingerprint, assembled so this test source stays clean.
trailer := "Co-" + "Authored-" + "By: X <x@y>"
writeFile(t, root, "tainted.md", "notes\n"+trailer+"\n")
out.Reset()
if code := runRun([]string{"comment-hygiene"}, &out, &bytes.Buffer{}); code != 1 {
t.Fatalf("planted fingerprint -> exit %d, want 1\n%s", code, out.String())
}
if !strings.Contains(out.String(), "tainted.md") {
t.Errorf("report missing offending path:\n%s", out.String())
}
}
func TestRunRun_UnknownWorkflowBlocked(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
// Not a builtin and no scaffolded entry -> blocked (2).
if code := runRun([]string{"nope"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 2 {
t.Fatalf("unknown workflow -> exit %d, want 2", code)
}
}
func TestRunNew_HappyPathAndGuards(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
// Before init: refused.
if code := runNew([]string{"checks"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 1 {
t.Fatalf("new before init -> %d, want 1", code)
}
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("init failed")
}
var out bytes.Buffer
if code := runNew([]string{"checks"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("new -> %d\n%s", code, out.String())
}
entry := filepath.Join(root, "tester", ".eeco", "workflows", "checks", "run")
if _, err := os.Stat(entry); err != nil {
t.Fatalf("scaffolded entry missing: %v", err)
}
// Duplicate refused.
if code := runNew([]string{"checks"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 1 {
t.Errorf("duplicate new -> %d, want 1", code)
}
// Bad name refused.
if code := runNew([]string{"Bad Name"}, &bytes.Buffer{}, &bytes.Buffer{}); code != 1 {
t.Errorf("bad name -> %d, want 1", code)
}
}
func TestRunNew_WrongArgCount(t *testing.T) {
if code := runNew(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 2 {
t.Errorf("no args -> %d, want 2", code)
}
}
added cmd/eeco/show.go
@@ -0,0 +1,39 @@
package main
import (
"fmt"
"io"
"strings"
"github.com/ajhahnde/eeco/internal/prompts"
)
// runShowPrompt handles `eeco show prompt [name]`. With no argument it lists
// every prompt in the canonical library (one name per line); with a name it
// prints that prompt's raw template body verbatim so an operator can audit the
// exact instruction string the binary ships. The bodies are embedded
// (internal/prompts), so this works offline and needs no workspace.
func runShowPrompt(args []string, stdout, stderr io.Writer) int {
switch len(args) {
case 0:
for _, n := range prompts.Names() {
fmt.Fprintln(stdout, n)
}
return 0
case 1:
body, err := prompts.Get(args[0])
if err != nil {
fmt.Fprintln(stderr, "eeco show prompt:", err)
fmt.Fprintln(stderr, "known prompts:", strings.Join(prompts.Names(), ", "))
return 2
}
fmt.Fprint(stdout, body)
if !strings.HasSuffix(body, "\n") {
fmt.Fprintln(stdout)
}
return 0
default:
fmt.Fprintln(stderr, showUsage)
return 2
}
}
added cmd/eeco/show_test.go
@@ -0,0 +1,54 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestRunShowPrompt_List(t *testing.T) {
var out, errb bytes.Buffer
if code := runShowPrompt(nil, &out, &errb); code != 0 {
t.Fatalf("exit = %d, want 0 (stderr: %s)", code, errb.String())
}
got := out.String()
for _, want := range []string{"get-project-type", "manifest-summary"} {
if !strings.Contains(got, want) {
t.Fatalf("list missing %q in:\n%s", want, got)
}
}
}
func TestRunShowPrompt_Get(t *testing.T) {
var out, errb bytes.Buffer
if code := runShowPrompt([]string{"get-project-type"}, &out, &errb); code != 0 {
t.Fatalf("exit = %d, want 0 (stderr: %s)", code, errb.String())
}
body := out.String()
if !strings.Contains(body, "classifier") {
t.Fatalf("prompt body missing expected text:\n%s", body)
}
if !strings.HasSuffix(body, "\n") {
t.Fatal("printed body should end with a newline")
}
}
func TestRunShowPrompt_Unknown(t *testing.T) {
var out, errb bytes.Buffer
if code := runShowPrompt([]string{"does-not-exist"}, &out, &errb); code != 2 {
t.Fatalf("exit = %d, want 2", code)
}
if out.Len() != 0 {
t.Fatalf("unknown prompt should print nothing to stdout, got: %s", out.String())
}
if !strings.Contains(errb.String(), "unknown prompt") {
t.Fatalf("stderr missing diagnostic:\n%s", errb.String())
}
}
func TestRunShowPrompt_TooManyArgs(t *testing.T) {
var out, errb bytes.Buffer
if code := runShowPrompt([]string{"a", "b"}, &out, &errb); code != 2 {
t.Fatalf("exit = %d, want 2", code)
}
}
added cmd/eeco/stats.go
@@ -0,0 +1,104 @@
package main
import (
"fmt"
"io"
"path/filepath"
"sort"
"strings"
"github.com/ajhahnde/eeco/internal/ai"
)
const statsUsage = `usage:
eeco stats print cumulative AI usage from the call ledger
Aggregates state/ai-calls.json: total gated AI calls, how many ran vs
parked, real cumulative token counts, and the recorded date range.
Read-only; makes no AI call.`
// runStats backs `eeco stats`: a read-only reporter that aggregates the
// AI-call ledger (state/ai-calls.json) into a one-glance cumulative usage
// readout. The ledger stores real provider token counts, so — unlike
// `eeco go --metrics`, whose figures are bytes/4 estimates — these totals are
// exact and carry no "≈". An uninitialised workspace simply has no ledger,
// so it reports the friendly zero-state and still exits 0; read-only never
// errors on "no data".
func runStats(args []string, stdout, stderr io.Writer) int {
fs := newFlagSet("stats", stderr, statsUsage)
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() > 0 {
fmt.Fprintln(stderr, statsUsage)
return 2
}
cfg, code := loadRepoConfig(stderr, "eeco stats")
if code != 0 {
return code
}
stateDir := filepath.Join(cfg.Workspace, "state")
printStats(stdout, ai.Summarize(stateDir))
return 0
}
// printStats writes the cumulative-usage readout in house style (the
// `eeco <verb>:` prefix, ` · ` separators). No "≈": every figure is a real
// ledger count.
func printStats(w io.Writer, s ai.UsageSummary) {
if s.TotalCalls == 0 {
fmt.Fprintln(w, "eeco stats: no AI calls recorded yet.")
return
}
fmt.Fprintf(w, "eeco stats: %d AI calls (%d ran, %d parked)%s\n",
s.TotalCalls, s.Ran, s.Parked, dateSpan(s.FirstTS, s.LastTS))
fmt.Fprintf(w, " tokens: %d input · %d cached · %d output (%d total)\n",
s.Tokens.Input, s.Tokens.CachedInput, s.Tokens.Output, s.Tokens.Total())
if line := formatProviders(s.ByProvider); line != "" {
fmt.Fprintf(w, " by provider: %s\n", line)
}
}
// dateSpan renders the ledger's recorded date range from the first/last
// RFC 3339 timestamps, using only the date part. Empty when undated, ` since
// <date>` when both fall on one day, ` from <d1> to <d2>` otherwise.
func dateSpan(first, last string) string {
if first == "" && last == "" {
return ""
}
d1, d2 := dateOf(first), dateOf(last)
if d1 == d2 {
return " since " + d1
}
return " from " + d1 + " to " + d2
}
// dateOf returns the YYYY-MM-DD date part of an RFC 3339 timestamp, or the
// whole string when it is shorter than a full date.
func dateOf(ts string) string {
if len(ts) >= 10 {
return ts[:10]
}
return ts
}
// formatProviders renders the provider-call breakdown as `name count`
// segments joined by ` · `. Keys are sorted so the output is deterministic
// (Go map iteration order is random).
func formatProviders(m map[string]int) string {
if len(m) == 0 {
return ""
}
names := make([]string, 0, len(m))
for name := range m {
names = append(names, name)
}
sort.Strings(names)
segs := make([]string, 0, len(names))
for _, name := range names {
segs = append(segs, fmt.Sprintf("%s %d", name, m[name]))
}
return strings.Join(segs, " · ")
}
added cmd/eeco/stats_test.go
@@ -0,0 +1,72 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestRunStats_OutsideRepo(t *testing.T) {
chdir(t, t.TempDir())
var out, errOut bytes.Buffer
if code := runStats(nil, &out, &errOut); code != 1 {
t.Fatalf("runStats exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "not inside a git repository") {
t.Errorf("missing helpful error:\n%s", errOut.String())
}
}
func TestRunStats_NoLedger(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out bytes.Buffer
if code := runStats(nil, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("runStats exit=%d, want 0", code)
}
if !strings.Contains(out.String(), "no AI calls recorded yet") {
t.Errorf("expected zero-state line:\n%s", out.String())
}
}
func TestRunStats_AggregatesLedger(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
// The fixture intentionally keeps legacy `anthropic` provider records: C5
// retired the in-binary native provider, but pre-C5 ledgers on disk still
// carry them and `eeco stats` must keep aggregating them (and the
// deterministic provider sort, anthropic < cli).
writeFile(t, wsPath(root, "state"), "ai-calls.json", `{"records":[
{"label":"a","provider":"anthropic","ran":true,"parked":false,
"tokens":{"input":1000,"cached_input":200,"output":500},"ts":"2026-05-21T10:00:00Z"},
{"label":"b","provider":"cli","ran":true,"parked":false,
"tokens":{"input":2000,"cached_input":0,"output":800},"ts":"2026-05-30T12:00:00Z"},
{"label":"c","provider":"anthropic","ran":false,"parked":true,
"tokens":{"input":0,"cached_input":0,"output":0},"ts":"2026-05-25T09:00:00Z"}
]}`)
var out bytes.Buffer
if code := runStats(nil, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("runStats exit=%d, want 0", code)
}
got := out.String()
for _, want := range []string{
"3 AI calls (2 ran, 1 parked)",
"from 2026-05-21 to 2026-05-30",
"3000 input · 200 cached · 1300 output (4500 total)",
"by provider: anthropic 2 · cli 1", // sorted, deterministic
} {
if !strings.Contains(got, want) {
t.Errorf("stats output missing %q:\n%s", want, got)
}
}
}
func TestRunStats_RejectsArgs(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var errOut bytes.Buffer
if code := runStats([]string{"extra"}, &bytes.Buffer{}, &errOut); code != 2 {
t.Fatalf("runStats with positional arg exit=%d, want 2", code)
}
}
added cmd/eeco/status.go
@@ -0,0 +1,20 @@
package main
import (
"io"
"github.com/ajhahnde/eeco/internal/tui"
)
// runStatus backs `eeco` with no arguments. It resolves config, then
// hands off to the control center: interactive when stdout/stdin are a
// terminal, otherwise a one-screen status digest. The not-in-repo and
// load-error handling stays here so the message and exit code are
// identical regardless of the terminal.
func runStatus(stdout, stderr io.Writer) int {
cfg, code := loadRepoConfig(stderr, "eeco")
if code != 0 {
return code
}
return tui.Run(cfg, version, stdout, stderr)
}
added cmd/eeco/tasks.go
@@ -0,0 +1,73 @@
package main
import (
"fmt"
"io"
"path/filepath"
"strings"
"time"
"github.com/ajhahnde/eeco/internal/queue"
)
const addTaskUsage = `usage:
eeco add task [--kind <tag>] [--detail <text>] "<title>"
Append an item to the workspace queue — eeco's single decision channel.
eeco writes one entry to <workspace>/state/queue.md; nothing is staged or
committed (Constraint 6). View the queue with "eeco queue" (or /queue in
the TUI); resolve an item by ticking its checkbox in queue.md.
flags:
--kind <tag> short queue tag for the item (default: task)
--detail <text> optional elaboration printed below the checklist row`
// runAddTask handles `eeco add task ... "<title>"`. It builds a queue
// item from the flags and the title positional and appends it to the
// workspace queue. The write stays inside the workspace (Constraint 1)
// and nothing is staged or committed (Constraint 6).
func runAddTask(args []string, stdout, stderr io.Writer) int {
fs := newFlagSet("add task", stderr, addTaskUsage)
kindFlag := fs.String("kind", "task", "short queue tag for the item")
detailFlag := fs.String("detail", "", "optional elaboration line")
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() != 1 {
fmt.Fprintln(stderr, addTaskUsage)
return 2
}
title := strings.TrimSpace(fs.Arg(0))
if title == "" {
fmt.Fprintln(stderr, "eeco add task: a title is required")
fmt.Fprintln(stderr, addTaskUsage)
return 2
}
kind := strings.TrimSpace(*kindFlag)
if kind == "" {
fmt.Fprintln(stderr, "eeco add task: --kind must not be empty")
return 2
}
cfg, code := loadInitedConfig(stderr, "eeco add task")
if code != 0 {
return code
}
item := queue.Item{
Kind: kind,
Title: title,
Project: filepath.Base(cfg.RepoRoot),
Detail: strings.TrimSpace(*detailFlag),
Date: time.Now().UTC(),
}
stateDir := filepath.Join(cfg.Workspace, "state")
if err := queue.Append(stateDir, item); err != nil {
fmt.Fprintln(stderr, "eeco add task:", err)
return 1
}
maybeAutoCommit(cfg.WorkspaceHistory.Auto(), cfg.UserDir, "add task", stderr)
fmt.Fprintf(stdout, "queued %s: %s\n", kind, title)
return 0
}
added cmd/eeco/tasks_test.go
@@ -0,0 +1,161 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestRunAddTask_WritesIntoWorkspace(t *testing.T) {
root := initRepo(t)
var out, errOut bytes.Buffer
code := runAdd([]string{"task", "ship the man page"}, &out, &errOut)
if code != 0 {
t.Fatalf("add task exit=%d stderr=%s", code, errOut.String())
}
path := filepath.Join(root, "tester", ".eeco", "state", "queue.md")
// Constraint 1: the write stays inside the workspace.
if !strings.HasPrefix(path, filepath.Join(root, "tester", ".eeco")+string(os.PathSeparator)) {
t.Errorf("queue written outside the workspace: %s", path)
}
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("queue file not written: %v", err)
}
got := string(body)
// Default kind is `task`; title is the checklist row; project is the
// repo basename.
if !strings.Contains(got, "- [ ] **task** — ship the man page _("+filepath.Base(root)+",") {
t.Errorf("queue row missing or malformed:\n%s", got)
}
if !strings.Contains(out.String(), "queued task: ship the man page") {
t.Errorf("stdout missing queued confirmation:\n%s", out.String())
}
}
func TestRunAddTask_KindAndDetail(t *testing.T) {
root := initRepo(t)
var out, errOut bytes.Buffer
code := runAdd([]string{
"task",
"--kind", "review",
"--detail", "check the brew formula",
"wire the man page into brew/scoop",
}, &out, &errOut)
if code != 0 {
t.Fatalf("add task exit=%d stderr=%s", code, errOut.String())
}
body, err := os.ReadFile(filepath.Join(root, "tester", ".eeco", "state", "queue.md"))
if err != nil {
t.Fatal(err)
}
got := string(body)
if !strings.Contains(got, "- [ ] **review** — wire the man page into brew/scoop _(") {
t.Errorf("custom kind not serialised:\n%s", got)
}
// Detail is the indented continuation line beneath the row.
if !strings.Contains(got, " check the brew formula") {
t.Errorf("detail not serialised as indented line:\n%s", got)
}
if !strings.Contains(out.String(), "queued review: wire the man page into brew/scoop") {
t.Errorf("stdout missing queued confirmation:\n%s", out.String())
}
}
func TestRunAddTask_Appends(t *testing.T) {
root := initRepo(t)
var out, errOut bytes.Buffer
if code := runAdd([]string{"task", "first"}, &out, &errOut); code != 0 {
t.Fatalf("first add task exit=%d stderr=%s", code, errOut.String())
}
out.Reset()
errOut.Reset()
if code := runAdd([]string{"task", "second"}, &out, &errOut); code != 0 {
t.Fatalf("second add task exit=%d stderr=%s", code, errOut.String())
}
body, err := os.ReadFile(filepath.Join(root, "tester", ".eeco", "state", "queue.md"))
if err != nil {
t.Fatal(err)
}
got := string(body)
first := strings.Index(got, "first")
second := strings.Index(got, "second")
if first < 0 || second < 0 {
t.Fatalf("both items not present:\n%s", got)
}
if first > second {
t.Errorf("items not appended in order:\n%s", got)
}
}
func TestRunAddTask_MissingTitle(t *testing.T) {
initRepo(t)
var out, errOut bytes.Buffer
code := runAdd([]string{"task"}, &out, &errOut)
if code != 2 {
t.Fatalf("add task with no title exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), "usage:") {
t.Errorf("stderr missing usage:\n%s", errOut.String())
}
}
func TestRunAddTask_BlankTitle(t *testing.T) {
initRepo(t)
var out, errOut bytes.Buffer
code := runAdd([]string{"task", " "}, &out, &errOut)
if code != 2 {
t.Fatalf("add task with blank title exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), "title is required") {
t.Errorf("stderr missing required-title message:\n%s", errOut.String())
}
}
func TestRunAddTask_EmptyKind(t *testing.T) {
initRepo(t)
var out, errOut bytes.Buffer
code := runAdd([]string{"task", "--kind", " ", "a title"}, &out, &errOut)
if code != 2 {
t.Fatalf("add task with empty --kind exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), "--kind must not be empty") {
t.Errorf("stderr missing empty-kind message:\n%s", errOut.String())
}
}
func TestRunAddTask_NotInRepo(t *testing.T) {
chdir(t, t.TempDir()) // no .git anywhere up the tree
var out, errOut bytes.Buffer
code := runAdd([]string{"task", "a title"}, &out, &errOut)
if code != 1 {
t.Fatalf("add task outside a repo exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "not inside a git repository") {
t.Errorf("stderr missing not-in-repo message:\n%s", errOut.String())
}
}
func TestRunAddTask_NotInitialized(t *testing.T) {
root := newGitRepo(t) // a git repo, but no `eeco init`
chdir(t, root)
var out, errOut bytes.Buffer
code := runAdd([]string{"task", "a title"}, &out, &errOut)
if code != 1 {
t.Fatalf("add task in uninitialised repo exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "not initialised") {
t.Errorf("stderr missing not-initialised message:\n%s", errOut.String())
}
}
added cmd/eeco/uninstall.go
@@ -0,0 +1,368 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/hooks"
"github.com/ajhahnde/eeco/internal/memory"
"github.com/ajhahnde/eeco/internal/queue"
)
const uninstallUsage = `usage:
eeco uninstall [--scope SCOPE]
--scope minimal bare handoff (default): paths + removal command
--scope facts handoff + memory facts
--scope queue handoff + open queue items
--scope everything handoff + facts + queue + scaffolded workflows + hooks
Writes a single eeco-handoff.md at the repository root summarising what
eeco knew about this project. Prints the git command for the operator
to remove the workspace by hand. Nothing is deleted by this command.`
// handoffFilename is the name written at the repository root.
const handoffFilename = "eeco-handoff.md"
// uninstallStdin is the reader `eeco uninstall` uses for its de-init
// confirmation prompt. It defaults to the process stdin; the cmd test
// suite pins it as needed (mirrors initStdin).
var uninstallStdin io.Reader = os.Stdin
// uninstallScope enumerates the optional sections that land in the
// handoff doc. minimal is the conservative default per Constraint 5.
type uninstallScope struct {
name string
facts bool
queue bool
extras bool
}
func parseUninstallScope(s string) (uninstallScope, error) {
switch s {
case "minimal":
return uninstallScope{name: s}, nil
case "facts":
return uninstallScope{name: s, facts: true}, nil
case "queue":
return uninstallScope{name: s, queue: true}, nil
case "everything":
return uninstallScope{name: s, facts: true, queue: true, extras: true}, nil
default:
return uninstallScope{}, fmt.Errorf("unknown --scope %q (use minimal|facts|queue|everything)", s)
}
}
// runUninstall writes a handoff note at the repo root summarising what
// eeco knew about the project, then prints the removal command for the
// operator. It never deletes anything (Constraint 6).
func runUninstall(args []string, stdout, stderr io.Writer) int {
fs := newFlagSet("uninstall", stderr, uninstallUsage)
scopeArg := fs.String("scope", "minimal", "sections to include: minimal|facts|queue|everything")
yes := fs.Bool("yes", false, "skip the workspace-history de-init confirmation prompt")
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() > 0 {
fmt.Fprintln(stderr, uninstallUsage)
return 2
}
scope, err := parseUninstallScope(*scopeArg)
if err != nil {
fmt.Fprintln(stderr, "eeco uninstall:", err)
return 2
}
cwd, err := os.Getwd()
if err != nil {
fmt.Fprintln(stderr, "eeco uninstall:", err)
return 1
}
cfg, err := config.Load(cwd, config.DefaultWorkspace)
if err != nil {
if errors.Is(err, config.ErrNotInRepo) {
fmt.Fprintln(stderr, "eeco uninstall: not inside a git repository.")
return 1
}
fmt.Fprintln(stderr, "eeco uninstall:", err)
return 1
}
if !config.IsInitialized(cfg) {
fmt.Fprintln(stderr, "eeco uninstall: no workspace at", cfg.Workspace, "— nothing to summarise.")
return 1
}
// De-init the private workspace-history repo, symmetric to init.
// Confirm-gated (--yes skips); removes ONLY <UserDir>/.git, never the
// data. Decide first so the handoff records the actual outcome.
relGit := relForDisplay(cfg.RepoRoot, filepath.Join(cfg.UserDir, ".git"))
hadHistory := privateRepoExists(cfg.UserDir)
removedHistory := false
if hadHistory {
doRemove := *yes
if !doRemove {
doRemove = confirm(uninstallStdin, stderr, fmt.Sprintf(
"remove eeco's private workspace-history repo (its commit log) at %s? the workspace files stay; only the history is removed. [y/N]: ", relGit))
}
if doRemove {
if err := removePrivateRepo(cfg); err != nil {
fmt.Fprintln(stderr, "eeco uninstall: could not remove workspace history:", err)
} else {
removedHistory = true
}
}
}
note, err := buildUninstallNote(cfg, scope, time.Now().UTC(), hadHistory, removedHistory)
if err != nil {
fmt.Fprintln(stderr, "eeco uninstall:", err)
return 1
}
path := uniqueRootPath(filepath.Join(cfg.RepoRoot, handoffFilename))
if err := os.WriteFile(path, []byte(note), 0o644); err != nil {
fmt.Fprintln(stderr, "eeco uninstall: write handoff:", err)
return 1
}
rel := filepath.Base(path)
if r, rerr := filepath.Rel(cfg.RepoRoot, path); rerr == nil {
rel = filepath.ToSlash(r)
}
relWorkspace := cfg.WorkspaceName
if r, rerr := filepath.Rel(cfg.RepoRoot, cfg.Workspace); rerr == nil {
relWorkspace = filepath.ToSlash(r)
}
fmt.Fprintf(stdout, "wrote: %s\n", rel)
fmt.Fprintf(stdout, "scope: %s\n", scope.name)
if hadHistory {
if removedHistory {
fmt.Fprintf(stdout, "history: removed (%s)\n", relGit)
} else {
fmt.Fprintln(stdout, "history: kept")
}
}
fmt.Fprintln(stdout, "next: review the handoff, carry forward anything still useful,")
fmt.Fprintf(stdout, " then remove the workspace by hand:\n")
fmt.Fprintf(stdout, " git rm -rf %s\n", relWorkspace)
fmt.Fprintln(stdout, "what was: a gitignored workspace at", relWorkspace+",")
fmt.Fprintln(stdout, " holding memory facts, an open-item queue, scaffolded user")
if removedHistory {
fmt.Fprintln(stdout, " workflows, and a hook ledger. The handoff summarises it;")
fmt.Fprintln(stdout, " the private workspace-history repo (.git) was removed,")
fmt.Fprintln(stdout, " but your workspace data files were not.")
} else {
fmt.Fprintln(stdout, " workflows, and a hook ledger. The handoff summarises it;")
fmt.Fprintln(stdout, " no files were deleted by this command.")
}
return 0
}
// relForDisplay renders p relative to root with forward slashes for
// operator-facing messages, falling back to p when it cannot.
func relForDisplay(root, p string) string {
if r, err := filepath.Rel(root, p); err == nil {
return filepath.ToSlash(r)
}
return p
}
// removePrivateRepo deletes ONLY the private workspace-history repo's .git
// directory at <UserDir>/.git, never the workspace data and never UserDir
// itself. It refuses if UserDir is empty or equals the repo root, so a
// misconfigured workspace can never make this touch the host repo's .git.
func removePrivateRepo(cfg *config.Config) error {
if cfg.UserDir == "" || cfg.UserDir == cfg.RepoRoot {
return fmt.Errorf("refusing to remove history: workspace dir is unset or equals the repo root")
}
return os.RemoveAll(filepath.Join(cfg.UserDir, ".git"))
}
// uniqueRootPath returns path, or path with a numeric suffix when path
// already exists, so a prior handoff is never overwritten.
func uniqueRootPath(path string) string {
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
return path
}
dir, base := filepath.Split(path)
ext := filepath.Ext(base)
stem := strings.TrimSuffix(base, ext)
for i := 2; ; i++ {
cand := filepath.Join(dir, fmt.Sprintf("%s-%d%s", stem, i, ext))
if _, err := os.Stat(cand); errors.Is(err, os.ErrNotExist) {
return cand
}
}
}
// buildUninstallNote renders the handoff doc body. Sections are gated by
// scope; minimal yields the heading, scope line, and removal block only.
func buildUninstallNote(cfg *config.Config, scope uninstallScope, now time.Time, hadHistory, removedHistory bool) (string, error) {
relWorkspace := cfg.WorkspaceName
if r, err := filepath.Rel(cfg.RepoRoot, cfg.Workspace); err == nil {
relWorkspace = filepath.ToSlash(r)
}
relUserDir := cfg.Username
if r, err := filepath.Rel(cfg.RepoRoot, cfg.UserDir); err == nil {
relUserDir = filepath.ToSlash(r)
}
var b strings.Builder
fmt.Fprintf(&b, "# eeco handoff — %s\n\n", now.Format(time.RFC3339))
b.WriteString("Written by `eeco uninstall`. Nothing in this file is committed,\n")
b.WriteString("and your workspace data files are intact. This note exists\n")
b.WriteString("so you can carry forward anything still useful before removing\n")
b.WriteString("the workspace by hand.\n\n")
fmt.Fprintf(&b, "scope: %s\n", scope.name)
fmt.Fprintf(&b, "workspace: %s\n", relWorkspace)
fmt.Fprintf(&b, "profile: %s\n", cfg.Profile)
b.WriteString("\n")
b.WriteString("## Removing the workspace\n\n")
b.WriteString("The workspace is gitignored, so `git rm -rf` is the only on-disk\n")
b.WriteString("removal you need. If the pre-commit or session-start hooks were\n")
b.WriteString("enabled, turn them off before removing the workspace so the\n")
b.WriteString("reversibility ledger can do its work:\n\n")
b.WriteString(" eeco hooks pre-commit off\n")
b.WriteString(" eeco hooks session-start off\n")
fmt.Fprintf(&b, " git rm -rf %s\n\n", relWorkspace)
b.WriteString("## Workspace history\n\n")
switch {
case !hadHistory:
b.WriteString("No private workspace-history repository was present.\n\n")
case removedHistory:
b.WriteString("eeco's private workspace-history repository (its commit log) was\n")
b.WriteString("removed. Your workspace data files were not touched.\n\n")
default:
b.WriteString("eeco's private workspace-history repository (its commit log) was\n")
b.WriteString("kept. Remove just the history by hand, leaving the data, with:\n\n")
fmt.Fprintf(&b, " rm -rf %s/.git\n\n", relUserDir)
}
if scope.facts {
facts, err := listFacts(cfg)
if err != nil {
return "", fmt.Errorf("list facts: %w", err)
}
fmt.Fprintf(&b, "## Memory facts (%d)\n\n", len(facts))
if len(facts) == 0 {
b.WriteString("(none)\n\n")
} else {
for _, f := range facts {
fmt.Fprintf(&b, "- **%s** (%s) — %s\n", f.Name, f.Type, f.Description)
}
b.WriteString("\n")
}
}
if scope.queue {
items, err := readQueueLines(cfg)
if err != nil {
return "", fmt.Errorf("read queue: %w", err)
}
fmt.Fprintf(&b, "## Open queue items (%d)\n\n", len(items))
if len(items) == 0 {
b.WriteString("(none)\n\n")
} else {
for _, line := range items {
fmt.Fprintf(&b, "- %s\n", line)
}
b.WriteString("\n")
}
}
if scope.extras {
flows, err := listScaffoldedWorkflows(cfg)
if err != nil {
return "", fmt.Errorf("list workflows: %w", err)
}
fmt.Fprintf(&b, "## Scaffolded workflows (%d)\n\n", len(flows))
if len(flows) == 0 {
b.WriteString("(none — only builtins were in use)\n\n")
} else {
for _, name := range flows {
fmt.Fprintf(&b, "- %s\n", name)
}
b.WriteString("\n")
}
lines := hooks.Status(cfg)
b.WriteString("## Hook ledger\n\n")
if len(lines) == 0 {
b.WriteString("(unavailable)\n\n")
} else {
for _, line := range lines {
fmt.Fprintf(&b, "- %s\n", line)
}
b.WriteString("\n")
}
}
return b.String(), nil
}
// listFacts loads every memory fact in the store. A missing memory
// directory yields an empty slice rather than an error so an uninstall
// against a freshly initialised workspace still succeeds.
func listFacts(cfg *config.Config) ([]*memory.Fact, error) {
store, err := memory.Open(cfg)
if err != nil {
return nil, err
}
return store.LoadAll()
}
// readQueueLines returns the bullet text of each unchecked queue item
// in <workspace>/state/queue.md. The queue file is owned by the user;
// we read it read-only and strip the checklist prefix for the handoff.
func readQueueLines(cfg *config.Config) ([]string, error) {
path := filepath.Join(cfg.Workspace, "state", queue.Filename)
b, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
var out []string
for raw := range strings.SplitSeq(string(b), "\n") {
line := strings.TrimSpace(raw)
const prefix = "- [ ]"
if !strings.HasPrefix(line, prefix) {
continue
}
out = append(out, strings.TrimSpace(line[len(prefix):]))
}
return out, nil
}
// listScaffoldedWorkflows returns the names of user-scaffolded workflows
// in <workspace>/workflows/. Builtins are embedded and do not appear
// here. A missing directory yields an empty slice.
func listScaffoldedWorkflows(cfg *config.Config) ([]string, error) {
dir := filepath.Join(cfg.Workspace, "workflows")
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
var names []string
for _, e := range entries {
if e.IsDir() {
names = append(names, e.Name())
}
}
sort.Strings(names)
return names, nil
}
added cmd/eeco/uninstall_test.go
@@ -0,0 +1,333 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/ajhahnde/eeco/internal/config"
)
func TestParseUninstallScope(t *testing.T) {
cases := []struct {
in string
wantFacts, wantQueue, wantX bool
wantErr bool
}{
{"minimal", false, false, false, false},
{"facts", true, false, false, false},
{"queue", false, true, false, false},
{"everything", true, true, true, false},
{"bogus", false, false, false, true},
}
for _, tc := range cases {
got, err := parseUninstallScope(tc.in)
if (err != nil) != tc.wantErr {
t.Errorf("%q: err=%v wantErr=%v", tc.in, err, tc.wantErr)
continue
}
if tc.wantErr {
continue
}
if got.facts != tc.wantFacts || got.queue != tc.wantQueue || got.extras != tc.wantX {
t.Errorf("%q: got %+v want facts=%v queue=%v extras=%v", tc.in, got, tc.wantFacts, tc.wantQueue, tc.wantX)
}
}
}
func TestRunUninstall_MinimalDefault(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
var out, errOut bytes.Buffer
code := runUninstall(nil, &out, &errOut)
if code != 0 {
t.Fatalf("runUninstall exit=%d stderr=%s", code, errOut.String())
}
handoff := filepath.Join(root, handoffFilename)
body, err := os.ReadFile(handoff)
if err != nil {
t.Fatalf("handoff missing: %v", err)
}
bodyStr := string(body)
if !strings.Contains(bodyStr, "# eeco handoff —") {
t.Errorf("handoff missing heading:\n%s", bodyStr)
}
if !strings.Contains(bodyStr, "scope: minimal") {
t.Errorf("handoff missing scope line:\n%s", bodyStr)
}
if !strings.Contains(bodyStr, "git rm -rf tester/.eeco") {
t.Errorf("handoff missing git rm command:\n%s", bodyStr)
}
// Minimal must not include optional sections.
if strings.Contains(bodyStr, "## Memory facts") {
t.Errorf("minimal handoff should not contain memory facts section")
}
if strings.Contains(bodyStr, "## Open queue items") {
t.Errorf("minimal handoff should not contain queue section")
}
if strings.Contains(bodyStr, "## Scaffolded workflows") {
t.Errorf("minimal handoff should not contain workflows section")
}
if strings.Contains(bodyStr, "## Hook ledger") {
t.Errorf("minimal handoff should not contain hook ledger section")
}
// Operator-facing output.
stdoutStr := out.String()
if !strings.Contains(stdoutStr, "wrote: eeco-handoff.md") {
t.Errorf("stdout missing wrote line:\n%s", stdoutStr)
}
if !strings.Contains(stdoutStr, "git rm -rf tester/.eeco") {
t.Errorf("stdout missing removal command:\n%s", stdoutStr)
}
if !strings.Contains(stdoutStr, "no files were deleted") {
t.Errorf("stdout missing no-delete reassurance:\n%s", stdoutStr)
}
}
func TestRunUninstall_EverythingIncludesAllSections(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
// Seed a memory fact.
fact := "---\nname: example-fact\ndescription: an example fact\ntype: reference\ncreated: 2026-01-01\nlast_used: 2026-01-01\n---\n\nbody\n"
writeFile(t, root, filepath.Join("tester", ".eeco", "memory", "example-fact.md"), fact)
// Seed a queue item.
queueBody := "# eeco queue\n\n- [ ] **test** — open thing _(repo, 2026-01-01)_\n"
writeFile(t, root, filepath.Join("tester", ".eeco", "state", "queue.md"), queueBody)
// Seed a scaffolded workflow.
writeFile(t, root, filepath.Join("tester", ".eeco", "workflows", "my-flow", "workflow.yaml"), "name: my-flow\n")
var out bytes.Buffer
code := runUninstall([]string{"--scope", "everything"}, &out, &bytes.Buffer{})
if code != 0 {
t.Fatalf("runUninstall exit=%d", code)
}
body, err := os.ReadFile(filepath.Join(root, handoffFilename))
if err != nil {
t.Fatalf("handoff missing: %v", err)
}
bodyStr := string(body)
for _, want := range []string{
"scope: everything",
"## Memory facts (1)",
"**example-fact** (reference) — an example fact",
"## Open queue items (1)",
"**test** — open thing",
"## Scaffolded workflows (1)",
"- my-flow",
"## Hook ledger",
"pre-commit:",
"session-start:",
} {
if !strings.Contains(bodyStr, want) {
t.Errorf("handoff missing %q:\n%s", want, bodyStr)
}
}
}
func TestRunUninstall_FactsScopeOmitsQueue(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
writeFile(t, root, filepath.Join("tester", ".eeco", "state", "queue.md"), "# eeco queue\n\n- [ ] **k** — q _(p, 2026-01-01)_\n")
var out bytes.Buffer
if code := runUninstall([]string{"--scope", "facts"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("runUninstall exit=%d", code)
}
body, _ := os.ReadFile(filepath.Join(root, handoffFilename))
bodyStr := string(body)
if !strings.Contains(bodyStr, "## Memory facts") {
t.Errorf("facts scope should include memory facts section:\n%s", bodyStr)
}
if strings.Contains(bodyStr, "## Open queue items") {
t.Errorf("facts scope should NOT include queue section:\n%s", bodyStr)
}
}
func TestRunUninstall_NotInRepo(t *testing.T) {
dir := t.TempDir()
chdir(t, dir)
var errOut bytes.Buffer
code := runUninstall(nil, &bytes.Buffer{}, &errOut)
if code == 0 {
t.Fatal("expected non-zero exit outside a git repo")
}
if !strings.Contains(errOut.String(), "not inside a git repository") {
t.Errorf("missing helpful error:\n%s", errOut.String())
}
}
func TestRunUninstall_NoWorkspace(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var errOut bytes.Buffer
code := runUninstall(nil, &bytes.Buffer{}, &errOut)
if code == 0 {
t.Fatal("expected non-zero exit with no workspace")
}
if !strings.Contains(errOut.String(), "nothing to summarise") {
t.Errorf("missing helpful error:\n%s", errOut.String())
}
}
func TestRunUninstall_UnknownScope(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
var errOut bytes.Buffer
code := runUninstall([]string{"--scope", "kitchen-sink"}, &bytes.Buffer{}, &errOut)
if code != 2 {
t.Fatalf("expected exit 2 for bad scope, got %d", code)
}
if !strings.Contains(errOut.String(), "unknown --scope") {
t.Errorf("missing error hint:\n%s", errOut.String())
}
}
func TestRunUninstall_DoesNotOverwriteExistingHandoff(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
if code := runUninstall(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("first runUninstall exit=%d", code)
}
var out bytes.Buffer
if code := runUninstall(nil, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("second runUninstall exit=%d", code)
}
if _, err := os.Stat(filepath.Join(root, "eeco-handoff.md")); err != nil {
t.Errorf("first handoff was lost: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "eeco-handoff-2.md")); err != nil {
t.Errorf("second handoff not written under unique name: %v", err)
}
if !strings.Contains(out.String(), "eeco-handoff-2.md") {
t.Errorf("stdout should report the unique handoff name:\n%s", out.String())
}
}
func TestRunUninstall_DeInitsHistoryWithYes(t *testing.T) {
requireGit(t)
defer setTrackHistory(true)()
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("setup init failed")
}
userDir := filepath.Join(root, "tester")
if !privateRepoExists(userDir) {
t.Fatal("private repo not created in setup")
}
var out bytes.Buffer
if code := runUninstall([]string{"--yes"}, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("uninstall exit; out:\n%s", out.String())
}
if privateRepoExists(userDir) {
t.Error(".git not removed with --yes")
}
// Workspace data must survive — only the history (.git) is removed.
if info, err := os.Stat(filepath.Join(userDir, ".eeco", "memory")); err != nil || !info.IsDir() {
t.Errorf("workspace data was deleted: %v", err)
}
if !strings.Contains(out.String(), "history: removed") {
t.Errorf("stdout missing history-removed line:\n%s", out.String())
}
body, _ := os.ReadFile(filepath.Join(root, handoffFilename))
if !strings.Contains(string(body), "Your workspace data files were not touched.") {
t.Errorf("handoff missing removed-history note:\n%s", string(body))
}
}
func TestRunUninstall_RemovesHistoryOnYesPrompt(t *testing.T) {
requireGit(t)
defer setTrackHistory(true)()
prev := uninstallStdin
uninstallStdin = strings.NewReader("y\n")
defer func() { uninstallStdin = prev }()
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("setup init failed")
}
userDir := filepath.Join(root, "tester")
if code := runUninstall(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("uninstall failed")
}
if privateRepoExists(userDir) {
t.Error(".git not removed after a 'y' prompt")
}
}
func TestRunUninstall_KeepsHistoryOnDecline(t *testing.T) {
requireGit(t)
defer setTrackHistory(true)()
// uninstallStdin defaults to EOF in TestMain → confirm declines.
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatal("setup init failed")
}
userDir := filepath.Join(root, "tester")
var out bytes.Buffer
if code := runUninstall(nil, &out, &bytes.Buffer{}); code != 0 {
t.Fatal("uninstall failed")
}
if !privateRepoExists(userDir) {
t.Error(".git removed though the prompt was declined")
}
if !strings.Contains(out.String(), "history: kept") {
t.Errorf("stdout missing history-kept line:\n%s", out.String())
}
body, _ := os.ReadFile(filepath.Join(root, handoffFilename))
if !strings.Contains(string(body), "rm -rf tester/.git") {
t.Errorf("handoff missing manual-removal hint:\n%s", string(body))
}
}
func TestRemovePrivateRepo_RefusesUnsafeTargets(t *testing.T) {
if err := removePrivateRepo(&config.Config{RepoRoot: "/x", UserDir: "/x"}); err == nil {
t.Error("removePrivateRepo did not refuse UserDir==RepoRoot")
}
if err := removePrivateRepo(&config.Config{RepoRoot: "/x", UserDir: ""}); err == nil {
t.Error("removePrivateRepo did not refuse empty UserDir")
}
}
func TestRunUninstall_NoAutoDelete(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
if code := runInit(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("setup init exit=%d", code)
}
if code := runUninstall(nil, &bytes.Buffer{}, &bytes.Buffer{}); code != 0 {
t.Fatalf("runUninstall exit=%d", code)
}
// Workspace must still exist (Constraint 6 — no auto-delete).
for _, sub := range []string{"memory", "state", "workflows", "engine", "docs"} {
p := filepath.Join(root, "tester", ".eeco", sub)
if info, err := os.Stat(p); err != nil || !info.IsDir() {
t.Errorf("uninstall must not delete %s; err=%v", p, err)
}
}
}
added cmd/eeco/update.go
@@ -0,0 +1,152 @@
package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/gitx"
"github.com/ajhahnde/eeco/internal/selfupdate"
)
// eecoReleaseSource is eeco's own canonical source. It is the product's
// own repository (already the module path in go.mod), not a third-party
// project, so naming it here is consistent with Constraint 4.
const eecoReleaseSource = "https://github.com/ajhahnde/eeco"
// runUpdate checks whether a newer eeco release exists. By default it
// is strictly read-only: it lists remote tags and compares, never
// downloading or applying anything. With --apply it shells out to the
// selfupdate package, which verifies the release with cosign + gh
// build-provenance before swapping the running binary.
func runUpdate(args []string, stdout, stderr io.Writer) int {
// Not newFlagSet: this relies on flag's default Usage for the unknown-flag
// diagnostic, which the shared one-line helper would replace.
fs := flag.NewFlagSet("update", flag.ContinueOnError)
fs.SetOutput(stderr)
apply := fs.Bool("apply", false, "download and apply the latest release after verification")
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() != 0 {
fmt.Fprintln(stderr, "usage: eeco update [--apply]")
return 2
}
cwd, err := os.Getwd()
if err != nil {
cwd = "."
}
tags, err := gitx.RemoteTags(cwd, eecoReleaseSource)
if err != nil {
if errors.Is(err, gitx.ErrUnavailable) {
fmt.Fprintln(stdout, "eeco update: git is unavailable — cannot check for a newer release.")
} else {
fmt.Fprintln(stdout, "eeco update: could not reach the release source (offline?) — skipped.")
}
fmt.Fprintf(stdout, " current: %s\n", version)
if *apply {
return 2
}
return 0
}
latest, ok := latestSemver(tags)
if !ok {
fmt.Fprintln(stdout, "eeco update: no released versions found.")
fmt.Fprintf(stdout, " current: %s\n", version)
if *apply {
return 2
}
return 0
}
latestTag := fmt.Sprintf("v%d.%d.%d", latest[0], latest[1], latest[2])
cur, curOK := parseSemver(version)
upToDate := curOK && !semverLess(cur, latest)
if !*apply {
if upToDate {
fmt.Fprintf(stdout, "eeco update: up to date (current %s, latest %s).\n", version, latestTag)
} else {
fmt.Fprintf(stdout, "eeco update: a newer release is available: %s (current %s).\n", latestTag, version)
fmt.Fprintln(stdout, " apply with: eeco update --apply (verifies SHA256SUMS, cosign, and provenance).")
}
return 0
}
if upToDate {
fmt.Fprintf(stdout, "eeco update --apply: already up to date (current %s).\n", version)
return 0
}
if !curOK {
fmt.Fprintf(stderr, "eeco update --apply: running build %q is not a released tag; reinstall to upgrade.\n", version)
return 2
}
// Not the shared loader: this load is --apply-only, adds the workspace-dir
// check below, and exits 2 (blocked) rather than the helper's 1.
cfg, err := config.Load(cwd, config.DefaultWorkspace)
if err != nil {
fmt.Fprintln(stderr, "eeco update --apply: run inside an initialised eeco repo (eeco init):", err)
return 2
}
if info, err := os.Stat(cfg.Workspace); err != nil || !info.IsDir() {
fmt.Fprintln(stderr, "eeco update --apply: workspace missing; run `eeco init` first.")
return 2
}
return selfupdate.Apply(cfg, version, latestTag, stdout, stderr, selfupdate.Options{})
}
// parseSemver parses a vMAJOR.MINOR.PATCH tag, ignoring an optional
// leading 'v' and any -prerelease / +build suffix. ok is false when the
// core is not three integers.
func parseSemver(s string) (v [3]int, ok bool) {
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
if i := strings.IndexAny(s, "-+"); i >= 0 {
s = s[:i]
}
parts := strings.Split(s, ".")
if len(parts) != 3 {
return v, false
}
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return v, false
}
v[i] = n
}
return v, true
}
// semverLess reports whether a precedes b.
func semverLess(a, b [3]int) bool {
for i := 0; i < 3; i++ {
if a[i] != b[i] {
return a[i] < b[i]
}
}
return false
}
// latestSemver returns the highest valid semver among tags.
func latestSemver(tags []string) (v [3]int, ok bool) {
for _, t := range tags {
p, valid := parseSemver(t)
if !valid {
continue
}
if !ok || semverLess(v, p) {
v, ok = p, true
}
}
return v, ok
}
added cmd/eeco/update_test.go
@@ -0,0 +1,88 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestRunUpdate_BadArgs(t *testing.T) {
var errOut bytes.Buffer
if code := runUpdate([]string{"now"}, &bytes.Buffer{}, &errOut); code != 2 {
t.Fatalf("extra arg -> exit %d, want 2", code)
}
if !strings.Contains(errOut.String(), "usage: eeco update") {
t.Errorf("missing usage:\n%s", errOut.String())
}
}
func TestRunUpdate_GitUnavailableIsNotAFailure(t *testing.T) {
// No git on PATH: the read-only check cannot run, but eeco update
// must still exit 0 (a check that cannot run is not a failure).
t.Setenv("PATH", "")
var out bytes.Buffer
if code := runUpdate(nil, &out, &bytes.Buffer{}); code != 0 {
t.Fatalf("git-unavailable -> exit %d, want 0", code)
}
if !strings.Contains(out.String(), "git is unavailable") {
t.Errorf("expected an unavailable note, got:\n%s", out.String())
}
}
func TestRunUpdate_ApplyBlockedWhenOffline(t *testing.T) {
// --apply cannot proceed without knowing the latest tag; an
// offline check turns into exit 2 (blocked) instead of 0.
t.Setenv("PATH", "")
var out bytes.Buffer
if code := runUpdate([]string{"--apply"}, &out, &bytes.Buffer{}); code != 2 {
t.Fatalf("apply-offline -> exit %d, want 2", code)
}
}
func TestRunUpdate_UnknownFlag(t *testing.T) {
var errOut bytes.Buffer
if code := runUpdate([]string{"--nope"}, &bytes.Buffer{}, &errOut); code != 2 {
t.Fatalf("unknown-flag -> exit %d, want 2", code)
}
}
func TestParseSemver(t *testing.T) {
cases := []struct {
in string
want [3]int
ok bool
}{
{"v0.4.0", [3]int{0, 4, 0}, true},
{"1.2.3", [3]int{1, 2, 3}, true},
{"v2.0.0-rc1", [3]int{2, 0, 0}, true},
{"0.0.0-dev", [3]int{0, 0, 0}, true},
{"v1.2", [3]int{}, false},
{"vX.Y.Z", [3]int{}, false},
{"", [3]int{}, false},
}
for _, c := range cases {
got, ok := parseSemver(c.in)
if ok != c.ok || (ok && got != c.want) {
t.Errorf("parseSemver(%q) = %v,%v want %v,%v", c.in, got, ok, c.want, c.ok)
}
}
}
func TestSemverLessAndLatest(t *testing.T) {
if !semverLess([3]int{0, 3, 0}, [3]int{0, 4, 0}) {
t.Error("0.3.0 should precede 0.4.0")
}
if semverLess([3]int{1, 0, 0}, [3]int{1, 0, 0}) {
t.Error("equal versions are not less")
}
if semverLess([3]int{0, 10, 0}, [3]int{0, 9, 9}) {
t.Error("0.10.0 should not precede 0.9.9")
}
got, ok := latestSemver([]string{"v0.1.0", "garbage", "v0.4.0", "v0.2.5"})
if !ok || got != [3]int{0, 4, 0} {
t.Errorf("latestSemver = %v,%v want {0 4 0},true", got, ok)
}
if _, ok := latestSemver([]string{"nope", "also-nope"}); ok {
t.Error("no valid semver should yield ok=false")
}
}
added cmd/eeco/workflows.go
@@ -0,0 +1,119 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/workflow"
)
const workflowsUsage = `usage:
eeco workflows [status] list scaffolded workflows and their on/off state
eeco workflows <name> <on|off> toggle a scaffolded workflow on or off
A scaffolded workflow lives under <workspace>/workflows/<name>/. Turning
it off creates an empty sentinel marker file inside that directory so
eeco skips it until "on" removes the marker. Builtin workflows are
unaffected: their contract is part of the frozen public surface.`
// runWorkflows backs `eeco workflows`. With no args (or "status") it
// lists every user-scaffolded workflow alphabetically with its on/off
// state. With two args (<name> <on|off>) it flips the workflow's
// active state by creating or removing a sentinel marker inside the
// workflow directory. Builtins are not toggleable here.
func runWorkflows(args []string, stdout, stderr io.Writer) int {
cfg, code := loadInitedConfig(stderr, "eeco workflows")
if code != 0 {
return code
}
if len(args) == 0 || (len(args) == 1 && args[0] == "status") {
return listWorkflows(cfg, stdout, stderr)
}
if len(args) != 2 {
fmt.Fprintln(stderr, workflowsUsage)
return 2
}
name, action := args[0], args[1]
if action != "on" && action != "off" {
fmt.Fprintln(stderr, workflowsUsage)
return 2
}
if !workflow.WorkflowExists(cfg, name) {
fmt.Fprintf(stderr, "eeco workflows: no scaffolded workflow named %q.\n", name)
fmt.Fprintln(stderr, "hint: run `eeco new <name>` to scaffold one, or `eeco workflows` to list known ones.")
return 2
}
switch action {
case "on":
already := !workflow.IsDisabled(cfg, name)
if err := workflow.Enable(cfg, name); err != nil {
fmt.Fprintln(stderr, "eeco workflows:", err)
return 1
}
if already {
fmt.Fprintf(stdout, "eeco workflows: %s already on\n", name)
} else {
fmt.Fprintf(stdout, "eeco workflows: %s on\n", name)
}
maybeAutoCommit(cfg.WorkspaceHistory.Auto(), cfg.UserDir, "workflows "+name+" "+action, stderr)
return 0
case "off":
already := workflow.IsDisabled(cfg, name)
if err := workflow.Disable(cfg, name); err != nil {
fmt.Fprintln(stderr, "eeco workflows:", err)
return 1
}
if already {
fmt.Fprintf(stdout, "eeco workflows: %s already off\n", name)
} else {
fmt.Fprintf(stdout, "eeco workflows: %s off\n", name)
}
maybeAutoCommit(cfg.WorkspaceHistory.Auto(), cfg.UserDir, "workflows "+name+" "+action, stderr)
return 0
}
return 0
}
// listWorkflows prints every user-scaffolded workflow with its on/off
// state, alphabetically. A workspace with no scaffolded workflows
// prints a one-line hint.
func listWorkflows(cfg *config.Config, stdout, stderr io.Writer) int {
dir := filepath.Join(cfg.Workspace, "workflows")
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
fmt.Fprintln(stdout, "no scaffolded workflows — `eeco new <name>` scaffolds one")
return 0
}
fmt.Fprintln(stderr, "eeco workflows:", err)
return 1
}
var names []string
for _, e := range entries {
if e.IsDir() {
names = append(names, e.Name())
}
}
if len(names) == 0 {
fmt.Fprintln(stdout, "no scaffolded workflows — `eeco new <name>` scaffolds one")
return 0
}
sort.Strings(names)
for _, n := range names {
state := "on"
if workflow.IsDisabled(cfg, n) {
state = "off"
}
fmt.Fprintf(stdout, "%s [%s]\n", n, state)
}
return 0
}
added cmd/eeco/workflows_test.go
@@ -0,0 +1,161 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
// seedWorkflow scaffolds one user workflow into the current repo's
// workspace via the public `eeco new` path. Returns the repo root.
func seedWorkflow(t *testing.T, name string) string {
t.Helper()
root := initRepo(t)
var out, errOut bytes.Buffer
if code := runNew([]string{name}, &out, &errOut); code != 0 {
t.Fatalf("seed workflow %q exit=%d stderr=%s", name, code, errOut.String())
}
return root
}
func TestRunWorkflows_OffThenOnRoundTrip(t *testing.T) {
root := seedWorkflow(t, "wf1")
var out, errOut bytes.Buffer
if code := runWorkflows([]string{"wf1", "off"}, &out, &errOut); code != 0 {
t.Fatalf("off exit=%d stderr=%s", code, errOut.String())
}
marker := filepath.Join(root, "tester", ".eeco", "workflows", "wf1", "disabled")
if _, err := os.Stat(marker); err != nil {
t.Fatalf("marker not created after off: %v", err)
}
out.Reset()
errOut.Reset()
if code := runWorkflows([]string{"wf1", "on"}, &out, &errOut); code != 0 {
t.Fatalf("on exit=%d stderr=%s", code, errOut.String())
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("marker not removed after on: err=%v", err)
}
}
func TestRunWorkflows_OffAlreadyOffIsNoop(t *testing.T) {
seedWorkflow(t, "wf1")
var out, errOut bytes.Buffer
if code := runWorkflows([]string{"wf1", "off"}, &out, &errOut); code != 0 {
t.Fatalf("first off exit=%d", code)
}
out.Reset()
errOut.Reset()
if code := runWorkflows([]string{"wf1", "off"}, &out, &errOut); code != 0 {
t.Fatalf("second off exit=%d", code)
}
if !strings.Contains(out.String(), "already off") {
t.Errorf("expected 'already off' note, got:\n%s", out.String())
}
}
func TestRunWorkflows_OnAlreadyOnIsNoop(t *testing.T) {
seedWorkflow(t, "wf1")
var out, errOut bytes.Buffer
if code := runWorkflows([]string{"wf1", "on"}, &out, &errOut); code != 0 {
t.Fatalf("on exit=%d", code)
}
if !strings.Contains(out.String(), "already on") {
t.Errorf("expected 'already on' note, got:\n%s", out.String())
}
}
func TestRunWorkflows_UnknownName(t *testing.T) {
initRepo(t)
var out, errOut bytes.Buffer
code := runWorkflows([]string{"no-such-wf", "off"}, &out, &errOut)
if code != 2 {
t.Fatalf("unknown name exit=%d, want 2", code)
}
if !strings.Contains(errOut.String(), "no scaffolded workflow") {
t.Errorf("stderr missing not-found message:\n%s", errOut.String())
}
}
func TestRunWorkflows_BadAction(t *testing.T) {
seedWorkflow(t, "wf1")
var out, errOut bytes.Buffer
code := runWorkflows([]string{"wf1", "toggle"}, &out, &errOut)
if code != 2 {
t.Fatalf("bad action exit=%d, want 2", code)
}
}
func TestRunWorkflows_OutsideRepo(t *testing.T) {
dir := t.TempDir()
chdir(t, dir)
var out, errOut bytes.Buffer
code := runWorkflows([]string{"wf1", "off"}, &out, &errOut)
if code != 1 {
t.Fatalf("outside-repo exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "not inside a git repository") {
t.Errorf("stderr missing not-in-repo message:\n%s", errOut.String())
}
}
func TestRunWorkflows_UninitialisedWorkspace(t *testing.T) {
root := newGitRepo(t)
chdir(t, root)
var out, errOut bytes.Buffer
code := runWorkflows([]string{"wf1", "off"}, &out, &errOut)
if code != 1 {
t.Fatalf("uninit exit=%d, want 1", code)
}
if !strings.Contains(errOut.String(), "not initialised") {
t.Errorf("stderr missing not-initialised message:\n%s", errOut.String())
}
}
func TestRunWorkflows_ListShowsAllWithState(t *testing.T) {
seedWorkflow(t, "wf-a")
var out, errOut bytes.Buffer
if code := runNew([]string{"wf-b"}, &out, &errOut); code != 0 {
t.Fatalf("seed wf-b exit=%d", code)
}
out.Reset()
errOut.Reset()
if code := runWorkflows([]string{"wf-b", "off"}, &out, &errOut); code != 0 {
t.Fatalf("off wf-b exit=%d", code)
}
out.Reset()
errOut.Reset()
if code := runWorkflows(nil, &out, &errOut); code != 0 {
t.Fatalf("list exit=%d stderr=%s", code, errOut.String())
}
listing := out.String()
for _, want := range []string{"wf-a [on]", "wf-b [off]"} {
if !strings.Contains(listing, want) {
t.Errorf("listing missing %q:\n%s", want, listing)
}
}
}
func TestRunWorkflows_ListEmptyHint(t *testing.T) {
initRepo(t)
var out, errOut bytes.Buffer
if code := runWorkflows([]string{"status"}, &out, &errOut); code != 0 {
t.Fatalf("status exit=%d stderr=%s", code, errOut.String())
}
if !strings.Contains(out.String(), "no scaffolded workflows") {
t.Errorf("empty hint missing:\n%s", out.String())
}
}
added docs/ARCHITECTURE.md
@@ -0,0 +1,379 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="../assets/eeco_logo_dark.png">
<img src="../assets/eeco_logo_light.png" alt="eeco" width="280">
</picture>
<h1>Architecture</h1>
<p>
<a href="../README.md"><b>README</b></a> ·
<a href="../VISION.md"><b>Vision</b></a> ·
<a href="COCKPIT.md"><b>Cockpit</b></a> ·
<a href="USAGE.md"><b>Usage</b></a> ·
<b>Architecture</b> ·
<a href="PUBLIC_API.md"><b>Public API</b></a> ·
<a href="../EXTENDING.md"><b>Extending</b></a> ·
<a href="../CONTRIBUTING.md"><b>Contributing</b></a> ·
<a href="UPGRADING.md"><b>Upgrading</b></a> ·
<a href="../VERSIONING.md"><b>Versioning</b></a> ·
<a href="../CHANGELOG.md"><b>Changelog</b></a> ·
<a href="../SECURITY.md"><b>Security</b></a>
</p>
</div>
---
A one-paragraph orientation, the directory tree, and the runtime story
behind a single `eeco` invocation. Companion to [`USAGE.md`](USAGE.md)
(the user-facing reference). The full specification lives in the build
runbook that ships with the source tree; this document is the short
overview for the operator and curious users.
## Overview
eeco is two complementary halves: a self-maintaining workflow ecosystem
(the heart) and a deterministic, no-AI-spend knowledge layer any AI
assistant can plug into. Architecturally, both are served by one process:
eeco is a single static Go binary that runs inside a target repository
while a developer is coding. It maintains a private, gitignored
workspace inside the repo (`.eeco/` by default) that holds the engine's
state, the memory store, scaffolded workflows, queue items, and hook
ledger. The binary detects the project profile, runs built-in or
user-scaffolded workflows under a strict exit-code contract, and gates
every AI call behind explicit consent and a budget cap. It never
commits, pushes, or activates a generated workflow on its own.
## Directory layout
```
cmd/eeco/
main.go CLI entry; subcommand dispatch (stdlib flag)
init.go eeco init (workspace bootstrap + .gitignore)
initgit.go init-only HOST .gitignore commit (sanctioned git write)
historygit.go private workspace-history repo ops (sanctioned git write)
history.go eeco history (log / snapshot)
status.go no-args path: digest or TUI
run.go eeco run [--ai] <workflow>
new.go eeco new <workflow> (template scaffolder)
gc.go eeco gc (memory garbage collection)
hooks.go eeco hooks (status / on|off / session-emit)
update.go eeco update (read-only remote tag check)
doctor.go eeco doctor (14 diagnostic probes)
internal/
config/ repo-root + profile detection; config.local
loader; WriteLocalKeys upsert; SessionSettingsPath
memory/ one-fact files, frontmatter, store, MEMORY.md
index, word-overlap Select, GC table
workflow/ registry, exit-code contract (0/1/2/3),
Run + ScriptRun, embed.FS templates, attribution
Detector, builtin workflows
ai/ Provider interface; cliProvider (shells
ai_command); notConfigured stub; shared Gate
(consent, budget, parking); AI-call ledger;
background ProjectDigest/Understand
queue/ append-only state/queue.md; portable presence
lock (.queue.lock); ErrLocked sentinel
hooks/ pre-commit (SHA/marker exact-match reversible);
session-start + commit-guard (JSON edit + workspace
backup + validate + restore); state/hooks.json ledger
gitx/ read-only git helpers: Available, TrackedFiles,
HeadSHA, ChangesSince, RemoteTags
tui/ Bubble Tea control center; OneScreen digest;
dispatch, completion, styles
workflows/ embedded builtin workflow definitions (embed.FS)
scripts/ build.sh, gen-packaging.sh, release.sh, regen-demo.sh
Makefile build / release / verify / gates / bench / packaging
.github/workflows/ ci.yml (matrix verify + Windows smoke);
release.yml (cross-build + sign + attest + upload)
docs/ USAGE.md (user guide), ARCHITECTURE.md (this file),
UPGRADING.md (post-v0.1.0 upgrade notes)
```
## Component map
The runtime divides into seven concerns. Each is a leaf package under
`internal/`; nothing in `internal/` is part of the frozen public surface.
| Concern | Package | Responsibility |
| -------------- | ---------------- | ------------------------------------------------------------------------------ |
| Config | `internal/config` | Resolve repo root, detect profile, load `config.local`, write upserts. |
| Memory | `internal/memory` | Fact files, frontmatter parse/serialise, GC, `MEMORY.md` index, relevance. |
| Workflow | `internal/workflow` | Registry, runner, contract enforcement, attribution detector, scaffolder. |
| AI provider | `internal/ai` | Provider interface, the CLI provider (shells `ai_command`), gating (consent / budget / park), AI-call ledger. |
| Queue | `internal/queue` | Single decision channel; append, count, list/resolve under file lock. |
| Hooks | `internal/hooks` | Reversible pre-commit + session-start wiring; ledger of every install. |
| Read-only git | `internal/gitx` | Tracked-set, HEAD SHA, change-since-SHA, remote tags. No write surface. |
| TUI | `internal/tui` | Bubble Tea control center; non-TTY → `OneScreen` and exit 0. |
The CLI under `cmd/eeco/` is a thin dispatch layer over these packages.
A no-argument invocation prints the status digest in a piped or CI
environment, or launches the TUI on a real terminal.
**Git-write boundary.** Engine packages (`internal/*`, including `gitx`)
are read-only with respect to git; every git *write* lives in package
`main` so engine code cannot mutate history. There are exactly two such
sites: `initgit.go` (the one-shot `eeco init` `.gitignore` commit on the
HOST repo) and `historygit.go` (the optional private workspace-history
repo — a separate, local, no-remote git repo inside the gitignored
`<username>/`, which records only what eeco already writes there and never
touches the host's tracked tree). `historygit.go` guards the nested-repo
hazard — git searching upward to the host repo — with a `.git`
stat-check plus a `--show-toplevel` assert before every write.
`eeco history compact` rewrites the private repo's log (squash-all to one
parentless commit via `commit-tree` + `reset --soft`) within those same
guards, so the host tree is never touched and the kept tree is unchanged.
## Runtime story (typical `eeco run leak-guard`)
1. `cmd/eeco/main.go` parses the subcommand and flags (stdlib `flag`).
2. `internal/config` walks upward to the repo root, detects the profile
(`go`, `python`, etc.), and loads `<workspace>/config.local` if the
workspace exists.
3. `cmd/eeco/run.go` constructs an `Env` (repo root, workspace path,
profile, automation level, queue handle, memory handle, optional
`Gate`) and looks up the named workflow in the registry.
4. The workflow runs with the repo root as its working directory. A
builtin runs natively in Go; a user workflow runs through `ScriptRun`,
which enforces the same contract.
5. Any AI call routes through `internal/ai.Gate`. The gate selects the
provider (the CLI provider or the not-configured stub), enforces
consent (`--ai` or `automation=auto`) and the per-invocation budget
cap, and on any skip or failure parks the prompt under
`<workspace>/state/parked/` and appends an `ai-parked` queue item.
Every attempt — ran, parked, or gated-out — is recorded to
`<workspace>/state/ai-calls.json`. eeco runs no in-binary model
client and no agentic tool loop; it configures the harness that runs
the AI (see `docs/COCKPIT.md`), and each gated pass is a single
provider call.
6. Decision-bearing output (a finding, a proposal, a draft handover)
becomes a queue item under a presence lock. Two concurrent writers
see `queue.ErrLocked`; the loser exits cleanly without corruption.
7. The runner returns one of four exit codes (see below). The process
exits with that code.
The TUI follows the same contract: every slash command dispatches to an
engine operation that already exists, and every free-text input is one
turn of a multi-turn conversation that passes through the same `Gate`
(built per turn, so each turn gets the configured budget).
## Workflow contract
Every workflow returns one of four exit codes:
| Code | Meaning |
| ---- | ------------------ |
| `0` | clean |
| `1` | finding or failure |
| `2` | blocked (a required tool is missing) |
| `3` | AI pass deferred (no consent) |
A workflow writes only inside the workspace and routes any decision
through the queue. Gates report-and-fail; they do not queue.
`bug-sweep` and `handover-refresh` queue; `comment-hygiene` and
`leak-guard` do not.
## Trust boundaries
eeco has three concentric zones:
```
+--------------------------------------------------------------+
| outside the repo |
| +--------------------------------------------------------+ |
| | tracked tree (eeco never writes here) | |
| | +-----------------------------------------------+ | |
| | | workspace (gitignored — eeco writes only here) | | |
| | | engine/ memory/ workflows/ state/ docs/ | | |
| | +-----------------------------------------------+ | |
| +--------------------------------------------------------+ |
+--------------------------------------------------------------+
```
Two opt-in, reversible touches escape the workspace:
- `.git/hooks/pre-commit` — local, repo-scoped, untracked. Installed
only if no pre-commit hook exists; removed only when the on-disk
script is byte-identical to what eeco wrote.
- Namespaced entries in the AI CLI's user-global JSON settings file:
the session-start emitter and the opt-in commit-guard PreToolUse hook
(which denies a `git commit` carrying AI attribution, in any repo). The
exact path is supplied by `session_settings_path` in `config.local` or
the `EECO_SESSION_SETTINGS` environment variable; unset means both are a
no-op.
Both touches are recorded in `<workspace>/state/hooks.json`. Removal
restores the file to its prior state.
The path guard in `internal/config` refuses `..` traversal and rejects
any write target outside the workspace.
The control-center chat lets the model invoke tools, but the tool
registry is **read-only by construction**: only read-only capabilities
(search the knowledge layer, read the project brief, list open decisions,
list memory) are ever registered — no write, git, or otherwise mutating
verb is. The boundary therefore holds by absence of capability, not by a
runtime check, so the workspace-write and tracked-tree-never-written
guarantees survive even if the model is adversarial or hijacked. The
serialized arguments of every tool call also pass the pre-write
attribution scanner before the tool runs; a flagged call aborts the whole
pass before any tool executes.
## Public surface
From v0.1.0 eeco follows semver over an explicitly frozen public
surface (pre-stability; see [`VERSIONING.md`](../VERSIONING.md) §2.1): the
CLI commands and flags, the workflow exit-code contract
and `Env` shape, the read-only `gitx` helpers, the `config.local` keys,
the memory frontmatter, the queue and hook-ledger formats, the builtin
workflow names, and the first line of `eeco version`. The exhaustive
enumeration is the freeze contract in [`PUBLIC_API.md`](PUBLIC_API.md).
Internal package APIs under `internal/` remain unfrozen.
## Build and release pipeline
`Makefile` orchestrates everything: `make build` produces the local
binary with version metadata injected via `-ldflags`; `make verify`
runs `go build/vet/test`; `make gates` runs `comment-hygiene` and
`leak-guard` against the working tree; `make bench` runs the
build-tagged perf gate against a generated 50k-file fixture; `make
release` cross-builds the six-platform matrix into `dist/`; `make
packaging` emits `eeco.rb` and `eeco.json` from the checksums.
CI runs `make verify` and `make gates` on every PR and `main` push,
across a Linux/Windows matrix with a dedicated Windows smoke step. On
a `v*` tag push, the release workflow cross-builds the matrix, signs
`SHA256SUMS` with keyless cosign, attests every archive with GitHub
build provenance, generates the Homebrew formula and Scoop manifest,
and uploads eleven assets to the GitHub Release page.
## Known scaling limits
eeco is built for a single developer's repository and a small, curated
knowledge store; its costs are bounded by that scale, not by sub-linear
algorithms. The places where a cost grows with input are listed here with
their bound, the trigger that would justify revisiting them, and — where a
tempting optimization was considered and declined — why. None is a hot path
today; the entries exist so a maintainer inherits the reasoning instead of
rediscovering it.
**Filesystem walks.** Two non-test tree walks scale with input. The
`manifest-refresh` knowledge-tree walk (`internal/manifest`) only
*enumerates directories* — no file reads — so it is O(directories) and
cheap. The gate workflows `comment-hygiene` and `leak-guard`
(`internal/workflow/scan.go`) walk the whole working tree *and read every
text file* — O(files × size) — skipping `.git` and the workspace.
*Bound:* a `make bench` CI gate fails if a 50,000-file fixture scan exceeds
a 5-second wall (`internal/workflow/bench_test.go`), so the cost is
measured, not assumed. *Revisit when:* a real repo approaches that file
count, or the bench wall creeps toward the budget. *Not optimized:* no
incremental or cached scan — the bench shows comfortable headroom at 50k
files and a cache would add a staleness surface for no current gain.
**AI-call ledger.** Every gated AI attempt appends one record by reading,
re-marshalling, and rewriting the whole `{"records":[…]}` file
(`internal/ai/ledger.go`, `appendAICall`); `eeco stats` reads the whole
file once (`internal/ai/summary.go`). The cost is O(n) per append for n
lifetime attempts, with no cap; `state/evolve-history.json` shares the same
shape and discipline. *Bound:* realistic solo use is hundreds to low
thousands of records at roughly 300 bytes each — a sub-megabyte file
rewritten in well under a millisecond. *Revisit when:* the ledger reaches
multiple megabytes, or append / `eeco stats` latency becomes perceptible.
*Not optimized:* the on-disk shape is frozen (`docs/PUBLIC_API.md`), so an
append-only migration is a breaking change (see the register below); a
format-preserving record cap was considered and deferred — at sub-megabyte
sizes there is no real cost, and because the ledger is an audit trail a cap
must first decide count-versus-age and delete-versus-archive, which is its
own design and versioning pass when real scale demands it.
**Memory relevance.** Relevance ranking tokenises the query and scores
every fact by keyword overlap — O(n·m) for n facts and m query terms —
then, in `memory.Select`, bumps `last_used` and re-saves each matched fact
as one atomic whole-file write per match (`internal/memory/select.go`).
*Bound:* the store is a small, GC-curated set — dozens of facts in
practice — so O(n·m) is negligible. The live query path (`eeco go` and
`eeco ask`, via `internal/ask`) runs the same overlap scan but deliberately
does **not** bump-and-save, so the per-match write cost is not paid today;
`memory.Select` itself is currently unused in production. *Revisit when:*
`Select` is wired into a frequent path, or the fact count grows past a few
hundred. *Not optimized:* no inverted index or ranking rewrite —
unjustified at a curated store of this size, where recall matters more than
speed (see the register).
**Brief budget ladder.** When `context_budget` is set, `eeco go --write`
collects the brief once and then re-renders it down a fixed ladder (full,
then progressively capped tiers) until it fits (`internal/brief`,
`RenderWithinBudget`). The expensive step — collecting memory, git state,
and workflows — runs **once**; only the in-memory Markdown render repeats,
at most about seven times, over already-trimmed slices. *Bound:* the ladder
is a fixed ≤7 rungs of cheap string building, and the feature is opt-in —
with no `context_budget` (the default) the ladder never runs. *Revisit
when:* effectively never at current brief sizes. *Not optimized:* no
incremental trimming or binary search over the ladder — at most seven
string builds do not justify the complexity.
**Read-only git helpers.** Every `internal/gitx` helper forks one `git`
subprocess per call, with no caching. Most are called once per invocation,
but two paths repeat the same call in one process: `evolve` calls
`ChangesSince` twice per run, and `memory-drift` calls `LastCommitDate`
once per fact carrying a `ref` (O(facts)). *Bound:* a subprocess fork costs
single-digit milliseconds, and both repeating paths are on-demand
maintenance workflows, not interactive hot paths. *Revisit when:* a future
interactive or per-keystroke path calls the same helper repeatedly in one
process. *Not optimized:* no gitx memoization — caching a HEAD SHA or
tracked set inside a process that may itself drive git writes (the private
workspace-history repo) adds a staleness surface on a trust-boundary read,
to save roughly one fork in a non-interactive workflow; the trade is not
worth it.
**Explicitly not optimized.** Recorded so they are not re-litigated:
- **gitx result caching** — declined; staleness on a trust-boundary read
outweighs roughly one saved subprocess fork in a non-interactive
workflow.
- **AI-ledger append-only / JSONL migration** — would end the
full-rewrite-per-append, but changes the frozen `{"records":[…]}` shape
(`docs/PUBLIC_API.md`) and is therefore a major-version, separate
planning pass — not done now.
- **Memory ranking rewrite (inverted index or scoring overhaul)** —
unjustified at a curated dozens-of-facts store; recall matters more than
speed at this scale.
## Extension seams
Adding a new capability means registering it at one known point and, for
the seams that are irreducibly multi-file, touching a short fixed set of
sites. This table is the maintainer's map: where each kind of extension
is registered, what else it touches, and how much friction that is. It
records the one HIGH-friction seam — a new hook type — as intentional
rather than as debt. The companion how-to for a newcomer is
[`EXTENDING.md`](../EXTENDING.md), which expands these rows into worked examples; this
section is the authoritative source of the registration points.
| Extension | Register at | Also touch | Friction |
| --- | --- | --- | --- |
| CLI verb | `cmd/eeco/main.go` (the `run` dispatch switch) | the `usage` const in the same file; a new `cmd/eeco/<verb>.go` runner, reusing the `loadInitedConfig` / `loadRepoConfig` / `newFlagSet` guards in `helpers.go` | low |
| Builtin workflow | `internal/workflow/registry.go` (the `DefaultRegistry` slice) | a new `internal/workflow/<name>.go` implementing the workflow interface (`Name` / `Summary` / `Run`) | low |
| `config.local` key | `internal/config` (the `Config` struct) | the config parse loop that assigns it; a `Default…` const if it carries a default | low |
| Memory frontmatter field | `internal/memory/fact.go` (the `Fact` struct) | `frontmatter.go` — the `setField` parse switch and the `Serialize` writer (and `Validate` if the field is constrained) | medium |
| AI provider | `internal/ai` (the `Select` chooser) | the new provider type (`Name` / `Run`); the `config.local` key that selects it, plus its parse | medium |
| TUI slash-command | `internal/tui/commands.go` (the `commandIndex`) | the `dispatch` switch in the same file; tab-completion is derived automatically | low |
| Hook type | `internal/hooks/hooks.go` (the name const + the `Names` list) | the `ledger` struct field; the enable / disable / refresh trio; the `Status` line; the `cmd/eeco/hooks.go` dispatch; and the session-emit path if the hook emits | HIGH |
The hook-type seam is the one irreducible multi-touch point, and the
friction is the price of the trust boundary. A hook is an opt-in,
reversible escape from the workspace (see Trust boundaries), so every
hook type carries a ledger field that records its install for exact,
byte-identical removal — and that ledger, the enable/disable/refresh
trio, the status read-out, and the CLI dispatch must stay in lock-step
with it. The coupling is deliberate: it is what makes a hook removable
without guesswork. New seams should prefer the single-registration shape
of the rows above; add a hook type only when the capability genuinely
needs to escape the workspace.
---
[← Prev: Usage](USAGE.md) · [Next: Public API →](PUBLIC_API.md)
added docs/CLAUDE_PLUGIN.md
@@ -0,0 +1,80 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="../assets/eeco_logo_dark.png">
<img src="../assets/eeco_logo_light.png" alt="eeco" width="280">
</picture>
<h1>Claude Code plugin</h1>
<p>
<a href="../README.md"><b>README</b></a> ·
<a href="../VISION.md"><b>Vision</b></a> ·
<a href="COCKPIT.md"><b>Cockpit</b></a> ·
<a href="USAGE.md"><b>Usage</b></a> ·
<a href="ARCHITECTURE.md"><b>Architecture</b></a> ·
<a href="PUBLIC_API.md"><b>Public API</b></a> ·
<a href="../VERSIONING.md"><b>Versioning</b></a> ·
<a href="../CHANGELOG.md"><b>Changelog</b></a> ·
<a href="../SECURITY.md"><b>Security</b></a>
</p>
</div>
---
eeco ships a small [Claude Code](https://github.com/ajhahnde/eeco-claude-plugin)
plugin that exposes the highest-value eeco verbs as user-triggered slash
commands inside a Claude Code session, and (from v0.2.0) grounds each session
with eeco's context brief. The plugin is a thin wrapper: it bundles no binary
and makes no AI calls of its own — everything shells out to the `eeco` binary
on your `PATH` and brings its output into the conversation.
The plugin lives in its own repository,
[`ajhahnde/eeco-claude-plugin`](https://github.com/ajhahnde/eeco-claude-plugin),
with its own release lifecycle; this page is a pointer.
## Prerequisite
The `eeco` binary must be installed and on your `PATH` (any `v0.x`) — see
[Install](../README.md#install). The plugin does not install eeco for you.
## Install
From inside Claude Code:
```text
/plugin marketplace add ajhahnde/eeco-claude-plugin
/plugin install eeco@ajhahnde
```
## Commands
- **`/eeco:go`** — assemble and read eeco's deterministic context brief (the
knowledge layer distilled). Pass through `--brief`, `--json`, `--metrics`,
`--copy`, `--write`.
- **`/eeco:ask`** — a deterministic, no-AI-spend ranked search over memory and
project knowledge. Pass through `--limit`, `--json`.
- **`/eeco:report-bug`** — file a friction report; writes a local record and
prints a pre-filled issue URL (nothing is sent automatically). Pass through
`--note`, `--cmd`.
## Session briefer
From **v0.2.0**, installing the plugin also bundles a SessionStart hook that
auto-injects eeco's deterministic context brief (the same readout as
[`eeco go`](USAGE.md)) into a Claude Code session at startup, resume, and
clear — so the session begins already grounded in your knowledge layer. The
briefer makes no AI call, emits nothing in a non-eeco repo, and no-ops when
`eeco` is not on your `PATH`. The
[plugin README](https://github.com/ajhahnde/eeco-claude-plugin#what-installing-wires-up)
is the source of truth for its behaviour and the install-time consent it implies.
## Feedback
`/eeco:report-bug` is the friction channel — it records the rough edge locally
and hands you a pre-filled issue URL for the
[eeco repository](https://github.com/ajhahnde/eeco).
---
[← Back: README](../README.md) · [Plugin repository →](https://github.com/ajhahnde/eeco-claude-plugin)
added docs/COCKPIT.md
@@ -0,0 +1,134 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="../assets/eeco_logo_dark.png">
<img src="../assets/eeco_logo_light.png" alt="eeco" width="280">
</picture>
<h1>Cockpit</h1>
<p>
<a href="../README.md"><b>README</b></a> ·
<a href="../VISION.md"><b>Vision</b></a> ·
<b>Cockpit</b> ·
<a href="USAGE.md"><b>Usage</b></a> ·
<a href="ARCHITECTURE.md"><b>Architecture</b></a> ·
<a href="PUBLIC_API.md"><b>Public API</b></a> ·
<a href="../EXTENDING.md"><b>Extending</b></a> ·
<a href="../CONTRIBUTING.md"><b>Contributing</b></a> ·
<a href="UPGRADING.md"><b>Upgrading</b></a> ·
<a href="../VERSIONING.md"><b>Versioning</b></a> ·
<a href="../CHANGELOG.md"><b>Changelog</b></a> ·
<a href="../SECURITY.md"><b>Security</b></a>
</p>
</div>
---
> **Status: documented; pre-1.0.** This is the canonical reference for the
> `eeco cockpit` surface — eeco as a provider-agnostic AI-cockpit generator.
> The cockpit ships today, but it is **pre-1.0 and not yet part of the frozen
> enumeration** in [`PUBLIC_API.md`](PUBLIC_API.md): it is governed by the
> pre-stability clause of [`VERSIONING.md`](../VERSIONING.md) §2.1, so its
> shapes MAY still change in a `v0.x` MINOR with a migration note. Treat this
> document as the working contract for the surface.
## What the cockpit is
eeco does not run AI as a product capability. It **generates and maintains the
config that makes a harness run AI well** — the provider-agnostic AI cockpit.
The AI runs in the harness; eeco is the cockpit's author and mechanic.
A **playbook** is eeco's neutral, harness-independent description of one AI
procedure: a description, a structured safety contract (`intent`), a capability
allowlist, ordered steps, and an output format. The shipped library lives in
`internal/playbooks` (one embedded JSON source per playbook, the single
reviewable source of truth). A **renderer** (one per harness target) is the only
component that knows a target's file layout and permission spelling.
## The safety invariant
The product-defining guarantee is a machine-checked one: an emitted artifact can
**never** grant a write-capable git verb that a playbook declares forbidden.
The gate (`ScanAllowlistForWriteGitVerbs`) derives both the allowlist and the
prose warning from the structured `intent`, and **generation refuses** rather
than silently drop a forbidden verb. The invariant is **uniform across every
target** — advisory targets do not relax or bypass it.
## Targets and fidelity
| Target | File | Shape | Harness enforcement |
|---|---|---|---|
| `claude` | `<username>/.claude/skills/<name>/SKILL.md` | per-playbook | **enforced** (`allowed-tools`) |
| `cursor` | `<username>/.cursor/rules/<name>.mdc` | per-playbook | advisory |
| `agents` | `<username>/AGENTS.md` | aggregate (one file for the set) | advisory |
| `gemini` | `<username>/GEMINI.md` | aggregate (one file for the set) | advisory |
**Fidelity honesty.** Only Claude enforces a tool allowlist at runtime. The
advisory targets carry a loud `ADVISORY ONLY — NOT HARNESS-ENFORCED` banner and
a fidelity line; `generate` and `status` report the achieved enforcement. eeco
never lets the operator believe enforcement that isn't there. (Enforced settings
emission for Gemini/Cursor — their separate permissions layers — is deferred to
a later slice.)
All artifacts are written **only** into the gitignored `<username>/` private
tree; a renderer-supplied path that is absolute or escapes via `..` is rejected
(`relUnder`), so generation can never write a tracked file.
## Selecting targets
eeco asks at first `eeco init` which harness(es) you use and records the active
set in `<username>/.eeco/cockpit.json` (the selection store, separate from the
emission ledger at `<workspace>/state/cockpit.json`). Manage it later with:
```
eeco cockpit target list # active targets (+ fidelity) and available ones
eeco cockpit target add <target> # activate a target
eeco cockpit target rm <target> # deselect a target (does NOT delete emitted files)
```
`eeco cockpit target rm` deselects only — it never deletes emitted files; run
`eeco cockpit off --target <t>` to remove them (reversibility stays an explicit,
consented step).
## Generating, verifying, removing
```
eeco cockpit generate [--target T] [--playbook P] # emit the active set (or one target/playbook)
eeco cockpit verify [--target T] [--playbook P] # check emitted artifacts match + hold the invariant
eeco cockpit off [--target T] [--playbook P] # remove eeco's artifacts (sha-gated, reversible)
eeco cockpit status # one line per emitted artifact
eeco cockpit show [--playbook P] # print the neutral playbook source (JSON)
```
With no `--target`, `generate`/`verify`/`off` act on the **active set**:
per-playbook targets render every selected playbook; aggregate targets render
one shared file for the whole set. `--target T` scopes to one target (it need
not be active — a one-off). Every emit is **reversible** (a pre-existing foreign
file is backed up and restored on `off`), **sha-gated** (a hand-edited artifact
is left untouched), and **byte-idempotent** (re-emit of unchanged bytes is a
no-op).
### Verification by target
- **Claude (enforced):** on-disk `allowed-tools` is re-scanned for write-git
verbs, the bytes are sha-compared against a fresh render, and an optional
`--parity <answer-key>` runs a 3-tier structural comparison against a
hand-built reference skill.
- **Advisory targets:** there is no answer key, so verification runs a
**self-consistency** check on the on-disk bytes — every forbidden verb/phrase
must still be surfaced, the step/output structure intact, the banner present,
and **no** write-git verb leaked into an Allowed block.
## Playbook library (C2)
`handover` (write into the private tree, propose-only), `commit` (propose a
Conventional-Commits message; never stages/commits/tags), `doc-drift` (report
changelog-vs-tag drift; reads tags via `git for-each-ref`, never `git tag`),
`memcheck` (audit memory-file anchors; never edits a memory). Every shipped
playbook carries **zero** write-git verbs in its allowlist — a test
(`TestAllSources_NoWriteGitVerb`) proves it at build time.
---
[← Prev: Vision](../VISION.md) · [Next: Usage →](USAGE.md)
added docs/PUBLIC_API.md
@@ -0,0 +1,384 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="../assets/eeco_logo_dark.png">
<img src="../assets/eeco_logo_light.png" alt="eeco" width="280">
</picture>
<h1>Public API</h1>
<p>
<a href="../README.md"><b>README</b></a> ·
<a href="../VISION.md"><b>Vision</b></a> ·
<a href="COCKPIT.md"><b>Cockpit</b></a> ·
<a href="USAGE.md"><b>Usage</b></a> ·
<a href="ARCHITECTURE.md"><b>Architecture</b></a> ·
<b>Public API</b> ·
<a href="../EXTENDING.md"><b>Extending</b></a> ·
<a href="../CONTRIBUTING.md"><b>Contributing</b></a> ·
<a href="UPGRADING.md"><b>Upgrading</b></a> ·
<a href="../VERSIONING.md"><b>Versioning</b></a> ·
<a href="../CHANGELOG.md"><b>Changelog</b></a> ·
<a href="../SECURITY.md"><b>Security</b></a>
</p>
</div>
---
This document is the exhaustive enumeration of eeco's **frozen public
surface**. Companion to [`USAGE.md`](USAGE.md) (the user-facing
reference) and [`ARCHITECTURE.md`](ARCHITECTURE.md) (the architecture
overview).
## Scope
From v0.1.0 eeco follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
under the pre-stability caveat of [`VERSIONING.md`](../VERSIONING.md) §2.1. The
semver promise covers exactly the items listed here and nothing else. A breaking
change to any one of them takes a MAJOR bump once eeco is post-1.0; while eeco is
on the `v0.x` line a MINOR MAY make it, with a CHANGELOG migration note. Internal
package APIs under `internal/` are not part of this surface and may change in any
release.
v0.1.0 is the first public release of the cockpit-generator product:
every CLI command, flag, config key, JSON top-level key, queue and
ledger format, memory frontmatter field, and builtin workflow name
enumerated below is part of the tracked surface from this release.
Cockpit (`eeco cockpit …`, the `cockpit-sync` builtin, `handover_glob`,
and `state/cockpit.json`) is **pre-1.0 and not yet frozen** — see
[`COCKPIT.md`](COCKPIT.md). It is deliberately **not** enumerated in the
frozen lists below.
## Frozen surface
### CLI commands and flags
Every command and flag documented in [`USAGE.md`](USAGE.md) §4. The
`eeco gates check-attribution` subcommand and its flag set
(`--paths`, `--commits`, `--no-commits`, `--no-files`, `--exclude`)
are frozen as documented in §9a; additional `eeco gates …`
subcommands are additive and may land in any minor release. Additional
`eeco history …` subcommands (e.g. `compact`) are likewise additive and
may land in any minor release.
The `eeco go --json` output is a JSON object whose **top-level keys**
are frozen: `project`, `profile`, `gate`, `top_level`, `initialized`,
`workflows`, `where_to_look`, `knowledge`, `open_decisions`. Their
meanings are documented in [`USAGE.md`](USAGE.md) §13. Nested object
fields are best-effort and may gain keys in a minor release.
The `eeco go --metrics` flag is **additive and not part of the frozen
surface**: it prints a human-readable assembly readout to stderr (timing,
brief size, and an estimated compression of the knowledge layer) and does
**not** appear in or alter the frozen `eeco go --json` nine-key surface.
Its wording and the token estimates are not frozen.
The `eeco stats` verb is frozen (its existence is covered by the
[`USAGE.md`](USAGE.md) §4 command list), but — like `--metrics` — its
**readout wording and the displayed figures are not frozen**: they are a
human-readable, evolving readout aggregated from the `state/ai-calls.json`
ledger, so a future minor release may add fields or formats.
The `eeco report-bug --submit` flag is **additive and not part of the
frozen surface**: the flag's existence is covered by [`USAGE.md`](USAGE.md)
§4, but its **submission mechanism and the lines it prints are not
frozen** — `--submit` currently opens the pre-filled issue URL in a
browser, and a future minor release may change or add to how the report
reaches the project. The invariant that `eeco report-bug` never sends
anything without a human action is part of the frozen behaviour.
The `eeco ask --json` output is a JSON object whose **top-level keys**
are frozen: `question`, `memory`, `code`. Both arrays are always present
(an empty list, never null). Their meanings are documented in
[`USAGE.md`](USAGE.md) §13. Nested object fields (the per-hit `score`,
`path`, `line`, etc.) are best-effort and may gain keys in a minor
release.
### Workflow contract
- **Exit codes:** `0` clean, `1` finding or failure, `2` blocked (a
required tool is missing), `3` AI pass deferred (no consent).
- The `Env` value passed to a workflow.
- The read-only `gitx` helpers a user workflow may import: `Available`,
`TrackedFiles`, `HeadSHA`, `ChangesSince`, `RemoteTags`,
`LatestSemverTag`, `LastCommitDate`, `SemverTags`.
### Config keys
Keys recognised in `<workspace>/config.local`: `profile`, `gate`,
`stale_days`, `attribution_pattern`, `automation`, `ai_command`,
`ai_budget`, `ai_provider`, `ai_model`, `ai_api_key_env`,
`session_settings_path`, `bug_report_dir`, `context_path`,
`context_budget`, `brief_include_notes`, `session_start_pinned_bodies`,
`session_start_docs`,
`session_start_mailbox`, `session_start_roadmap_glob`, `session_files`,
`version_locations`, `version_anchor`, `pre_commit_workflows`,
`post_merge_workflows`, `workspace_history`.
Unknown keys are tolerated and preserved for forward compatibility.
The `workspace_history` key selects whether `eeco init` stands up a
private, local git repository inside the gitignored workspace directory
to version eeco's own knowledge layer, and how often it commits:
`off` (no repo), `manual` (the default — commit only on
`eeco history snapshot`), or `auto` (commits automatically after each
mutating verb). An unknown value falls back to the default. The repo has
no remote and is never pushed; see [`USAGE.md`](USAGE.md) §11a.
The `gate` key is repeatable: each occurrence declares one step of the
project's parse/build gate chain (a whitespace-split command). The
first occurrence resets the profile default so the operator-declared
chain fully replaces it; subsequent occurrences append. A lone empty
`gate=` clears the chain. The `gate` builtin workflow runs the chain in
declared order, with the repository root as the working directory,
stopping at the first failing step.
The four `session_start_*` keys tune what the bundled session-start
hook (`eeco hooks session-start on`) surfaces:
- `session_start_docs` — repeatable, repo-relative path; explicit
reading routine in order. When unset the hook auto-detects from a
built-in list (`docs/PUBLIC_API.md`, `docs/ARCHITECTURE.md`,
`CHANGELOG.md`, `ARCHITECTURE.md`, `docs/USAGE.md`, `README.md`).
Paths that point outside the repo are rejected at parse time.
- `session_start_mailbox` — repo-relative filename of the mailbox the
hook checks for unprocessed content. Default: `Ideas.md`. Empty
disables the mailbox block.
- `session_start_roadmap_glob` — glob, relative to the repo root, for
the live planning surface; the most-recently-modified match is
appended to the reading routine. Default: `roadmap*.md`. Empty
disables roadmap discovery.
- `session_start_pinned_bodies` — boolean, default `false`. When
`true`, the bundled session-start hook composes a fourth block that
emits the full body of every `pin: true` memory fact. The
`--with-pinned-bodies` flag on `eeco hooks session-emit` enables
this for one invocation without editing config; the flag and the
config key compose harmlessly.
The `eeco hooks session-emit` subcommand also accepts `--if-initialized`,
which suppresses all output unless the working directory holds an
initialized eeco workspace (the `IsInitialized` 5-subdir check). It
composes with `--with-pinned-bodies`. The command installed by
`eeco hooks session-start on` (and rewritten by `… refresh`) now carries
this flag, so the bundled brief emits only inside an eeco workspace —
repos without one stay silent regardless of which docs they contain.
The three `ai_*` provider keys select and tune which provider gated AI
passes use:
- `ai_provider` — `cli` or `none`, or empty to auto-select. `cli` uses
the CLI provider when `ai_command` is set, otherwise the
not-configured stub (every pass parks). Empty, `none`, or any
unknown/legacy value (e.g. `anthropic`) auto-selects the CLI provider
when `ai_command` is set, otherwise the not-configured stub — never a
config error. eeco runs no in-binary model client; the AI lives in the
harness eeco configures.
- `ai_model` — an inert legacy key. It is read and passed through but
ignored by the CLI provider; there is no native API path to consume
it. Kept only so an old `config.local` loads unchanged.
- `ai_api_key_env` — an inert legacy key naming an environment variable.
The retired native provider read its API key from it; the CLI provider
does not. Kept only for backward-compatible config loading; no key
value is read from or written to disk.
The `version_locations` key is repeatable; each value is a
`<repo-relative-path>:<RE2-regex>` pair split on the first colon, and
the regex must declare at least one capture group. Absolute paths and
`..` traversal are rejected at parse time. The `version-sync` builtin
workflow consumes the list; with no entries the workflow exits 0. The
reserved value `auto` switches `version-sync` to scan a fixed set of
common version files instead of an explicit list; it must stand alone
and cannot be mixed with `path:regex` entries.
The `version_anchor` key is single-valued and selects the source of
truth `version-sync` compares declared `version_locations` against.
Three modes: unset (default) keeps the consistency-only behaviour
(first declared location is the anchor); `tag` uses the latest
semver-shaped (`vX.Y.Z`) tag reachable from HEAD and lets declared
locations be semver-`>=` the tag so a release commit can bump declared
locations ahead of the not-yet-pushed tag (backward-drift still fails);
a `<repo-relative-path>:<RE2-regex>` value designates a file whose
captured version is the source of truth, and declared locations must
strict-equal it. Absolute paths and `..` traversal in the
designated-file form are rejected at parse time; the regex must
declare at least one capture group.
The `pre_commit_workflows` key is repeatable; each value is one
builtin workflow name. The first occurrence in the file resets the
binary default (`leak-guard`, `version-sync`), subsequent occurrences
append. An empty value clears the list and `eeco hooks pre-commit on`
refuses to install. Whitespace inside a value is rejected at parse
time; unknown workflow names are rejected at hook-install time.
The `post_merge_workflows` key is repeatable with the same semantics as
`pre_commit_workflows`; each value is one builtin workflow name run by
the `post-merge` hook after a merge. The first occurrence resets the
binary default (`memory-drift`, `doc-drift`, `manifest-refresh`),
subsequent occurrences
append. An empty value clears the list and `eeco hooks post-merge on`
refuses to install. Whitespace inside a value is rejected at parse time;
unknown workflow names are rejected at hook-install time. The binary
default additionally wires the pre-1.0 `cockpit-sync` machinery (not part
of the frozen builtin enumeration — see [`COCKPIT.md`](COCKPIT.md)).
The `context_budget` key is single-valued: a non-negative integer byte
cap on the file `eeco go --write` renders. When positive, `eeco go
--write` trims the saved brief down a deterministic ladder (full, then
the smaller `--brief` form with progressively shorter lists) until it
fits the budget. `0` (the default) means no cap; an empty value resets
to the default; a negative value is rejected at parse time.
The `brief_include_notes` key is single-valued and boolean: when set
truthy, `eeco go` adds a **Recent notes** section to the Markdown
brief, listing the five newest files under `<workspace>/notes/`. The
JSON brief (`eeco go --json`) is unchanged — the nine frozen
top-level keys remain the only surface; notes live on the Markdown
channel only. Accepted values are the standard `strconv.ParseBool`
set (`true`/`false`, `1`/`0`, `t`/`f`, case-insensitive); an empty
value resets to the default `false`; anything else is rejected at
parse time.
The `session_files` key is repeatable; each value declares one
text/markdown file where the `session-start` hook maintains a marker
block carrying the same content `eeco hooks session-emit` prints. An
entry is either repo-relative (held inside the repo by the same
path-traversal guard `session_start_docs` uses) or absolute (matching
the precedent set by `session_settings_path`). Whitespace inside a
value is rejected at parse time. With no entries the file-delivery
channel is disabled; the JSON-settings channel keyed by
`session_settings_path` is independent and either channel alone is
enough for `eeco hooks session-start on`. The block is fenced by
`<!-- eeco:session:start -->` / `<!-- eeco:session:end -->`; bytes
outside the marker pair are never edited. `eeco hooks session-start
refresh` re-renders the block; `eeco hooks session-start off` removes
the block (or the whole file when eeco created it and the block was
its only content).
### Memory frontmatter
Each memory fact carries flat, shell- and Go-parseable frontmatter:
| Field | Meaning |
| ------------- | ---------------------------------------------------------------- |
| `name` | kebab-case identifier. |
| `description` | one-line summary, used for relevance matching. |
| `type` | one of `user`, `feedback`, `project`, `reference`, `finding`. |
| `created` | creation date, `YYYY-MM-DD`. |
| `last_used` | date the fact was last surfaced, `YYYY-MM-DD`. |
| `ref` | optional repo-relative path; garbage collection validates it. |
| `expires` | optional expiry date, `YYYY-MM-DD`. |
| `status` | optional; for `finding` facts only — `open` or `resolved`. |
| `pin` | `true` or `false`; a pinned fact is never garbage-collected. |
| `source` | optional snippet (≤120 chars) of what triggered the fact. |
| `agent` | optional assistant identity that recorded the fact. |
| `disabled` | optional; `true` hides the fact from `eeco go` and `eeco ask` and exempts it from garbage collection. Omission means `false`. |
`source`, `agent`, and `disabled` are additive and optional on the
wire: a fact file without them still loads.
`disabled` is omitted from serialisation when `false` so legacy facts
round-trip without gaining a new line. The `eeco add fact` CLI requires
`--provenance` (which populates `source`) for `--type=feedback` and
`--type=user` facts; the store layer remains permissive so a
hand-authored fact can still be loaded if its provenance is unknown.
### Queue file format
`state/queue.md` is a Markdown checklist. Each item is one line —
`- [ ] **<kind>** — <title> _(<project>, <date>)_` — followed by an
indented detail line. The count of unchecked items is the queue count.
### Ledger formats
Three ledger files inside `<workspace>/state/` are part of the frozen
surface. All follow the same additive discipline: adding a top-level
key or a per-record field is non-breaking; older ledgers without it
still load. Removing or renaming a ledger filename, a top-level key,
or a documented per-record field is breaking and ships in a major
release only.
**`state/hooks.json`** records every hook eeco has installed, so each
one can be cleanly reverted. The toggleable hook names are
`pre-commit`, `post-merge`, `session-start`, `commit-msg`, and
`commit-guard` (`eeco hooks <name> on|off`); the ledger carries one
record per hook (`pre_commit`, `post_merge`, `session_start`,
`commit_msg`, `commit_guard`). The `session_start` record may carry an
additional `files[]` array — one entry per file the file-delivery
channel manages (`session_files`), each recording its `path`, the
`sha256` of the eeco-written block at install time, and a `created`
boolean; a ledger without the field still loads. The `commit_msg` and
`commit_guard` records are optional; a ledger without the key still
loads (absent = off). Additive `eeco hooks` subcommands and managed
hook names (like `commit-guard`) are non-breaking, the same way additive
`eeco gates` subcommands are: they extend the surface without changing
the frozen verb set.
**`state/evolve-history.json`** records every workflow candidate the
`evolve` builtin has surfaced, so a recurring signal is proposed
exactly once in its lifetime and the operator's resolve-state on the
queue row can be reconciled back into the ledger. Top-level shape:
`{ "records": [ … ] }`. Per-record fields:
| Field | Meaning |
| ------------------- | ----------------------------------------------------------------------- |
| `signal_kind` | the signal class (`commit-type` today; future kinds are additive). |
| `signal_key` | the specific signal value (e.g. `fix`). |
| `count_at_proposal` | the signal's count in the inspected history window at proposal time. |
| `queue_kind` | the queue row's `Kind` (always `evolve`). |
| `queue_title` | the queue row's `Title` (e.g. `Workflow candidate: fix-workflow`). |
| `proposed_at` | RFC 3339 UTC timestamp of the proposal. |
| `resolved` | optional; `true` once the queue row's checkbox is ticked. Omitted false. |
| `resolved_at` | optional; RFC 3339 UTC timestamp of the resolution. Omitted when empty. |
A corrupt ledger file degrades to the empty ledger so a broken file
never wedges `evolve`; the next save rewrites it. The same
additive-field discipline applies — later slices may add
fields (e.g. accepted-vs-rejected disambiguation) without breaking
older readers.
**`state/ai-calls.json`** records every gated provider attempt — ran,
parked, or gated-out — so the operator has an audit trail of what the
AI was asked and what it produced. It stores hashes, never raw text:
the prompt and response bodies live under `state/parked/` when
applicable and are not duplicated here. Top-level shape:
`{ "records": [ … ] }`. Per-record fields:
| Field | Meaning |
| ----------------- | ----------------------------------------------------------------------------- |
| `label` | the call's parking/ledger key (e.g. `evolve`, `bug-sweep`). |
| `provider` | the selected provider's name (`cli` or `none`; `anthropic` may appear in old ledgers — a legacy/tolerated value, not selectable). |
| `model` | optional; the model the provider resolved (omitted when none was resolved). |
| `prompt_sha256` | SHA-256 of the folded prompt (the same bytes the parked file stores). |
| `response_sha256` | optional; SHA-256 of the response text. Omitted on a parked pass, except a pass blocked by the attribution filter, which still records the blocked response's hash. |
| `ran` | `true` when the provider produced text. |
| `parked` | `true` when the pass was parked (no consent, over budget, or provider error). |
| `park_reason` | optional; why the pass parked. Omitted when empty. |
| `tokens` | `{ input, cached_input, output }` token counts; zero on a pass parked before the provider was called. |
| `tools` | optional; the tool names the model invoked in this round (a tool-using chat pass records one entry per round). Omitted when empty. |
| `ts` | RFC 3339 UTC timestamp of the attempt. |
A corrupt `ai-calls.json` degrades to the empty ledger and the next
write rewrites it; recording is best-effort and never turns a gated
pass into a hard failure.
### Builtin workflow names
`comment-hygiene`, `leak-guard`, `version-sync`, `gate`, `bug-sweep`,
`handover-refresh`, `evolve`, `memory-drift`, `doc-drift`,
`manifest-refresh`. Removing or renaming any one of these is a breaking
change.
### `eeco version` output
The first line, `eeco <version>`, is stable. The indented `commit:` and
`built:` lines are best-effort build metadata; they may change and are
not intended to be parsed.
## Not frozen
Internal package APIs under `internal/` — the packages the CLI and the
builtin workflows are implemented on top of — are deliberately excluded
from the public surface and may change in any release.
---
[← Prev: Architecture](ARCHITECTURE.md) · [Next: Extending →](../EXTENDING.md)
added docs/UPGRADING.md
@@ -0,0 +1,78 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="../assets/eeco_logo_dark.png">
<img src="../assets/eeco_logo_light.png" alt="eeco" width="280">
</picture>
<h1>Upgrading</h1>
<p>
<a href="../README.md"><b>README</b></a> ·
<a href="../VISION.md"><b>Vision</b></a> ·
<a href="COCKPIT.md"><b>Cockpit</b></a> ·
<a href="USAGE.md"><b>Usage</b></a> ·
<a href="ARCHITECTURE.md"><b>Architecture</b></a> ·
<a href="PUBLIC_API.md"><b>Public API</b></a> ·
<a href="../EXTENDING.md"><b>Extending</b></a> ·
<a href="../CONTRIBUTING.md"><b>Contributing</b></a> ·
<b>Upgrading</b> ·
<a href="../VERSIONING.md"><b>Versioning</b></a> ·
<a href="../CHANGELOG.md"><b>Changelog</b></a> ·
<a href="../SECURITY.md"><b>Security</b></a>
</p>
</div>
---
How to move between eeco releases. From v0.1.0 onward this file is the
canonical upgrade log: each release that needs upgrade steps or carries a
breaking change lands a section here.
## v0.1.0 and onward
eeco is **pre-stability** on the `v0.x` line. The public surface
(commands, flags, exit codes, workflow contract, config keys, memory
frontmatter, queue and ledger formats, builtin workflow names — see
[`PUBLIC_API.md`](PUBLIC_API.md)) follows semver under the pre-1.0 caveat
of [`VERSIONING.md`](../VERSIONING.md) §2.1: while eeco is pre-1.0 a MINOR
MAY make a breaking change, called out here and in the
[`CHANGELOG.md`](../CHANGELOG.md) `### Changed` entry with its migration
path; a PATCH never breaks. Once v1.0.0 ships, a breaking change requires
a major-version bump and a dedicated section in this file.
Each release section that needs one will document:
- **Required steps** — anything an existing workspace must do to remain
valid (usually nothing for a PATCH; pre-1.0 a MINOR may carry steps).
- **Removed or renamed surfaces** — every breaking change with the old
name, the new name, and a migration note.
- **New optional features** — config keys, commands, or workflows added
in the release that an existing workspace can adopt at its own pace.
A workspace from any `v0.x` release reads cleanly with any later `v0.x`
release: an unknown `config.local` key is tolerated; an unknown queue or
memory field is preserved on write.
## Migrating a legacy workspace
A repository that already ran an older eeco workspace at `<repo>/.eeco`
moves to the per-user layout with one command:
```
eeco migrate v1
```
This relocates the workspace to `<repo>/<username>/.eeco`, relinks the
hook ledger, and rewrites the `.gitignore` line — idempotently. Running
`eeco init` inside such a repository offers the same migration
interactively.
> **Note** — the `v1` in `eeco migrate v1` names the **workspace-layout
> generation** (the per-user `<username>/.eeco` layout), **not** the
> product version. The verb keeps its name as frozen CLI surface even
> though the product re-launches at v0.1.0.
---
[← Prev: Contributing](../CONTRIBUTING.md) · [Next: Versioning →](../VERSIONING.md)
added docs/USAGE.md
@@ -0,0 +1,1519 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="../assets/eeco_logo_dark.png">
<img src="../assets/eeco_logo_light.png" alt="eeco" width="280">
</picture>
<h1>Usage</h1>
<p>
<a href="../README.md"><b>README</b></a> ·
<a href="../VISION.md"><b>Vision</b></a> ·
<a href="COCKPIT.md"><b>Cockpit</b></a> ·
<b>Usage</b> ·
<a href="ARCHITECTURE.md"><b>Architecture</b></a> ·
<a href="PUBLIC_API.md"><b>Public API</b></a> ·
<a href="../EXTENDING.md"><b>Extending</b></a> ·
<a href="../CONTRIBUTING.md"><b>Contributing</b></a> ·
<a href="UPGRADING.md"><b>Upgrading</b></a> ·
<a href="../VERSIONING.md"><b>Versioning</b></a> ·
<a href="../CHANGELOG.md"><b>Changelog</b></a> ·
<a href="../SECURITY.md"><b>Security</b></a>
</p>
</div>
---
Everything eeco can do. The runtime story, directory layout, trust
boundaries, and frozen public surface are documented in
[`ARCHITECTURE.md`](ARCHITECTURE.md).
## 1. Install
Single static binary, zero runtime dependencies. Four install routes:
pre-built binary, Homebrew, Scoop, from source.
### 1.1 Pre-built binary (recommended)
Download the archive for your platform from the
[releases page](https://github.com/ajhahnde/eeco/releases), verify it
against `SHA256SUMS`, extract the `eeco` binary, and put it on your
`PATH`.
| OS | Arch | Archive |
| --------- | ------- | ------------------------------------------ |
| `darwin` | `amd64` | `eeco_<version>_darwin_amd64.tar.gz` |
| `darwin` | `arm64` | `eeco_<version>_darwin_arm64.tar.gz` |
| `linux` | `amd64` | `eeco_<version>_linux_amd64.tar.gz` |
| `linux` | `arm64` | `eeco_<version>_linux_arm64.tar.gz` |
| `windows` | `amd64` | `eeco_<version>_windows_amd64.zip` |
| `windows` | `arm64` | `eeco_<version>_windows_arm64.zip` |
Checksum verification:
```
shasum -a 256 -c SHA256SUMS
```
Signature and provenance (recommended). `SHA256SUMS` is signed
keylessly with cosign — no key to fetch or trust on file; the signing
identity is the release workflow itself. Verify it:
```
cosign verify-blob \
--certificate SHA256SUMS.pem \
--certificate-identity-regexp \
'^https://github.com/ajhahnde/eeco/\.github/workflows/release\.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--signature SHA256SUMS.sig \
SHA256SUMS
```
Each archive also carries GitHub build provenance. Verify a downloaded
archive came from this repository's release workflow:
```
gh attestation verify eeco_<version>_<os>_<arch>.<ext> --repo ajhahnde/eeco
```
macOS Gatekeeper. The `darwin` binaries are distributed unsigned by
design; their integrity is established by the `SHA256SUMS` signature
and provenance above rather than Apple's notary service. After
extracting, clear the quarantine attribute the browser sets on the
download:
```
xattr -d com.apple.quarantine ./eeco
```
The cosign + provenance route above is the supported integrity check
on macOS; the darwin binaries are intentionally distributed without an
Apple Developer ID signature.
### 1.2 From source
Requires Go 1.24+.
Released archives are produced by GitHub Actions on every `v*` tag and
uploaded to the [releases page](https://github.com/ajhahnde/eeco/releases).
Running `make release` locally is an alternate path — useful for
offline rebuilds and for verifying that a published release is
byte-identical to a fresh build from the tag.
Reproducing the released binaries byte-for-byte requires the same Go
toolchain CI used. `go.mod` pins it; when your installed Go differs,
set `GOTOOLCHAIN=go1.24.2` so the build fetches and uses the pinned
version.
```
git clone https://github.com/ajhahnde/eeco
cd eeco
make build # ./eeco with injected version
make release VERSION=vX.Y.Z # cross-build the published matrix into dist/
make verify # go build ./... && go vet ./... && go test ./...
```
`make build` produces `./eeco` with `version`, `commit`, and `buildDate`
injected via `-ldflags` so `eeco version` reports the real build
identity. `make release` writes archives plus `SHA256SUMS` into `dist/`;
`make packaging` writes `eeco.rb` and `eeco.json` from the checksums.
### 1.3 Homebrew (macOS, Linux)
```
brew install ajhahnde/eeco/eeco
```
Installs the binary from the latest tagged release via the
`ajhahnde/eeco` tap. `brew upgrade eeco` follows new tags. The formula
is generated per release and published with the release assets as
`eeco.rb`.
### 1.4 Scoop (Windows)
```
scoop bucket add eeco https://github.com/ajhahnde/scoop-eeco
scoop install eeco
```
`scoop update eeco` follows new tags. The manifest is published with
the release assets as `eeco.json`.
## 2. First use in a project — `eeco init`
```
cd /path/to/your/repo
eeco init
```
`eeco init`:
- creates a **private, per-user workspace** at `<repo>/<owner>/.eeco/`
and adds `/<owner>/` to `.gitignore` if it is not already ignored, so
nothing eeco does can leak into your tracked history. `<owner>` is your
git `user.name`; override it with `--username NAME` or the
`EECO_USERNAME` environment variable (resolution order: flag, env, git
`user.name`, then a prompt). `--workspace NAME` renames the inner
`.eeco` engine directory;
- scaffolds the engine, memory store, builtin workflows, and state
inside that directory;
- detects the project **profile** (zig, python, node, go, generic) and
the matching parse/build gate, and classifies the project **type** to
choose the knowledge directories it scaffolds. Detection is a
deterministic marker scan; `init_detection_threshold` (a value in
`[0,1]` in `config.local`) is the confidence at or above which init
accepts the scan without prompting (`0`, the default, uses the
detector's own default). `--type CATEGORY` forces a type and skips
detection; `--ai` opts into a single gated AI pass for an ambiguous
tree.
**The one commit eeco makes.** When init adds the `.gitignore` line it
stages *only that file*, commits it as `eeco init`, and pushes — the
single sanctioned write to your git history (everything else eeco writes
stays in the gitignored workspace). `--no-commit` skips the commit and
push; `--no-push` commits locally without pushing. On a non-git or
not-yet-real repo the step is skipped with a manual hint, and any git
failure is a warning — init never fails because of it.
**Migrating an older workspace.** If a legacy `<repo>/.eeco` workspace
from before the per-user layout is found, init reports it and offers to
run `eeco migrate v1` (§4) before continuing; declining makes no changes.
This is also the "new project, professional setup" path: one command
gives a repo its private workspace plus the whole ecosystem.
## 3. The control center (TUI) — `eeco`
Run `eeco` with no arguments to open the control center. Not a terminal
(piped/CI)? It prints a one-screen status digest and exits 0 instead.
The control center opens on a home screen: a centred eeco logo at the
top, the version and one rotating usage tip below it, a hint above the
input, and a thin dim status footer (repo, profile, gate, automation,
memory and queue counts, hook state, last run) below the input. Every
command is discoverable by typing `/` (the slash-command palette) or
pressing `?` (the shortcut overlay), so the home screen stays
uncluttered. Typing or running a command collapses the home view to
input + footer so streamed output reads cleanly above; clearing the
input recovers the home view. Output stays in the normal terminal
scrollback after you quit — no full-screen takeover.
Typing `/` opens a slash-command palette: a live dropdown of every
command with its one-line purpose, between the input and the footer.
Filter it by typing (`/h` narrows to `/help` and `/hooks`), move the
highlight with Up/Down, and accept the selection with Tab or Enter; the
palette closes on a space, a newline, or when the input clears.
The input is a multi-line composer: type or paste a request that spans
several lines. Enter submits; Alt+Enter or Ctrl+J inserts a newline
(Ctrl+J is the fallback where a terminal swallows Alt+Enter). The box
grows with the draft up to eight rows and then scrolls internally,
shrinking back when you submit or clear.
Free text (no leading slash) no longer opens an in-binary chat. eeco
configures the harness that runs the AI (see `eeco cockpit`), so the
control center stays a deterministic command surface rather than a
second-rate agent: a free-text line prints a dim one-line reminder to
use a slash command (`/run`, `/memory`, …) or `?` for help, and spends
nothing. The in-binary chat turn, its read-only tool loop, and `/clear`
were retired in the move to the cockpit model.
Input accepts:
| Input | What it does |
| ------------------ | --------------------------------------------------------- |
| `/run [--ai] <wf>` | run a workflow (`--ai` opts into one gated pass) |
| `/queue` | show items awaiting a decision |
| `/memory` | list stored facts |
| `/gc` | run memory garbage collection |
| `/new <wf>` | scaffold a new workflow |
| `/hooks [<n> on\|off]` | show or toggle the opt-in reversible hooks |
| `/settings [<k> <v>]` | view or set the AI config (persists to config.local) |
| `/help` | command and key reference |
| `/quit` | leave the control center |
| plain text | a dim reminder to use a slash command (chat is retired) |
Keys: Up/Down browse command history at the top and bottom of the
composer (and move the cursor within a multi-line draft otherwise, or
move the palette highlight when it is open), Tab completes a command or
workflow name, Alt+Enter / Ctrl+J insert a newline, `?` toggles a
shortcut overlay, Esc interrupts a running task — an animated spinner in
the footer marks one in flight. `q` (on an empty line), `/quit`, and
Ctrl-C always leave the terminal in a sane state. `NO_COLOR` is honoured.
Plain text never spends silently: each conversation turn is consented and
budget-capped, and parked-and-queued when it cannot run (the conversation
does not advance on a parked turn). Commands never require AI.
## 4. Commands
```
eeco open the control center (digest if non-TTY)
eeco init [--no-track] bootstrap the ecosystem in this repo (--no-track skips the private workspace-history repo)
eeco migrate v1 [--yes] move a legacy <repo>/.eeco workspace under <username>/
eeco run <workflow> run one workflow (read-only / safe by default)
eeco run --ai <workflow> allow this run's gated, budget-capped AI pass
eeco new <workflow> scaffold a new workflow from the template
eeco gc run memory garbage collection
eeco queue print the workspace queue (resolve by editing queue.md)
eeco stats print cumulative AI usage from the call ledger (real token counts)
eeco hooks <name> on|off toggle an opt-in hook (reversible; names: pre-commit, post-merge, session-start, commit-msg, commit-guard)
eeco hooks session-start refresh re-render every session_files block from current project state
eeco hooks <name> refresh rewrite an installed hook (pre-commit, post-merge, commit-msg, commit-guard) with the current eeco binary path
eeco gates check-attribution [flags] scan tracked files + commit bodies for AI-attribution fingerprints
eeco update [--apply] check for a newer release; --apply verifies + swaps
eeco doctor run workspace and config diagnostics
eeco go [--brief] [--metrics] [--write|--json|--copy] print an AI-ready brief; --brief trims it; --metrics adds a stderr readout; --json emits JSON, --write saves, --copy clipboards
eeco ask [--limit N] [--json] "<question>" answer a question with ranked file:line pointers (no AI)
eeco docs new [--overwrite] <target> scaffold a tracked-tree doc (targets: vision, readme)
eeco docs refresh <target> re-render a scaffolded doc's marker-wrapped block (targets: vision, readme)
eeco docs compact [--keep-last N --heading <prefix>] [--archive <path>] [--dry-run] <path> archive old regions of <path> (markers, or heading-discovered with --keep-last) into a sibling
eeco history show the private workspace-history log (recent commits)
eeco history snapshot [-m <msg>] commit the current workspace state into the private history repo
eeco history compact [--dry-run] [--yes] squash the private history log into one commit (reflog-recoverable)
eeco guide page the in-binary user manual through the host pager
eeco uninstall [--yes] write a handoff summary, print the removal command, and de-init the private history repo (--yes skips the confirm)
eeco report-bug [--submit] file a structured bug report (--submit opens the pre-filled issue URL in your browser)
eeco add note "<text>" append a free-form note to the workspace
eeco add fact --description "<s>" [flags] "<body>" record a durable memory fact
eeco add task "<title>" append an item to the workspace queue
eeco adaptations <name> on|off toggle an AI-adaptation fact on or off
eeco workflows [<name> on|off] list scaffolded workflows or toggle one on/off
eeco show notes list the workspace notes, newest first
eeco show adaptations list AI-adaptation facts with their on/off state
eeco show prompt [name] list the prompt library or print one prompt body
eeco refresh-manifest [<dir>] rebuild the .ai.json manifests in the knowledge dirs
eeco version print the version
eeco help command reference
```
**Exit codes** (also the workflow contract): `0` clean ·
`1` finding/failure · `2` blocked (a required tool is missing) ·
`3` AI pass deferred (no `--ai`).
## 5. Builtin workflows
| Workflow | Inspects | Writes | AI |
| ------------------ | -------------------------------- | ------------- | ---- |
| `comment-hygiene` | source/docs for tooling fingerprints | nothing | no |
| `leak-guard` | staged + tracked tree, commit msg | nothing | no |
| `version-sync` | declared `version_locations` | nothing | no |
| `gate` | the declared `gate` command chain | nothing | no |
| `bug-sweep` | code, statically | a triage ledger | opt |
| `handover-refresh` | code/docs/git history | queue proposal | opt |
| `evolve` | run history + repeated actions | queue | opt |
| `memory-drift` | memory facts with a `ref` | queue | no |
| `doc-drift` | `CHANGELOG.md` vs git tags | queue | no |
| `manifest-refresh` | knowledge dirs (paths + kinds) | `.ai.json` | no |
- **`comment-hygiene`** fails if any shippable file carries an
AI-attribution / tooling string.
- **`leak-guard`** blocks a commit that would leak an attribution
string, a `Co-Authored-By` trailer, or a private-workspace path into
tracked files.
- **`version-sync`** reports drift between the version strings declared
in `<workspace>/config.local`'s `version_locations` list. Each entry
is a `path:regex` pair (split on the first colon); the path is
repo-relative and the regex must declare at least one capture group
(group 1 captures the version string). With no `version_locations`
entries the workflow exits 0 (the gate is opt-in per project). Use
the multiline flag `(?m)` when the regex anchors on the start of a
line further down a file — Go's `^` is start-of-string by default.
Set `version_locations=auto` instead of an explicit list to let
version-sync discover the version locations itself. In auto mode it
scans a fixed, high-precision set of common version files — `VERSION`,
`CHANGELOG.md` (the first `## [vX.Y.Z]` heading), `package.json`,
`pyproject.toml`, and `Cargo.toml` — and reports drift across
whichever of them the project actually has. A file that is absent, or
present but carrying no version-shaped string, is skipped. `auto`
cannot be mixed with explicit `path:regex` entries — it is the whole
list or none of it — and composes with `version_anchor` the same way
an explicit list does.
The `version_anchor` config key selects the source of truth declared
locations are checked against. Three modes:
- **Unset (default) — consistency-only.** The first declared location
is the anchor; the rest must match it. Exits 0 when every declared
location agrees, 1 when any drifts, and 2 when a declared path is
missing on disk. The slice-1 behaviour, preserved for backward
compatibility.
- **`version_anchor=tag` — tag-anchor.** The latest semver-shaped
(`vX.Y.Z`) tag reachable from `HEAD` is the source of truth.
Declared locations must be semver-`>=` the tag, so a release commit
can bump declared locations ahead of the not-yet-pushed tag (the
tag is pushed after the commit). Backward-drift still fails. If no
semver-shaped tag is reachable yet, falls back to the
consistency-only behaviour with a note in the summary.
- **`version_anchor=<path>:<regex>` — designated-file mode.** Same
shape as a `version_locations` entry; the captured version is the
source of truth and every declared location must strict-equal it.
A missing path exits 2 (blocked) so a typo surfaces rather than
silently falling through to consistency-only.
Worked example. Declare two entries that both must carry the latest
released tag — the `CHANGELOG.md` heading and the matching footer
compare-link, which often drift when a release bumps one but
forgets the other — and enable tag-anchor so a release commit's
bump catches a forgotten location:
```
version_locations=CHANGELOG.md:(?m)^## \[v(\d+\.\d+\.\d+)\]
version_locations=CHANGELOG.md:(?m)^\[v(\d+\.\d+\.\d+)\]: https://github
version_anchor=tag
```
Once `version_locations` is declared, wire the gate into the
pre-commit hook so drift is caught before the commit lands rather
than only under `make gates`: `eeco hooks pre-commit on` installs a
`.git/hooks/pre-commit` that runs the default workflow chain
(`leak-guard` + `version-sync`) and blocks the commit on a non-zero
exit. See §9 for the override knob and the byte-identical reversal
contract.
- **`gate`** runs the project's declared parse/build gate — the ordered
command chain in `<workspace>/config.local`'s `gate` key — step by
step, with the repository root as the working directory, stopping at
the first failure. The `gate` key is repeatable: each occurrence adds
one step (a whitespace-split command), and the first occurrence resets
the profile default so the operator-declared chain fully replaces it.
A bare single-command `gate` is a one-step chain; a project with no
gate (the generic profile, or a lone `gate=`) is a clean no-op, so the
gate is opt-in per project. Every step's command is checked on `PATH`
before the first step runs, so a chain that cannot complete exits 2
(blocked); a step that exits non-zero exits 1 and stops the chain.
Run it with `eeco run gate`, or add `gate` to `pre_commit_workflows`
(§9) to run the whole validation chain before each commit.
Worked example. A Go project that wants vet, a static linter, and the
unit tests as one gate, run in order and stopping at the first to
fail:
```
gate=go vet ./...
gate=staticcheck ./...
gate=go test ./...
```
- **`bug-sweep`** does a static read and maintains an append-only triage
ledger (open findings → resolved log); with `--ai` it performs the
reasoning pass, otherwise it reports and parks the prompt.
- **`handover-refresh`** drafts a dated handover plus a "what's now
dead" diff and queues it for your approval — it never overwrites a
note directly.
- **`evolve`** watches for repetition and *proposes* new workflows into
the queue. It never activates one on its own. The
proposal pass is two-stage: a **deterministic signal extraction**
scans the most recent 40 commit subjects for repeated
conventional-commit types (`<type>(<scope>)?!?: …`) and surfaces one
workflow candidate per type that appears at least three times (cap
five candidates per run, sorted count-desc then key-asc). Each
candidate becomes its own `evolve` queue item — the operator
resolves or dismisses one at a time. The gated AI pass becomes
**optional enrichment** layered on top: `--ai` (or `automation=auto`)
opts into it; on no consent or any provider skip eeco still exits 0
when the deterministic pass found at least one candidate, and only
preserves the exit-3 (AI deferred) contract when there is genuinely
nothing to report — no consent **and** no deterministic candidates.
It also maintains a **repetition ledger** at
`<workspace>/state/evolve-history.json`. Each surfaced candidate
writes one record (signal kind + key, count at proposal, the queue
row's kind + title, the proposal timestamp); a re-run suppresses any
candidate whose `(signal_kind, signal_key)` is already in the
ledger, so a recurring signal is proposed exactly once in its
lifetime even after the queue item is ticked. Each run also
reconciles unresolved records against the queue: a record whose
queue row is now checked flips to `resolved: true` with a
`resolved_at` timestamp. Reconciliation runs once per evolve
invocation — the queue is not watched live, so an operator who
ticks a row between runs sees the ledger update on the next
`eeco run evolve`. The re-propose-on-signal-recurrence knob is a
follow-on slice; today a resolved record still suppresses. A
future slice may add a file-touch signal alongside the commit-type
one (deferred because it needs a new `gitx` helper).
- **`memory-drift`** flags a memory fact whose `ref:` file has been
committed on a later calendar day than the fact's own `created:` date
— a sign the fact may now describe code that has moved on. `eeco gc`
already catches a `ref:` that no longer exists on disk; `memory-drift`
catches the complementary case where the file is still there but has
changed since the fact was written. One review item per stale fact is
routed to the queue — eeco flags the drift, you reconcile the fact. It
needs git on `PATH` (it exits 2, blocked, without it) and exits 0 when
every ref-carrying fact is still current. Run it with `eeco run
memory-drift`, or wire it to run automatically after a merge with the
`post-merge` hook (§9), where it is in the default workflow list.
- **`doc-drift`** flags drift between the release sections in
`CHANGELOG.md` and the project's git tags. Two cases: a `vX.Y.Z` git
tag with no `## [vX.Y.Z]` CHANGELOG section (a release that was never
documented), and a `## [vX.Y.Z]` section with no matching tag (a
release documented but never tagged). The newest section is exempt: a
release commit adds the `## [vX.Y.Z]` heading before the `vX.Y.Z` tag
is pushed, so a section strictly ahead of the latest tag is the
expected release-in-progress state, not drift. One review item per
drift is routed to the queue — eeco flags it, you reconcile the
`CHANGELOG.md` or the tag. It needs git on `PATH` (it exits 2,
blocked, without it) and exits 0 when `CHANGELOG.md` is absent, no
tags exist yet, or every tag and section agree. Run it with `eeco run
doc-drift`, or let the `post-merge` hook (§9) run it automatically — it
is in that hook's default workflow list alongside `memory-drift` and
`manifest-refresh`.
- **`manifest-refresh`** rebuilds the per-directory `.ai.json` manifests
across the knowledge dirs — the project-type directories scaffolded
under `<repo>/<owner>/` as siblings of the engine workspace. Each
manifest is a deterministic skeleton — a JSON document `{"dir",
"purpose"?, "items":[{"path","kind"}…]}` with `kind` either `"file"`
or `"dir"`, sorted by path; the optional `desc`/`find_when` fields are
reserved for a future enrichment pass and stay empty in the
deterministic walk. The `.ai.json` file is skipped in its own walk, so
a re-run over unchanged dirs reproduces byte-identical output. Writes
land in the gitignored per-user area, never the tracked tree; it needs
no AI and never blocks. Run it on demand with `eeco refresh-manifest
[<dir>]` (the same deterministic rebuild, optionally scoped to one
knowledge dir), or let the `post-merge` hook (§9) run it.
Run any of them from the TUI or `eeco run <name>`.
These ten are the complete builtin set. Additional builtin workflows
are tracked as a post-v0.1.0 item.
### Scaffolding a workflow with `eeco new`
`eeco new <name>` scaffolds a user workflow under
`<workspace>/workflows/<name>/` from a per-profile template. The
scaffold layout is picked from the project's detected profile:
- `go` profile — `run` PATH-checks `go` and runs `go vet ./...` as
the inert default.
- `python` profile — `run` PATH-checks `python3` and runs
`python3 -m compileall -q .` as the inert default.
- `generic` profile (and any profile without a dedicated template:
`zig`, `rust`, `node`) — `run` is a bare sh stub that prints a
placeholder line and exits 0.
In every case the scaffolded `run` is a starting point — replace the
default body with the real check this workflow is meant to perform.
The chosen template is internal to eeco; the verb shape `eeco new
<name>` is unchanged.
### Toggling a scaffolded workflow on or off
Once scaffolded, a workflow is **on** by default; eeco runs it
whenever it is asked to (a manual `eeco run <name>`, a hook chain
that names it, the TUI). To pause a workflow without deleting it:
```
eeco workflows <name> off # mark <name> as disabled
eeco workflows <name> on # bring <name> back online
eeco workflows # list every scaffolded workflow + state
```
`off` plants an empty sentinel marker file at
`<workspace>/workflows/<name>/disabled`; the loader sees it and
reports the workflow as blocked (exit 2) without running it. `on`
removes the marker. Repeating the same action is a clean no-op.
Builtin workflows are unaffected by this toggle — their contract is
part of the frozen public surface. To remove a scaffolded workflow
for good, delete its directory.
## 6. Memory and garbage collection
The store holds one fact per file (flat frontmatter: name, description,
type, dates, optional `ref:`/`expires:`, `pin:`), with a regenerated
`MEMORY.md` index and `[[links]]` between facts. Types: `user`,
`feedback`, `project`, `reference`, `finding`.
`eeco gc` (or the Memory screen) keeps the store honest:
- a memory whose `ref:` path is gone, that has expired, a resolved
finding, or an unused reference past the stale window is **archived**
to the recoverable attic;
- anything **load-bearing** (project/feedback/user) is **queued for
your decision**, never silently deleted;
- `pin: true` exempts a fact permanently.
This is automated and conservative — the algorithm never loses a fact
that still matters; it asks you instead.
Re-running `eeco gc` (for example through the `post-merge` hook chain)
no longer files duplicate `gc-review` items for an unresolved finding:
an open queue row for the same fact + reason is recognised and the
second filing is skipped. Resolving the row (checking its box) lets a
future trigger file again, so the queue is never wedged on a stale
recommendation.
`eeco add fact` records a fact without hand-authoring the frontmatter:
```
eeco add fact --description "<summary>" [--type <type>] [--name <slug>] [--ref <path>] [--pin] [--provenance <text>] [--agent <name>] "<body>"
```
It writes one frontmatter file under `<workspace>/memory/` and
regenerates `MEMORY.md`. `--description` is the one-line summary that
drives relevance matching and is required. `--type` is one of `user`,
`feedback`, `project`, `reference`, `finding` and defaults to `project`
— the safe default, since `project`/`user`/`feedback` facts are queued
for review rather than archived. `--name` is the lower-kebab-case
filename stem; when omitted it is derived from `--description`. `--ref`
records a repo-relative path the fact documents (garbage collection
validates it); `--pin` exempts the fact from collection.
`--provenance "<text>"` records the snippet that triggered the fact
(`source:` in frontmatter, capped at 120 characters); `--agent <name>`
records which assistant filed it (`agent:`). Both are **required for
`--type feedback` and `--type user`** — those types are durable
adaptations to the operator, and an unauthored adaptation is the silent
drift `eeco show adaptations` exists to surface. Hand-authored facts
remain permissive: an older fact file without the new fields still
loads.
eeco refuses to overwrite an existing fact — pass a different `--name`,
or edit the file directly. The command needs an initialised workspace;
it writes only inside it (Constraint 1) and never stages or commits
(Constraint 6). Editing a fact later is `$EDITOR
<workspace>/memory/<name>.md`; removing one is `rm`.
### Auditing AI adaptations
A `feedback`- or `user`-typed memory fact is a durable adaptation to
the operator — every future assistant briefed by `eeco go` honours it.
`eeco show adaptations` lists every adaptation newest-first with its
provenance and on/off state, so the operator can audit *what the AI is
doing automatically now* without grepping the memory directory:
```
eeco show adaptations
```
`eeco adaptations <name> on|off` flips a `disabled:` flag on one fact
in place and regenerates the `MEMORY.md` index so the change shows at
once. A disabled fact is **preserved on disk** but hidden from
`eeco go` briefs and `eeco ask` rankings, so re-enabling is one CLI
flip:
```
eeco adaptations terse-feedback off # mute it without deleting
eeco adaptations terse-feedback on # restore it
```
`eeco gc` skips disabled facts so a deliberately muted adaptation
never gets routed for staleness review.
A disabled fact is hidden from the AI-facing surfaces (`eeco go`,
`eeco ask`, the TUI relevance ranker) but stays **visible — marked —
on the human audit surfaces**: the regenerated `MEMORY.md` index lists
it under a `## disabled` heading, and the TUI `/memory` screen tags its
line `[off]`. This holds for a fact of any type, so a disabled
`project` or `reference` fact is never silently lost from view.
`eeco show adaptations` is the audit surface; the fact file under
`<workspace>/memory/<name>.md` is the source of truth. Constraints 1 +
6 hold (writes inside the workspace; nothing staged or committed).
## 7. The queue — the only thing that interrupts you
Workflows and GC never act unilaterally on a meaningful change. They
append an item to the queue. View it with `eeco queue` (or `/queue` in
the TUI); the status digest shows the open count. The queue file is
yours — you resolve an item by ticking its checkbox (`[ ]` → `[x]`) or
deleting the row in `<workspace>/state/queue.md`. Nothing else nags you;
nothing runs unless you invoke it.
You can also file a queue item yourself — or have an assistant file one
for you — with `eeco add task`:
```
eeco add task "wire the man page into brew/scoop"
eeco add task --kind review --detail "check the brew formula" "audit packaging"
```
The title is the checklist row; `--kind` is the short queue tag (default
`task`); `--detail` is an optional elaboration printed beneath the row.
The item is appended to `<workspace>/state/queue.md` and shows up
alongside workflow- and GC-filed items in `eeco queue` (or `/queue`) and
the status digest's queue count. `eeco add task` writes only inside the
workspace (Constraint 1) and never stages or commits (Constraint 6); it
requires an initialised workspace. Like `eeco add fact`, it closes the
read/write loop — an assistant briefed by `eeco go` can route a decision
back into eeco's one decision channel instead of letting it evaporate.
## 8. AI configuration
AI is **opt-in** (`--ai`, or an `automation` level that grants standing
consent) and **budget-capped**. eeco no longer runs an in-binary model
client — it configures the harness that runs the AI. The one inference
path it keeps for its own chores is a **CLI provider** that shells any
program of your choice (prompt on stdin, reply on stdout); with none
configured, every AI pass is parked, never failed. Configure in
`<workspace>/config.local`, or set the common keys from the control
center with `/settings`:
```
automation = propose # manual | propose | scaffold | auto
ai_budget = 1 # gated passes per invocation; 0 disables AI
# CLI provider — any program, prompt on stdin, reply on stdout:
ai_command = yourcli --print
ai_provider = cli # cli | none; empty/unknown auto-selects
```
- **Auto-select (default).** With `ai_provider` empty or unset — or an
unknown/legacy value such as `anthropic` — a configured `ai_command`
picks the CLI provider; otherwise every AI pass is **parked and
queued**, never failed — the deterministic, non-AI result still
stands. `ai_provider = none` is also accepted; nothing here is ever a
config error.
- `ai_model` and `ai_api_key_env` are inert legacy keys: read and passed
through, but ignored by the CLI provider (there is no native API path).
They stay only so an old `config.local` loads unchanged.
- Only `automation=auto` is itself standing AI consent; every other
level needs an explicit `--ai` on the run.
- The same gating — consent, budget cap, and prompt-parking — wraps
every provider call, so a run is always safe and never an uncontrolled
spend. Every attempt is recorded to the AI-call ledger
(`state/ai-calls.json`): label, provider, resolved model, prompt and
response hashes, and token usage.
`/settings` (no arguments) shows the current values; `/settings <key>
<value>` writes one to `config.local`. A change applies the next time
eeco starts; a long-lived session keeps the budget cap it began with.
### Cumulative AI usage — `eeco stats`
`eeco stats` aggregates that ledger (`state/ai-calls.json`) into a
one-glance readout of how much AI eeco has actually used on this project:
```
eeco stats
eeco stats: 12 AI calls (9 ran, 3 parked) from 2026-05-21 to 2026-05-30
tokens: 18432 input · 4096 cached · 6210 output (28738 total)
by provider: cli 9 · none 3
```
It reports the total number of gated AI calls, how many actually ran
versus were parked, the cumulative token counts, the per-provider call
breakdown, and the recorded date range. Unlike `eeco go --metrics`,
whose token figures are bytes/4 estimates marked with `≈`, these token
totals are the **real provider counts** the ledger stored, so they carry
no `≈`. The command is **read-only** and makes **no** AI call; an
uninitialised workspace or an empty ledger reports `no AI calls recorded
yet` and still exits 0. The exact wording and the displayed figures are a
human-readable readout and are **not** part of the frozen surface.
## 9. Hooks (opt-in, reversible)
Off by default. Inspect with `eeco hooks` (or `/hooks`); toggle with
`eeco hooks <name> on|off` (or `/hooks <name> on|off`). Names:
`pre-commit`, `post-merge`, `session-start`, `commit-msg`,
`commit-guard`.
- **pre-commit** — a local `.git/hooks/pre-commit` (untracked,
repo-scoped) that runs the configured builtin workflows in declared
order and stops at the first non-zero exit, blocking the commit when
any step fails. Defaults to `leak-guard` (blocks a leaking commit)
followed by `version-sync` (silent on projects that have not declared
`version_locations`, so opt-in per project). Override the list with
one or more `pre_commit_workflows` keys in `config.local`; the first
occurrence resets the binary default, subsequent occurrences append.
An empty `pre_commit_workflows=` value clears the list and `eeco
hooks pre-commit on` refuses to install. The hook is installed only
if no pre-commit hook already exists, and removed only when the
on-disk script is byte-identical to what eeco wrote; a foreign or
hand-edited hook is always left untouched. After upgrading the eeco
binary (for example a `brew upgrade` that moves the binary) or editing
`pre_commit_workflows`, run `eeco hooks pre-commit refresh` to rewrite
the installed script with the current binary path and workflow set.
- **post-merge** — a local `.git/hooks/post-merge` (untracked,
repo-scoped) that runs the configured drift-detection workflows after
a `git merge` / `git pull` — the moment another author's changes land,
the natural time to re-check whether eeco's recorded state has drifted
from the code. Defaults to `memory-drift` followed by `doc-drift` (both
silent no-ops on a project with no memory `ref:` facts and no
CHANGELOG/tags). Unlike pre-commit it does **not** block: the merge has
already completed, so a finding or a missing tool surfaces as a queue
item, never as a hook failure (each step's exit is swallowed and the
hook does not use `set -e`). The drift workflows file at most one open
queue item per finding — a repeated run does not pile up duplicates of
a finding still awaiting a decision. Override the list with one or more
`post_merge_workflows` keys in `config.local` (same reset-then-append
semantics as `pre_commit_workflows`; an empty `post_merge_workflows=`
clears the list and `eeco hooks post-merge on` refuses to install).
Install / removal / refresh behave exactly as for pre-commit: a foreign
or hand-edited hook is always left untouched.
- **session-start** — composes up to three blocks at session start
and stays silent when every block is empty:
1. A reading routine — repo-relative paths to read before
substantive work. Auto-detected from a built-in list
(`docs/PUBLIC_API.md`, `docs/ARCHITECTURE.md`, `CHANGELOG.md`,
`ARCHITECTURE.md`, `docs/USAGE.md`, `README.md`); existing
entries surface, missing ones are skipped. The
most-recently-modified match of `roadmap*.md` is appended as
the live planning surface. Override the doc list with one or
more `session_start_docs` keys in `config.local`; change the
roadmap pattern with `session_start_roadmap_glob` (empty
disables discovery).
2. A mailbox warning — surfaces when `Ideas.md` at the repo root
has content beyond its empty template (the parser skips the
file header and any HTML comment blocks). Change the filename
with `session_start_mailbox` in `config.local`; an empty value
disables the block. The hook does not create the file — it
surfaces only when the operator opted in by creating it.
3. A one-line queue reminder — `eeco: N items awaiting a decision`
when the queue is non-empty.
4. **(opt-in)** Pinned memory bodies — the full body of every
`pin: true` memory fact under `<workspace>/memory/`, separated by
Markdown dividers. Off by default, so the hook output is
byte-identical until enabled. Opt in via
`session_start_pinned_bodies=true` in
`config.local` (workspace default) or the `--with-pinned-bodies`
flag on `eeco hooks session-emit` (one invocation only). Useful
when an AI assistant treats the hook output as a system-reminder
so a pinned policy memory (for example a no-AI-attribution rule)
lands in the model's context at session start.
The session-start hook ships with two brand-free delivery channels;
use either alone or compose both:
- **JSON settings file** — an exact-match-removable entry in an AI
CLI's user-global JSON settings file (the Claude Code shape). Set
the file location with `session_settings_path` in `config.local`
or the `EECO_SESSION_SETTINGS` environment variable. The file is
backed up (inside the workspace) before the edit and re-validated
after; an invalid result is rolled back.
- **Marker block in a text/markdown file** — eeco maintains a
delimited block of the same content the JSON channel renders, in
one or more files the assistant already reads at session start
(`CLAUDE.md` for Claude Code, `GEMINI.md` for Gemini CLI,
`AGENTS.md` for OpenAI Codex, `.cursorrules` for Cursor,
`~/.config/<tool>/...`, etc.). Declare one path per `session_files=` line in
`config.local`; each value is either repo-relative (held inside
the repo) or absolute. The block is fenced by HTML comments —
`<!-- eeco:session:start -->` / `<!-- eeco:session:end -->` —
and replaces in place on re-`on` or `refresh`. The block content
is whatever `eeco hooks session-emit` prints. eeco never edits
bytes outside the marker pair, and a block whose contents have
been hand-edited since install is left untouched on `off`. Files
eeco itself created are removed when `off` would leave them
empty; files that already existed are restored byte-identical to
their pre-`on` state when the block is at end-of-file.
Neither channel is configured by default: `eeco hooks session-start
on` reports "not configured" until at least one of the two is set.
When the file channel is wired, run `eeco hooks session-start refresh`
to re-render the block — the JSON channel does not need it (it
carries a command eeco runs at session start, so it is always
fresh).
- **commit-msg** — a local `.git/hooks/commit-msg` (untracked,
repo-scoped) that rejects commit messages carrying an AI-attribution
trailer. The policy is universal and lives inside the eeco binary:
the on-disk script is a one-line wrapper that execs
`eeco hooks commit-msg-check "$1"`, so the pattern set refreshes with
every `brew upgrade eeco`. Matches the trailer-anchored forms
(`Co-Authored-By:` lines naming `claude`, `anthropic`, or
`noreply@anthropic`) plus the Claude Code robot-emoji (`U+1F916`)
"Generated-with" signature; deliberately stricter than the broad
repo-tree scan so a
docs commit that *discusses* the policy ("remove Co-Authored-By
trailer") still passes. Exit code 1 prints the matched line and names
`--no-verify` explicitly as the bypass; conscious overrides remain
available without surprise. Install / removal behave exactly as for
pre-commit and post-merge: foreign hooks are never modified. After a
`brew upgrade eeco` rewires the eeco binary path, run `eeco hooks
commit-msg refresh` to rewrite the on-disk wrapper without an
off-then-on toggle.
- **commit-guard** — the harness-layer companion to commit-msg. Where
the git `commit-msg` hook guards eeco's **own** repository, the
commit-guard installs a **Claude Code PreToolUse hook** that denies any
pending `git commit` carrying AI attribution — in **any** repository the
assistant drives, and not bypassable by `git commit --no-verify` (the
hook sits above git). It runs the **same** attribution detector
`leak-guard` uses, so the patterns stay one source of truth (the foreign
repo's own `attribution_pattern` entries are honoured when its config
loads). Default **off**; enable with `eeco hooks commit-guard on`, which
installs a `PreToolUse` group into the JSON settings file named by
`session_settings_path` (or `EECO_SESSION_SETTINGS`) — the same file the
session-start JSON channel uses, backed up and re-validated the same way;
it reports "not configured" when neither is set. The runner detects a
real `git commit` token-by-token (so `git status` and `echo "git commit"`
never fire), scans the assembled message (`-m`/`-F`/`COMMIT_EDITMSG`),
the staged diff, and the raw command, and **denies only on a positive
finding** — any parse or infrastructure uncertainty degrades **open**
(the commit proceeds), so a session is never wedged; the git `pre-commit`
/ `commit-msg` hooks and CI remain the hard gates. `off` removes only
eeco's group (foreign `PreToolUse` groups are preserved); after a
`brew upgrade eeco` run `eeco hooks commit-guard refresh` to rewrite the
binary path.
Every action is recorded in `<workspace>/state/hooks.json` so it can
be cleanly undone. The git-hook scripts and the JSON-settings writes —
the session-start and commit-guard entries plus any `session_files`
blocks — are the only touches eeco ever makes outside the gitignored
workspace.
## 9a. Gates — composed read-only policy checks
`eeco gates check-attribution` is a CI-facing companion to the
`commit-msg` hook (§9). The hook blocks at the source on a developer
machine; the gate is the CI backstop that catches a leak when a
developer bypassed the hook (`--no-verify`) or never installed it. Both
share the same trailer-anchored pattern set so prose discussing the
policy never false-fires.
Scope:
- **Tracked-file scan** — broad: delegates to the same detector
`comment-hygiene` uses (line-anchored `Co-Authored-By:`, the
`Generated with/by <ai-ish>` co-marketing line, and the robot-emoji
Generated signature). Enumerates the file list from `git ls-files`
and filters to a built-in text-extension allowlist (`.md`, `.sh`,
`.go`, `.zig`, `.S`, `.inc`, `.zon`, `.yml`, `.yaml`, `.txt`, `.ld`,
`.json`, `.toml`). Hits print as `path:line: excerpt`.
- **Commit-body scan** — strict trailer-anchored: `Co-Authored-By:`
lines naming `claude`, `anthropic`, or `noreply@anthropic`, plus the
robot-emoji "Generated" signature. Default range is
`origin/main..HEAD`; falls back to `HEAD~10..HEAD` with a stderr
notice when `origin/main` is unresolvable (fresh clone, detached
HEAD, initial commit). Hits print as `commit <short-sha>: line N:
excerpt`.
Flags (all optional):
| Flag | Effect |
| --- | --- |
| `--paths "<a b c>"` | override the tracked-files filter; pass an explicit space-separated list. |
| `--commits N` | force `HEAD~N..HEAD` instead of the default range. |
| `--no-commits` | skip the commit-body scan (file-only). |
| `--no-files` | skip the file scan (commits-only). |
| `--exclude <path>` | repeatable; repo-relative path to skip during the file scan. |
Exit codes: `0` clean, `1` on any hit (sets stderr with one line per
finding plus a `N finding(s)` summary), `2` on usage error. The CI
invocation pattern is one step before `git push`-eligible builds:
```yaml
- name: ai-attribution gate
run: eeco gates check-attribution
```
Use `actions/checkout@v5` with `fetch-depth: 0` so the commit-body
range resolves; default `1` makes `origin/main..HEAD` empty and the
fallback notice triggers.
## 10. Safety guarantees
- eeco writes only inside the repo's gitignored workspace; a path guard
enforces this and refuses `..` traversal.
- It never commits or pushes your project's tracked tree. The optional
private workspace-history repo (see §11a) commits only inside the
gitignored workspace, locally, and is never pushed.
- No AI-attribution string is ever written to a tracked file.
- Everything it does is invoked by you or queued for you.
## 11. Removing eeco from a project
`eeco uninstall` writes a single `eeco-handoff.md` at the repository
root summarising what eeco knew about the project, then prints the
git command to remove the workspace by hand. It never deletes anything
itself, so nothing is lost if you change your mind.
```
eeco uninstall # bare handoff (just paths + removal command)
eeco uninstall --scope facts # + memory facts
eeco uninstall --scope queue # + open queue items
eeco uninstall --scope everything # + facts + queue + scaffolded workflows + hooks
```
The handoff file is written at the repository root, alongside your
tracked tree; the file itself is not gitignored, so you choose whether
to keep, commit, or delete it. The workspace is gitignored, so a single
`git rm -rf .eeco` (substitute your workspace name) is the only on-disk
removal needed once the handoff has been reviewed. Turn opt-in hooks
off first (`eeco hooks pre-commit off`, `eeco hooks session-start off`)
so the reversibility ledger can do its work before the workspace goes.
If the private workspace-history repo exists (§11a), `eeco uninstall`
offers to de-init it — removing only its `.git`, never your data — after
a confirmation prompt. Pass `--yes` to skip the prompt, or decline to
keep the history and remove it yourself later. Your workspace data files
are never deleted by `eeco uninstall`.
## 11a. Workspace history (the private logbook)
At `eeco init`, eeco stands up a **private, local git repository inside
its own gitignored workspace directory** (`<username>/`) to version its
knowledge layer — memory facts, the queue, decisions, manifests — over
time. This repository has **no remote and is never pushed**, and it
**never touches your project's tracked tree**: it records only what eeco
already writes inside its own gitignored workspace, so it is invisible to
your project's git.
The `workspace_history` config key controls it:
- `manual` (default) — the repo exists; eeco commits only when you run
`eeco history snapshot`.
- `auto` — as `manual`, plus eeco commits automatically after each
mutating verb (`add fact`/`note`/`task`, `gc`, `go --write`, `run`,
`new`, …); still local-only, no remote.
- `off` — no private repo at all.
```
eeco init # creates the private repo (manual)
eeco init --no-track # skip it for this run
eeco history # show the recent commit log
eeco add note "decided X" # mutate the workspace
eeco history snapshot # commit the current state
eeco history snapshot -m "milestone" # …with a message
eeco history compact --dry-run # preview: how many commits would collapse
eeco history compact # squash the whole log into one (confirms first)
eeco history compact --yes # …without the prompt
```
Under `auto` the log grows a commit per mutating verb, so it can get long.
`eeco history compact` squashes the **entire** history into a single commit
whose contents are the current workspace state — the git pendant to the
docs-compaction protocol. Your workspace files are never touched (only the
commit log is collapsed), and the old commits stay reachable through the git
reflog, so the squash is recoverable; eeco runs no `git gc`. It is manual
only — there is no auto-trigger — and it confirms before rewriting unless you
pass `--yes`; `--dry-run` reports the count and changes nothing.
The repo is created opt-out (`--no-track` for one run, or
`workspace_history=off` durably) and removed at `eeco uninstall` (§11).
Inside it eeco has a free hand — commit, and later squash — because
nothing there is shared or tracked by the host repo.
## 12. Filing bug reports
`eeco report-bug` captures friction with eeco itself into a structured
Markdown record and prints a pre-filled GitHub Issues URL so the
report can reach the project in one click. The command works for
everyone: anyone who installed eeco can file, with or without an
initialised workspace.
```
eeco report-bug --note "leak-guard tripped on a doc-only commit" \
--cmd "eeco run leak-guard"
```
Both flags are optional. The record contains the eeco version, a
whitelisted environment snapshot (OS, arch, Go version, `SHELL`,
`TERM`, and any `EECO_*` variables — never the full env), the note,
and the invoking command.
Where the record lands:
- **Inside an initialised workspace.** Records go to
`<workspace>/bug-reports/`; override the directory with the
`bug_report_dir` key in `<workspace>/config.local` (workspace-
relative — absolute paths and `..` traversal are rejected at parse
time).
- **Anywhere else** (no `eeco init` yet, or not inside a git repo at
all). Records go to `~/.eeco/bug-reports/` so a fresh-install user
can file a report against eeco itself without having to set anything
up first. The repo's tracked tree is never touched.
Sharing the record:
`eeco report-bug` prints a pre-filled URL like
<https://github.com/ajhahnde/eeco/issues/new?title=...&body=...>.
Opening it in a browser fills the issue form with the record body
already pasted — clicking Submit files it to the project. The local
file is your durable copy; you can refine it before clicking, or
attach it by hand if the URL ends up too long for the browser. Nothing
is sent automatically — the upstream side of the loop is always a
human click.
Pass `--submit` to have eeco open that URL for you (the `$BROWSER`
command if set, otherwise the platform default opener). It is opt-in
and changes nothing else: the form opens pre-filled, you still review
and click Submit, and the command still reports that nothing was sent
automatically — opening a pre-filled form is not the same as sending.
If no browser can be opened, the command prints a short note alongside
the URL and still exits 0; `--submit` is a convenience, never a failure
point.
## 13. Briefing an AI assistant
`eeco go` prints a compact, deterministic project brief written for an
AI assistant. It is the fast, cheap way to bring any assistant — not
only the strongest — up to speed on a project: one command returns a
map of the project instead of a scan across many files.
```
eeco go
```
The brief has six sections:
- **Working with eeco** — what eeco is, the read-only commands an
assistant can run, and the rule that decisions go to eeco's queue
rather than silent edits.
- **Project** — the detected profile, the parse/build gate, and the
top-level layout (taken from the tracked set, so build artifacts and
the workspace stay out).
- **Where to look** — every memory fact that records a file pointer,
as a topic → file map. This is the assistant's shortcut to the right
file.
- **What eeco knows** — the load-bearing memory facts (project,
feedback, user).
- **Open decisions** — the items waiting in eeco's queue.
- **Recording back** — how the assistant keeps the brief useful:
record durable facts and route decisions through the queue.
`eeco go` calls no AI provider — the brief is assembled entirely from
what eeco already tracks, so it costs no provider budget and is safe to
run any number of times. The output is deterministic: the same
workspace state produces the same brief.
The brief is Markdown by default — written for an assistant to read.
The `--json` flag emits the same project state as a JSON object instead,
the machine-readable form for a downstream agent or script that parses
the brief rather than reading it:
```
eeco go --json
```
The JSON object has nine top-level keys — `project`, `profile`, `gate`
(an argv array), `top_level`, `initialized`, `workflows`,
`where_to_look` (`description`/`ref` objects), `knowledge`
(`name`/`description`/`type` objects), and `open_decisions`. Every array
is always present, an empty list rather than null, so a consumer can
iterate without a nil check. These top-level keys are part of eeco's
frozen public surface. `--json` prints to stdout and cannot be combined
with `--write`.
By default the brief is printed to stdout and nothing is written. The
`--write` flag saves it instead to a stable file inside the workspace —
`.eeco/context.md` by default — so an assistant's instructions file can
point at a fixed path rather than re-running the command each session:
```
eeco go --write # writes .eeco/context.md
```
The destination is workspace-relative; override it with the
`context_path` key in `<workspace>/config.local` (absolute paths and
`..` traversal are rejected). `--write` requires an initialised
workspace, and the file it writes is byte-identical to the stdout
brief. The file lives in the gitignored workspace, so it is never
committed; re-run `eeco go --write` whenever you want it refreshed.
For an assistant on a tight context budget the `--brief` flag reshapes
the output: the "Working with eeco" preamble and "Recording back" outro
drop out, and the per-section lists (`Where to look`, `What eeco knows`,
`Open decisions`) are capped at five entries each. `--brief` composes
with `--json` and `--write`, so every delivery axis gets the size knob:
```
eeco go --brief # smaller Markdown brief
eeco go --brief --json # smaller JSON brief
eeco go --brief --write # writes the smaller variant
```
In `--brief --json` the nine frozen top-level keys are preserved —
arrays may simply be shorter, never absent or null — so a consumer that
parses the JSON brief sees the same shape, never an unexpected key
disappearance.
To keep the persisted `.eeco/context.md` under a known size, set the
`context_budget` key in `<workspace>/config.local` to a maximum **byte**
count (roughly four bytes per token is a useful rule of thumb):
```
context_budget=4000 # cap the --write brief at 4000 bytes
```
When `context_budget` is set, `eeco go --write` trims the saved brief
down a deterministic ladder until it fits: it tries the full brief
first, then the smaller `--brief` form, then that form with the
per-section lists capped progressively shorter (5, 4, 3, 2, 1, 0). It
writes the largest tier that fits and reports which one — for example
`wrote .eeco/context.md (brief, 1840/4000 bytes)`. A `context_budget`
of `0` (the default) means no cap. The budget applies only to
`--write`; bare `eeco go` and `eeco go --brief` printed to stdout are
unchanged. If the budget is so small that even the smallest tier
overruns it, eeco still writes that smallest brief and prints a note —
a brief slightly over budget beats no brief at all.
To surface the recent free-form notes from `eeco add note` (§17)
inside the brief, set the `brief_include_notes` key in
`<workspace>/config.local`:
```
brief_include_notes=true
```
When enabled, `eeco go` adds a **Recent notes** section between
**What eeco knows** and **Open decisions**, listing the five newest
notes from `<workspace>/notes/` with their UTC timestamp and first
line. The section appears in Markdown output only — `eeco go --json`
still emits exactly the nine frozen top-level keys
([`PUBLIC_API.md`](PUBLIC_API.md) §"CLI commands and flags"), since
notes belong to the assistant-prose channel rather than a parsed
brief. The key accepts the standard `strconv.ParseBool` set —
`true`/`false`, `1`/`0`, `t`/`f` (in either case); a typo like
`yes`/`no` is rejected at parse time so the operator notices.
The default is `false`, so bare `eeco go` omits the section; the key
composes with `--brief`, `--write`, and `--copy` exactly like the other
sections.
To wire the brief into a session, either point your assistant's
instructions file at `.eeco/context.md`, or — if you use the bundled
session-start hook (§9) — add `session_start_docs=.eeco/context.md` to
`config.local` so the hook lists it in the reading routine
automatically. Any assistant that can read a file or run a shell
command can consume the brief; eeco assumes no particular tool.
For a chat-only assistant — Gemini web, a Gem, AI Studio, ChatGPT,
or any LLM behind a chat box — the `--copy` flag writes the brief to
the host operating system's clipboard so you can paste it in one
shot:
```
eeco go --copy # full Markdown brief on the clipboard
eeco go --copy --brief # smaller brief for a tight context window
eeco go --copy --json # structured payload for a chat that parses JSON
```
`--copy` works on macOS (pbcopy), Windows (clip.exe), and Linux
(wl-copy on Wayland, xclip or xsel on X11). When none of those tools
is on `PATH` eeco exits 2 with an install hint — the same
"blocked: required tool missing" contract every other workflow
follows. `--copy` does not compose with `--write` (one delivery axis
per invocation); it does compose with `--brief` and `--json`.
Re-run `eeco go --copy` whenever the project state has moved on.
### Measuring the brief — `--metrics`
The `--metrics` flag adds a one-line readout to **stderr** after the
brief, so you can see what the brief actually saved:
```
eeco go --metrics
eeco go: assembled in 412µs · brief 1840 bytes (≈460 tokens) · distilled ≈980 tokens of project knowledge into ≈460 (≈53% smaller)
```
It reports three things: how long the brief took to assemble (real
wall-clock), the brief's own size, and how much of the project's
knowledge layer — the on-disk memory facts, the queue, and any notes —
the brief distils. Byte counts are real measurements; token figures are
**estimates** via the ~4-bytes-per-token heuristic and are marked with a
leading `≈`, never presented as exact counts (eeco ships no tokenizer,
by design). The compression percentage is grounded in real on-disk bytes
and is never reported below `0%`.
The readout goes to stderr, so it never mixes into the brief itself:
`eeco go --metrics > brief.md` writes a clean brief to the file and
prints the readout to the terminal. It always describes the **canonical
Markdown brief** — even alongside `--json` or `--copy`, where it measures
the Markdown brief the assistant would read — and it never appears in
`--json` stdout, so the nine frozen top-level keys
([`PUBLIC_API.md`](PUBLIC_API.md) §"CLI commands and flags") are
untouched. With `--write` under a `context_budget` the readout still
describes that canonical brief, not the budget-trimmed file that was
persisted — the metric measures how much eeco's brief compresses the
knowledge layer, a stable property, rather than one write's fitted size.
`--metrics` is off by default and composes with every other flag.
### Briefing assistants other than Claude Code
eeco's delivery channels are brand-free — point them at whichever
assistant you use:
- **Gemini CLI** (Google's terminal-native assistant) reads
`GEMINI.md` at session start. Set `session_files=GEMINI.md` in
`<workspace>/config.local`, then `eeco hooks session-start on`.
Same recipe as Claude Code with `CLAUDE.md`.
- **OpenAI Codex** reads `AGENTS.md`. Set `session_files=AGENTS.md`.
- **Cursor** reads `.cursorrules`. Set `session_files=.cursorrules`.
- Any other assistant whose instructions file lives at a known
path: add that path as a `session_files=` line and eeco will keep
a marker block in it (§9).
For an assistant with no terminal and no filesystem access (Gemini
web, Gems, AI Studio, ChatGPT, any chat box), `eeco go --copy` is
the one-shot bridge — render the brief into the clipboard and paste
it into the chat. Re-run when the project state moves on.
### Asking a targeted question
Where `eeco go` is a one-shot overview, `eeco ask` answers a *specific*
question with a ranked set of pointers — the interactive counterpart to
the static brief:
```
eeco ask "where is the project brief rendered"
```
The answer has two sections: **Memory** — the memory facts whose name,
description, or body overlaps the question, shown as a topic → file map
when the fact carries a `ref`; and **Code** — the best-matching lines in
the repository's tracked files, each as a `path:line` reference with the
matching line. Results are ranked by how many distinct question words
they match, with stable tie-breaks, so the same question over the same
tree always gives the same answer.
```
eeco ask --limit 3 "how does version-sync detect drift" # cap code hits
eeco ask --json "queue" # machine-readable
```
`--limit N` caps the number of code locations (default 10); every
matching memory fact is always shown. `--json` emits an object with
three frozen top-level keys — `question`, `memory`, `code` — both arrays
always present, never null. Like `eeco go`, `eeco ask` calls no AI
provider (relevance is a word-overlap score over what eeco already
tracks), reads anywhere in the repo but writes nothing, and works in any
git repository — the memory section is simply empty when the workspace
is not initialised. Binary and very large files are skipped.
## 14. Applying an update
`eeco update` checks for a newer release and prints the gap, but does
not download anything. The opt-in `--apply` flag turns it into a
verified self-replace: download the platform release archive, verify
its sha256 against the published `SHA256SUMS`, verify the cosign
signature on `SHA256SUMS`, verify the GitHub build-provenance
attestation on the archive, then atomically swap the running binary.
```
eeco update --apply
```
Verification gates. All three must pass before any swap. Any one
failure leaves the running binary untouched and leaves the downloaded
files in `<workspace>/state/update-<tag>/` for inspection.
- `cosign verify-blob` against the keyless certificate identity baked
into the release workflow (`refs/tags/v…` on this repo).
- `sha256` of the downloaded archive equal to its line in `SHA256SUMS`.
- `gh attestation verify <archive> --repo ajhahnde/eeco`.
Requirements. `cosign` and `gh` must be on `PATH`. The command must run
inside an initialised eeco workspace — `<workspace>/state/` holds the
staging directory, the backup, and the swap ledger
(`<workspace>/state/binary.json`). The running binary must be on a
released tag (`v…`); an unpinned `0.0.0-dev` build refuses with a
reinstall hint.
Package-manager handoff. If the resolved running-binary path lives
under a known package-manager root (Homebrew, Scoop, Linuxbrew),
`--apply` refuses and points at the package manager's upgrade verb
(`brew upgrade eeco` or `scoop update eeco`). Self-replace would fight
the package manager on its next sync; the manager owns its prefix.
Rollback. The previous binary is preserved at
`<workspace>/state/update-<tag>/eeco.bak` (or `eeco.exe.bak` on
Windows). To revert by hand:
```
mv <workspace>/state/update-<tag>/eeco.bak <running-path>
```
The exact `<running-path>` is recorded in
`<workspace>/state/binary.json` under `running_path`.
What stays the same. Bare `eeco update` (no flag) is unchanged: it
still lists the gap and exits cleanly, without touching any binary.
Constraint 6 holds — eeco only swaps when the operator passed
`--apply`.
## 15. Reading the in-binary guide
`eeco guide` pages the user manual built into the binary — a verbatim
mirror of this file (`docs/USAGE.md`) at the tag the binary was built
from. Every install route ships the manual offline; no network access
needed, no doc directory to clone.
```
eeco guide
```
The pager selection is `$PAGER` first (split on whitespace; the first
token must be on `PATH`), then `less -R` if `less` is available, then
a plain stdout dump as the final fallback. When stdout is not a
terminal (piped or redirected) the command always dumps the manual
plainly and exits 0 — the same one-screen path the TUI uses for
`eeco` with no arguments.
At a terminal the manual is prettified for reading: Markdown tables
become box-drawing grids, headings are styled with a rule, and inline
`code` / **bold** / links are rendered rather than shown as raw markup.
`NO_COLOR` drops the colour but keeps the layout. Piped or redirected
output stays raw Markdown, byte-identical to `docs/USAGE.md`, so a
script that captures the guide (`eeco guide > usage.md`) gets the
source unchanged.
Quick recipes.
```
eeco guide # page interactively
eeco guide | less # explicit pager pipeline
eeco guide > usage.md # capture the embedded snapshot
PAGER='less +/^## 4' eeco guide # jump straight to the commands table
```
The embedded snapshot is locked to the binary version. Pair it with
`eeco update --apply` to keep the manual in sync with the running
release. The tracked-tree `docs/USAGE.md` and the in-binary guide
stay byte-identical in CI — a release commit that forgets to re-sync
the mirror fails the build.
## 16. Authoring tracked docs
`eeco docs new <target>` seeds a tracked-tree documentation file at
the repository root. Two targets ship today:
| Target | File written | Purpose |
| -------- | ------------- | --------------------------------------------- |
| `vision` | `VISION.md` | seed a short manifesto: what this project is for, what it gives you, what it deliberately is not, and where the roadmap lives |
| `readme` | `README.md` | seed a new-reader-friendly README: tagline, what the project does, a quick-start recipe, and a short "how it works" |
The scaffolder is deterministic — no AI call, no provider spend — and
project-shape-aware: the rendered "See also" only links to companion
docs that already exist (`README.md`, `docs/USAGE.md`,
`docs/ARCHITECTURE.md`). The `readme` target's "See also" never
self-links, so the link set narrows to `docs/USAGE.md` and
`docs/ARCHITECTURE.md`.
```
eeco docs new vision # write VISION.md (refuse if it exists)
eeco docs new --overwrite vision # replace an existing VISION.md
eeco docs new readme # write README.md (refuse if it exists)
eeco docs new --overwrite readme # replace an existing README.md
```
Refuse-on-existing is the default. Pass `--overwrite` to replace a
prior scaffold or hand-edited file. **`--overwrite` discards the
existing body** — use it deliberately, especially on a populated
`README.md`. eeco writes the file but never stages or commits it
(Constraint 6); review the result and run `git add <file> && git
commit` yourself.
The seed file carries a one-line HTML comment at the top marking it
as a scaffold and naming the eeco version it was generated against.
The comment is invisible in any rendered Markdown view and can be
deleted in the same edit you fill the body in.
`eeco docs refresh <target>` re-renders the project-state-derived
region of a previously-scaffolded doc — the same project-shape-aware
"See also" block `eeco docs new` rendered — leaving operator prose
outside the marker pair untouched. The region is delimited by a pair
of HTML-comment markers:
```
<!-- eeco:docs:start -->
… rendered content …
<!-- eeco:docs:end -->
```
Markers inside fenced code blocks are ignored, mirroring `eeco docs
compact`. Unmatched, nested, or out-of-order markers exit 1 with a
parse error naming the offending line; the file is not touched. A
scaffold with no markers is auto-initialised: the freshly
rendered block is appended at EOF, marker-wrapped, with a blank-line
separator. The operator removes the prior in-place block manually.
Where `--overwrite` on `eeco docs new` discards the entire body,
`refresh` is the non-destructive complement.
```
eeco docs refresh vision # re-render VISION.md's marker block
eeco docs refresh readme # re-render README.md's marker block
```
`eeco docs compact <path>` moves resolved regions of a long doc into
a sibling archive file so the working copy stays short, then leaves a
single-line pointer stub in place at each cut. A region is anything
between a pair of HTML-comment markers:
```
<!-- eeco:archive:start -->
… content to archive …
<!-- eeco:archive:end -->
```
Markers inside fenced code blocks are ignored, so a doc explaining
the markers (this very paragraph in the original `docs/USAGE.md`)
does not trigger a self-archive.
```
eeco docs compact roadmap_v1.x.md
eeco docs compact --dry-run roadmap_v1.x.md
eeco docs compact --archive history.md roadmap_v1.x.md
```
The default archive destination is `<basename>.archive<ext>` next to
the source — e.g. `roadmap_v1.x.md` → `roadmap_v1.x.archive.md`.
Override the destination with `--archive`; pass a `--dry-run` to
preview which line ranges would move without writing. Re-running
after a successful compact is an idempotent no-op (`no archive
markers found`); the archive is appended to on subsequent runs so a
long-lived doc can be compacted in waves.
Unmatched, nested, or out-of-order markers exit 1 with a parse error
naming the offending line; the archive is not touched. Both paths
must resolve inside the repository (absolute paths and `..` traversal
are rejected). eeco writes the source and the archive but never
stages or commits either (Constraint 6); review both files and `git
add` / commit when ready.
For a log that grows by prepending a new section each time — a session
journal, a changelog, a steering file — placing markers by hand every
round is fiddly. `--keep-last N` discovers the archivable regions by
heading instead. Pair it with `--heading <prefix>`, the heading-line
prefix that opens an archivable section:
```
eeco docs compact --keep-last 4 --heading "## Snapshot" RESUME.md
```
This keeps the **N most-recent** matching sections — newest meaning
topmost in the file — and archives everything older. The `#` run in
`--heading` fixes the section level: a section runs from its heading to
the next heading of the same or a higher level, so a live tail such as a
trailing `## Next session` or `## Pointers` block is never swallowed. A
deeper sub-heading inside a section does not split it, and (as in marker
mode) any heading inside a fenced code block is ignored. Adjacent
archived sections collapse into one pointer stub.
`--keep-last` and explicit markers are mutually exclusive: running
heading mode on a source that still carries a paired marker region exits
1 rather than mixing the two schemes. `--keep-last` requires `--heading`
and vice versa. `--dry-run` and `--archive` work the same in both modes;
re-running once only the kept window remains is an idempotent no-op.
## 17. Keeping notes
A note is a place to scribble — a half-formed idea, a reminder, a
thread to pick up later. It is deliberately neither a memory fact
(frontmatter-strict, matched to AI relevance) nor a queue item (the
append-only decision channel): routing scratch text into either of
those pollutes their contract. Notes get their own surface.
```
eeco add note "check round-robin fairness in queue promotion"
eeco show notes
```
`eeco add note "<text>"` saves the text verbatim to one plain Markdown
file under `<workspace>/notes/`, named `YYYY-MM-DD-HHMMSS-<slug>.md`
(the timestamp is UTC; the slug is derived from the text). The
directory is created on first use, so a fresh `eeco init` is not
required — being inside a git repository is enough. eeco writes only
inside the workspace (Constraint 1) and never stages or commits
(Constraint 6).
`eeco show notes` lists every note newest-first, one row per note:
the local date and time followed by the note's first line. An empty or
absent notes directory prints a friendly hint and exits 0.
This first slice is append + list only. To edit a note, open it in your
editor (`$EDITOR <workspace>/notes/<file>`); to remove one, delete the
file (`rm <workspace>/notes/<file>`).
---
[← Prev: Cockpit](COCKPIT.md) · [Next: Architecture →](ARCHITECTURE.md)
added go.mod
@@ -0,0 +1,34 @@
module github.com/ajhahnde/eeco
go 1.24.2
require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/creack/pty v1.1.24
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.27.0 // indirect
)
added go.sum
@@ -0,0 +1,58 @@
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
added internal/ai/ai.go
@@ -0,0 +1,414 @@
// Package ai is the pluggable AI-provider bridge with eeco's shared,
// opt-in gating.
//
// A Provider runs a single Request and returns a Response. Every call
// goes through a Gate that enforces the floor invariants from PLAN.md:
//
// - consent: a pass runs only with --ai or an automation level that
// implies consent;
// - budget cap: a fixed number of gated passes per invocation (a
// tool-using pass may make several model calls but counts as one);
// - never a silent spend, never a hard failure: on no-consent, over
// budget, or provider error the prompt is parked to state/ and a
// queue item is appended, and the caller falls back to its non-AI
// path.
//
// One provider is wired: a generic CLI-based provider that shells an
// operator-chosen command (eeco no longer runs an in-binary model client;
// the AI lives in the harness eeco configures). An unconfigured setup
// yields a stub whose Run cleanly reports "not configured" (handled as a
// parked pass, not an error). Every attempt is recorded to the AI-call
// ledger (state/ai-calls.json). No provider brand appears in product copy.
package ai
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/queue"
)
// ErrNotConfigured is returned by a provider that has nothing wired. The
// Gate treats it like any other non-result: the prompt is parked, never
// surfaced as a hard failure.
var ErrNotConfigured = errors.New("ai provider not configured")
// Message is one turn in a multi-turn conversation. Role is "user" or
// "assistant"; the System block stays on Request.System. The transcript
// is folded to a single prompt string for the CLI provider (foldPrompt).
type Message struct {
Role string // "user" | "assistant"
Text string // plain text turn
}
// Request is one gated provider call. System is the deterministic,
// stable-across-calls block (cheap to recompute, the natural cache
// prefix); User is the volatile per-call instruction or input. A
// provider may ignore Model and Cache.
//
// Messages carries multi-turn history; when it is non-empty the provider
// uses it in place of folding System+User into one user turn (and User is
// ignored). Single-turn callers leave Messages nil and keep today's
// behaviour byte-identical.
type Request struct {
Label string // parking + ledger key
System string // cacheable deterministic block (stable across calls)
User string // volatile per-call instruction / input
Messages []Message // multi-turn history; non-empty overrides User
Model string // optional model override; provider may ignore
Cache bool // hint: ephemeral-cache the System block
}
// Usage reports token accounting for one provider call. Zero for
// providers that do not surface it (the CLI provider) and on every
// parked pass.
type Usage struct {
InputTokens int
CachedInputTokens int
OutputTokens int
}
// Response is the result of one provider call. Model is the model the
// provider actually resolved (may differ from Request.Model); empty when
// the provider does not resolve a model (the CLI provider leaves it
// empty).
type Response struct {
Text string
Model string
Usage Usage
}
// Provider runs a single Request and returns a Response. The
// implementation must respect ctx cancellation and must not write to the
// tracked tree.
type Provider interface {
// Name is an internal identifier for selection and logging only; it
// is never written into product copy or the tracked tree.
Name() string
Run(ctx context.Context, req Request) (Response, error)
}
// foldPrompt collapses a Request to the single prompt string the
// stdin-fed CLI provider, the parked-prompt file, and the ledger hash all
// share. With Messages set it renders a transcript (optional System block,
// then "User: …" / "Assistant: …" per turn); otherwise an empty System
// yields exactly User, so today's User-only callers feed byte-identical
// stdin to the CLI provider.
func foldPrompt(req Request) string {
if len(req.Messages) > 0 {
var b strings.Builder
if req.System != "" {
b.WriteString(req.System)
b.WriteString("\n\n")
}
for i, m := range req.Messages {
if i > 0 {
b.WriteString("\n\n")
}
role := "User"
if m.Role == "assistant" {
role = "Assistant"
}
b.WriteString(role)
b.WriteString(": ")
b.WriteString(m.Text)
}
return strings.TrimSpace(b.String())
}
if req.System == "" {
return req.User
}
return req.System + "\n\n" + req.User
}
// cliProvider shells a configured command, feeding the folded prompt on
// stdin and taking stdout as the response. The command is operator-chosen
// via `ai_command`; no specific tool is assumed or named. It ignores
// Model and Cache and reports no token usage.
type cliProvider struct{ argv []string }
func (cliProvider) Name() string { return "cli" }
func (c cliProvider) Run(ctx context.Context, req Request) (Response, error) {
if len(c.argv) == 0 {
return Response{}, ErrNotConfigured
}
cmd := exec.CommandContext(ctx, c.argv[0], c.argv[1:]...)
cmd.Stdin = strings.NewReader(foldPrompt(req))
var out, errb bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errb
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(errb.String())
if msg == "" {
msg = err.Error()
}
return Response{}, fmt.Errorf("provider call failed: %s", msg)
}
return Response{Text: strings.TrimSpace(out.String())}, nil
}
// notConfigured is the clean stub used when no provider is wired.
type notConfigured struct{}
func (notConfigured) Name() string { return "none" }
func (notConfigured) Run(context.Context, Request) (Response, error) {
return Response{}, ErrNotConfigured
}
// Select returns the provider implied by config. An explicit
// `ai_provider == "cli"` selects the CLI provider when `ai_command` is
// set, else the not-configured stub. Every other value — empty/auto, the
// legacy `anthropic` (the in-binary API provider was retired), or any
// unknown string — falls back to auto: a configured `ai_command` picks the
// CLI provider, else the not-configured stub. An unrecognised value is
// always tolerated (floor invariant — never fail on this key).
func Select(cfg *config.Config) Provider {
if cfg == nil {
return notConfigured{}
}
if cfg.AIProvider == "cli" {
if p, ok := selectCLI(cfg); ok {
return p
}
return notConfigured{}
}
// Auto (and any other value, incl. legacy "anthropic"): a configured
// command wins, else unconfigured.
if p, ok := selectCLI(cfg); ok {
return p
}
return notConfigured{}
}
// selectCLI returns the CLI provider when `ai_command` is set.
func selectCLI(cfg *config.Config) (Provider, bool) {
if len(cfg.AICommand) == 0 {
return nil, false
}
return cliProvider{argv: append([]string(nil), cfg.AICommand...)}, true
}
// Outcome reports what the Gate did with a request. Exactly one of Ran
// or Skipped is true. When Skipped is true the prompt was parked and a
// queue item was appended; Reason explains why the pass did not run.
// Usage carries the provider's token accounting on Ran (zero on Skipped).
type Outcome struct {
Text string
Ran bool
Skipped bool
Parked string // path of the parked prompt, when Skipped
Reason string
Usage Usage // the provider's token accounting on Ran (zero on Skipped)
}
// ResponseScanner inspects a provider response before the Gate hands it to the
// caller. Returns nil/empty for a clean response, else one human-readable
// description per violation. Text-only + caller-agnostic so a future tool-use
// slice can reuse it on serialized tool-call arguments. The detector lives in
// internal/workflow and is injected to keep internal/ai workflow-import-free.
type ResponseScanner func(text string) []string
// Gate wraps a Provider with consent, a budget cap, and prompt-parking.
// A Gate is single-invocation: Budget is spent across all Run calls on
// the instance.
type Gate struct {
Provider Provider
// Consent is true when --ai was passed or the automation level
// implies standing consent.
Consent bool
// Budget is the maximum number of gated passes per invocation (a
// tool-using pass may make several model calls but counts as one);
// <= 0 disables AI.
Budget int
// StateDir is <workspace>/state: parked prompts, queue.md, and the
// AI-call ledger live here.
StateDir string
// Project is a short handle (repo basename) for queue items.
Project string
// Scanner, when non-nil, runs on every successful provider response before
// it is recorded or returned. A non-empty result blocks the pass.
Scanner ResponseScanner
spent int
}
// NewGate builds the Gate for one invocation from config, the --ai flag,
// and a pre-write response scanner. Consent is the flag OR an automation
// level that implies it. The scanner is a required parameter so every call
// site names the attribution filter explicitly; pass nil only where no
// filtering is wanted (test Gates keep the nil-safe zero value).
func NewGate(cfg *config.Config, aiFlag bool, scanner ResponseScanner) *Gate {
return &Gate{
Provider: Select(cfg),
Consent: aiFlag || cfg.Automation.ImpliesAIConsent(),
Budget: cfg.AIBudget,
StateDir: filepath.Join(cfg.Workspace, "state"),
Project: filepath.Base(cfg.RepoRoot),
Scanner: scanner,
}
}
// Run executes one gated pass. It never returns a hard failure for a
// missing consent, an exhausted budget, or a provider error: in every
// such case the prompt is parked, a queue item is appended, the attempt
// is recorded to the ledger, and the returned Outcome has Skipped set so
// the caller takes its non-AI path. A non-nil error means parking itself
// failed (a real I/O fault).
func (g *Gate) Run(ctx context.Context, req Request) (Outcome, error) {
if !g.Consent {
reason := "AI pass not consented (use --ai or set automation=auto)"
g.recordCall(req, g.providerName(), "", "", false, true, reason, Usage{}, nil)
return g.park(req, reason)
}
if g.Budget <= 0 || g.spent >= g.Budget {
reason := fmt.Sprintf("AI budget exhausted (cap %d)", g.Budget)
g.recordCall(req, g.providerName(), "", "", false, true, reason, Usage{}, nil)
return g.park(req, reason)
}
g.spent++
prov := g.Provider
if prov == nil {
prov = notConfigured{}
}
resp, err := prov.Run(ctx, req)
if err != nil {
reason := "provider unavailable: " + err.Error()
g.recordCall(req, prov.Name(), resp.Model, "", false, true, reason, resp.Usage, nil)
return g.park(req, reason)
}
if strings.TrimSpace(resp.Text) == "" {
reason := "provider returned no text"
g.recordCall(req, prov.Name(), resp.Model, "", false, true, reason, resp.Usage, nil)
return g.park(req, reason)
}
// Pre-write attribution filter (Slice 3): enforce eeco's no-AI-attribution
// rule on text eeco itself initiates, before it can reach the workspace and
// be copied into the tracked tree. A flagged response is blocked like any
// other non-result — recorded (the blocked response's hash IS captured, and
// its real token cost stands; the call did happen) then parked — and the
// caller falls back to its non-AI path.
if g.Scanner != nil {
if v := g.Scanner(resp.Text); len(v) > 0 {
reason := "AI response blocked: attribution violation (" + strings.Join(v, "; ") + ")"
g.recordCall(req, prov.Name(), resp.Model, resp.Text, false, true, reason, resp.Usage, nil)
return g.park(req, reason)
}
}
g.recordCall(req, prov.Name(), resp.Model, resp.Text, true, false, "", resp.Usage, nil)
return Outcome{Text: resp.Text, Ran: true, Usage: resp.Usage}, nil
}
// providerName returns the selected provider's name for ledger records on
// paths where no provider call is attempted.
func (g *Gate) providerName() string {
if g.Provider == nil {
return notConfigured{}.Name()
}
return g.Provider.Name()
}
// park writes the folded prompt under StateDir/parked/ and appends a
// queue item so the spend is visible and recoverable. The parked file
// lives inside the gitignored workspace (write-scope floor invariant).
func (g *Gate) park(req Request, reason string) (Outcome, error) {
out := Outcome{Skipped: true, Reason: reason}
if g.StateDir == "" {
// No place to park: still never a hard failure for the caller.
return out, nil
}
dir := filepath.Join(g.StateDir, "parked")
if err := os.MkdirAll(dir, 0o755); err != nil {
return out, fmt.Errorf("park prompt: %w", err)
}
ts := time.Now().UTC()
name := sanitize(req.Label) + "-" + ts.Format("20060102T150405.000000000Z") + ".md"
path := filepath.Join(dir, name)
body := fmt.Sprintf(
"parked AI prompt\n\nlabel: %s\nreason: %s\ntime: %s\n\n----- prompt -----\n%s\n",
req.Label, reason, ts.Format(time.RFC3339), foldPrompt(req))
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
return out, fmt.Errorf("park prompt: %w", err)
}
out.Parked = path
rel := path
if r, err := filepath.Rel(filepath.Dir(g.StateDir), path); err == nil {
rel = r
}
qerr := queue.Append(g.StateDir, queue.Item{
Kind: "ai-parked",
Title: "AI pass parked for " + req.Label,
Project: g.Project,
Detail: reason + "\nprompt saved: " + rel,
Date: ts,
})
if qerr != nil {
return out, fmt.Errorf("park prompt: queue: %w", qerr)
}
return out, nil
}
// ProjectDigest is the deterministic, no-spend System block for the
// background project-understanding pass: the profile plus the sorted
// top-level entry names. Reading file names is not an AI spend; only a
// gated provider call is.
func ProjectDigest(cfg *config.Config) string {
var names []string
if cfg != nil {
if ents, err := os.ReadDir(cfg.RepoRoot); err == nil {
for _, e := range ents {
if e.Name() == ".git" || e.Name() == cfg.WorkspaceName {
continue
}
names = append(names, e.Name())
}
}
}
sort.Strings(names)
prof := "generic"
if cfg != nil {
prof = string(cfg.Profile)
}
return fmt.Sprintf(
"Profile: %s\nTop-level entries: %s\n",
prof, strings.Join(names, ", "))
}
// Understand runs the background project-understanding pass through the
// Gate: it is a provider call subject to the same consent, budget, and
// parking as any other (PLAN.md §AI providers). The deterministic digest
// is the cacheable System block; the instruction is the User turn.
func Understand(ctx context.Context, g *Gate, cfg *config.Config) (Outcome, error) {
req := Request{
Label: "project-understanding",
System: ProjectDigest(cfg),
User: "Summarise this project and its likely maintenance risks. Be concrete and terse.",
Cache: true,
}
return g.Run(ctx, req)
}
// sanitize keeps a label safe as a filename component.
func sanitize(s string) string {
s = strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-':
return r
default:
return '-'
}
}, s)
if s == "" {
return "pass"
}
return s
}
added internal/ai/ai_test.go
@@ -0,0 +1,369 @@
package ai
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/ajhahnde/eeco/internal/config"
)
// fakeProvider counts calls and returns a scripted result.
type fakeProvider struct {
calls int
text string
err error
}
func (f *fakeProvider) Name() string { return "fake" }
func (f *fakeProvider) Run(context.Context, Request) (Response, error) {
f.calls++
return Response{Text: f.text}, f.err
}
// usageProvider returns a scripted Response carrying token usage so the
// Gate's usage-threading can be exercised.
type usageProvider struct {
text string
usage Usage
}
func (usageProvider) Name() string { return "usage" }
func (p usageProvider) Run(context.Context, Request) (Response, error) {
return Response{Text: p.text, Usage: p.usage}, nil
}
func newGate(t *testing.T, p Provider, consent bool, budget int) (*Gate, string) {
t.Helper()
state := filepath.Join(t.TempDir(), "state")
if err := os.MkdirAll(state, 0o755); err != nil {
t.Fatal(err)
}
return &Gate{Provider: p, Consent: consent, Budget: budget, StateDir: state, Project: "proj"}, state
}
func assertParked(t *testing.T, state string, out Outcome) {
t.Helper()
if !out.Skipped || out.Ran {
t.Fatalf("want Skipped, got %+v", out)
}
if out.Parked == "" {
t.Fatal("Skipped outcome must record a parked-prompt path")
}
if _, err := os.Stat(out.Parked); err != nil {
t.Fatalf("parked file missing: %v", err)
}
q, err := os.ReadFile(filepath.Join(state, "queue.md"))
if err != nil {
t.Fatalf("queue not written: %v", err)
}
if !strings.Contains(string(q), "ai-parked") {
t.Errorf("queue missing ai-parked item:\n%s", q)
}
}
func TestGate_NoConsentParksWithoutSpending(t *testing.T) {
fp := &fakeProvider{text: "result"}
g, state := newGate(t, fp, false, 5)
out, err := g.Run(context.Background(), Request{Label: "unit", User: "the prompt"})
if err != nil {
t.Fatal(err)
}
if fp.calls != 0 {
t.Errorf("provider called %d times without consent; want 0", fp.calls)
}
assertParked(t, state, out)
}
func TestGate_BudgetExhaustedParks(t *testing.T) {
fp := &fakeProvider{text: "ok"}
g, state := newGate(t, fp, true, 1)
if out, err := g.Run(context.Background(), Request{Label: "a", User: "p1"}); err != nil || !out.Ran {
t.Fatalf("first call should run: out=%+v err=%v", out, err)
}
out, err := g.Run(context.Background(), Request{Label: "b", User: "p2"})
if err != nil {
t.Fatal(err)
}
if fp.calls != 1 {
t.Errorf("provider called %d times; budget 1 must cap at 1", fp.calls)
}
assertParked(t, state, out)
}
func TestGate_ZeroBudgetParks(t *testing.T) {
fp := &fakeProvider{text: "ok"}
g, state := newGate(t, fp, true, 0)
out, err := g.Run(context.Background(), Request{Label: "z", User: "p"})
if err != nil {
t.Fatal(err)
}
if fp.calls != 0 {
t.Errorf("zero budget must not spend; calls=%d", fp.calls)
}
assertParked(t, state, out)
}
func TestGate_ProviderErrorParksNotFatal(t *testing.T) {
fp := &fakeProvider{err: errors.New("boom")}
g, state := newGate(t, fp, true, 3)
out, err := g.Run(context.Background(), Request{Label: "e", User: "p"})
if err != nil {
t.Fatalf("provider failure must not be a hard error: %v", err)
}
assertParked(t, state, out)
if !strings.Contains(out.Reason, "boom") {
t.Errorf("reason should carry provider error, got %q", out.Reason)
}
}
func TestGate_EmptyResultParks(t *testing.T) {
fp := &fakeProvider{text: " "}
g, state := newGate(t, fp, true, 3)
out, err := g.Run(context.Background(), Request{Label: "blank", User: "p"})
if err != nil {
t.Fatal(err)
}
assertParked(t, state, out)
}
func TestGate_SuccessReturnsText(t *testing.T) {
fp := &fakeProvider{text: " the answer "}
g, _ := newGate(t, fp, true, 1)
out, err := g.Run(context.Background(), Request{Label: "ok", User: "p"})
if err != nil {
t.Fatal(err)
}
if !out.Ran || out.Skipped {
t.Fatalf("want Ran, got %+v", out)
}
if out.Text != " the answer " {
t.Errorf("Text = %q (gate must not mangle provider text)", out.Text)
}
}
func TestSelect(t *testing.T) {
// After C5 the provider set is {cli, none}: a configured `ai_command`
// picks the CLI provider, everything else parks. The legacy
// `ai_provider=anthropic` is tolerated and behaves exactly like auto
// (the in-binary API provider was retired).
cmd := []string{"echo", "hi"}
tests := []struct {
name string
cfg *config.Config
want string // expected provider Name()
}{
{"nil cfg", nil, "none"},
{"auto, no command", &config.Config{}, "none"},
{"auto, command picks cli", &config.Config{AICommand: cmd}, "cli"},
{"explicit cli with command", &config.Config{AIProvider: "cli", AICommand: cmd}, "cli"},
{"explicit cli without command", &config.Config{AIProvider: "cli"}, "none"},
{"legacy anthropic, no command, falls to none", &config.Config{AIProvider: "anthropic"}, "none"},
{"legacy anthropic with command, falls to cli", &config.Config{AIProvider: "anthropic", AICommand: cmd}, "cli"},
{"unknown provider with command falls back to cli", &config.Config{AIProvider: "bogus", AICommand: cmd}, "cli"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := Select(tc.cfg).Name(); got != tc.want {
t.Errorf("Select() = %q, want %q", got, tc.want)
}
})
}
}
func TestNotConfigured_RunReportsSentinel(t *testing.T) {
_, err := notConfigured{}.Run(context.Background(), Request{})
if !errors.Is(err, ErrNotConfigured) {
t.Errorf("err = %v, want ErrNotConfigured", err)
}
}
func TestCLIProvider_RunsConfiguredCommand(t *testing.T) {
if _, err := os.Stat("/bin/sh"); err != nil {
t.Skip("no /bin/sh")
}
// Echo stdin back: proves the folded prompt is fed in and stdout is
// the result. An empty System folds to exactly User.
p := cliProvider{argv: []string{"/bin/sh", "-c", "cat"}}
got, err := p.Run(context.Background(), Request{User: "hello prompt"})
if err != nil {
t.Fatal(err)
}
if got.Text != "hello prompt" {
t.Errorf("got %q, want %q", got.Text, "hello prompt")
}
}
func TestCLIProvider_FoldsSystemAndUser(t *testing.T) {
if _, err := os.Stat("/bin/sh"); err != nil {
t.Skip("no /bin/sh")
}
p := cliProvider{argv: []string{"/bin/sh", "-c", "cat"}}
got, err := p.Run(context.Background(), Request{System: "S", User: "U"})
if err != nil {
t.Fatal(err)
}
if got.Text != "S\n\nU" {
t.Errorf("folded prompt = %q, want %q", got.Text, "S\n\nU")
}
}
func TestFoldPrompt(t *testing.T) {
if got := foldPrompt(Request{User: "only"}); got != "only" {
t.Errorf("User-only fold = %q, want %q", got, "only")
}
if got := foldPrompt(Request{System: "S", User: "U"}); got != "S\n\nU" {
t.Errorf("System+User fold = %q, want %q", got, "S\n\nU")
}
// An empty Messages must fall through to the single-turn branch
// byte-for-byte, so the four single-turn callers stay unchanged.
if got := foldPrompt(Request{System: "S", User: "U", Messages: nil}); got != "S\n\nU" {
t.Errorf("empty-Messages fold = %q, want byte-identical %q", got, "S\n\nU")
}
}
func TestFoldPrompt_Transcript(t *testing.T) {
req := Request{
System: "SYS",
User: "ignored when Messages is set",
Messages: []Message{
{Role: "user", Text: "hello"},
{Role: "assistant", Text: "hi there"},
{Role: "user", Text: "more"},
},
}
want := "SYS\n\nUser: hello\n\nAssistant: hi there\n\nUser: more"
if got := foldPrompt(req); got != want {
t.Errorf("transcript fold = %q, want %q", got, want)
}
// Without a System block the transcript leads with the first turn.
noSys := Request{Messages: []Message{{Role: "user", Text: "just me"}}}
if got := foldPrompt(noSys); got != "User: just me" {
t.Errorf("system-less transcript = %q, want %q", got, "User: just me")
}
}
func TestCLIProvider_EmptyArgvIsNotConfigured(t *testing.T) {
_, err := cliProvider{}.Run(context.Background(), Request{})
if !errors.Is(err, ErrNotConfigured) {
t.Errorf("err = %v, want ErrNotConfigured", err)
}
}
func TestGate_ThreadsUsageOnRan(t *testing.T) {
// The Gate must surface the provider's token accounting on a ran pass.
fp := &usageProvider{text: "ok", usage: Usage{InputTokens: 12, CachedInputTokens: 3, OutputTokens: 7}}
g, _ := newGate(t, fp, true, 1)
out, err := g.Run(context.Background(), Request{Label: "u", User: "p"})
if err != nil {
t.Fatal(err)
}
if !out.Ran {
t.Fatalf("want Ran, got %+v", out)
}
if out.Usage != (Usage{InputTokens: 12, CachedInputTokens: 3, OutputTokens: 7}) {
t.Errorf("Usage = %+v, want it threaded from the provider", out.Usage)
}
}
// fragCoAB assembles the trailer key from fragments so this tracked test
// file carries no contiguous attribution literal for eeco's own leak-guard
// to flag. The scanner under test is injected inline (a func value), never
// imported from internal/workflow — that import would cycle.
const fragCoAB = "Co-" + "Authored-" + "By"
func inlineAttributionScanner(s string) []string {
if strings.Contains(s, fragCoAB) {
return []string{"line 1: co-authored-by trailer"}
}
return nil
}
func TestGate_FilterBlocksAttributionAndRecordsHash(t *testing.T) {
resp := fragCoAB + ": A Bot <b@x>\n"
fp := &fakeProvider{text: resp}
g, state := newGate(t, fp, true, 1)
g.Scanner = inlineAttributionScanner
out, err := g.Run(context.Background(), Request{Label: "evolve", User: "p"})
if err != nil {
t.Fatal(err)
}
assertParked(t, state, out)
if fp.calls != 1 {
t.Errorf("provider should have run once before the block; calls=%d", fp.calls)
}
if !strings.Contains(out.Reason, "attribution") {
t.Errorf("park reason should name the attribution block, got %q", out.Reason)
}
if out.Text != "" {
t.Errorf("blocked response text must never reach the caller, got %q", out.Text)
}
l := readLedger(t, state)
if len(l.Records) != 1 {
t.Fatalf("want 1 ledger record, got %d", len(l.Records))
}
rec := l.Records[0]
if rec.Ran || !rec.Parked {
t.Errorf("blocked pass must record ran=false parked=true, got %+v", rec)
}
if rec.ResponseSHA256 != sha256Hex(resp) {
t.Errorf("blocked pass must record the response hash, got %q want %q", rec.ResponseSHA256, sha256Hex(resp))
}
if !strings.Contains(rec.ParkReason, "attribution") {
t.Errorf("ledger park reason should name the attribution block, got %q", rec.ParkReason)
}
}
func TestGate_FilterPassesCleanResponse(t *testing.T) {
fp := &fakeProvider{text: " a clean answer "}
g, state := newGate(t, fp, true, 1)
g.Scanner = inlineAttributionScanner
out, err := g.Run(context.Background(), Request{Label: "evolve", User: "p"})
if err != nil {
t.Fatal(err)
}
if !out.Ran || out.Skipped {
t.Fatalf("clean response must pass, got %+v", out)
}
if out.Text != " a clean answer " {
t.Errorf("Text = %q (filter must not mangle a clean response)", out.Text)
}
rec := readLedger(t, state).Records[0]
if !rec.Ran || rec.Parked || rec.ResponseSHA256 == "" {
t.Errorf("clean pass record = %+v, want ran with a response hash", rec)
}
}
func TestGate_NilScannerSkipsFilter(t *testing.T) {
// Attribution text with a nil Scanner must pass: the filter is nil-safe.
fp := &fakeProvider{text: fragCoAB + ": A Bot <b@x>\n"}
g, _ := newGate(t, fp, true, 1)
out, err := g.Run(context.Background(), Request{Label: "evolve", User: "p"})
if err != nil {
t.Fatal(err)
}
if !out.Ran {
t.Fatalf("nil scanner must not block; got %+v", out)
}
}
func TestUnderstand_IsGated(t *testing.T) {
// No consent: the background pass must park, never spend.
fp := &fakeProvider{text: "summary"}
g, state := newGate(t, fp, false, 5)
cfg := &config.Config{RepoRoot: t.TempDir(), Profile: config.ProfileGo}
out, err := Understand(context.Background(), g, cfg)
if err != nil {
t.Fatal(err)
}
if fp.calls != 0 {
t.Errorf("Understand spent without consent (calls=%d)", fp.calls)
}
assertParked(t, state, out)
}
added internal/ai/gated_boundary_test.go
@@ -0,0 +1,50 @@
package ai
import (
"context"
"testing"
)
// Trust-boundary suite H1.6, invariant (c) part 1: the AI gate stays gated.
// No-consent / zero-budget / exhausted-budget always PARK and call the
// provider exactly zero times before the gate. A future second entry point,
// or a refactor that moves the budget check below the provider call, fails
// this one named guard.
func TestBoundary_GatedNeverSpends(t *testing.T) {
ctx := context.Background()
cases := []struct {
name string
consent bool
budget int
prime bool // run one successful pass first, to exhaust a budget of 1
wantCalls int // provider calls expected after the gated invocation
}{
{"no-consent", false, 5, false, 0},
{"zero-budget", true, 0, false, 0},
{"exhausted-budget", true, 1, true, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fp := &fakeProvider{text: "ok"}
g, state := newGate(t, fp, tc.consent, tc.budget)
if tc.prime {
out, err := g.Run(ctx, Request{Label: "prime", User: "p"})
if err != nil || !out.Ran {
t.Fatalf("priming pass should run: out=%+v err=%v", out, err)
}
}
out, err := g.Run(ctx, Request{Label: "b", User: "p"})
if err != nil {
t.Fatalf("gated call returned a hard error: %v", err)
}
assertParked(t, state, out)
if fp.calls != tc.wantCalls {
t.Errorf("provider calls = %d, want %d (the gate must park BEFORE the provider)", fp.calls, tc.wantCalls)
}
})
}
}
added internal/ai/ledger.go
@@ -0,0 +1,129 @@
package ai
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
// AICallsFilename is the central AI-call ledger filename inside
// <workspace>/state/. It records one entry per gated provider attempt —
// ran, parked, or gated-out — so the operator has an audit trail of what
// the AI was asked and what it produced. Frozen surface; renaming or
// removing it is a breaking change.
const AICallsFilename = "ai-calls.json"
// aiCallTokens is the token accounting for one recorded call. Zero on
// parked passes and for providers that do not surface usage.
type aiCallTokens struct {
Input int `json:"input"`
CachedInput int `json:"cached_input"`
Output int `json:"output"`
}
// aiCallRecord is one entry in the AI-call ledger. It stores hashes, not
// raw text: the prompt and response bodies stay under state/parked/ when
// applicable and are never duplicated here. ResponseSHA256 and ParkReason
// are additive (omitted from the wire when empty) so older ledgers
// round-trip.
type aiCallRecord struct {
Label string `json:"label"`
Provider string `json:"provider"`
Model string `json:"model,omitempty"`
PromptSHA256 string `json:"prompt_sha256"`
ResponseSHA256 string `json:"response_sha256,omitempty"`
Ran bool `json:"ran"`
Parked bool `json:"parked"`
ParkReason string `json:"park_reason,omitempty"`
Tokens aiCallTokens `json:"tokens"`
Tools []string `json:"tools,omitempty"`
TS string `json:"ts"`
}
// aiCallLedger is the on-disk shape of the AI-call ledger.
type aiCallLedger struct {
Records []aiCallRecord `json:"records"`
}
// recordCall appends one record for a gated attempt. It is best-effort:
// a missing StateDir or any I/O fault is swallowed so the ledger can
// never turn a gated pass into a hard failure (floor invariant). The
// prompt hash is over the same folded prompt the CLI provider feeds and
// the parked file stores, so a ledger entry pins exactly to its parked
// prompt. tools is the tool names the model invoked in this round; nil
// (every pre-tool-use caller) marshals away under omitempty, so existing
// records are byte-identical.
func (g *Gate) recordCall(req Request, provider, model, respText string, ran, parked bool, reason string, usage Usage, tools []string) {
if g.StateDir == "" {
return
}
rec := aiCallRecord{
Label: req.Label,
Provider: provider,
Model: model,
PromptSHA256: sha256Hex(foldPrompt(req)),
Ran: ran,
Parked: parked,
ParkReason: reason,
Tokens: aiCallTokens{
Input: usage.InputTokens,
CachedInput: usage.CachedInputTokens,
Output: usage.OutputTokens,
},
Tools: tools,
TS: time.Now().UTC().Format(time.RFC3339),
}
if respText != "" {
rec.ResponseSHA256 = sha256Hex(respText)
}
_ = appendAICall(g.StateDir, rec)
}
// appendAICall loads the ledger, appends rec, and writes it back. A
// missing file is the empty ledger; a corrupt file degrades to the empty
// ledger so a broken file is never fatal — the next write rewrites it
// from scratch (the evolve-history discipline). Marshalled with
// indentation and a trailing newline so the file is human-inspectable.
func appendAICall(stateDir string, rec aiCallRecord) error {
if err := os.MkdirAll(stateDir, 0o755); err != nil {
return fmt.Errorf("ai ledger: state dir: %w", err)
}
ledger := loadAICalls(stateDir)
ledger.Records = append(ledger.Records, rec)
b, err := json.MarshalIndent(ledger, "", " ")
if err != nil {
return fmt.Errorf("ai ledger: encode: %w", err)
}
return os.WriteFile(filepath.Join(stateDir, AICallsFilename), append(b, '\n'), 0o644)
}
// loadAICalls reads <stateDir>/ai-calls.json. A missing or corrupt file
// is the empty ledger.
func loadAICalls(stateDir string) aiCallLedger {
var l aiCallLedger
b, err := os.ReadFile(filepath.Join(stateDir, AICallsFilename))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return l
}
return l
}
if len(b) == 0 {
return l
}
if jerr := json.Unmarshal(b, &l); jerr != nil {
return aiCallLedger{}
}
return l
}
// sha256Hex returns the lowercase hex SHA-256 of s.
func sha256Hex(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
added internal/ai/ledger_test.go
@@ -0,0 +1,130 @@
package ai
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func readLedger(t *testing.T, stateDir string) aiCallLedger {
t.Helper()
b, err := os.ReadFile(filepath.Join(stateDir, AICallsFilename))
if err != nil {
t.Fatalf("read ledger: %v", err)
}
var l aiCallLedger
if err := json.Unmarshal(b, &l); err != nil {
t.Fatalf("decode ledger: %v", err)
}
return l
}
func TestRecordCall_RanAndParkedAppend(t *testing.T) {
state := t.TempDir()
g := &Gate{StateDir: state}
// A ran pass with usage + a response.
g.recordCall(
Request{Label: "evolve", System: "S", User: "U"},
"anthropic", "claude-haiku-4-5", "the answer",
true, false, "", Usage{InputTokens: 10, CachedInputTokens: 4, OutputTokens: 6}, nil)
// A parked pass: no response text, a reason, zero usage.
g.recordCall(
Request{Label: "evolve", System: "S", User: "U"},
"none", "", "",
false, true, "AI budget exhausted (cap 0)", Usage{}, nil)
l := readLedger(t, state)
if len(l.Records) != 2 {
t.Fatalf("want 2 records, got %d", len(l.Records))
}
ran := l.Records[0]
if !ran.Ran || ran.Parked {
t.Errorf("first record: want ran, got %+v", ran)
}
if ran.PromptSHA256 != sha256Hex("S\n\nU") {
t.Errorf("prompt hash = %q, want hash of folded prompt", ran.PromptSHA256)
}
if ran.ResponseSHA256 != sha256Hex("the answer") {
t.Errorf("response hash = %q, want hash of response text", ran.ResponseSHA256)
}
if ran.Provider != "anthropic" || ran.Model != "claude-haiku-4-5" {
t.Errorf("provider/model not recorded: %+v", ran)
}
if ran.Tokens != (aiCallTokens{Input: 10, CachedInput: 4, Output: 6}) {
t.Errorf("tokens = %+v, want input=10 cached=4 output=6", ran.Tokens)
}
parked := l.Records[1]
if parked.Ran || !parked.Parked {
t.Errorf("second record: want parked, got %+v", parked)
}
if parked.ResponseSHA256 != "" {
t.Errorf("parked record must carry no response hash, got %q", parked.ResponseSHA256)
}
if parked.ParkReason == "" {
t.Error("parked record must carry the park reason")
}
}
func TestRecordCall_NoStateDirIsNoop(t *testing.T) {
g := &Gate{StateDir: ""}
// Must not panic and must not write anywhere.
g.recordCall(Request{Label: "x", User: "p"}, "none", "", "", false, true, "r", Usage{}, nil)
}
func TestAppendAICall_CorruptFileResets(t *testing.T) {
state := t.TempDir()
if err := os.WriteFile(filepath.Join(state, AICallsFilename), []byte("{not json"), 0o644); err != nil {
t.Fatal(err)
}
// A corrupt ledger must not wedge the append: it degrades to empty,
// then the new record lands as the sole entry.
if err := appendAICall(state, aiCallRecord{Label: "fresh", Provider: "none", Ran: false, Parked: true}); err != nil {
t.Fatalf("append over corrupt file: %v", err)
}
l := readLedger(t, state)
if len(l.Records) != 1 || l.Records[0].Label != "fresh" {
t.Errorf("corrupt file must reset to a single fresh record, got %+v", l.Records)
}
}
// TestRecordCall_NoToolsOmitsField is the back-compat firewall: a record
// written with nil tools (every pre-v1.7.0 caller) must not emit a "tools"
// key, so older ledgers and the five existing call sites round-trip
// byte-identically.
func TestRecordCall_NoToolsOmitsField(t *testing.T) {
state := t.TempDir()
g := &Gate{StateDir: state}
g.recordCall(Request{Label: "evolve", User: "U"}, "anthropic", "m", "ans",
true, false, "", Usage{}, nil)
b, err := os.ReadFile(filepath.Join(state, AICallsFilename))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), "\"tools\"") {
t.Errorf("nil tools must not emit a tools key; ledger = %s", b)
}
}
// TestRecordCall_WithToolsSerializes proves a tool-using round records the
// invoked tool names under the additive "tools" field.
func TestRecordCall_WithToolsSerializes(t *testing.T) {
state := t.TempDir()
g := &Gate{StateDir: state}
g.recordCall(Request{Label: "tui-request", User: "U"}, "anthropic", "m", "ans",
true, false, "", Usage{}, []string{"search_knowledge", "project_brief"})
l := readLedger(t, state)
if len(l.Records) != 1 {
t.Fatalf("want 1 record, got %d", len(l.Records))
}
got := l.Records[0].Tools
if len(got) != 2 || got[0] != "search_knowledge" || got[1] != "project_brief" {
t.Errorf("tools = %v, want [search_knowledge project_brief]", got)
}
}
added internal/ai/summary.go
@@ -0,0 +1,61 @@
package ai
// TokenTotals is the summed token usage across ledger records. The fields
// mirror the unexported per-call accounting (input, cached input, output);
// these are real provider counts, never estimates.
type TokenTotals struct {
Input int
CachedInput int
Output int
}
// Total is the sum of the three token classes.
func (t TokenTotals) Total() int { return t.Input + t.CachedInput + t.Output }
// UsageSummary is the aggregated view of the AI-calls ledger
// (<workspace>/state/ai-calls.json). It is the read-only seam cmd/eeco uses
// to surface cumulative AI usage without reaching the unexported ledger types.
type UsageSummary struct {
TotalCalls int
Ran int
Parked int
Tokens TokenTotals
ByProvider map[string]int // provider name -> call count
FirstTS string // earliest record ts ("" if no dated records)
LastTS string // latest record ts ("" if no dated records)
}
// Summarize aggregates <stateDir>/ai-calls.json into a UsageSummary. A
// missing or corrupt ledger yields the zero summary (loadAICalls already
// degrades both to the empty ledger), so callers never need to special-case
// an absent workspace. ts values are RFC 3339 UTC, so a lexical compare is
// chronological — no time.Parse and no clock seam are needed; the date range
// derives from the data itself.
func Summarize(stateDir string) UsageSummary {
ledger := loadAICalls(stateDir)
sum := UsageSummary{ByProvider: map[string]int{}}
for _, r := range ledger.Records {
sum.TotalCalls++
if r.Ran {
sum.Ran++
}
if r.Parked {
sum.Parked++
}
sum.Tokens.Input += r.Tokens.Input
sum.Tokens.CachedInput += r.Tokens.CachedInput
sum.Tokens.Output += r.Tokens.Output
if r.Provider != "" {
sum.ByProvider[r.Provider]++
}
if r.TS != "" {
if sum.FirstTS == "" || r.TS < sum.FirstTS {
sum.FirstTS = r.TS
}
if r.TS > sum.LastTS {
sum.LastTS = r.TS
}
}
}
return sum
}
added internal/ai/summary_test.go
@@ -0,0 +1,76 @@
package ai
import (
"os"
"path/filepath"
"testing"
)
// writeLedger drops a crafted ai-calls.json into stateDir. The body is the
// frozen on-disk shape, so hand-authoring it is safe.
func writeLedger(t *testing.T, stateDir, body string) {
t.Helper()
if err := os.MkdirAll(stateDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(stateDir, AICallsFilename), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestSummarize_Aggregates(t *testing.T) {
dir := t.TempDir()
writeLedger(t, dir, `{"records":[
{"label":"a","provider":"anthropic","ran":true,"parked":false,
"tokens":{"input":1000,"cached_input":200,"output":500},"ts":"2026-05-21T10:00:00Z"},
{"label":"b","provider":"cli","ran":true,"parked":false,
"tokens":{"input":2000,"cached_input":0,"output":800},"ts":"2026-05-30T12:00:00Z"},
{"label":"c","provider":"anthropic","ran":false,"parked":true,
"tokens":{"input":0,"cached_input":0,"output":0},"ts":"2026-05-25T09:00:00Z"}
]}`)
s := Summarize(dir)
if s.TotalCalls != 3 {
t.Errorf("TotalCalls = %d, want 3", s.TotalCalls)
}
if s.Ran != 2 {
t.Errorf("Ran = %d, want 2", s.Ran)
}
if s.Parked != 1 {
t.Errorf("Parked = %d, want 1", s.Parked)
}
if s.Tokens.Input != 3000 || s.Tokens.CachedInput != 200 || s.Tokens.Output != 1300 {
t.Errorf("Tokens = %+v, want {3000 200 1300}", s.Tokens)
}
if got := s.Tokens.Total(); got != 4500 {
t.Errorf("Tokens.Total() = %d, want 4500", got)
}
if s.ByProvider["anthropic"] != 2 || s.ByProvider["cli"] != 1 {
t.Errorf("ByProvider = %v, want anthropic:2 cli:1", s.ByProvider)
}
if s.FirstTS != "2026-05-21T10:00:00Z" {
t.Errorf("FirstTS = %q, want 2026-05-21T10:00:00Z", s.FirstTS)
}
if s.LastTS != "2026-05-30T12:00:00Z" {
t.Errorf("LastTS = %q, want 2026-05-30T12:00:00Z", s.LastTS)
}
}
func TestSummarize_MissingFile(t *testing.T) {
s := Summarize(t.TempDir())
if s.TotalCalls != 0 || s.Tokens.Total() != 0 || len(s.ByProvider) != 0 {
t.Errorf("missing ledger should be the zero summary, got %+v", s)
}
if s.FirstTS != "" || s.LastTS != "" {
t.Errorf("missing ledger should have empty range, got first=%q last=%q", s.FirstTS, s.LastTS)
}
}
func TestSummarize_EmptyRecords(t *testing.T) {
dir := t.TempDir()
writeLedger(t, dir, `{"records":[]}`)
s := Summarize(dir)
if s.TotalCalls != 0 || s.Tokens.Total() != 0 {
t.Errorf("empty ledger should be the zero summary, got %+v", s)
}
}
added internal/ask/ask.go
@@ -0,0 +1,358 @@
// Package ask answers a free-form question about a project with a
// deterministic, no-AI-spend, ranked set of pointers: the matching
// memory facts first (eeco's curated topic→file map) and then the
// best-matching code locations as path:line references.
//
// It is the engine behind `eeco ask`. Where `eeco go` (package brief)
// gives a one-shot project overview, `eeco ask` is the interactive
// counterpart: a fast, precise pointer into the codebase for any
// assistant, beyond the static brief.
//
// The package only reads — the resolved config, the memory store, and
// the repository's tracked files — and writes nothing. It calls no AI
// provider: relevance is a simple word-overlap score, the same
// tokenisation the memory store uses for fact selection. The output
// carries no timestamp and every list is in a stable sort order, so a
// given question over a given tree always produces the same answer.
package ask
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/gitx"
"github.com/ajhahnde/eeco/internal/memory"
)
// DefaultLimit is the number of code locations Search returns when the
// caller passes a non-positive limit.
const DefaultLimit = 10
// maxFileBytes caps the size of a file ask will scan. Larger files are
// skipped: they are almost always generated or vendored, and reading
// them would blow the time budget for a command meant to feel instant.
const maxFileBytes = 256 * 1024
// snippetCap bounds the length of a code-line snippet in the output so
// one long minified line cannot dominate the answer.
const snippetCap = 160
// tokenSplit matches the inverse of a word character (letters, digits,
// underscore). Tokenisation mirrors internal/memory/select.go: lowercase,
// split on non-word runs, dedupe. A private copy lives here rather than
// widening the memory package's surface — the two are tiny, independent,
// and ask layers its own scoring on top.
var tokenSplit = regexp.MustCompile(`[^\p{L}\p{N}_]+`)
// Result is the structured answer to one question: the matching memory
// facts and the matching code locations, each ranked. It is the data
// behind `eeco ask`, independent of how it is rendered — Render turns it
// into Markdown, RenderJSON into a JSON object, so the two always
// describe the same answer. Both slice fields are always non-nil so the
// JSON form renders an empty list rather than null.
type Result struct {
Question string `json:"question"`
Memory []MemoryHit `json:"memory"`
Code []CodeHit `json:"code"`
}
// MemoryHit is one memory fact whose name, description, or body shares a
// word with the question. Ref is the repo-relative file the fact points
// at, empty when the fact carries none. Score is the count of distinct
// question terms the fact matched.
type MemoryHit struct {
Name string `json:"name"`
Description string `json:"description"`
Ref string `json:"ref"`
Score int `json:"score"`
}
// CodeHit is one line in a tracked source file that shares a word with
// the question. Path is repo-relative and slash-separated, Line is
// 1-based, Text is the trimmed (and length-capped) line, and Score is
// the count of distinct question terms the line matched.
type CodeHit struct {
Path string `json:"path"`
Line int `json:"line"`
Text string `json:"text"`
Score int `json:"score"`
}
// Search answers question for cfg: it scores the memory store and the
// repository's tracked files by word overlap with the question and
// returns the ranked matches. limit caps the number of code locations
// returned (a non-positive limit means DefaultLimit); every matching
// memory fact is returned. It reads the memory store only when the
// workspace is initialised and degrades gracefully when it is not — the
// code search still runs, so `eeco ask` is useful in any git repo.
//
// A non-nil error means a real I/O fault while walking the tree or
// reading the store; an unmatched question is not an error (the Result
// simply carries empty lists).
func Search(cfg *config.Config, question string, limit int) (Result, error) {
if cfg == nil {
return Result{}, errors.New("ask.Search: nil config")
}
if limit <= 0 {
limit = DefaultLimit
}
res := Result{
Question: strings.TrimSpace(question),
Memory: []MemoryHit{},
Code: []CodeHit{},
}
terms := tokenize(question)
if len(terms) == 0 {
return res, nil
}
mem, err := searchMemory(cfg, terms)
if err != nil {
return Result{}, err
}
res.Memory = mem
code, err := searchCode(cfg, terms, limit)
if err != nil {
return Result{}, err
}
res.Code = code
return res, nil
}
// searchMemory scores each fact by the number of distinct question terms
// found across its name, description, and body. It reads the store
// read-only (it does not call memory.Select, which would bump last_used
// and re-save). Facts are sorted by score descending, then name
// ascending, for a stable order.
func searchMemory(cfg *config.Config, terms map[string]struct{}) ([]MemoryHit, error) {
if !config.IsInitialized(cfg) {
return []MemoryHit{}, nil
}
store, err := memory.Open(cfg)
if err != nil {
return nil, fmt.Errorf("ask: open memory: %w", err)
}
facts, err := store.LoadAll()
if err != nil {
return nil, fmt.Errorf("ask: load memory: %w", err)
}
hits := []MemoryHit{}
for _, f := range facts {
if f.Disabled {
continue
}
score := overlapCount(terms, tokenize(f.Name+" "+f.Description+" "+f.Body))
if score == 0 {
continue
}
hits = append(hits, MemoryHit{
Name: f.Name,
Description: f.Description,
Ref: f.Ref,
Score: score,
})
}
sort.SliceStable(hits, func(i, j int) bool {
if hits[i].Score != hits[j].Score {
return hits[i].Score > hits[j].Score
}
return hits[i].Name < hits[j].Name
})
return hits, nil
}
// searchCode scans every tracked text file and scores each line by the
// number of distinct question terms it contains. The top limit lines are
// returned, ranked by score descending, then path ascending, then line
// ascending — a fully deterministic order.
func searchCode(cfg *config.Config, terms map[string]struct{}, limit int) ([]CodeHit, error) {
files, err := collectFiles(cfg)
if err != nil {
return nil, err
}
hits := []CodeHit{}
for _, rel := range files {
data, err := os.ReadFile(filepath.Join(cfg.RepoRoot, filepath.FromSlash(rel)))
if err != nil {
// A file listed by git but unreadable now (a race, a broken
// symlink) is skipped, not fatal: the answer degrades rather
// than aborting.
continue
}
if len(data) > maxFileBytes || bytes.IndexByte(data, 0) >= 0 {
continue // oversized or binary
}
for i, raw := range strings.Split(string(data), "\n") {
score := overlapCount(terms, tokenize(raw))
if score == 0 {
continue
}
hits = append(hits, CodeHit{
Path: rel,
Line: i + 1,
Text: snippet(raw),
Score: score,
})
}
}
sort.SliceStable(hits, func(i, j int) bool {
if hits[i].Score != hits[j].Score {
return hits[i].Score > hits[j].Score
}
if hits[i].Path != hits[j].Path {
return hits[i].Path < hits[j].Path
}
return hits[i].Line < hits[j].Line
})
if len(hits) > limit {
hits = hits[:limit]
}
return hits, nil
}
// collectFiles lists the repository's text-file candidates, repo-relative
// and slash-separated. It prefers git's tracked set so build artifacts,
// the eeco workspace, and other untracked clutter stay out of the search;
// it falls back to a recursive directory walk when git is unavailable or
// the repo has no tracked files (the same two-branch strategy the brief
// uses for the top-level listing). Either path skips the .git directory
// and eeco's own workspace.
func collectFiles(cfg *config.Config) ([]string, error) {
if tracked, err := gitx.TrackedFiles(cfg.RepoRoot); err == nil && len(tracked) > 0 {
out := make([]string, 0, len(tracked))
for _, p := range tracked {
seg, _, _ := strings.Cut(p, "/")
if seg == cfg.WorkspaceName {
continue
}
out = append(out, p)
}
sort.Strings(out)
return out, nil
}
var out []string
err := filepath.WalkDir(cfg.RepoRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if path == cfg.RepoRoot {
return nil
}
if name := d.Name(); name == ".git" || name == cfg.WorkspaceName {
return filepath.SkipDir
}
return nil
}
rel, err := filepath.Rel(cfg.RepoRoot, path)
if err != nil {
return err
}
out = append(out, filepath.ToSlash(rel))
return nil
})
if err != nil {
return nil, fmt.Errorf("ask: walk repo: %w", err)
}
sort.Strings(out)
return out, nil
}
// Render serialises a Result to the Markdown answer. When the answer is
// empty it renders a single guidance line instead of empty sections.
func Render(r Result) string {
var b strings.Builder
fmt.Fprintf(&b, "# eeco ask: %q\n\n", r.Question)
if len(r.Memory) == 0 && len(r.Code) == 0 {
b.WriteString("No matches — try different terms, or run `eeco go` for the project brief.\n")
return b.String()
}
b.WriteString("## Memory\n\n")
if len(r.Memory) == 0 {
b.WriteString("No matching facts.\n")
} else {
for _, m := range r.Memory {
if m.Ref != "" {
fmt.Fprintf(&b, "- %s → `%s`\n", m.Description, m.Ref)
} else {
fmt.Fprintf(&b, "- %s — %s\n", m.Name, m.Description)
}
}
}
b.WriteString("\n## Code\n\n")
if len(r.Code) == 0 {
b.WriteString("No matching code.\n")
} else {
for _, c := range r.Code {
fmt.Fprintf(&b, "- `%s:%d` %s\n", c.Path, c.Line, c.Text)
}
}
return b.String()
}
// RenderJSON serialises a Result to an indented JSON object — the
// machine-readable counterpart to Render. The three top-level keys
// (question, memory, code) are frozen; the arrays are always present,
// never null.
func RenderJSON(r Result) (string, error) {
out, err := json.MarshalIndent(r, "", " ")
if err != nil {
return "", fmt.Errorf("ask: marshal json: %w", err)
}
return string(out) + "\n", nil
}
// snippet trims a code line and caps its length so one very long line
// cannot dominate the answer.
func snippet(line string) string {
s := strings.TrimSpace(line)
if len(s) > snippetCap {
s = s[:snippetCap] + "…"
}
return s
}
// tokenize lowercases s, splits it on non-word runs, drops single
// characters (which carry little signal and inflate code-search noise),
// and returns the distinct tokens as a set.
func tokenize(s string) map[string]struct{} {
out := map[string]struct{}{}
for _, t := range tokenSplit.Split(strings.ToLower(s), -1) {
if len(t) <= 1 {
continue
}
out[t] = struct{}{}
}
return out
}
// overlapCount returns the number of distinct terms present in both sets.
func overlapCount(terms, hay map[string]struct{}) int {
short, long := terms, hay
if len(hay) < len(terms) {
short, long = hay, terms
}
n := 0
for k := range short {
if _, ok := long[k]; ok {
n++
}
}
return n
}
added internal/ask/ask_test.go
@@ -0,0 +1,273 @@
package ask
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/memory"
)
// fixture builds a throwaway repo (a bare .git so the directory-walk
// fallback engages) with a few source files carrying known terms, and
// returns a loaded config rooted at it.
func fixture(t *testing.T) *config.Config {
t.Helper()
root := t.TempDir()
mustWrite(t, root, ".git/HEAD", "ref: refs/heads/main\n")
mustWrite(t, root, "go.mod", "module sample\n\ngo 1.24\n")
mustWrite(t, root, "boot.go", "// boot path setup\nfunc boot() {}\n")
mustWrite(t, root, "internal/render/render.go", "// render the project brief\nfunc Render() {}\n")
mustWrite(t, root, "README.md", "A sample project.\n")
// A large file and a binary file must both be skipped by the scan.
mustWrite(t, root, "big.txt", "boot\n"+strings.Repeat("x\n", maxFileBytes))
mustWrite(t, root, "blob.bin", "boot\x00path\n")
cfg, err := config.Load(root, "")
if err != nil {
t.Fatalf("config.Load: %v", err)
}
return cfg
}
func mustWrite(t *testing.T, dir, rel, content string) {
t.Helper()
full := filepath.Join(dir, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestSearch_CodeMatchesAndRanks(t *testing.T) {
cfg := fixture(t)
res, err := Search(cfg, "render project brief", 10)
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(res.Code) == 0 {
t.Fatal("expected code matches, got none")
}
// The render.go line carries three of the query terms; it must rank
// first regardless of path order.
top := res.Code[0]
if !strings.Contains(top.Path, "render.go") {
t.Errorf("top hit should be render.go, got %s:%d (%s)", top.Path, top.Line, top.Text)
}
if top.Score < 2 {
t.Errorf("top hit score = %d, want >= 2", top.Score)
}
}
func TestSearch_SkipsBinaryAndOversized(t *testing.T) {
cfg := fixture(t)
res, err := Search(cfg, "boot path", 50)
if err != nil {
t.Fatalf("Search: %v", err)
}
for _, c := range res.Code {
if c.Path == "blob.bin" {
t.Error("binary file blob.bin should be skipped")
}
if c.Path == "big.txt" {
t.Error("oversized file big.txt should be skipped")
}
}
if len(res.Code) == 0 {
t.Error("expected boot.go to match 'boot path'")
}
}
func TestSearch_Deterministic(t *testing.T) {
cfg := fixture(t)
first, err := Search(cfg, "boot render project", 10)
if err != nil {
t.Fatal(err)
}
second, err := Search(cfg, "boot render project", 10)
if err != nil {
t.Fatal(err)
}
if Render(first) != Render(second) {
t.Error("Search is not deterministic across runs")
}
}
func TestSearch_LimitCapsCode(t *testing.T) {
cfg := fixture(t)
res, err := Search(cfg, "boot render project sample func", 1)
if err != nil {
t.Fatal(err)
}
if len(res.Code) > 1 {
t.Errorf("--limit 1 returned %d code hits", len(res.Code))
}
}
func TestSearch_EmptyQuestion(t *testing.T) {
cfg := fixture(t)
res, err := Search(cfg, "a !! ?", 10) // only short / non-word tokens
if err != nil {
t.Fatal(err)
}
if len(res.Code) != 0 || len(res.Memory) != 0 {
t.Error("a question with no usable terms should match nothing")
}
// Non-nil slices so JSON renders [] not null.
if res.Memory == nil || res.Code == nil {
t.Error("slices must be non-nil")
}
}
func TestSearch_MemoryHits(t *testing.T) {
cfg := fixture(t)
// IsInitialized requires the scaffolded subdirs to exist.
for _, sub := range []string{"engine", "memory", "workflows", "state", "docs"} {
if err := os.MkdirAll(filepath.Join(cfg.Workspace, sub), 0o755); err != nil {
t.Fatal(err)
}
}
store, err := memory.Open(cfg)
if err != nil {
t.Fatal(err)
}
now := time.Now()
fact := &memory.Fact{
Name: "boot-path",
Description: "where the boot path is configured",
Type: memory.TypeProject,
Created: now,
LastUsed: now,
Ref: "boot.go",
Body: "the boot sequence starts here",
}
if err := store.Save(fact); err != nil {
t.Fatal(err)
}
// Snapshot last_used as persisted, before the search.
before, err := store.LoadAll()
if err != nil {
t.Fatal(err)
}
wantLastUsed := before[0].LastUsed
res, err := Search(cfg, "boot path", 10)
if err != nil {
t.Fatal(err)
}
if len(res.Memory) == 0 {
t.Fatal("expected a memory hit for 'boot path'")
}
if res.Memory[0].Ref != "boot.go" {
t.Errorf("memory hit ref = %q, want boot.go", res.Memory[0].Ref)
}
// Search must not mutate the store (unlike memory.Select, which bumps
// last_used): the persisted last_used is unchanged after the search.
after, err := store.LoadAll()
if err != nil {
t.Fatal(err)
}
if !after[0].LastUsed.Equal(wantLastUsed) {
t.Errorf("ask.Search mutated last_used: was %v, now %v", wantLastUsed, after[0].LastUsed)
}
}
func TestSearch_DisabledFactsHidden(t *testing.T) {
cfg := fixture(t)
for _, sub := range []string{"engine", "memory", "workflows", "state", "docs"} {
if err := os.MkdirAll(filepath.Join(cfg.Workspace, sub), 0o755); err != nil {
t.Fatal(err)
}
}
store, err := memory.Open(cfg)
if err != nil {
t.Fatal(err)
}
now := time.Now()
enabled := &memory.Fact{
Name: "enabled-boot", Description: "boot path enabled",
Type: memory.TypeProject, Created: now, LastUsed: now, Body: "boot path lives here",
}
disabled := &memory.Fact{
Name: "disabled-boot", Description: "boot path disabled",
Type: memory.TypeProject, Created: now, LastUsed: now, Body: "boot path lives here too",
Disabled: true,
}
for _, f := range []*memory.Fact{enabled, disabled} {
if err := store.Save(f); err != nil {
t.Fatal(err)
}
}
res, err := Search(cfg, "boot path", 10)
if err != nil {
t.Fatal(err)
}
for _, h := range res.Memory {
if h.Name == "disabled-boot" {
t.Errorf("disabled fact leaked into ask Memory results: %+v", h)
}
}
var foundEnabled bool
for _, h := range res.Memory {
if h.Name == "enabled-boot" {
foundEnabled = true
}
}
if !foundEnabled {
t.Error("enabled fact missing from ask Memory results")
}
}
func TestSearch_NotInitializedEmptyMemory(t *testing.T) {
cfg := fixture(t) // no config.local written → not initialised
res, err := Search(cfg, "boot path", 10)
if err != nil {
t.Fatal(err)
}
if len(res.Memory) != 0 {
t.Error("uninitialised workspace should yield no memory hits")
}
if len(res.Code) == 0 {
t.Error("code search should still run when uninitialised")
}
}
func TestRender_EmptyState(t *testing.T) {
out := Render(Result{Question: "nothing here", Memory: []MemoryHit{}, Code: []CodeHit{}})
if !strings.Contains(out, "No matches") {
t.Errorf("empty answer should render the no-matches guidance:\n%s", out)
}
if strings.Contains(out, "## Code") {
t.Errorf("empty answer should not render section headers:\n%s", out)
}
}
func TestRenderJSON_KeysAndNonNull(t *testing.T) {
out, err := RenderJSON(Result{Question: "q", Memory: []MemoryHit{}, Code: []CodeHit{}})
if err != nil {
t.Fatal(err)
}
if !json.Valid([]byte(out)) {
t.Fatalf("RenderJSON produced invalid JSON:\n%s", out)
}
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(out), &raw); err != nil {
t.Fatal(err)
}
for _, k := range []string{"question", "memory", "code"} {
if _, ok := raw[k]; !ok {
t.Errorf("JSON missing frozen top-level key %q", k)
}
}
if string(raw["memory"]) == "null" || string(raw["code"]) == "null" {
t.Error("arrays must serialise as [] not null")
}
}
added internal/brief/brief.go
@@ -0,0 +1,695 @@
// Package brief renders a deterministic, no-AI-spend project brief for
// an AI assistant: what eeco is, the shape of the project, where to look
// for detail, what eeco already knows, and the open decisions.
//
// It is the engine behind `eeco go`. The brief lets any assistant —
// not only the strongest — pick up a project quickly and cheaply: one
// command returns a compact map instead of a scan across many files.
//
// The package only reads — the resolved config, the memory store, and
// the queue file — and writes nothing. The output carries no timestamp
// and lists facts in the store's stable sort order, so it is
// reproducible and safe to snapshot in a golden test.
//
// Collect gathers the brief once into a Data value; Render turns that
// value into the Markdown brief and RenderJSON into a JSON object, so
// the two representations always describe the same project state.
package brief
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/gitx"
"github.com/ajhahnde/eeco/internal/memory"
"github.com/ajhahnde/eeco/internal/notes"
"github.com/ajhahnde/eeco/internal/queue"
"github.com/ajhahnde/eeco/internal/workflow"
)
// nowFunc is the clock the assembly timer reads. It is a package var so a
// test can pin elapsed time and assert exact output, mirroring the
// injectable memory.Store.Now seam and the os.Stdin seams in cmd/eeco.
var nowFunc = time.Now
// EstimateTokens approximates a token count from a byte length via the
// ~4-bytes-per-token heuristic. It is a deliberate estimate, never a real
// tokenizer count — eeco ships zero runtime dependencies, so no tokenizer
// can be embedded. Callers MUST present the result with a "≈" prefix so
// it never reads as a precise figure. EstimateTokens(0) == 0.
func EstimateTokens(n int) int { return n / 4 }
// AssemblyMetrics reports one `eeco go` brief assembly. The byte fields
// are real measurements; token figures derived from them (via
// EstimateTokens) are estimates. The value carries no project state and
// is never part of the brief or the --json surface.
type AssemblyMetrics struct {
Elapsed time.Duration // wall-clock for Collect + the Markdown render
BriefBytes int // real bytes of the rendered Markdown brief
KnowledgeBytes int // real on-disk size of the distilled knowledge layer
}
// Measure renders the Markdown brief for cfg — the full brief, or the
// smaller --brief variant when brief is true — exactly as Render and
// RenderBrief do, and reports how the assembly went. The returned text is
// byte-identical to Render(cfg) / RenderBrief(cfg), so a --metrics readout
// never perturbs stdout or the brief goldens.
//
// Elapsed times only Collect plus the Markdown render — the work
// "assembling the brief" names. The knowledge-byte baseline is measured
// outside that window: reading the layer off disk is not part of how long
// the brief took to build. A nil config is an error, mirroring Render.
func Measure(cfg *config.Config, brief bool) (string, AssemblyMetrics, error) {
if cfg == nil {
return "", AssemblyMetrics{}, errors.New("brief.Measure: nil config")
}
start := nowFunc()
d, err := Collect(cfg)
if err != nil {
return "", AssemblyMetrics{}, err
}
if brief {
d.TrimToBrief()
}
text := renderMarkdown(d)
elapsed := nowFunc().Sub(start)
kb, err := knowledgeBytes(cfg)
if err != nil {
return "", AssemblyMetrics{}, err
}
return text, AssemblyMetrics{
Elapsed: elapsed,
BriefBytes: len(text),
KnowledgeBytes: kb,
}, nil
}
// knowledgeBytes sums the real on-disk size of the knowledge layer the
// brief distills: every memory fact file, the queue, and every note. It
// mirrors memory.Store.LoadAll's selection (skips MEMORY.md, dot-prefixed
// entries, the attic and other directories, and non-.md files) but counts
// disabled facts too — they are real bytes on disk that the brief omits,
// which is exactly the compression a "distilled M into N" readout should
// report. A missing file or directory contributes 0, not an error (an
// uninitialised or empty workspace honestly distills 0 bytes); any other
// I/O fault is wrapped and returned.
func knowledgeBytes(cfg *config.Config) (int, error) {
total := 0
// Memory facts: <workspace>/memory/*.md, same selection as LoadAll.
n, err := dirMarkdownBytes(filepath.Join(cfg.Workspace, "memory"), memory.IndexFilename)
if err != nil {
return 0, err
}
total += n
// Queue: <workspace>/state/<queue.Filename>.
n, err = fileBytes(filepath.Join(cfg.Workspace, "state", queue.Filename))
if err != nil {
return 0, err
}
total += n
// Notes: <workspace>/notes/*.md (counted unconditionally — the
// baseline is what knowledge exists, not what the brief chose to show).
n, err = dirMarkdownBytes(filepath.Join(cfg.Workspace, "notes"), "")
if err != nil {
return 0, err
}
total += n
return total, nil
}
// dirMarkdownBytes sums the size of every regular ".md" file directly
// under dir, skipping subdirectories, dot-prefixed entries, and the file
// named skip (the MEMORY.md index for the memory dir; "" skips nothing).
// DirEntry.Info avoids a second stat per file. A missing directory is 0
// bytes, not an error.
func dirMarkdownBytes(dir, skip string) (int, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return 0, nil
}
return 0, err
}
total := 0
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if name == skip || strings.HasPrefix(name, ".") || !strings.HasSuffix(name, ".md") {
continue
}
info, err := e.Info()
if err != nil {
return 0, err
}
total += int(info.Size())
}
return total, nil
}
// fileBytes returns the on-disk size of path. A missing file is 0 bytes,
// not an error; any other stat fault is returned.
func fileBytes(path string) (int, error) {
info, err := os.Stat(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return 0, nil
}
return 0, err
}
return int(info.Size()), nil
}
// Data is the deterministic project brief in structured form: the data
// behind `eeco go`, independent of how it is rendered. Render turns it
// into Markdown, RenderJSON into a JSON object. The static onboarding
// prose ("Working with eeco", "Recording back") is not part of Data —
// it carries no project state and lives only in the Markdown brief.
type Data struct {
Project string `json:"project"`
Profile string `json:"profile"`
Gate []string `json:"gate"`
TopLevel []string `json:"top_level"`
Initialized bool `json:"initialized"`
Workflows []string `json:"workflows"`
WhereToLook []Pointer `json:"where_to_look"`
Knowledge []KnowledgeFact `json:"knowledge"`
OpenDecisions []string `json:"open_decisions"`
// BriefMode is set by TrimToBrief and controls Markdown rendering:
// when true the "Working with eeco" preamble and "Recording back"
// outro are omitted. It carries no project state and is excluded
// from JSON output so the nine-frozen-top-level-key contract holds.
BriefMode bool `json:"-"`
// IncludeNotes mirrors cfg.BriefIncludeNotes and gates the "Recent
// notes" section in the Markdown render. The Notes payload itself is
// hidden from JSON for the same nine-frozen-top-level-key reason:
// notes belong to the assistant-prose channel, not the
// machine-parsed brief.
IncludeNotes bool `json:"-"`
Notes []notes.Note `json:"-"`
}
// briefCap is the per-section list cap TrimToBrief enforces — the same
// N=5 ceiling Render already applies to the open-decisions section, now
// extended to the where-to-look and knowledge lists so an assistant on
// a tight context budget always reads a bounded brief.
const briefCap = 5
// TrimToBrief reshapes d into the smaller brief form: BriefMode is set
// so Render skips the preamble and outro sections, and each per-section
// list is capped at briefCap. JSON output is unchanged in shape — the
// nine top-level keys remain, with arrays possibly shorter.
func (d *Data) TrimToBrief() {
d.trimToCap(briefCap)
}
// trimToCap sets BriefMode and caps each per-section list at n entries.
// n is the per-section ceiling TrimToBrief and the RenderWithinBudget
// ladder share; n == 0 empties the lists, leaving only the fixed
// section scaffolding. Each list is only ever shortened (resliced),
// never written into, so a caller may trim a shallow copy of one
// Collect result repeatedly without disturbing the original.
func (d *Data) trimToCap(n int) {
d.BriefMode = true
if len(d.WhereToLook) > n {
d.WhereToLook = d.WhereToLook[:n]
}
if len(d.Knowledge) > n {
d.Knowledge = d.Knowledge[:n]
}
if len(d.OpenDecisions) > n {
d.OpenDecisions = d.OpenDecisions[:n]
}
if len(d.Notes) > n {
d.Notes = d.Notes[:n]
}
}
// BudgetReport describes the outcome of RenderWithinBudget: which trim
// tier produced the returned brief, its byte size, and whether it fit
// the requested budget.
type BudgetReport struct {
// Tier is "full", "brief", or "brief (cap N)" — the trim tier the
// returned text was rendered from.
Tier string
// Bytes is the byte length of the returned brief.
Bytes int
// Met is true when Bytes is within the requested budget. It is
// false only when even the smallest tier (cap 0) overruns — the
// caller still receives that smallest brief.
Met bool
}
// Pointer is one topic → file entry: a memory fact that carries a ref,
// the fastest path for an assistant to the right file.
type Pointer struct {
Description string `json:"description"`
Ref string `json:"ref"`
}
// KnowledgeFact is one load-bearing memory fact — project, feedback, or
// user — in the terse name/description/type shape of the MEMORY.md index.
type KnowledgeFact struct {
Name string `json:"name"`
Description string `json:"description"`
Type string `json:"type"`
}
// Collect assembles the structured project brief for cfg. It reads the
// memory store and the queue file when the workspace is initialised and
// degrades gracefully when it is not: Project, Profile, Gate, TopLevel,
// and Workflows are populated either way. Every slice field is non-nil
// so the JSON form renders an empty list rather than null. A non-nil
// error means a real I/O fault while reading the store or the queue.
func Collect(cfg *config.Config) (Data, error) {
if cfg == nil {
return Data{}, errors.New("brief.Collect: nil config")
}
d := Data{
Project: filepath.Base(cfg.RepoRoot),
Profile: string(cfg.Profile),
Gate: config.GateSteps(cfg.Gate),
TopLevel: []string{},
Workflows: workflow.DefaultRegistry().Names(),
WhereToLook: []Pointer{},
Knowledge: []KnowledgeFact{},
OpenDecisions: []string{},
}
d.TopLevel = append(d.TopLevel, topLevel(cfg)...)
d.Initialized = config.IsInitialized(cfg)
if !d.Initialized {
return d, nil
}
store, err := memory.Open(cfg)
if err != nil {
return Data{}, fmt.Errorf("brief: open memory: %w", err)
}
facts, err := store.LoadAll()
if err != nil {
return Data{}, fmt.Errorf("brief: load memory: %w", err)
}
for _, f := range facts {
if f.Disabled {
continue
}
if ref := strings.TrimSpace(f.Ref); ref != "" {
d.WhereToLook = append(d.WhereToLook, Pointer{Description: f.Description, Ref: ref})
}
switch f.Type {
case memory.TypeProject, memory.TypeFeedback, memory.TypeUser:
d.Knowledge = append(d.Knowledge, KnowledgeFact{
Name: f.Name,
Description: f.Description,
Type: string(f.Type),
})
}
}
items, err := queueLines(cfg)
if err != nil {
return Data{}, fmt.Errorf("brief: read queue: %w", err)
}
d.OpenDecisions = append(d.OpenDecisions, items...)
if cfg.BriefIncludeNotes {
d.IncludeNotes = true
list, err := notes.List(filepath.Join(cfg.Workspace, "notes"))
if err != nil {
return Data{}, fmt.Errorf("brief: list notes: %w", err)
}
// The full brief still caps notes at briefCap so a workspace with
// dozens of scribbles cannot balloon the brief; the trim ladder
// shortens it further when budget is tight.
if len(list) > briefCap {
list = list[:briefCap]
}
d.Notes = list
}
return d, nil
}
// Render assembles the Markdown project brief for cfg. It reads the
// memory store and the queue file when the workspace is initialised and
// degrades gracefully when it is not: the "Working with eeco" and
// "Project" sections render either way. A non-nil error means a real
// I/O fault while reading the store or the queue.
func Render(cfg *config.Config) (string, error) {
d, err := Collect(cfg)
if err != nil {
return "", err
}
return renderMarkdown(d), nil
}
// RenderBrief is Render's smaller sibling: it collects the same data
// then trims via TrimToBrief, so the assistant-facing preamble and
// outro drop out and each per-section list is capped at briefCap.
func RenderBrief(cfg *config.Config) (string, error) {
d, err := Collect(cfg)
if err != nil {
return "", err
}
d.TrimToBrief()
return renderMarkdown(d), nil
}
// RenderJSON assembles the project brief for cfg as an indented JSON
// object — the machine-readable counterpart to Render, for a downstream
// agent or script rather than an assistant reading prose. It carries the
// same project state as the Markdown brief and is equally deterministic.
func RenderJSON(cfg *config.Config) (string, error) {
d, err := Collect(cfg)
if err != nil {
return "", err
}
return marshalData(d)
}
// RenderJSONBrief is RenderJSON's smaller sibling: TrimToBrief caps the
// per-section arrays before marshalling, keeping the nine top-level
// keys (arrays may be shorter, never absent or null).
func RenderJSONBrief(cfg *config.Config) (string, error) {
d, err := Collect(cfg)
if err != nil {
return "", err
}
d.TrimToBrief()
return marshalData(d)
}
// RenderWithinBudget renders the Markdown brief for cfg trimmed to fit
// maxBytes. It is the engine behind `eeco go --write` when the
// `context_budget` config key is set: the persisted brief an assistant
// re-reads each session stays under a known size.
//
// It walks a deterministic ladder — the full brief, then the brief form
// (preamble/outro dropped) with per-section lists capped at 5, 4, 3, 2,
// 1, and finally 0 — and returns the largest tier whose rendered byte
// length is within maxBytes. When skipFull is set (the caller passed
// --brief) the full tier is left out and the ladder starts at the brief
// form. maxBytes <= 0 means no cap: the full brief (or, with skipFull,
// the brief form) is returned unchanged.
//
// When even the cap-0 tier overruns maxBytes the smallest brief is
// returned anyway with BudgetReport.Met false — a brief slightly over
// budget beats no brief at all. A non-nil error means a real I/O fault
// while reading the store or the queue.
func RenderWithinBudget(cfg *config.Config, maxBytes int, skipFull bool) (string, BudgetReport, error) {
base, err := Collect(cfg)
if err != nil {
return "", BudgetReport{}, err
}
// renderTier renders a shallow copy of base trimmed to cap n; a
// negative n leaves the full brief untrimmed. trimToCap only
// reshortens slices, so each tier is independent of the others.
renderTier := func(n int) string {
d := base
if n >= 0 {
d.trimToCap(n)
}
return renderMarkdown(d)
}
tierName := func(n int) string {
switch {
case n < 0:
return "full"
case n == briefCap:
return "brief"
default:
return fmt.Sprintf("brief (cap %d)", n)
}
}
// The ladder, widest tier first: full (cap -1), then briefCap down
// to 0. skipFull drops the full tier.
ladder := []int{-1}
for n := briefCap; n >= 0; n-- {
ladder = append(ladder, n)
}
if skipFull {
ladder = ladder[1:]
}
if maxBytes <= 0 {
n := ladder[0]
text := renderTier(n)
return text, BudgetReport{Tier: tierName(n), Bytes: len(text), Met: true}, nil
}
var text string
var n int
for _, n = range ladder {
text = renderTier(n)
if len(text) <= maxBytes {
return text, BudgetReport{Tier: tierName(n), Bytes: len(text), Met: true}, nil
}
}
// Nothing fit: text/n hold the last (smallest) tier rendered.
return text, BudgetReport{Tier: tierName(n), Bytes: len(text), Met: false}, nil
}
// renderMarkdown serialises a Data value to the Markdown brief. When
// d.BriefMode is set the preamble and outro sections are omitted; every
// other section renders as in the full brief so the smaller form stays
// a strict subset.
func renderMarkdown(d Data) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s — eeco project brief\n\n", d.Project)
b.WriteString("Written by `eeco go`: a deterministic, no-AI-spend project brief.\n")
b.WriteString("Read this once instead of scanning the tree, then open the files\n")
b.WriteString("named under \"Where to look\" for detail.\n\n")
if !d.BriefMode {
writeWorkingWithEeco(&b, d.Workflows)
}
writeProject(&b, d)
writeWhereToLook(&b, d)
writeKnowledge(&b, d)
if d.IncludeNotes {
writeNotes(&b, d)
}
writeDecisions(&b, d)
if !d.BriefMode {
writeRecordingBack(&b)
}
return b.String()
}
// marshalData turns a Data value into the indented JSON brief.
func marshalData(d Data) (string, error) {
out, err := json.MarshalIndent(d, "", " ")
if err != nil {
return "", fmt.Errorf("brief: marshal json: %w", err)
}
return string(out) + "\n", nil
}
// writeWorkingWithEeco explains eeco to the assistant: the durable
// context it keeps and the safe, read-only commands worth running. The
// builtin list is taken from the registry so it never drifts.
func writeWorkingWithEeco(b *strings.Builder, workflows []string) {
b.WriteString("## Working with eeco\n\n")
b.WriteString("This repo uses eeco — a local tool that keeps project memory and a\n")
b.WriteString("decision queue so an assistant carries durable context across\n")
b.WriteString("sessions. Commands you can run (read-only, safe by default):\n\n")
b.WriteString("- `eeco go` — print this brief\n")
b.WriteString("- `eeco doctor` — workspace and configuration diagnostics\n")
fmt.Fprintf(b, "- `eeco run <workflow>` — run a workflow (builtins: %s)\n",
strings.Join(workflows, ", "))
b.WriteString("- `eeco gc` — memory garbage collection\n\n")
b.WriteString("Findings and decisions go to eeco's queue, not silent edits to the\n")
b.WriteString("tracked tree.\n\n")
}
// writeProject states the detected profile, the parse/build gate, and
// the repository's top-level layout.
func writeProject(b *strings.Builder, d Data) {
b.WriteString("## Project\n\n")
fmt.Fprintf(b, "- profile: %s\n", d.Profile)
gate := "(none)"
if len(d.Gate) > 0 {
gate = strings.Join(d.Gate, " && ")
}
fmt.Fprintf(b, "- gate: %s\n", gate)
if len(d.TopLevel) == 0 {
b.WriteString("- top-level: (empty)\n\n")
return
}
fmt.Fprintf(b, "- top-level: %s\n\n", strings.Join(d.TopLevel, ", "))
}
// topLevel lists the repository's top-level entry names. It derives
// them from git's tracked set when git is available, so build
// artifacts, the eeco workspace, and other untracked clutter stay out
// of the brief; it falls back to a directory listing otherwise. Either
// path skips the .git directory and eeco's own per-user workspace dir
// (cfg.Username, which holds the .eeco engine workspace), and the
// result is sorted, so the brief is deterministic.
func topLevel(cfg *config.Config) []string {
skip := func(seg string) bool {
return seg == ".git" || seg == cfg.WorkspaceName ||
(cfg.Username != "" && seg == cfg.Username)
}
if tracked, err := gitx.TrackedFiles(cfg.RepoRoot); err == nil && len(tracked) > 0 {
seen := map[string]struct{}{}
var out []string
for _, p := range tracked {
seg, _, _ := strings.Cut(p, "/")
if skip(seg) {
continue
}
if _, ok := seen[seg]; ok {
continue
}
seen[seg] = struct{}{}
out = append(out, seg)
}
sort.Strings(out)
return out
}
// No git, or an unborn repo: fall back to a directory listing.
ents, err := os.ReadDir(cfg.RepoRoot)
if err != nil {
return nil
}
var out []string
for _, e := range ents {
if skip(e.Name()) {
continue
}
out = append(out, e.Name())
}
return out
}
// writeWhereToLook turns memory facts that carry a ref into a topic →
// file map: the fastest path for an assistant to the right file.
func writeWhereToLook(b *strings.Builder, d Data) {
b.WriteString("## Where to look\n\n")
if !d.Initialized {
b.WriteString("Workspace not initialised — run `eeco init` to start project memory.\n\n")
return
}
if len(d.WhereToLook) == 0 {
b.WriteString("No file pointers recorded yet.\n")
} else {
for _, p := range d.WhereToLook {
fmt.Fprintf(b, "- %s → `%s`\n", p.Description, p.Ref)
}
}
b.WriteString("\n")
}
// writeKnowledge lists the load-bearing facts — project, feedback, and
// user — as terse name/description lines, the same shape as the
// MEMORY.md index.
func writeKnowledge(b *strings.Builder, d Data) {
b.WriteString("## What eeco knows\n\n")
if !d.Initialized {
b.WriteString("Workspace not initialised — no project memory yet.\n\n")
return
}
if len(d.Knowledge) == 0 {
b.WriteString("No durable facts recorded yet.\n")
} else {
for _, f := range d.Knowledge {
fmt.Fprintf(b, "- %s — %s (%s)\n", f.Name, f.Description, f.Type)
}
}
b.WriteString("\n")
}
// writeNotes lists the most recent free-form workspace notes. The
// section appears only when cfg.BriefIncludeNotes is set (mirrored on
// d.IncludeNotes); the timestamp is formatted in UTC so the brief stays
// reproducible across machines, and the list is already capped at
// briefCap by Collect (with the trim ladder shortening further when
// budget is tight).
func writeNotes(b *strings.Builder, d Data) {
b.WriteString("## Recent notes\n\n")
if !d.Initialized {
b.WriteString("Workspace not initialised — no notes yet.\n\n")
return
}
if len(d.Notes) == 0 {
b.WriteString("No notes recorded yet — add one with `eeco add note \"...\"`.\n\n")
return
}
for _, n := range d.Notes {
fmt.Fprintf(b, "- %s — %s\n", n.When.UTC().Format("2006-01-02 15:04"), n.Summary)
}
b.WriteString("\n")
}
// writeDecisions reports the open queue items — the only things eeco
// flags as needing a human decision.
func writeDecisions(b *strings.Builder, d Data) {
b.WriteString("## Open decisions\n\n")
if !d.Initialized {
b.WriteString("Workspace not initialised — no queue yet.\n\n")
return
}
if len(d.OpenDecisions) == 0 {
b.WriteString("None — nothing is waiting on a decision.\n\n")
return
}
fmt.Fprintf(b, "%d open:\n", len(d.OpenDecisions))
for _, it := range d.OpenDecisions {
fmt.Fprintf(b, "- %s\n", it)
}
b.WriteString("\n")
}
// queueLines returns the text of each unchecked queue item, the same
// read-only extraction `eeco uninstall` uses. A missing queue file is
// not an error: the workspace simply has no open items yet.
func queueLines(cfg *config.Config) ([]string, error) {
data, err := os.ReadFile(filepath.Join(cfg.Workspace, "state", queue.Filename))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
var out []string
const prefix = "- [ ]"
for raw := range strings.SplitSeq(string(data), "\n") {
line := strings.TrimSpace(raw)
if !strings.HasPrefix(line, prefix) {
continue
}
out = append(out, strings.TrimSpace(line[len(prefix):]))
}
return out, nil
}
// writeRecordingBack tells the assistant how to keep the brief useful:
// record durable facts and route decisions through the queue.
func writeRecordingBack(b *strings.Builder) {
b.WriteString("## Recording back\n\n")
b.WriteString("Keep this brief useful for the next session: record durable facts\n")
b.WriteString("in eeco's memory store and route findings and decisions through its\n")
b.WriteString("queue rather than acting silently. Run `eeco doctor` if anything\n")
b.WriteString("here looks stale.\n")
}
added internal/brief/brief_test.go
@@ -0,0 +1,964 @@
package brief
import (
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/memory"
"github.com/ajhahnde/eeco/internal/notes"
)
// updateGolden rewrites the golden file instead of comparing it. Run
// `go test ./internal/brief/ -update` after an intentional brief change
// and commit the regenerated golden with the code.
var updateGolden = flag.Bool("update", false, "rewrite golden files under testdata/")
// TestMain pins the workspace owner so config.Load resolves a
// deterministic username across machines. The workspace then lives at
// <root>/tester/.eeco, which the golden fixtures encode.
func TestMain(m *testing.M) {
os.Setenv("EECO_USERNAME", "tester")
os.Exit(m.Run())
}
func TestRender_Golden(t *testing.T) {
cfg := sampleRepo(t)
seedSample(t, cfg)
got, err := Render(cfg)
if err != nil {
t.Fatalf("Render: %v", err)
}
golden := filepath.Join("testdata", "brief.golden")
if *updateGolden {
if err := os.MkdirAll("testdata", 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(golden, []byte(got), 0o644); err != nil {
t.Fatal(err)
}
return
}
raw, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("read golden %s (run with -update to create): %v", golden, err)
}
// Git for Windows can rewrite LF to CRLF on checkout when
// .gitattributes is not honoured; normalise so the golden still
// matches.
want := strings.ReplaceAll(string(raw), "\r\n", "\n")
if want != got {
t.Errorf("brief differs from %s — re-run with -update if intentional.\n--- want ---\n%s\n--- got ---\n%s", golden, want, got)
}
}
func TestRender_NoWorkspace(t *testing.T) {
root := filepath.Join(t.TempDir(), "bare")
mkdirs(t, root, ".git", "src")
writeFile(t, filepath.Join(root, "go.mod"), "module bare\n")
cfg, err := config.Load(root, config.DefaultWorkspace)
if err != nil {
t.Fatalf("config.Load: %v", err)
}
got, err := Render(cfg)
if err != nil {
t.Fatalf("Render: %v", err)
}
for _, want := range []string{
"## Working with eeco",
"## Project",
"Workspace not initialised — run `eeco init`",
"Workspace not initialised — no project memory yet.",
"Workspace not initialised — no queue yet.",
} {
if !strings.Contains(got, want) {
t.Errorf("no-workspace brief missing %q:\n%s", want, got)
}
}
}
func TestRender_NilConfig(t *testing.T) {
if _, err := Render(nil); err == nil {
t.Fatal("Render(nil) should return an error")
}
}
// sampleRepo builds a deterministic initialised repository: a fake .git
// marker, a go.mod (go profile), two tracked top-level directories, and
// a scaffolded eeco workspace. The fake .git makes the tracked-set
// lookup fall back to a directory listing, which keeps the fixture
// independent of a real git checkout.
func sampleRepo(t *testing.T) *config.Config {
t.Helper()
root := filepath.Join(t.TempDir(), "sample")
// EECO_USERNAME=tester (pinned in TestMain) scopes the workspace under
// <root>/tester/.eeco, so the scaffolded subdirs must live there too
// for IsInitialized to see an initialised workspace.
mkdirs(t, root, ".git", "cmd", "docs",
filepath.Join("tester", config.DefaultWorkspace, "engine"),
filepath.Join("tester", config.DefaultWorkspace, "memory"),
filepath.Join("tester", config.DefaultWorkspace, "workflows"),
filepath.Join("tester", config.DefaultWorkspace, "state"),
filepath.Join("tester", config.DefaultWorkspace, "docs"),
)
writeFile(t, filepath.Join(root, "go.mod"), "module sample\n\ngo 1.24\n")
cfg, err := config.Load(root, config.DefaultWorkspace)
if err != nil {
t.Fatalf("config.Load: %v", err)
}
return cfg
}
func mkdirs(t *testing.T, root string, subs ...string) {
t.Helper()
for _, s := range subs {
if err := os.MkdirAll(filepath.Join(root, s), 0o755); err != nil {
t.Fatal(err)
}
}
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// seedSample populates cfg's workspace with a deterministic set of
// memory facts and queue items — the shared fixture behind the Markdown
// and JSON brief golden tests, so the two always describe one state.
func seedSample(t *testing.T, cfg *config.Config) {
t.Helper()
store, err := memory.Open(cfg)
if err != nil {
t.Fatalf("memory.Open: %v", err)
}
day := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
for _, f := range []*memory.Fact{
{Name: "api-docs", Description: "external API reference", Type: memory.TypeReference, Created: day, LastUsed: day, Ref: "docs/api.md"},
{Name: "auth-flow", Description: "auth flow lives here", Type: memory.TypeProject, Created: day, LastUsed: day, Ref: "internal/auth/auth.go"},
{Name: "terse-comments", Description: "keep comments terse", Type: memory.TypeFeedback, Created: day, LastUsed: day},
} {
if err := store.Save(f); err != nil {
t.Fatalf("save %s: %v", f.Name, err)
}
}
writeFile(t, filepath.Join(cfg.Workspace, "state", "queue.md"),
"- [ ] **finding** — comment-hygiene flagged a tooling string _(sample, 2026-01-01)_\n"+
" internal/auth/auth.go:12\n"+
"- [ ] **handover** — draft handover ready for review _(sample, 2026-01-01)_\n"+
" state/parked/handover.md\n")
}
func TestEstimateTokens(t *testing.T) {
for _, tc := range []struct {
in, want int
}{
{0, 0},
{4, 1},
{7, 1},
{8, 2},
{4000, 1000},
} {
if got := EstimateTokens(tc.in); got != tc.want {
t.Errorf("EstimateTokens(%d) = %d, want %d", tc.in, got, tc.want)
}
}
}
func TestMeasure_PinnedClock(t *testing.T) {
cfg := sampleRepo(t)
seedSample(t, cfg)
// nowFunc is read once at the start of the timed window and once at
// the end; pin the two reads to a fixed 5ms gap and assert Elapsed
// exactly — the determinism proof for the timing readout.
start := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
calls := 0
nowFunc = func() time.Time {
calls++
if calls == 1 {
return start
}
return start.Add(5 * time.Millisecond)
}
t.Cleanup(func() { nowFunc = time.Now })
_, m, err := Measure(cfg, false)
if err != nil {
t.Fatalf("Measure: %v", err)
}
if m.Elapsed != 5*time.Millisecond {
t.Errorf("Elapsed = %s, want 5ms", m.Elapsed)
}
if calls != 2 {
t.Errorf("nowFunc called %d times, want 2 (one per window edge)", calls)
}
}
func TestMeasure_KnowledgeBytesExact(t *testing.T) {
cfg := sampleRepo(t)
store, err := memory.Open(cfg)
if err != nil {
t.Fatalf("memory.Open: %v", err)
}
day := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
// Three facts, one disabled — disabled facts are real on-disk bytes
// the brief omits, so they must still count toward the baseline.
for _, f := range []*memory.Fact{
{Name: "alpha", Description: "first fact", Type: memory.TypeProject, Created: day, LastUsed: day, Ref: "a.go"},
{Name: "beta", Description: "second fact", Type: memory.TypeFeedback, Created: day, LastUsed: day},
{Name: "gamma", Description: "muted fact", Type: memory.TypeProject, Created: day, LastUsed: day, Disabled: true},
} {
if err := store.Save(f); err != nil {
t.Fatalf("save %s: %v", f.Name, err)
}
}
queuePath := filepath.Join(cfg.Workspace, "state", "queue.md")
writeFile(t, queuePath, "- [ ] **finding** — weigh this _(sample, 2026-01-01)_\n")
// Sum the same files independently and assert the helper matches.
var want int64
for _, name := range []string{"alpha.md", "beta.md", "gamma.md"} {
fi, err := os.Stat(filepath.Join(cfg.Workspace, "memory", name))
if err != nil {
t.Fatalf("stat %s: %v", name, err)
}
want += fi.Size()
}
qi, err := os.Stat(queuePath)
if err != nil {
t.Fatalf("stat queue: %v", err)
}
want += qi.Size()
_, m, err := Measure(cfg, false)
if err != nil {
t.Fatalf("Measure: %v", err)
}
if int64(m.KnowledgeBytes) != want {
t.Errorf("KnowledgeBytes = %d, want %d (3 facts incl. disabled + queue)", m.KnowledgeBytes, want)
}
full, err := Render(cfg)
if err != nil {
t.Fatalf("Render: %v", err)
}
if m.BriefBytes != len(full) {
t.Errorf("BriefBytes = %d, want %d (len of Render output)", m.BriefBytes, len(full))
}
}
func TestMeasure_CompressionMath(t *testing.T) {
cfg := sampleRepo(t)
seedSample(t, cfg)
_, m, err := Measure(cfg, false)
if err != nil {
t.Fatalf("Measure: %v", err)
}
// The readout's token figures and percentage are pure functions of
// the measured bytes; recompute them here so the format helper can be
// trusted to present the same numbers.
if got := EstimateTokens(m.BriefBytes); got != m.BriefBytes/4 {
t.Errorf("brief tokens = %d, want %d", got, m.BriefBytes/4)
}
if m.KnowledgeBytes <= 0 {
t.Fatalf("fixture should distil real knowledge bytes, got %d", m.KnowledgeBytes)
}
wantPct := max(0, (m.KnowledgeBytes-m.BriefBytes)*100/m.KnowledgeBytes)
if wantPct > 100 {
t.Errorf("computed savedPct %d out of range", wantPct)
}
}
func TestMeasure_BriefVariant(t *testing.T) {
cfg := sampleRepo(t)
seedBig(t, cfg)
text, m, err := Measure(cfg, true)
if err != nil {
t.Fatalf("Measure: %v", err)
}
wantText, err := RenderBrief(cfg)
if err != nil {
t.Fatalf("RenderBrief: %v", err)
}
if text != wantText {
t.Errorf("Measure(cfg,true) text != RenderBrief(cfg)")
}
full, _, err := Measure(cfg, false)
if err != nil {
t.Fatalf("Measure full: %v", err)
}
if m.BriefBytes >= len(full) {
t.Errorf("brief variant BriefBytes %d should be smaller than full %d", m.BriefBytes, len(full))
}
}
func TestMeasure_NoWorkspace(t *testing.T) {
root := filepath.Join(t.TempDir(), "bare")
mkdirs(t, root, ".git", "src")
writeFile(t, filepath.Join(root, "go.mod"), "module bare\n")
cfg, err := config.Load(root, config.DefaultWorkspace)
if err != nil {
t.Fatalf("config.Load: %v", err)
}
_, m, err := Measure(cfg, false)
if err != nil {
t.Fatalf("Measure: %v", err)
}
if m.KnowledgeBytes != 0 {
t.Errorf("uninitialised workspace KnowledgeBytes = %d, want 0", m.KnowledgeBytes)
}
if m.BriefBytes <= 0 {
t.Errorf("BriefBytes = %d, want a non-empty brief even without a workspace", m.BriefBytes)
}
}
func TestMeasure_NilConfig(t *testing.T) {
if _, _, err := Measure(nil, false); err == nil {
t.Fatal("Measure(nil, …) should return an error")
}
}
func TestMeasure_TextMatchesRender(t *testing.T) {
cfg := sampleRepo(t)
seedSample(t, cfg)
text, _, err := Measure(cfg, false)
if err != nil {
t.Fatalf("Measure: %v", err)
}
want, err := Render(cfg)
if err != nil {
t.Fatalf("Render: %v", err)
}
if text != want {
t.Errorf("Measure(cfg,false) text != Render(cfg) — metrics must never perturb the brief")
}
}
func TestRenderJSON_Golden(t *testing.T) {
cfg := sampleRepo(t)
seedSample(t, cfg)
got, err := RenderJSON(cfg)
if err != nil {
t.Fatalf("RenderJSON: %v", err)
}
if !json.Valid([]byte(got)) {
t.Fatalf("RenderJSON output is not valid JSON:\n%s", got)
}
golden := filepath.Join("testdata", "brief.json.golden")
if *updateGolden {
if err := os.MkdirAll("testdata", 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(golden, []byte(got), 0o644); err != nil {
t.Fatal(err)
}
return
}
raw, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("read golden %s (run with -update to create): %v", golden, err)
}
want := strings.ReplaceAll(string(raw), "\r\n", "\n")
if want != got {
t.Errorf("JSON brief differs from %s — re-run with -update if intentional.\n--- want ---\n%s\n--- got ---\n%s", golden, want, got)
}
}
func TestRenderJSON_NoWorkspace(t *testing.T) {
root := filepath.Join(t.TempDir(), "bare")
mkdirs(t, root, ".git", "src")
writeFile(t, filepath.Join(root, "go.mod"), "module bare\n")
cfg, err := config.Load(root, config.DefaultWorkspace)
if err != nil {
t.Fatalf("config.Load: %v", err)
}
got, err := RenderJSON(cfg)
if err != nil {
t.Fatalf("RenderJSON: %v", err)
}
var d Data
if err := json.Unmarshal([]byte(got), &d); err != nil {
t.Fatalf("unmarshal: %v\n%s", err, got)
}
if d.Initialized {
t.Error("no-workspace brief should report initialized=false")
}
if d.Profile != "go" {
t.Errorf("profile = %q, want go", d.Profile)
}
// Slice fields must marshal as an empty list, never null, so a
// consumer can iterate without a nil check.
for _, want := range []string{`"where_to_look": []`, `"knowledge": []`, `"open_decisions": []`} {
if !strings.Contains(got, want) {
t.Errorf("no-workspace JSON missing %s:\n%s", want, got)
}
}
}
func TestRenderJSON_NilConfig(t *testing.T) {
if _, err := RenderJSON(nil); err == nil {
t.Fatal("RenderJSON(nil) should return an error")
}
}
func TestRenderBrief_Golden(t *testing.T) {
cfg := sampleRepo(t)
seedSample(t, cfg)
got, err := RenderBrief(cfg)
if err != nil {
t.Fatalf("RenderBrief: %v", err)
}
golden := filepath.Join("testdata", "brief.brief.golden")
if *updateGolden {
if err := os.MkdirAll("testdata", 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(golden, []byte(got), 0o644); err != nil {
t.Fatal(err)
}
return
}
raw, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("read golden %s (run with -update to create): %v", golden, err)
}
want := strings.ReplaceAll(string(raw), "\r\n", "\n")
if want != got {
t.Errorf("brief differs from %s — re-run with -update if intentional.\n--- want ---\n%s\n--- got ---\n%s", golden, want, got)
}
}
func TestRenderBrief_StripsPreambleAndOutro(t *testing.T) {
cfg := sampleRepo(t)
seedSample(t, cfg)
got, err := RenderBrief(cfg)
if err != nil {
t.Fatalf("RenderBrief: %v", err)
}
for _, dropped := range []string{
"## Working with eeco",
"## Recording back",
} {
if strings.Contains(got, dropped) {
t.Errorf("brief should omit %q in --brief mode:\n%s", dropped, got)
}
}
for _, kept := range []string{
"## Project",
"## Where to look",
"## What eeco knows",
"## Open decisions",
} {
if !strings.Contains(got, kept) {
t.Errorf("brief should keep %q in --brief mode:\n%s", kept, got)
}
}
}
func TestRenderJSONBrief_Golden(t *testing.T) {
cfg := sampleRepo(t)
seedSample(t, cfg)
got, err := RenderJSONBrief(cfg)
if err != nil {
t.Fatalf("RenderJSONBrief: %v", err)
}
if !json.Valid([]byte(got)) {
t.Fatalf("RenderJSONBrief output is not valid JSON:\n%s", got)
}
golden := filepath.Join("testdata", "brief.brief.json.golden")
if *updateGolden {
if err := os.MkdirAll("testdata", 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(golden, []byte(got), 0o644); err != nil {
t.Fatal(err)
}
return
}
raw, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("read golden %s (run with -update to create): %v", golden, err)
}
want := strings.ReplaceAll(string(raw), "\r\n", "\n")
if want != got {
t.Errorf("JSON brief differs from %s — re-run with -update if intentional.\n--- want ---\n%s\n--- got ---\n%s", golden, want, got)
}
}
func TestRenderJSONBrief_KeepsFrozenKeys(t *testing.T) {
cfg := sampleRepo(t)
seedSample(t, cfg)
got, err := RenderJSONBrief(cfg)
if err != nil {
t.Fatalf("RenderJSONBrief: %v", err)
}
// Constraint: the nine frozen top-level keys remain present after a
// brief trim — arrays may be shorter, never absent or null.
for _, key := range []string{
`"project"`, `"profile"`, `"gate"`, `"top_level"`, `"initialized"`,
`"workflows"`, `"where_to_look"`, `"knowledge"`, `"open_decisions"`,
} {
if !strings.Contains(got, key) {
t.Errorf("brief JSON missing frozen key %s:\n%s", key, got)
}
}
// The BriefMode flag is rendering metadata, not project state, so
// the json:"-" tag must hide it from the JSON brief.
if strings.Contains(got, "BriefMode") || strings.Contains(got, `"brief_mode"`) {
t.Errorf("brief JSON leaks the BriefMode flag:\n%s", got)
}
}
func TestTrimToBrief_CapsLists(t *testing.T) {
d := Data{
WhereToLook: make([]Pointer, 8),
Knowledge: make([]KnowledgeFact, 9),
OpenDecisions: make([]string, 7),
}
d.TrimToBrief()
if !d.BriefMode {
t.Error("TrimToBrief should set BriefMode")
}
if got := len(d.WhereToLook); got != briefCap {
t.Errorf("WhereToLook len = %d, want %d", got, briefCap)
}
if got := len(d.Knowledge); got != briefCap {
t.Errorf("Knowledge len = %d, want %d", got, briefCap)
}
if got := len(d.OpenDecisions); got != briefCap {
t.Errorf("OpenDecisions len = %d, want %d", got, briefCap)
}
}
func TestTrimToBrief_LeavesShortListsAlone(t *testing.T) {
d := Data{
WhereToLook: make([]Pointer, 2),
Knowledge: make([]KnowledgeFact, 1),
OpenDecisions: make([]string, 3),
}
d.TrimToBrief()
if len(d.WhereToLook) != 2 || len(d.Knowledge) != 1 || len(d.OpenDecisions) != 3 {
t.Errorf("TrimToBrief truncated below-cap lists: %+v", d)
}
}
// seedBig populates cfg's workspace with a dozen ref-carrying project
// facts and a dozen queue items — a fixture large enough that the full
// brief, the brief form, and the lower trim-ladder caps each render to a
// distinct size, so the RenderWithinBudget ladder can be exercised.
func seedBig(t *testing.T, cfg *config.Config) {
t.Helper()
store, err := memory.Open(cfg)
if err != nil {
t.Fatalf("memory.Open: %v", err)
}
day := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
for i := range 12 {
f := &memory.Fact{
Name: fmt.Sprintf("fact-%02d", i),
Description: fmt.Sprintf("load-bearing project fact number %02d", i),
Type: memory.TypeProject,
Created: day,
LastUsed: day,
Ref: fmt.Sprintf("internal/pkg%02d/file.go", i),
}
if err := store.Save(f); err != nil {
t.Fatalf("save %s: %v", f.Name, err)
}
}
var qb strings.Builder
for i := range 12 {
fmt.Fprintf(&qb,
"- [ ] **finding** — open decision number %02d to weigh _(sample, 2026-01-01)_\n internal/pkg%02d/file.go:1\n",
i, i)
}
writeFile(t, filepath.Join(cfg.Workspace, "state", "queue.md"), qb.String())
}
func TestRenderWithinBudget_NoCapReturnsFull(t *testing.T) {
cfg := sampleRepo(t)
seedSample(t, cfg)
got, rep, err := RenderWithinBudget(cfg, 0, false)
if err != nil {
t.Fatalf("RenderWithinBudget: %v", err)
}
full, err := Render(cfg)
if err != nil {
t.Fatalf("Render: %v", err)
}
if got != full {
t.Errorf("no-cap budget brief should equal Render output\n--- got ---\n%s\n--- want ---\n%s", got, full)
}
if rep.Tier != "full" || !rep.Met || rep.Bytes != len(got) {
t.Errorf("report = %+v, want {full %d true}", rep, len(got))
}
}
func TestRenderWithinBudget_NoCapSkipFullReturnsBrief(t *testing.T) {
cfg := sampleRepo(t)
seedSample(t, cfg)
got, rep, err := RenderWithinBudget(cfg, 0, true)
if err != nil {
t.Fatalf("RenderWithinBudget: %v", err)
}
brief, err := RenderBrief(cfg)
if err != nil {
t.Fatalf("RenderBrief: %v", err)
}
if got != brief {
t.Errorf("no-cap skipFull brief should equal RenderBrief output")
}
if rep.Tier != "brief" || !rep.Met {
t.Errorf("report = %+v, want brief/met", rep)
}
}
func TestRenderWithinBudget_FullFitsLargeBudget(t *testing.T) {
cfg := sampleRepo(t)
seedBig(t, cfg)
full, err := Render(cfg)
if err != nil {
t.Fatalf("Render: %v", err)
}
got, rep, err := RenderWithinBudget(cfg, len(full)+1024, false)
if err != nil {
t.Fatalf("RenderWithinBudget: %v", err)
}
if got != full {
t.Errorf("a budget above the full size should return the full brief")
}
if rep.Tier != "full" || !rep.Met {
t.Errorf("report = %+v, want full/met", rep)
}
}
func TestRenderWithinBudget_StepsDown(t *testing.T) {
cfg := sampleRepo(t)
seedBig(t, cfg)
full, err := Render(cfg)
if err != nil {
t.Fatalf("Render: %v", err)
}
brief, err := RenderBrief(cfg)
if err != nil {
t.Fatalf("RenderBrief: %v", err)
}
if len(brief) >= len(full) {
t.Fatalf("fixture not discriminating: brief %d not smaller than full %d", len(brief), len(full))
}
validTier := func(s string) bool {
if s == "full" || s == "brief" {
return true
}
return strings.HasPrefix(s, "brief (cap ")
}
// Walk the budget down from above-full to below-the-smallest-tier.
// Whenever the budget is met the brief must fit, the tier name must
// be from the known set, and a tighter budget must never yield a
// larger brief than a looser one.
prevLen := len(full) + 1
for budget := len(full) + 64; budget >= 1; budget -= 32 {
got, rep, err := RenderWithinBudget(cfg, budget, false)
if err != nil {
t.Fatalf("RenderWithinBudget(%d): %v", budget, err)
}
if !validTier(rep.Tier) {
t.Errorf("budget %d: unexpected tier %q", budget, rep.Tier)
}
if rep.Bytes != len(got) {
t.Errorf("budget %d: report Bytes %d != len(got) %d", budget, rep.Bytes, len(got))
}
if rep.Met && len(got) > budget {
t.Errorf("budget %d: Met but brief is %d bytes", budget, len(got))
}
if len(got) > prevLen {
t.Errorf("budget %d: brief grew to %d bytes as the budget tightened (prev %d)", budget, len(got), prevLen)
}
prevLen = len(got)
}
}
func TestRenderWithinBudget_ImpossibleBudget(t *testing.T) {
cfg := sampleRepo(t)
seedBig(t, cfg)
got, rep, err := RenderWithinBudget(cfg, 1, false)
if err != nil {
t.Fatalf("RenderWithinBudget: %v", err)
}
if rep.Met {
t.Error("a 1-byte budget cannot be met")
}
if rep.Tier != "brief (cap 0)" {
t.Errorf("Tier = %q, want \"brief (cap 0)\"", rep.Tier)
}
if got == "" {
t.Error("the smallest brief should still be returned, not an empty string")
}
// The smallest tier drops every per-section list but keeps the
// fixed scaffolding, so a real brief is still written.
if !strings.Contains(got, "## Project") {
t.Errorf("cap-0 brief missing the Project section:\n%s", got)
}
}
func TestRenderWithinBudget_SkipFullExcludesFull(t *testing.T) {
cfg := sampleRepo(t)
seedBig(t, cfg)
full, err := Render(cfg)
if err != nil {
t.Fatalf("Render: %v", err)
}
got, rep, err := RenderWithinBudget(cfg, len(full)+1024, true)
if err != nil {
t.Fatalf("RenderWithinBudget: %v", err)
}
if got == full {
t.Error("skipFull should never return the full brief, even under a large budget")
}
if rep.Tier != "brief" {
t.Errorf("Tier = %q, want brief", rep.Tier)
}
}
func TestRenderWithinBudget_NilConfig(t *testing.T) {
if _, _, err := RenderWithinBudget(nil, 0, false); err == nil {
t.Fatal("RenderWithinBudget(nil, …) should return an error")
}
}
// seedNotes drops a deterministic set of three notes into cfg's
// workspace, oldest first; List returns them newest first, which the
// with-notes golden encodes.
func seedNotes(t *testing.T, cfg *config.Config) {
t.Helper()
notesDir := filepath.Join(cfg.Workspace, "notes")
fixtures := []struct {
when time.Time
text string
}{
{time.Date(2026, 1, 1, 11, 15, 0, 0, time.UTC), "Sketch refactor for module split"},
{time.Date(2026, 1, 2, 14, 30, 0, 0, time.UTC), "Open question about hook chain"},
{time.Date(2026, 1, 3, 9, 0, 0, 0, time.UTC), "Investigate cache eviction"},
}
for _, n := range fixtures {
if _, err := notes.Add(notesDir, n.text, n.when); err != nil {
t.Fatalf("notes.Add: %v", err)
}
}
}
func TestRender_DefaultExcludesNotes(t *testing.T) {
cfg := sampleRepo(t)
seedSample(t, cfg)
seedNotes(t, cfg)
// cfg.BriefIncludeNotes is the zero value (false): the section must
// stay out of the Markdown brief even though notes live on disk, so
// bare `eeco go` is byte-identical to the notes-free output.
got, err := Render(cfg)
if err != nil {
t.Fatalf("Render: %v", err)
}
if strings.Contains(got, "## Recent notes") {
t.Errorf("default-off brief should not include the Recent notes section:\n%s", got)
}
}
func TestRender_WithNotes_Golden(t *testing.T) {
cfg := sampleRepo(t)
seedSample(t, cfg)
seedNotes(t, cfg)
cfg.BriefIncludeNotes = true
got, err := Render(cfg)
if err != nil {
t.Fatalf("Render: %v", err)
}
golden := filepath.Join("testdata", "brief.with_notes.golden")
if *updateGolden {
if err := os.MkdirAll("testdata", 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(golden, []byte(got), 0o644); err != nil {
t.Fatal(err)
}
return
}
raw, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("read golden %s (run with -update to create): %v", golden, err)
}
want := strings.ReplaceAll(string(raw), "\r\n", "\n")
if want != got {
t.Errorf("with-notes brief differs from %s — re-run with -update if intentional.\n--- want ---\n%s\n--- got ---\n%s", golden, want, got)
}
}
func TestRender_WithNotesEnabledNoNotesOnDisk(t *testing.T) {
cfg := sampleRepo(t)
seedSample(t, cfg)
cfg.BriefIncludeNotes = true
got, err := Render(cfg)
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.Contains(got, "## Recent notes") {
t.Errorf("enabled brief should still emit the section header when no notes exist:\n%s", got)
}
if !strings.Contains(got, "No notes recorded yet") {
t.Errorf("enabled brief with no notes should print the empty-state message:\n%s", got)
}
}
func TestRenderJSON_WithNotesEnabledKeepsFrozenKeys(t *testing.T) {
cfg := sampleRepo(t)
seedSample(t, cfg)
seedNotes(t, cfg)
cfg.BriefIncludeNotes = true
got, err := RenderJSON(cfg)
if err != nil {
t.Fatalf("RenderJSON: %v", err)
}
if !json.Valid([]byte(got)) {
t.Fatalf("RenderJSON output is not valid JSON:\n%s", got)
}
// The nine frozen top-level keys must remain — adding the notes
// surface must not leak a tenth key into the public JSON contract.
for _, key := range []string{
`"project"`, `"profile"`, `"gate"`, `"top_level"`, `"initialized"`,
`"workflows"`, `"where_to_look"`, `"knowledge"`, `"open_decisions"`,
} {
if !strings.Contains(got, key) {
t.Errorf("JSON brief missing frozen key %s:\n%s", key, got)
}
}
for _, leak := range []string{`"notes"`, `"Notes"`, `"IncludeNotes"`, `"include_notes"`} {
if strings.Contains(got, leak) {
t.Errorf("JSON brief leaked %s — notes belong to the Markdown channel only:\n%s", leak, got)
}
}
}
func TestTrimToCap_CapsNotes(t *testing.T) {
d := Data{
Notes: make([]notes.Note, 8),
}
d.trimToCap(briefCap)
if got := len(d.Notes); got != briefCap {
t.Errorf("Notes len = %d, want %d", got, briefCap)
}
d.Notes = make([]notes.Note, briefCap)
d.trimToCap(briefCap)
if got := len(d.Notes); got != briefCap {
t.Errorf("Notes len = %d, want %d (no-op at cap)", got, briefCap)
}
d.Notes = make([]notes.Note, 2)
d.trimToCap(briefCap)
if got := len(d.Notes); got != 2 {
t.Errorf("Notes len = %d, want 2 (below-cap left alone)", got)
}
}
func TestCollect_NotesCappedAtBriefCap(t *testing.T) {
cfg := sampleRepo(t)
notesDir := filepath.Join(cfg.Workspace, "notes")
// Twelve notes, distinct UTC minutes so the sort is deterministic.
for i := range 12 {
when := time.Date(2026, 1, 1, 10, i, 0, 0, time.UTC)
if _, err := notes.Add(notesDir, fmt.Sprintf("note number %02d", i), when); err != nil {
t.Fatalf("notes.Add: %v", err)
}
}
cfg.BriefIncludeNotes = true
d, err := Collect(cfg)
if err != nil {
t.Fatalf("Collect: %v", err)
}
if got := len(d.Notes); got != briefCap {
t.Errorf("Notes len = %d, want %d (Collect caps before the trim ladder runs)", got, briefCap)
}
}
func TestCollect_DisabledFactsHidden(t *testing.T) {
cfg := sampleRepo(t)
store, err := memory.Open(cfg)
if err != nil {
t.Fatalf("memory.Open: %v", err)
}
day := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
enabled := &memory.Fact{
Name: "active-feedback", Description: "active feedback",
Type: memory.TypeFeedback, Created: day, LastUsed: day,
}
disabled := &memory.Fact{
Name: "muted-feedback", Description: "muted feedback",
Type: memory.TypeFeedback, Created: day, LastUsed: day,
Disabled: true,
}
disabledWithRef := &memory.Fact{
Name: "muted-pointer", Description: "muted pointer",
Type: memory.TypeProject, Created: day, LastUsed: day,
Ref: "internal/secret.go", Disabled: true,
}
for _, f := range []*memory.Fact{enabled, disabled, disabledWithRef} {
if err := store.Save(f); err != nil {
t.Fatalf("save %s: %v", f.Name, err)
}
}
d, err := Collect(cfg)
if err != nil {
t.Fatalf("Collect: %v", err)
}
for _, k := range d.Knowledge {
if k.Name == "muted-feedback" {
t.Errorf("disabled fact %q leaked into Knowledge", k.Name)
}
}
for _, p := range d.WhereToLook {
if p.Ref == "internal/secret.go" {
t.Errorf("disabled fact ref %q leaked into WhereToLook", p.Ref)
}
}
// Enabled feedback fact must still appear.
var found bool
for _, k := range d.Knowledge {
if k.Name == "active-feedback" {
found = true
break
}
}
if !found {
t.Error("enabled feedback fact missing from Knowledge")
}
}
added internal/brief/degrade_boundary_test.go
@@ -0,0 +1,37 @@
package brief
import (
"path/filepath"
"strings"
"testing"
"github.com/ajhahnde/eeco/internal/config"
)
// writeMalformedFact drops a .md with broken frontmatter (opening '---', no
// closing '---') into the memory dir so memory.LoadAll's ParseFact step aborts
// the whole load. Named *.md and not dot-prefixed so LoadAll does not skip it.
func writeMalformedFact(t *testing.T, cfg *config.Config) {
t.Helper()
writeFile(t, filepath.Join(cfg.Workspace, "memory", "broken.md"), "---\nname: broken\n")
}
// TestBoundary_BriefFailsClosedOnMalformedMemory pins the foreground arm of
// the H1.6 degrade matrix: a deliberately-invoked `eeco go` fails LOUD on a
// corrupt knowledge layer (exit 1) rather than silently distilling a
// misleading brief. Paired with internal/hooks
// TestBoundary_SessionEmitDegradesOpenOnMalformedMemory, which pins the
// background hook's fail-OPEN posture. The asymmetry is intentional (Option A,
// H1.6): each boundary degrades-open cleanly OR fails-loud cleanly, never the
// dangerous middle of a wrong/partial result. No production change.
func TestBoundary_BriefFailsClosedOnMalformedMemory(t *testing.T) {
cfg := sampleRepo(t)
writeMalformedFact(t, cfg)
if _, err := Collect(cfg); err == nil || !strings.Contains(err.Error(), "load memory") {
t.Fatalf("Collect must fail closed with a load-memory error, got %v", err)
}
if _, err := Render(cfg); err == nil {
t.Fatalf("Render must fan the load-memory failure (eeco go exit 1), got nil")
}
}
added internal/brief/testdata/brief.brief.golden
@@ -0,0 +1,28 @@
# sample — eeco project brief
Written by `eeco go`: a deterministic, no-AI-spend project brief.
Read this once instead of scanning the tree, then open the files
named under "Where to look" for detail.
## Project
- profile: go
- gate: go vet ./...
- top-level: cmd, docs, go.mod
## Where to look
- external API reference → `docs/api.md`
- auth flow lives here → `internal/auth/auth.go`
## What eeco knows
- auth-flow — auth flow lives here (project)
- terse-comments — keep comments terse (feedback)
## Open decisions
2 open:
- **finding** — comment-hygiene flagged a tooling string _(sample, 2026-01-01)_
- **handover** — draft handover ready for review _(sample, 2026-01-01)_
added internal/brief/testdata/brief.brief.json.golden
@@ -0,0 +1,52 @@
{
"project": "sample",
"profile": "go",
"gate": [
"go vet ./..."
],
"top_level": [
"cmd",
"docs",
"go.mod"
],
"initialized": true,
"workflows": [
"bug-sweep",
"cockpit-sync",
"comment-hygiene",
"doc-drift",
"evolve",
"gate",
"handover-refresh",
"leak-guard",
"manifest-refresh",
"memory-drift",
"version-sync"
],
"where_to_look": [
{
"description": "external API reference",
"ref": "docs/api.md"
},
{
"description": "auth flow lives here",
"ref": "internal/auth/auth.go"
}
],
"knowledge": [
{
"name": "auth-flow",
"description": "auth flow lives here",
"type": "project"
},
{
"name": "terse-comments",
"description": "keep comments terse",
"type": "feedback"
}
],
"open_decisions": [
"**finding** — comment-hygiene flagged a tooling string _(sample, 2026-01-01)_",
"**handover** — draft handover ready for review _(sample, 2026-01-01)_"
]
}
added internal/brief/testdata/brief.golden
@@ -0,0 +1,48 @@
# sample — eeco project brief
Written by `eeco go`: a deterministic, no-AI-spend project brief.
Read this once instead of scanning the tree, then open the files
named under "Where to look" for detail.
## Working with eeco
This repo uses eeco — a local tool that keeps project memory and a
decision queue so an assistant carries durable context across
sessions. Commands you can run (read-only, safe by default):
- `eeco go` — print this brief
- `eeco doctor` — workspace and configuration diagnostics
- `eeco run <workflow>` — run a workflow (builtins: bug-sweep, cockpit-sync, comment-hygiene, doc-drift, evolve, gate, handover-refresh, leak-guard, manifest-refresh, memory-drift, version-sync)
- `eeco gc` — memory garbage collection
Findings and decisions go to eeco's queue, not silent edits to the
tracked tree.
## Project
- profile: go
- gate: go vet ./...
- top-level: cmd, docs, go.mod
## Where to look
- external API reference → `docs/api.md`
- auth flow lives here → `internal/auth/auth.go`
## What eeco knows
- auth-flow — auth flow lives here (project)
- terse-comments — keep comments terse (feedback)
## Open decisions
2 open:
- **finding** — comment-hygiene flagged a tooling string _(sample, 2026-01-01)_
- **handover** — draft handover ready for review _(sample, 2026-01-01)_
## Recording back
Keep this brief useful for the next session: record durable facts
in eeco's memory store and route findings and decisions through its
queue rather than acting silently. Run `eeco doctor` if anything
here looks stale.
added internal/brief/testdata/brief.json.golden
@@ -0,0 +1,52 @@
{
"project": "sample",
"profile": "go",
"gate": [
"go vet ./..."
],
"top_level": [
"cmd",
"docs",
"go.mod"
],
"initialized": true,
"workflows": [
"bug-sweep",
"cockpit-sync",
"comment-hygiene",
"doc-drift",
"evolve",
"gate",
"handover-refresh",
"leak-guard",
"manifest-refresh",
"memory-drift",
"version-sync"
],
"where_to_look": [
{
"description": "external API reference",
"ref": "docs/api.md"
},
{
"description": "auth flow lives here",
"ref": "internal/auth/auth.go"
}
],
"knowledge": [
{
"name": "auth-flow",
"description": "auth flow lives here",
"type": "project"
},
{
"name": "terse-comments",
"description": "keep comments terse",
"type": "feedback"
}
],
"open_decisions": [
"**finding** — comment-hygiene flagged a tooling string _(sample, 2026-01-01)_",
"**handover** — draft handover ready for review _(sample, 2026-01-01)_"
]
}
added internal/brief/testdata/brief.with_notes.golden
@@ -0,0 +1,54 @@
# sample — eeco project brief
Written by `eeco go`: a deterministic, no-AI-spend project brief.
Read this once instead of scanning the tree, then open the files
named under "Where to look" for detail.
## Working with eeco
This repo uses eeco — a local tool that keeps project memory and a
decision queue so an assistant carries durable context across
sessions. Commands you can run (read-only, safe by default):
- `eeco go` — print this brief
- `eeco doctor` — workspace and configuration diagnostics
- `eeco run <workflow>` — run a workflow (builtins: bug-sweep, cockpit-sync, comment-hygiene, doc-drift, evolve, gate, handover-refresh, leak-guard, manifest-refresh, memory-drift, version-sync)
- `eeco gc` — memory garbage collection
Findings and decisions go to eeco's queue, not silent edits to the
tracked tree.
## Project
- profile: go
- gate: go vet ./...
- top-level: cmd, docs, go.mod
## Where to look
- external API reference → `docs/api.md`
- auth flow lives here → `internal/auth/auth.go`
## What eeco knows
- auth-flow — auth flow lives here (project)
- terse-comments — keep comments terse (feedback)
## Recent notes
- 2026-01-03 09:00 — Investigate cache eviction
- 2026-01-02 14:30 — Open question about hook chain
- 2026-01-01 11:15 — Sketch refactor for module split
## Open decisions
2 open:
- **finding** — comment-hygiene flagged a tooling string _(sample, 2026-01-01)_
- **handover** — draft handover ready for review _(sample, 2026-01-01)_
## Recording back
Keep this brief useful for the next session: record durable facts
in eeco's memory store and route findings and decisions through its
queue rather than acting silently. Run `eeco doctor` if anything
here looks stale.
added internal/clip/clip.go
@@ -0,0 +1,116 @@
// Package clip writes a string to the host operating system's
// clipboard via the platform's native clipboard tool. It is the
// delivery channel behind `eeco go --copy`: a one-shot paste path for
// an assistant that has no terminal and no filesystem access (a
// chat-only LLM, a web-only Gemini session, an AI Studio prompt).
//
// The package shells out — macOS uses pbcopy, Windows uses clip.exe,
// Linux uses wl-copy on Wayland and falls back to xclip then xsel on
// X11. No third-party dependency is taken; the stdlib suffices. When
// no clipboard tool is reachable on PATH the package returns
// ErrNoClipboardTool so the caller can render a precise install hint
// and exit cleanly under eeco's "blocked (required tool missing)"
// contract.
package clip
import (
"errors"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
)
// ErrNoClipboardTool is returned when no platform clipboard tool was
// found on PATH. Callers should treat this as the workflow contract's
// exit-2 ("blocked: required tool missing").
var ErrNoClipboardTool = errors.New("no clipboard tool found on PATH")
// runner executes the chosen clipboard tool with the given args,
// feeding text to its stdin. The default uses os/exec; tests
// substitute a fake.
var runner = execRunner
// lookPath finds a tool on PATH. Indirected for testability.
var lookPath = exec.LookPath
// getenv reads an environment variable. Indirected for testability.
var getenv = os.Getenv
// goos reports the build target. Indirected for testability.
var goos = func() string { return runtime.GOOS }
// Copy writes text to the platform clipboard. It returns
// ErrNoClipboardTool when no supported tool is on PATH, or a wrapped
// error when the tool exits non-zero.
func Copy(text string) error {
name, args, ok := detect()
if !ok {
return ErrNoClipboardTool
}
if err := runner(name, args, text); err != nil {
return fmt.Errorf("clip: %s: %w", name, err)
}
return nil
}
// detect picks a clipboard tool for the current OS and environment.
// The returned (name, args) pair is ready to hand to runner. ok is
// false when no candidate tool resolves on PATH.
func detect() (string, []string, bool) {
switch goos() {
case "darwin":
if _, err := lookPath("pbcopy"); err == nil {
return "pbcopy", nil, true
}
case "windows":
if _, err := lookPath("clip.exe"); err == nil {
return "clip.exe", nil, true
}
if _, err := lookPath("clip"); err == nil {
return "clip", nil, true
}
case "linux", "freebsd", "openbsd", "netbsd":
if getenv("WAYLAND_DISPLAY") != "" {
if _, err := lookPath("wl-copy"); err == nil {
return "wl-copy", nil, true
}
}
if _, err := lookPath("xclip"); err == nil {
return "xclip", []string{"-selection", "clipboard"}, true
}
if _, err := lookPath("xsel"); err == nil {
return "xsel", []string{"--clipboard", "--input"}, true
}
if _, err := lookPath("wl-copy"); err == nil {
return "wl-copy", nil, true
}
}
return "", nil, false
}
// InstallHint returns a one-line "install one of these" message tuned
// to the current platform. Callers use it to render a helpful stderr
// message when Copy returns ErrNoClipboardTool.
func InstallHint() string {
switch goos() {
case "darwin":
return "install pbcopy (ships with macOS — check that /usr/bin is on PATH)"
case "windows":
return "install clip.exe (ships with Windows — check that the System32 directory is on PATH)"
case "linux", "freebsd", "openbsd", "netbsd":
return "install one of: wl-copy (Wayland), xclip, or xsel"
default:
return "no clipboard tool is bundled for this platform"
}
}
func execRunner(name string, args []string, stdin string) error {
// #nosec G204 — name and args are chosen by detect() from a fixed
// allow-list of platform clipboard tools; stdin carries the brief
// text and is not interpolated into the command line.
cmd := exec.Command(name, args...)
cmd.Stdin = strings.NewReader(stdin)
return cmd.Run()
}
added internal/clip/clip_test.go
@@ -0,0 +1,156 @@
package clip
import (
"errors"
"os/exec"
"strings"
"testing"
)
func withFakes(t *testing.T, os string, env map[string]string, present map[string]bool, capture *capturedRun) {
t.Helper()
oldGoos, oldEnv, oldLook, oldRunner := goos, getenv, lookPath, runner
t.Cleanup(func() {
goos, getenv, lookPath, runner = oldGoos, oldEnv, oldLook, oldRunner
})
goos = func() string { return os }
getenv = func(k string) string { return env[k] }
lookPath = func(name string) (string, error) {
if present[name] {
return "/fake/bin/" + name, nil
}
return "", exec.ErrNotFound
}
if capture != nil {
runner = func(name string, args []string, stdin string) error {
capture.name = name
capture.args = args
capture.stdin = stdin
capture.calls++
return capture.err
}
}
}
type capturedRun struct {
name string
args []string
stdin string
calls int
err error
}
func TestDetect_DarwinUsesPbcopy(t *testing.T) {
withFakes(t, "darwin", nil, map[string]bool{"pbcopy": true}, nil)
name, args, ok := detect()
if !ok || name != "pbcopy" || len(args) != 0 {
t.Fatalf("got name=%q args=%v ok=%v, want pbcopy [] true", name, args, ok)
}
}
func TestDetect_LinuxWaylandPrefersWlCopy(t *testing.T) {
withFakes(t, "linux",
map[string]string{"WAYLAND_DISPLAY": "wayland-0"},
map[string]bool{"wl-copy": true, "xclip": true},
nil,
)
name, _, ok := detect()
if !ok || name != "wl-copy" {
t.Fatalf("got name=%q ok=%v, want wl-copy true", name, ok)
}
}
func TestDetect_LinuxX11FallsBackToXclip(t *testing.T) {
withFakes(t, "linux", nil, map[string]bool{"xclip": true, "xsel": true}, nil)
name, args, ok := detect()
if !ok || name != "xclip" || strings.Join(args, " ") != "-selection clipboard" {
t.Fatalf("got name=%q args=%v ok=%v, want xclip -selection clipboard true", name, args, ok)
}
}
func TestDetect_LinuxXselFallback(t *testing.T) {
withFakes(t, "linux", nil, map[string]bool{"xsel": true}, nil)
name, args, ok := detect()
if !ok || name != "xsel" || strings.Join(args, " ") != "--clipboard --input" {
t.Fatalf("got name=%q args=%v ok=%v, want xsel --clipboard --input true", name, args, ok)
}
}
func TestDetect_LinuxNoWaylandEnvButWlCopyPresent(t *testing.T) {
// No X11 tool; wl-copy reachable but WAYLAND_DISPLAY unset. The
// final fallback inside the linux branch picks it up.
withFakes(t, "linux", nil, map[string]bool{"wl-copy": true}, nil)
name, _, ok := detect()
if !ok || name != "wl-copy" {
t.Fatalf("got name=%q ok=%v, want wl-copy true", name, ok)
}
}
func TestDetect_WindowsUsesClipExe(t *testing.T) {
withFakes(t, "windows", nil, map[string]bool{"clip.exe": true}, nil)
name, _, ok := detect()
if !ok || name != "clip.exe" {
t.Fatalf("got name=%q ok=%v, want clip.exe true", name, ok)
}
}
func TestDetect_NoToolReturnsFalse(t *testing.T) {
withFakes(t, "linux", nil, nil, nil)
if _, _, ok := detect(); ok {
t.Fatalf("detect() ok=true with no tools present; want false")
}
}
func TestDetect_UnknownGoosReturnsFalse(t *testing.T) {
withFakes(t, "plan9", nil, map[string]bool{"pbcopy": true}, nil)
if _, _, ok := detect(); ok {
t.Fatalf("detect() ok=true on unknown GOOS; want false")
}
}
func TestCopy_PipesTextToTool(t *testing.T) {
var cap capturedRun
withFakes(t, "darwin", nil, map[string]bool{"pbcopy": true}, &cap)
if err := Copy("hello brief"); err != nil {
t.Fatalf("Copy: %v", err)
}
if cap.name != "pbcopy" || cap.stdin != "hello brief" || cap.calls != 1 {
t.Fatalf("got name=%q stdin=%q calls=%d, want pbcopy hello brief 1", cap.name, cap.stdin, cap.calls)
}
}
func TestCopy_NoToolReturnsSentinel(t *testing.T) {
withFakes(t, "linux", nil, nil, &capturedRun{})
err := Copy("anything")
if !errors.Is(err, ErrNoClipboardTool) {
t.Fatalf("Copy err=%v, want ErrNoClipboardTool", err)
}
}
func TestCopy_RunnerErrorIsWrapped(t *testing.T) {
cap := capturedRun{err: errors.New("boom")}
withFakes(t, "darwin", nil, map[string]bool{"pbcopy": true}, &cap)
err := Copy("payload")
if err == nil || !strings.Contains(err.Error(), "clip: pbcopy") || !strings.Contains(err.Error(), "boom") {
t.Fatalf("Copy err=%v, want wrapped clip: pbcopy: boom", err)
}
}
func TestInstallHint_PlatformSpecific(t *testing.T) {
cases := []struct {
os string
want string
}{
{"darwin", "pbcopy"},
{"linux", "wl-copy"},
{"windows", "clip.exe"},
{"plan9", "no clipboard tool"},
}
for _, c := range cases {
withFakes(t, c.os, nil, nil, nil)
got := InstallHint()
if !strings.Contains(got, c.want) {
t.Errorf("InstallHint on %s = %q, want substring %q", c.os, got, c.want)
}
}
}
added internal/cockpit/agents.go
@@ -0,0 +1,35 @@
package cockpit
// agentsRenderer emits the whole selected playbook set as one AGENTS.md — the
// cross-tool agent-instructions convention, plain Markdown with no
// frontmatter. AGENTS.md is purely advisory (no tool-permission enforcement
// of any kind), so the file leads with the ADVISORY banner and a fidelity
// report. It is an aggregate target: one shared file for the set, emitted via
// the GenerateAll/VerifyAll/OffAll path (the per-playbook Renderer methods
// exist for interface completeness but are off the emit path — Generate
// rejects aggregate targets).
type agentsRenderer struct{}
func (agentsRenderer) Target() string { return "agents" }
// Enforcement reports that AGENTS.md is advisory only. It satisfies the
// Fidelity interface.
func (agentsRenderer) Enforcement() Enforcement { return EnforcementAdvisory }
// AggRelPath is the single shared artifact path for the whole set.
func (agentsRenderer) AggRelPath() string { return "AGENTS.md" }
// RenderAll renders the set as one AGENTS.md document, deterministic in set
// order (renderAggregateMarkdown sorts by Name).
func (agentsRenderer) RenderAll(ps []Playbook) ([]byte, error) {
return renderAggregateMarkdown(ps,
"AGENTS.md — eeco-generated agent playbooks",
"This file aggregates the eeco cockpit playbooks for any AGENTS.md-aware tool."), nil
}
// RelPath / Render satisfy Renderer for interface completeness; the aggregate
// emit path (GenerateAll) is what actually drives this target.
func (r agentsRenderer) RelPath(Playbook) string { return r.AggRelPath() }
func (r agentsRenderer) Render(p Playbook) ([]byte, error) {
return r.RenderAll([]Playbook{p})
}
added internal/cockpit/agents_test.go
@@ -0,0 +1,88 @@
package cockpit
import (
"strings"
"testing"
)
// synthPlaybook builds a minimal read-only playbook for aggregate-render
// tests, with a distinct name so set ordering is observable.
func synthPlaybook(name string) Playbook {
return Playbook{
Name: name,
Description: "synthetic " + name + " playbook for tests",
Intent: Intent{
Guarantee: "reads and reports only",
Forbidden: []string{"git commit", "edit any tracked file"},
},
Capabilities: []Capability{
{Kind: "tool", Name: "Read"},
{Kind: "bash", Verb: "git status", Scope: "*"},
},
Steps: []Step{
{Index: 0, Title: "look", Body: "inspect the tree"},
{Index: 1, Title: "report", Body: "print findings"},
},
OutputFormat: "a short report",
}
}
func twoPlaybooks(t *testing.T) []Playbook {
t.Helper()
return []Playbook{loadHandover(t), synthPlaybook("zeta")}
}
func TestAgentsRender_Structure(t *testing.T) {
out, err := agentsRenderer{}.RenderAll(twoPlaybooks(t))
if err != nil {
t.Fatalf("RenderAll: %v", err)
}
got := string(out)
if strings.HasPrefix(got, "---") {
t.Error("AGENTS.md must not carry YAML frontmatter")
}
for _, want := range []string{"# AGENTS.md", advisoryBanner, "## Fidelity report", "### " + headingForbidden, "### " + headingAllowed} {
if !strings.Contains(got, want) {
t.Errorf("AGENTS.md missing %q", want)
}
}
// Both playbooks' sections present (sorted by Name → handover before zeta).
hIdx := strings.Index(got, "## Handover")
zIdx := strings.Index(got, "## Zeta")
if hIdx < 0 || zIdx < 0 {
t.Fatalf("missing a playbook section: handover@%d zeta@%d", hIdx, zIdx)
}
if hIdx > zIdx {
t.Error("sections not sorted by Name (handover should precede zeta)")
}
}
func TestAgentsRender_SetOrderStable(t *testing.T) {
a, err := agentsRenderer{}.RenderAll([]Playbook{loadHandover(t), synthPlaybook("zeta")})
if err != nil {
t.Fatal(err)
}
b, err := agentsRenderer{}.RenderAll([]Playbook{synthPlaybook("zeta"), loadHandover(t)})
if err != nil {
t.Fatal(err)
}
if string(a) != string(b) {
t.Error("aggregate render not stable under input reordering")
}
}
func TestAgentsAggRelPath(t *testing.T) {
if got := (agentsRenderer{}).AggRelPath(); got != "AGENTS.md" {
t.Errorf("AggRelPath = %q, want AGENTS.md", got)
}
if !IsAggregateTarget("agents") {
t.Error("agents should be an aggregate target")
}
}
func TestAgentsEnforcement(t *testing.T) {
if got := (agentsRenderer{}).Enforcement(); got != EnforcementAdvisory {
t.Errorf("agents enforcement = %v, want advisory", got)
}
}
added internal/cockpit/claude.go
@@ -0,0 +1,140 @@
package cockpit
import (
"errors"
"fmt"
"strings"
)
// claudeRenderer emits a Playbook as a Claude Code SKILL.md: YAML
// frontmatter with the three keys Claude reads (name, description,
// allowed-tools) followed by a Markdown body. The harness auto-discovers a
// skill only at .claude/skills/<name>/SKILL.md, so RelPath is fixed to that
// layout.
type claudeRenderer struct{}
func (claudeRenderer) Target() string { return "claude" }
// Enforcement reports that Claude enforces the allowed-tools allowlist at
// runtime — the one enforced target. It satisfies the Fidelity interface.
func (claudeRenderer) Enforcement() Enforcement { return EnforcementEnforced }
func (claudeRenderer) RelPath(p Playbook) string {
return ".claude/skills/" + p.Name + "/SKILL.md"
}
// Render produces the SKILL.md bytes. It is deterministic and rejects a
// Playbook whose single-line frontmatter fields (description, composed
// allowed-tools) would span multiple lines — the frontmatter here is
// line-oriented, so a stray newline would corrupt it.
func (r claudeRenderer) Render(p Playbook) ([]byte, error) {
desc := strings.TrimSpace(p.Description)
allowed := strings.Join(composeAllowedTools(p), ", ")
if strings.ContainsAny(desc, "\r\n") {
return nil, errors.New("playbook description must be a single line")
}
if strings.ContainsAny(allowed, "\r\n") {
return nil, errors.New("composed allowed-tools must be a single line")
}
var b strings.Builder
b.WriteString("---\n")
fmt.Fprintf(&b, "name: %s\n", p.Name)
fmt.Fprintf(&b, "description: %s\n", desc)
fmt.Fprintf(&b, "allowed-tools: %s\n", allowed)
b.WriteString("---\n")
fmt.Fprintf(&b, "# %s\n", deriveTitle(p.Name))
b.WriteString(deriveSafetyWarning(p.Intent))
b.WriteString("\n")
for _, s := range p.Steps {
fmt.Fprintf(&b, "\n## Step %d — %s\n", s.Index, s.Title)
if body := strings.TrimRight(s.Body, "\n"); body != "" {
b.WriteString(body)
b.WriteString("\n")
}
if len(s.Runs) > 0 {
b.WriteString("\n```\n")
for _, run := range s.Runs {
b.WriteString(run)
b.WriteString("\n")
}
b.WriteString("```\n")
}
}
if out := strings.TrimRight(p.OutputFormat, "\n"); out != "" {
b.WriteString("\n## Output\n")
b.WriteString(out)
b.WriteString("\n")
}
return []byte(b.String()), nil
}
// composeAllowedTools walks Capabilities in declared order and renders each
// to its Claude allowlist spelling: a tool becomes its Name; a bash
// capability becomes Bash(<Verb>:<Scope>), defaulting Scope to "*". The
// declared order is preserved (no reorder, no dedupe) so the JSON stays the
// single source of truth and the output is deterministic.
func composeAllowedTools(p Playbook) []string {
out := make([]string, 0, len(p.Capabilities))
for _, c := range p.Capabilities {
switch c.Kind {
case "tool":
if c.Name != "" {
out = append(out, c.Name)
}
case "bash":
if c.Verb == "" {
continue
}
scope := c.Scope
if scope == "" {
scope = "*"
}
out = append(out, fmt.Sprintf("Bash(%s:%s)", c.Verb, scope))
}
}
return out
}
// deriveSafetyWarning builds the bold body warning from the structured
// Intent — never hand-written in the body data. It opens with the positive
// guarantee and names every Intent.Forbidden phrase verbatim, so the
// rendered warning is provably in sync with the gate's denylist.
func deriveSafetyWarning(in Intent) string {
var b strings.Builder
b.WriteString("**")
if g := strings.TrimSpace(in.Guarantee); g != "" {
b.WriteString(g)
if !strings.HasSuffix(g, ".") {
b.WriteString(".")
}
b.WriteString(" ")
}
if len(in.Forbidden) > 0 {
b.WriteString("Never: ")
b.WriteString(strings.Join(in.Forbidden, ", "))
b.WriteString(".")
}
b.WriteString("**")
return b.String()
}
// deriveTitle turns a playbook name into a body heading: word-split on "-"
// and "_", each word capitalized, joined with spaces ("handover" ->
// "Handover", "doc-drift" -> "Doc Drift").
func deriveTitle(name string) string {
fields := strings.FieldsFunc(name, func(r rune) bool { return r == '-' || r == '_' })
for i, w := range fields {
if w == "" {
continue
}
fields[i] = strings.ToUpper(w[:1]) + w[1:]
}
if len(fields) == 0 {
return name
}
return strings.Join(fields, " ")
}
added internal/cockpit/claude_test.go
@@ -0,0 +1,156 @@
package cockpit
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// loadHandover reads the real shipped handover source (the
// internal/playbooks data file) and unmarshals it into a Playbook. Reading
// the file directly, rather than importing internal/playbooks, keeps the
// cockpit package free of the import cycle while still exercising the real
// source through the machinery.
func loadHandover(t *testing.T) Playbook {
t.Helper()
b, err := os.ReadFile(filepath.Join("..", "playbooks", "data", "handover.json"))
if err != nil {
t.Fatalf("read handover source: %v", err)
}
var pb Playbook
if err := json.Unmarshal(b, &pb); err != nil {
t.Fatalf("parse handover source: %v", err)
}
return pb
}
func TestClaudeRender_Structure(t *testing.T) {
pb := loadHandover(t)
out, err := claudeRenderer{}.Render(pb)
if err != nil {
t.Fatalf("Render: %v", err)
}
got := string(out)
if !strings.HasPrefix(got, "---\n") {
t.Errorf("output does not open with frontmatter fence:\n%s", got)
}
if !strings.HasSuffix(got, "\n") {
t.Error("output does not end with a trailing newline")
}
for _, want := range []string{
"name: handover\n",
"description: " + pb.Description + "\n",
"allowed-tools: ",
"# Handover\n",
"## Step 0 — ",
"## Step 1 — ",
"## Step 2 — ",
"## Step 3 — ",
"## Step 4 — ",
"## Output\n",
} {
if !strings.Contains(got, want) {
t.Errorf("rendered SKILL.md missing %q:\n%s", want, got)
}
}
// The allowed-tools line carries the composed allowlist, single-line.
allowLine := frontmatterValue(t, out, "allowed-tools")
for _, want := range []string{
"Read", "Write", "Grep", "Glob", "Agent", "Task", "AskUserQuestion",
"Bash(git status:*)", "Bash(git stash list:*)", "Bash(date:*)", "Bash(head:*)",
} {
if !strings.Contains(allowLine, want) {
t.Errorf("allowed-tools missing %q: %s", want, allowLine)
}
}
// The safety warning is derived from Intent and must name every
// forbidden phrase verbatim, so the prose is provably in sync with the
// gate's denylist.
for _, phrase := range pb.Intent.Forbidden {
if !strings.Contains(got, phrase) {
t.Errorf("safety warning missing forbidden phrase %q", phrase)
}
}
}
// frontmatterValue returns the value of a single-line frontmatter key.
func frontmatterValue(t *testing.T, content []byte, key string) string {
t.Helper()
for _, line := range strings.Split(string(content), "\n") {
if k, v, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(k) == key {
return strings.TrimSpace(v)
}
}
t.Fatalf("frontmatter key %q not found", key)
return ""
}
func TestClaudeRender_Deterministic(t *testing.T) {
pb := loadHandover(t)
a, err := claudeRenderer{}.Render(pb)
if err != nil {
t.Fatal(err)
}
b, err := claudeRenderer{}.Render(pb)
if err != nil {
t.Fatal(err)
}
if string(a) != string(b) {
t.Error("Render is not deterministic for the same Playbook")
}
}
func TestClaudeRender_RejectsMultilineFrontmatter(t *testing.T) {
pb := loadHandover(t)
pb.Description = "line one\nline two"
r := claudeRenderer{}
if _, err := r.Render(pb); err == nil {
t.Error("expected an error for a multi-line description")
}
}
func TestComposeAllowedTools_OrderAndSpelling(t *testing.T) {
pb := Playbook{
Capabilities: []Capability{
{Kind: "tool", Name: "Read"},
{Kind: "bash", Verb: "git status", Scope: "*"},
{Kind: "bash", Verb: "date"}, // default scope "*"
},
}
got := composeAllowedTools(pb)
want := []string{"Read", "Bash(git status:*)", "Bash(date:*)"}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("entry %d = %q, want %q", i, got[i], want[i])
}
}
}
func TestDeriveTitle(t *testing.T) {
cases := map[string]string{
"handover": "Handover",
"doc-drift": "Doc Drift",
"bug_sweep": "Bug Sweep",
}
for in, want := range cases {
if got := deriveTitle(in); got != want {
t.Errorf("deriveTitle(%q) = %q, want %q", in, got, want)
}
}
}
func TestClaudeRelPath(t *testing.T) {
got := claudeRenderer{}.RelPath(Playbook{Name: "handover"})
want := ".claude/skills/handover/SKILL.md"
if got != want {
t.Errorf("RelPath = %q, want %q", got, want)
}
}
added internal/cockpit/cursor.go
@@ -0,0 +1,49 @@
package cockpit
import (
"errors"
"fmt"
"strings"
)
// cursorRenderer emits a Playbook as a modern Cursor rule file
// (.cursor/rules/<name>.mdc): YAML frontmatter (description / globs /
// alwaysApply) followed by the shared advisory body. Cursor rules are
// advisory — Cursor does not enforce a tool allowlist from an .mdc at runtime
// (its enforced settings live in a separate permissions layer, out of scope
// until C4) — so every emitted file carries the ADVISORY banner. Like Claude,
// Cursor is per-playbook (one .mdc per playbook), so it flows through the
// unchanged per-playbook emit machinery.
type cursorRenderer struct{}
func (cursorRenderer) Target() string { return "cursor" }
// Enforcement reports that Cursor rules are advisory only. It satisfies the
// Fidelity interface.
func (cursorRenderer) Enforcement() Enforcement { return EnforcementAdvisory }
func (cursorRenderer) RelPath(p Playbook) string {
return ".cursor/rules/" + p.Name + ".mdc"
}
// Render produces the .mdc bytes. It is deterministic and rejects a Playbook
// whose single-line frontmatter field (description) would span multiple lines
// — the frontmatter is line-oriented, mirroring the Claude renderer's guard.
func (cursorRenderer) Render(p Playbook) ([]byte, error) {
desc := strings.TrimSpace(p.Description)
if strings.ContainsAny(desc, "\r\n") {
return nil, errors.New("playbook description must be a single line")
}
var b strings.Builder
b.WriteString("---\n")
fmt.Fprintf(&b, "description: %s\n", desc)
b.WriteString("globs:\n")
b.WriteString("alwaysApply: false\n")
b.WriteString("---\n")
fmt.Fprintf(&b, "# %s\n\n", deriveTitle(p.Name))
b.WriteString(advisoryBanner)
b.WriteString("\n\n")
renderPlaybookBody(&b, p, "##")
return []byte(b.String()), nil
}
added internal/cockpit/cursor_test.go
@@ -0,0 +1,70 @@
package cockpit
import (
"strings"
"testing"
)
func TestCursorRender_Structure(t *testing.T) {
pb := loadHandover(t)
out, err := cursorRenderer{}.Render(pb)
if err != nil {
t.Fatalf("Render: %v", err)
}
got := string(out)
if !strings.HasPrefix(got, "---\ndescription: ") {
t.Errorf("output does not open with .mdc frontmatter:\n%s", got[:min(120, len(got))])
}
for _, want := range []string{"globs:", "alwaysApply: false", advisoryBanner, "## " + headingForbidden, "## " + headingAllowed, "## " + headingOutput} {
if !strings.Contains(got, want) {
t.Errorf("rendered .mdc missing %q", want)
}
}
// Every forbidden phrase must appear verbatim (honesty: can't silently drop).
for _, ph := range pb.Intent.Forbidden {
if !strings.Contains(got, ph) {
t.Errorf("rendered .mdc missing forbidden phrase %q", ph)
}
}
// Every step heading is present.
if n := strings.Count(got, "## "+headingStep); n < len(pb.Steps) {
t.Errorf("rendered .mdc has %d step headings, want >= %d", n, len(pb.Steps))
}
}
func TestCursorRender_Deterministic(t *testing.T) {
pb := loadHandover(t)
a, err := cursorRenderer{}.Render(pb)
if err != nil {
t.Fatal(err)
}
b, err := cursorRenderer{}.Render(pb)
if err != nil {
t.Fatal(err)
}
if string(a) != string(b) {
t.Error("cursor render not deterministic")
}
}
func TestCursorRelPath(t *testing.T) {
pb := loadHandover(t)
if got := (cursorRenderer{}).RelPath(pb); got != ".cursor/rules/handover.mdc" {
t.Errorf("RelPath = %q, want .cursor/rules/handover.mdc", got)
}
}
func TestCursorRender_RejectsMultilineDescription(t *testing.T) {
pb := loadHandover(t)
pb.Description = "line one\nline two"
if _, err := (cursorRenderer{}).Render(pb); err == nil {
t.Error("expected an error for a multi-line description")
}
}
func TestCursorEnforcement(t *testing.T) {
if got := (cursorRenderer{}).Enforcement(); got != EnforcementAdvisory {
t.Errorf("cursor enforcement = %v, want advisory", got)
}
}
added internal/cockpit/emit.go
@@ -0,0 +1,392 @@
package cockpit
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/ajhahnde/eeco/internal/config"
)
// GenerateResult reports what Generate did. Action is one of "already
// current", "generated", "updated", or "regenerated". Fidelity is the
// target's harness-runtime enforcement (recomputed from the target, never
// persisted) so the message can flag an advisory emit.
type GenerateResult struct {
Path string
Action string
Backup string
Fidelity Enforcement
}
// Message renders the human one-liner for a generate outcome. An advisory
// target appends a "not harness-enforced" note so the operator never mistakes
// the emit for an enforced policy.
func (r GenerateResult) Message() string {
msg := r.Action + " " + r.Path
if r.Backup != "" {
msg += " (backup " + r.Backup + ")"
}
if r.Fidelity == EnforcementAdvisory {
msg += " [advisory — not harness-enforced]"
}
return msg
}
// VerifyResult reports a verify outcome. Clean is true only when the
// on-disk artifact matches the freshly-rendered bytes, holds the safety
// invariant, and (when requested) passes parity. Detail is the line to
// print either way.
type VerifyResult struct {
Clean bool
Detail string
}
// OffResult reports a removal outcome. Changed is true when something was
// removed or the ledger was updated (the caller commits workspace history
// only when Changed).
type OffResult struct {
Changed bool
Message string
}
// Generate renders pb for target and writes it under cfg.UserDir,
// reversibly. It refuses (writing nothing, no ledger) when the composed
// allowlist would grant a forbidden write-git verb — the safety invariant.
// A pre-existing foreign file is backed up first; re-emitting an unchanged
// artifact is a byte-idempotent no-op (no write, no backup, no ledger
// churn).
func Generate(cfg *config.Config, pb Playbook, target string) (GenerateResult, error) {
r, ok := rendererFor(target)
if !ok {
return GenerateResult{}, unknownTargetErr(target)
}
if _, agg := isAggregate(r); agg {
return GenerateResult{}, fmt.Errorf("target %q is aggregate (one shared file for the set); emit it via `eeco cockpit generate --target %s` without --playbook", target, target)
}
content, err := r.Render(pb)
if err != nil {
return GenerateResult{}, err
}
if hits := ScanAllowlistForWriteGitVerbs(composeAllowedTools(pb), pb.Intent.forbiddenVerbs()); len(hits) > 0 {
return GenerateResult{}, fmt.Errorf(
"refusing to emit %s/%s: forbidden write-git verb(s) in allowlist: %s",
target, pb.Name, strings.Join(hits, ", "))
}
dst, err := userArtifactPath(cfg, r.RelPath(pb))
if err != nil {
return GenerateResult{}, err
}
newSHA := sha256hex(content)
l, err := loadLedger(cfg)
if err != nil {
return GenerateResult{}, err
}
priorIdx := l.find(target, pb.Name)
hasPrior := priorIdx >= 0 && l.Records[priorIdx].Installed
var prior record
if priorIdx >= 0 {
prior = l.Records[priorIdx]
}
// Idempotency: an installed record whose recorded sha and on-disk sha
// both equal the freshly-rendered sha means nothing to do.
if hasPrior && prior.SHA256 == newSHA {
if onDisk, rerr := os.ReadFile(dst); rerr == nil && sha256hex(onDisk) == newSHA {
return GenerateResult{Path: dst, Action: "already current", Fidelity: fidelityOf(r)}, nil
}
}
leafDir := filepath.Dir(dst)
createdDir := prior.Created
backup := prior.Backup
if !hasPrior {
// No prior eeco artifact here. A file present now is foreign — back
// it up — and the leaf dir is "created by us" only if it is absent.
_, statErr := os.Stat(leafDir)
createdDir = errors.Is(statErr, os.ErrNotExist)
if existing, rerr := os.ReadFile(dst); rerr == nil {
bp, berr := backupExisting(cfg, target, pb.Name, existing)
if berr != nil {
return GenerateResult{}, berr
}
backup = bp
} else if !errors.Is(rerr, os.ErrNotExist) {
return GenerateResult{}, fmt.Errorf("inspect %s: %w", dst, rerr)
}
}
if err := writeFileAtomic(dst, content, 0o644); err != nil {
return GenerateResult{}, err
}
l.upsert(record{
Installed: true,
Target: target,
Playbook: pb.Name,
Path: dst,
SHA256: newSHA,
Backup: backup,
Created: createdDir,
At: time.Now().UTC().Format(time.RFC3339),
})
if err := saveLedger(cfg, l); err != nil {
return GenerateResult{}, err
}
action := "generated"
switch {
case hasPrior:
action = "regenerated"
case backup != "":
action = "updated"
}
return GenerateResult{Path: dst, Action: action, Backup: backup, Fidelity: fidelityOf(r)}, nil
}
// Verify recomputes the desired bytes for pb/target and checks the on-disk
// artifact against them, plus the safety invariant on the on-disk
// allowlist. When parityKey is non-empty it also runs the structural parity
// check against that answer-key SKILL.md. It never mutates anything.
func Verify(cfg *config.Config, pb Playbook, target, parityKey string) (VerifyResult, error) {
r, ok := rendererFor(target)
if !ok {
return VerifyResult{}, unknownTargetErr(target)
}
if _, agg := isAggregate(r); agg {
return VerifyResult{}, fmt.Errorf("target %q is aggregate; verify it via `eeco cockpit verify --target %s` without --playbook", target, target)
}
desired, err := r.Render(pb)
if err != nil {
return VerifyResult{}, err
}
dst, err := userArtifactPath(cfg, r.RelPath(pb))
if err != nil {
return VerifyResult{}, err
}
onDisk, rerr := os.ReadFile(dst)
if errors.Is(rerr, os.ErrNotExist) {
return VerifyResult{Clean: false, Detail: fmt.Sprintf("%s/%s: not emitted (run `eeco cockpit generate`)", target, pb.Name)}, nil
}
if rerr != nil {
return VerifyResult{}, fmt.Errorf("read %s: %w", dst, rerr)
}
// Advisory per-playbook targets (cursor) have no SKILL.md allowlist and no
// answer key: the on-disk safety check is self-consistency, asserted on
// the literal on-disk bytes (S4) so a renderer regression or hand-edit
// that drops the Forbidden block fails. Drift (any hand-edit) is reported
// first.
if fidelityOf(r) == EnforcementAdvisory {
if sha256hex(onDisk) != sha256hex(desired) {
return VerifyResult{Clean: false, Detail: fmt.Sprintf(
"%s/%s: drifted (hand-edited; run `eeco cockpit generate` to restore)", target, pb.Name)}, nil
}
sc := checkSelfConsistencyBytes(onDisk, []Playbook{pb})
if !sc.OK {
return VerifyResult{Clean: false, Detail: fmt.Sprintf(
"%s/%s: self-consistency FAILED: %s", target, pb.Name, strings.Join(sc.Notes, "; "))}, nil
}
return VerifyResult{Clean: true, Detail: fmt.Sprintf("%s/%s: clean (advisory — not harness-enforced)", target, pb.Name)}, nil
}
// Enforced target (claude): on-disk allowlist safety scan first (C1 order),
// then drift, then optional parity.
if hits := ScanAllowlistForWriteGitVerbs(parseAllowedTools(onDisk), pb.Intent.forbiddenVerbs()); len(hits) > 0 {
return VerifyResult{Clean: false, Detail: fmt.Sprintf(
"%s/%s: SAFETY VIOLATION — forbidden write-git verb(s) on disk: %s",
target, pb.Name, strings.Join(hits, ", "))}, nil
}
if sha256hex(onDisk) != sha256hex(desired) {
return VerifyResult{Clean: false, Detail: fmt.Sprintf(
"%s/%s: drifted (hand-edited; run `eeco cockpit generate` to restore)", target, pb.Name)}, nil
}
if parityKey != "" {
pr, perr := Parity(pb, target, parityKey)
if perr != nil {
return VerifyResult{}, perr
}
if !pr.OK() {
return VerifyResult{Clean: false, Detail: fmt.Sprintf(
"%s/%s: clean, but parity FAILED vs %s: %s", target, pb.Name, parityKey, strings.Join(pr.Notes, "; "))}, nil
}
return VerifyResult{Clean: true, Detail: fmt.Sprintf("%s/%s: clean + parity OK vs %s", target, pb.Name, parityKey)}, nil
}
return VerifyResult{Clean: true, Detail: fmt.Sprintf("%s/%s: clean", target, pb.Name)}, nil
}
// Off removes eeco's emitted artifact, sha-gated and reversible. A
// hand-edited file (on-disk sha != recorded sha) is left untouched. When
// the artifact matches, it is removed; a backed-up pre-eeco file is
// restored, otherwise a leaf skill dir eeco created is pruned. A missing
// file or absent record is a clean no-op.
func Off(cfg *config.Config, pb Playbook, target string) (OffResult, error) {
r, ok := rendererFor(target)
if !ok {
return OffResult{}, unknownTargetErr(target)
}
if _, agg := isAggregate(r); agg {
return OffResult{}, fmt.Errorf("target %q is aggregate; remove it via `eeco cockpit off --target %s` without --playbook", target, target)
}
dst, err := userArtifactPath(cfg, r.RelPath(pb))
if err != nil {
return OffResult{}, err
}
l, err := loadLedger(cfg)
if err != nil {
return OffResult{}, err
}
i := l.find(target, pb.Name)
if i < 0 || !l.Records[i].Installed {
return OffResult{Changed: false, Message: fmt.Sprintf("%s/%s: not emitted", target, pb.Name)}, nil
}
rec := l.Records[i]
onDisk, rerr := os.ReadFile(dst)
if errors.Is(rerr, os.ErrNotExist) {
l.clear(target, pb.Name)
if err := saveLedger(cfg, l); err != nil {
return OffResult{}, err
}
return OffResult{Changed: true, Message: fmt.Sprintf("%s/%s: already removed; ledger cleared", target, pb.Name)}, nil
}
if rerr != nil {
return OffResult{}, fmt.Errorf("read %s: %w", dst, rerr)
}
if sha256hex(onDisk) != rec.SHA256 {
return OffResult{Changed: false, Message: fmt.Sprintf("%s/%s: edited since generate — left untouched", target, pb.Name)}, nil
}
switch {
case rec.Backup != "":
// Restore the pre-eeco file by atomically replacing eeco's artifact
// (writeFileAtomic does temp + rename), so there is never a window
// where the path is absent: a failed restore leaves eeco's artifact
// in place and the ledger untouched, so off stays retryable. If the
// backup is unreadable, remove the artifact rather than leave it.
if bb, berr := os.ReadFile(rec.Backup); berr == nil {
if werr := writeFileAtomic(dst, bb, 0o644); werr != nil {
return OffResult{}, werr
}
} else if rerr := os.Remove(dst); rerr != nil {
return OffResult{}, fmt.Errorf("remove %s: %w", dst, rerr)
}
default:
if err := os.Remove(dst); err != nil {
return OffResult{}, fmt.Errorf("remove %s: %w", dst, err)
}
if rec.Created {
// Prune the leaf skill dir only if eeco created it and it is now
// empty; os.Remove fails (and is ignored) on a non-empty dir.
_ = os.Remove(filepath.Dir(dst))
}
}
l.clear(target, pb.Name)
if err := saveLedger(cfg, l); err != nil {
return OffResult{}, err
}
return OffResult{Changed: true, Message: fmt.Sprintf("%s/%s: removed (reversed)", target, pb.Name)}, nil
}
// Status returns one line per ledger record reflecting on-disk reality, so
// a hand-removed or hand-edited artifact reads honestly. With no records it
// reports the C1 surface as not emitted.
func Status(cfg *config.Config) []string {
l, _ := loadLedger(cfg)
if len(l.Records) == 0 {
return []string{"claude/handover: not emitted"}
}
lines := make([]string, 0, len(l.Records))
for _, rec := range l.Records {
state := recordStatus(rec)
if rec.Playbook == "" {
// Aggregate record (AGENTS.md / GEMINI.md): keyed on target alone.
lines = append(lines, fmt.Sprintf("%s: %s (aggregate, ADVISORY)", rec.Target, state))
continue
}
line := rec.Target + "/" + rec.Playbook + ": " + state
if enf, ok := TargetFidelity(rec.Target); ok && enf == EnforcementAdvisory {
line += " (advisory)"
}
lines = append(lines, line)
}
return lines
}
func recordStatus(rec record) string {
b, err := os.ReadFile(rec.Path)
if errors.Is(err, os.ErrNotExist) {
return "off"
}
if err != nil {
return "unknown (" + err.Error() + ")"
}
if sha256hex(b) == rec.SHA256 {
return "on"
}
return "off (edited)"
}
// backupExisting copies a pre-existing artifact into
// <workspace>/state/backups before it is overwritten, mirroring the hooks
// package's backup discipline (inside the workspace, never beside the
// target file).
func backupExisting(cfg *config.Config, target, playbook string, orig []byte) (string, error) {
dir := filepath.Join(cfg.Workspace, "state", "backups")
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", fmt.Errorf("backup dir: %w", err)
}
name := fmt.Sprintf("cockpit-%s-%s-%s.md", target, playbook, time.Now().UTC().Format("20060102T150405.000000000Z"))
bp := filepath.Join(dir, name)
if err := os.WriteFile(bp, orig, 0o644); err != nil {
return "", fmt.Errorf("write backup: %w", err)
}
return bp, nil
}
// userArtifactPath joins a renderer's relative artifact path to cfg.UserDir
// after the write-scope-floor guard (relUnder), so a renderer can never write
// outside the gitignored private tree (an absolute or "../"-escaping RelPath
// is rejected, not silently joined).
func userArtifactPath(cfg *config.Config, rel string) (string, error) {
clean, err := relUnder(rel)
if err != nil {
return "", err
}
return filepath.Join(cfg.UserDir, clean), nil
}
// writeFileAtomic mirrors internal/hooks' same-directory temp + rename
// discipline so a crash mid-write cannot leave a truncated artifact.
func writeFileAtomic(path string, content []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("ensure dir %s: %w", dir, err)
}
tmp, err := os.CreateTemp(dir, ".eeco-cockpit-*")
if err != nil {
return fmt.Errorf("temp file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, werr := tmp.Write(content); werr != nil {
tmp.Close()
return fmt.Errorf("write temp file: %w", werr)
}
if cerr := tmp.Close(); cerr != nil {
return fmt.Errorf("close temp file: %w", cerr)
}
if perm == 0 {
perm = 0o644
}
if cherr := os.Chmod(tmpName, perm); cherr != nil {
return fmt.Errorf("chmod temp file: %w", cherr)
}
if rerr := os.Rename(tmpName, path); rerr != nil {
return fmt.Errorf("replace file: %w", rerr)
}
return nil
}
added internal/cockpit/emit_aggregate.go
@@ -0,0 +1,209 @@
package cockpit
import (
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/ajhahnde/eeco/internal/config"
)
// GenerateAll emits an aggregate target's single shared artifact for the whole
// playbook set, reversibly. The uniform safety gate runs on EVERY playbook in
// the set first: a forbidden write-git verb in any one refuses the whole emit
// (nothing written, no ledger churn), naming the offending playbook — advisory
// targets do not relax the invariant. The artifact dir is cfg.UserDir (never
// pruned), so off can never remove the private tree. Re-emitting unchanged
// bytes is a byte-idempotent no-op.
func GenerateAll(cfg *config.Config, set []Playbook, target string) (GenerateResult, error) {
r, ok := rendererFor(target)
if !ok {
return GenerateResult{}, unknownTargetErr(target)
}
agg, ok := isAggregate(r)
if !ok {
return GenerateResult{}, fmt.Errorf("target %q is per-playbook; use Generate", target)
}
for _, pb := range set {
if hits := ScanAllowlistForWriteGitVerbs(composeAllowedTools(pb), pb.Intent.forbiddenVerbs()); len(hits) > 0 {
return GenerateResult{}, fmt.Errorf(
"refusing to emit %s: playbook %q has forbidden write-git verb(s) in allowlist: %s",
target, pb.Name, strings.Join(hits, ", "))
}
}
content, err := agg.RenderAll(set)
if err != nil {
return GenerateResult{}, err
}
dst, err := userArtifactPath(cfg, agg.AggRelPath())
if err != nil {
return GenerateResult{}, err
}
newSHA := sha256hex(content)
l, err := loadLedger(cfg)
if err != nil {
return GenerateResult{}, err
}
priorIdx := l.findAgg(target)
hasPrior := priorIdx >= 0 && l.Records[priorIdx].Installed
var prior record
if priorIdx >= 0 {
prior = l.Records[priorIdx]
}
if hasPrior && prior.SHA256 == newSHA {
if onDisk, rerr := os.ReadFile(dst); rerr == nil && sha256hex(onDisk) == newSHA {
return GenerateResult{Path: dst, Action: "already current", Fidelity: fidelityOf(r)}, nil
}
}
backup := prior.Backup
if !hasPrior {
// The aggregate dir is cfg.UserDir, which always exists and is never
// pruned, so only a pre-existing foreign file needs backing up.
if existing, rerr := os.ReadFile(dst); rerr == nil {
bp, berr := backupExisting(cfg, target, "_all", existing)
if berr != nil {
return GenerateResult{}, berr
}
backup = bp
} else if !errors.Is(rerr, os.ErrNotExist) {
return GenerateResult{}, fmt.Errorf("inspect %s: %w", dst, rerr)
}
}
if err := writeFileAtomic(dst, content, 0o644); err != nil {
return GenerateResult{}, err
}
l.upsertAgg(record{
Installed: true,
Target: target,
Playbook: "", // aggregate: keyed on target alone
Path: dst,
SHA256: newSHA,
Backup: backup,
Created: false, // dir is cfg.UserDir — never created, never pruned
At: time.Now().UTC().Format(time.RFC3339),
})
if err := saveLedger(cfg, l); err != nil {
return GenerateResult{}, err
}
action := "generated"
switch {
case hasPrior:
action = "regenerated"
case backup != "":
action = "updated"
}
return GenerateResult{Path: dst, Action: action, Backup: backup, Fidelity: fidelityOf(r)}, nil
}
// VerifyAll checks an aggregate target's on-disk artifact against the bytes
// freshly rendered for set, then runs the self-consistency safety check on the
// on-disk bytes (S4) — the advisory analog of the enforced allowlist scan,
// since these targets have no answer key. It never mutates anything.
func VerifyAll(cfg *config.Config, set []Playbook, target string) (VerifyResult, error) {
r, ok := rendererFor(target)
if !ok {
return VerifyResult{}, unknownTargetErr(target)
}
agg, ok := isAggregate(r)
if !ok {
return VerifyResult{}, fmt.Errorf("target %q is per-playbook; use Verify", target)
}
desired, err := agg.RenderAll(set)
if err != nil {
return VerifyResult{}, err
}
dst, err := userArtifactPath(cfg, agg.AggRelPath())
if err != nil {
return VerifyResult{}, err
}
onDisk, rerr := os.ReadFile(dst)
if errors.Is(rerr, os.ErrNotExist) {
return VerifyResult{Clean: false, Detail: fmt.Sprintf("%s: not emitted (run `eeco cockpit generate`)", target)}, nil
}
if rerr != nil {
return VerifyResult{}, fmt.Errorf("read %s: %w", dst, rerr)
}
if sha256hex(onDisk) != sha256hex(desired) {
return VerifyResult{Clean: false, Detail: fmt.Sprintf(
"%s: drifted (hand-edited; run `eeco cockpit generate` to restore)", target)}, nil
}
sc := checkSelfConsistencyBytes(onDisk, set)
if !sc.OK {
return VerifyResult{Clean: false, Detail: fmt.Sprintf(
"%s: self-consistency FAILED: %s", target, strings.Join(sc.Notes, "; "))}, nil
}
return VerifyResult{Clean: true, Detail: fmt.Sprintf("%s: clean (advisory — not harness-enforced)", target)}, nil
}
// OffAll removes an aggregate target's shared artifact, sha-gated and
// reversible. A hand-edited file (on-disk sha != recorded sha) is left
// untouched. It restores a backed-up foreign file or removes eeco's artifact,
// then clears the target-only record. It NEVER prunes a directory, so it can
// never remove cfg.UserDir (the private tree).
func OffAll(cfg *config.Config, target string) (OffResult, error) {
r, ok := rendererFor(target)
if !ok {
return OffResult{}, unknownTargetErr(target)
}
agg, ok := isAggregate(r)
if !ok {
return OffResult{}, fmt.Errorf("target %q is per-playbook; use Off", target)
}
dst, err := userArtifactPath(cfg, agg.AggRelPath())
if err != nil {
return OffResult{}, err
}
l, err := loadLedger(cfg)
if err != nil {
return OffResult{}, err
}
i := l.findAgg(target)
if i < 0 || !l.Records[i].Installed {
return OffResult{Changed: false, Message: fmt.Sprintf("%s: not emitted", target)}, nil
}
rec := l.Records[i]
onDisk, rerr := os.ReadFile(dst)
if errors.Is(rerr, os.ErrNotExist) {
l.clearAgg(target)
if err := saveLedger(cfg, l); err != nil {
return OffResult{}, err
}
return OffResult{Changed: true, Message: fmt.Sprintf("%s: already removed; ledger cleared", target)}, nil
}
if rerr != nil {
return OffResult{}, fmt.Errorf("read %s: %w", dst, rerr)
}
if sha256hex(onDisk) != rec.SHA256 {
return OffResult{Changed: false, Message: fmt.Sprintf("%s: edited since generate — left untouched", target)}, nil
}
if rec.Backup != "" {
// Restore the pre-eeco file by atomically replacing eeco's artifact, so
// the path is never absent mid-restore; an unreadable backup falls back
// to removing the artifact.
if bb, berr := os.ReadFile(rec.Backup); berr == nil {
if werr := writeFileAtomic(dst, bb, 0o644); werr != nil {
return OffResult{}, werr
}
} else if remErr := os.Remove(dst); remErr != nil {
return OffResult{}, fmt.Errorf("remove %s: %w", dst, remErr)
}
} else if err := os.Remove(dst); err != nil {
return OffResult{}, fmt.Errorf("remove %s: %w", dst, err)
}
l.clearAgg(target)
if err := saveLedger(cfg, l); err != nil {
return OffResult{}, err
}
return OffResult{Changed: true, Message: fmt.Sprintf("%s: removed (reversed)", target)}, nil
}
added internal/cockpit/emit_aggregate_test.go
@@ -0,0 +1,189 @@
package cockpit
import (
"os"
"path/filepath"
"testing"
)
func aggSet(t *testing.T) []Playbook {
t.Helper()
return []Playbook{loadHandover(t), synthPlaybook("zeta")}
}
// TestGenerateAllOffAll_ReversibleUserDirSurvives is the headline correctness
// proof: an aggregate emit is fully reversible and never removes the private
// tree (the aggregate dir is cfg.UserDir, Created=false → off only removes the
// file).
func TestGenerateAllOffAll_ReversibleUserDirSurvives(t *testing.T) {
cfg := testConfig(t)
set := aggSet(t)
dst := filepath.Join(cfg.UserDir, "AGENTS.md")
res, err := GenerateAll(cfg, set, "agents")
if err != nil {
t.Fatalf("GenerateAll: %v", err)
}
if res.Action != "generated" || res.Fidelity != EnforcementAdvisory {
t.Fatalf("unexpected result action=%q fidelity=%v", res.Action, res.Fidelity)
}
if _, err := os.Stat(dst); err != nil {
t.Fatalf("AGENTS.md not written: %v", err)
}
off, err := OffAll(cfg, "agents")
if err != nil {
t.Fatalf("OffAll: %v", err)
}
if !off.Changed {
t.Error("OffAll reported no change")
}
if _, err := os.Stat(dst); !os.IsNotExist(err) {
t.Errorf("AGENTS.md should be gone, stat err=%v", err)
}
if _, err := os.Stat(cfg.UserDir); err != nil {
t.Errorf("UserDir must survive off, stat err=%v", err)
}
}
// TestGenerateAll_Idempotent: re-emitting unchanged bytes is a no-op (no
// backup churn, "already current").
func TestGenerateAll_Idempotent(t *testing.T) {
cfg := testConfig(t)
set := aggSet(t)
if _, err := GenerateAll(cfg, set, "agents"); err != nil {
t.Fatal(err)
}
res, err := GenerateAll(cfg, set, "agents")
if err != nil {
t.Fatal(err)
}
if res.Action != "already current" {
t.Errorf("second GenerateAll action=%q, want already current", res.Action)
}
if res.Backup != "" {
t.Errorf("idempotent re-gen produced a backup %q", res.Backup)
}
}
// TestCoexistence_PerPlaybookAndAggregate proves a per-playbook record
// (claude/handover) and an aggregate record (agents) coexist under distinct
// ledger keys, and that off of the aggregate leaves the per-playbook artifact
// and record untouched (the orphan-bug guard).
func TestCoexistence_PerPlaybookAndAggregate(t *testing.T) {
cfg := testConfig(t)
pb := loadHandover(t)
set := aggSet(t)
if _, err := Generate(cfg, pb, "claude"); err != nil {
t.Fatalf("Generate claude: %v", err)
}
if _, err := GenerateAll(cfg, set, "agents"); err != nil {
t.Fatalf("GenerateAll agents: %v", err)
}
claudeFile := filepath.Join(cfg.UserDir, ".claude", "skills", "handover", "SKILL.md")
agentsFile := filepath.Join(cfg.UserDir, "AGENTS.md")
for _, f := range []string{claudeFile, agentsFile} {
if _, err := os.Stat(f); err != nil {
t.Fatalf("expected %s present: %v", f, err)
}
}
l, _ := loadLedger(cfg)
if l.find("claude", "handover") < 0 || l.findAgg("agents") < 0 {
t.Fatalf("ledger missing a record: %+v", l.Records)
}
if _, err := OffAll(cfg, "agents"); err != nil {
t.Fatalf("OffAll agents: %v", err)
}
// The per-playbook artifact + record survive.
if _, err := os.Stat(claudeFile); err != nil {
t.Errorf("claude artifact removed by aggregate off: %v", err)
}
if _, err := os.Stat(agentsFile); !os.IsNotExist(err) {
t.Errorf("AGENTS.md should be gone: %v", err)
}
l2, _ := loadLedger(cfg)
if l2.find("claude", "handover") < 0 {
t.Error("aggregate off cleared the per-playbook record")
}
if l2.findAgg("agents") >= 0 {
t.Error("aggregate record not cleared after off")
}
}
// TestGenerateAll_ForeignBackupRestore: a pre-existing foreign AGENTS.md is
// backed up on generate and restored byte-for-byte on off.
func TestGenerateAll_ForeignBackupRestore(t *testing.T) {
cfg := testConfig(t)
set := aggSet(t)
dst := filepath.Join(cfg.UserDir, "AGENTS.md")
if err := os.MkdirAll(cfg.UserDir, 0o755); err != nil {
t.Fatal(err)
}
foreign := "# Someone else's AGENTS.md\n\nhand-written.\n"
if err := os.WriteFile(dst, []byte(foreign), 0o644); err != nil {
t.Fatal(err)
}
res, err := GenerateAll(cfg, set, "agents")
if err != nil {
t.Fatalf("GenerateAll: %v", err)
}
if res.Action != "updated" || res.Backup == "" {
t.Fatalf("expected updated+backup, got action=%q backup=%q", res.Action, res.Backup)
}
if _, err := OffAll(cfg, "agents"); err != nil {
t.Fatalf("OffAll: %v", err)
}
restored, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("foreign AGENTS.md not restored: %v", err)
}
if string(restored) != foreign {
t.Errorf("restored content != original foreign:\n%s", restored)
}
}
// TestVerifyAll_DriftDetected: a hand-edit drifts the aggregate artifact.
func TestVerifyAll_DriftDetected(t *testing.T) {
cfg := testConfig(t)
set := aggSet(t)
if _, err := GenerateAll(cfg, set, "agents"); err != nil {
t.Fatal(err)
}
vr, err := VerifyAll(cfg, set, "agents")
if err != nil {
t.Fatal(err)
}
if !vr.Clean {
t.Fatalf("fresh emit should verify clean: %q", vr.Detail)
}
dst := filepath.Join(cfg.UserDir, "AGENTS.md")
if err := os.WriteFile(dst, []byte("tampered\n"), 0o644); err != nil {
t.Fatal(err)
}
vr2, err := VerifyAll(cfg, set, "agents")
if err != nil {
t.Fatal(err)
}
if vr2.Clean {
t.Error("expected drift to be detected")
}
}
// TestAggregateGuards: the per-playbook entry points reject an aggregate
// target, and the aggregate entry points reject a per-playbook target.
func TestAggregateGuards(t *testing.T) {
cfg := testConfig(t)
pb := loadHandover(t)
if _, err := Generate(cfg, pb, "agents"); err == nil {
t.Error("Generate should reject an aggregate target")
}
if _, err := GenerateAll(cfg, aggSet(t), "claude"); err == nil {
t.Error("GenerateAll should reject a per-playbook target")
}
}
added internal/cockpit/emit_backup_test.go
@@ -0,0 +1,102 @@
package cockpit
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestGenerateOff_RestoresForeignFile proves the reversibility contract for
// a pre-existing foreign artifact: generate backs it up and overwrites;
// off removes eeco's emit and restores the original byte-for-byte.
func TestGenerateOff_RestoresForeignFile(t *testing.T) {
cfg := testConfig(t)
pb := loadHandover(t)
dst := filepath.Join(cfg.UserDir, ".claude", "skills", "handover", "SKILL.md")
foreign := "---\nname: handover\ndescription: someone else's skill\nallowed-tools: Read\n---\n# Theirs\n"
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, []byte(foreign), 0o644); err != nil {
t.Fatal(err)
}
res, err := Generate(cfg, pb, "claude")
if err != nil {
t.Fatalf("Generate: %v", err)
}
if res.Action != "updated" || res.Backup == "" {
t.Fatalf("expected an updated action with a backup, got action=%q backup=%q", res.Action, res.Backup)
}
if _, err := os.Stat(res.Backup); err != nil {
t.Fatalf("backup file not present: %v", err)
}
cur, _ := os.ReadFile(dst)
if string(cur) == foreign {
t.Error("generate did not overwrite the foreign file")
}
off, err := Off(cfg, pb, "claude")
if err != nil {
t.Fatalf("Off: %v", err)
}
if !off.Changed {
t.Error("Off reported no change")
}
restored, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("foreign file not restored: %v", err)
}
if string(restored) != foreign {
t.Errorf("restored content != original foreign file:\n%s", string(restored))
}
}
// TestVerify_ParityPath exercises the optional --parity branch of Verify
// against a synthetic answer key matching the emitted shape.
func TestVerify_ParityPath(t *testing.T) {
cfg := testConfig(t)
pb := loadHandover(t)
if _, err := Generate(cfg, pb, "claude"); err != nil {
t.Fatal(err)
}
// A synthetic answer key in the skill layout whose allowlist core is a
// subset of the emitted one and carries no write-git verb.
keyRoot := t.TempDir()
key := filepath.Join(keyRoot, ".claude", "skills", "handover", "SKILL.md")
if err := os.MkdirAll(filepath.Dir(key), 0o755); err != nil {
t.Fatal(err)
}
answer := "---\nname: handover\ndescription: x\nallowed-tools: Read, Write, Bash(git status:*)\n---\n" +
"# Handover\n## Step 0 — a\n## Step 1 — b\n## Step 2 — c\n## Step 3 — d\n## Step 4 — e\n## Output\nx\n"
if err := os.WriteFile(key, []byte(answer), 0o644); err != nil {
t.Fatal(err)
}
vr, err := Verify(cfg, pb, "claude", key)
if err != nil {
t.Fatalf("Verify --parity: %v", err)
}
if !vr.Clean || !strings.Contains(vr.Detail, "parity OK") {
t.Errorf("expected clean + parity OK, got clean=%v detail=%q", vr.Clean, vr.Detail)
}
}
func TestAccessors(t *testing.T) {
if (claudeRenderer{}).Target() != "claude" {
t.Error("claudeRenderer.Target")
}
if got := Targets(); len(got) == 0 || got[0] != "claude" {
t.Errorf("Targets() = %v (want claude first)", got)
}
if _, ok := rendererFor("nosuchharness"); ok {
t.Error("rendererFor returned a renderer for an unknown target")
}
r := GenerateResult{Path: "/p", Action: "generated", Backup: "/b"}
if msg := r.Message(); !strings.Contains(msg, "/p") || !strings.Contains(msg, "/b") {
t.Errorf("GenerateResult.Message = %q", msg)
}
}
added internal/cockpit/fidelity_test.go
@@ -0,0 +1,61 @@
package cockpit
import (
"strings"
"testing"
)
func TestFidelityOf_PerTarget(t *testing.T) {
cases := map[string]Enforcement{
"claude": EnforcementEnforced,
"cursor": EnforcementAdvisory,
"agents": EnforcementAdvisory,
"gemini": EnforcementAdvisory,
}
for target, want := range cases {
got, ok := TargetFidelity(target)
if !ok {
t.Errorf("%s: not a known target", target)
continue
}
if got != want {
t.Errorf("%s fidelity = %v, want %v", target, got, want)
}
}
if _, ok := TargetFidelity("nosuch"); ok {
t.Error("unknown target reported as known")
}
}
func TestEnforcementString(t *testing.T) {
if EnforcementEnforced.String() != "enforced" || EnforcementAdvisory.String() != "advisory" {
t.Error("Enforcement.String mismatch")
}
}
// TestAdvisoryMessageSuffix: an advisory generate result flags itself; an
// enforced one does not.
func TestAdvisoryMessageSuffix(t *testing.T) {
adv := GenerateResult{Path: "/p", Action: "generated", Fidelity: EnforcementAdvisory}
if !strings.Contains(adv.Message(), "advisory") {
t.Errorf("advisory message missing the advisory note: %q", adv.Message())
}
enf := GenerateResult{Path: "/p", Action: "generated", Fidelity: EnforcementEnforced}
if strings.Contains(enf.Message(), "advisory") {
t.Errorf("enforced message should carry no advisory note: %q", enf.Message())
}
}
// TestBannerPresentRegression guards the honesty banner against silent
// removal — every advisory renderer must embed it.
func TestBannerPresentRegression(t *testing.T) {
pb := loadHandover(t)
cur, _ := cursorRenderer{}.Render(pb)
ag, _ := agentsRenderer{}.RenderAll([]Playbook{pb})
gm, _ := geminiRenderer{}.RenderAll([]Playbook{pb})
for name, out := range map[string][]byte{"cursor": cur, "agents": ag, "gemini": gm} {
if !strings.Contains(string(out), advisoryBanner) {
t.Errorf("%s render dropped the ADVISORY banner", name)
}
}
}
added internal/cockpit/gate.go
@@ -0,0 +1,86 @@
package cockpit
import "strings"
// defaultForbiddenGitVerbs is the write/mutate git subcommand denylist used
// when a Playbook does not declare its own Intent.ForbiddenGitVerbs. These
// are the second token of a `git <verb>` invocation; any of them appearing
// in a composed allowlist is a hard generation failure (the safety
// invariant — see ScanAllowlistForWriteGitVerbs). Purely read-only
// inspection subcommands (status, log, diff, describe, show) are deliberately
// absent. "branch" is the exception in this list: it is dual-mode — bare /
// -l / --list / --show-current only read, while -d/-D/-m/-M/-f/<name> create,
// rename, or delete a ref — so it is denied here and its read-only forms pass
// via readOnlyGitCompounds, exactly like "stash" / "stash list".
var defaultForbiddenGitVerbs = []string{
"add", "commit", "push", "tag", "reset", "rebase", "merge", "restore", "switch", "checkout", "branch",
"stash", "fetch", "pull", "clone", "mv", "rm", "apply", "cherry-pick", "revert", "am", "gc",
"worktree", "notes", "update-ref", "fast-import", "format-patch", "send-email", "commit-tree", "write-tree",
}
// readOnlyGitCompounds are `git <verb> <subverb>` phrases that inspect
// rather than mutate, even though their first subcommand token is in the
// denylist. "git stash" mutates the stash; "git stash list" / "git stash
// show" only read it. "git branch" creates, renames, or deletes a ref; "git
// branch --show-current" only prints the current branch name. The gate lets
// these compounds through so a Playbook can declare a precise read-only
// capability without tripping the write-verb scan. Keep the denylist tight:
// add another read-only branch form (--list, -l) here only when a playbook
// actually needs it.
var readOnlyGitCompounds = map[string]bool{
"branch --show-current": true,
"stash list": true,
"stash show": true,
}
// ScanAllowlistForWriteGitVerbs reports the forbidden git write verbs found
// in a composed allowlist (the `allowed-tools` entries — "Bash(git
// commit:*)", "Read", …). An empty result means the safety invariant holds.
//
// It keys on the command phrase inside each Bash(...) entry, not on a
// substring: a bare "git stash" (second token in the denylist) is a hit,
// while the explicit read-only compound "git stash list" passes. Non-git
// and non-bash entries are ignored. forbidden is the denylist
// (Intent.forbiddenVerbs supplies it).
func ScanAllowlistForWriteGitVerbs(allowlist, forbidden []string) []string {
deny := make(map[string]bool, len(forbidden))
for _, f := range forbidden {
deny[f] = true
}
var hits []string
for _, entry := range allowlist {
verb := bashVerb(entry)
if verb == "" {
continue
}
fields := strings.Fields(verb)
if len(fields) < 2 || fields[0] != "git" {
continue
}
sub := fields[1]
if !deny[sub] {
continue
}
if len(fields) >= 3 && readOnlyGitCompounds[fields[1]+" "+fields[2]] {
continue
}
hits = append(hits, sub)
}
return hits
}
// bashVerb extracts the command phrase from a "Bash(<verb>:<scope>)" entry,
// dropping the trailing ":<scope>". A non-Bash entry returns "". The scope
// is split off at the last colon so a verb (which carries no colon) is
// preserved intact.
func bashVerb(entry string) string {
inner, ok := strings.CutPrefix(entry, "Bash(")
if !ok {
return ""
}
inner = strings.TrimSuffix(inner, ")")
if i := strings.LastIndex(inner, ":"); i >= 0 {
inner = inner[:i]
}
return strings.TrimSpace(inner)
}
added internal/cockpit/gate_test.go
@@ -0,0 +1,72 @@
package cockpit
import (
"os"
"path/filepath"
"testing"
"github.com/ajhahnde/eeco/internal/config"
)
func TestScanAllowlistForWriteGitVerbs(t *testing.T) {
base := composeAllowedTools(loadHandover(t))
cases := []struct {
name string
allowlist []string
want []string
}{
{"real handover allowlist holds", base, nil},
{"git commit is forbidden", append(append([]string{}, base...), "Bash(git commit:*)"), []string{"commit"}},
{"git push is forbidden", append(append([]string{}, base...), "Bash(git push:*)"), []string{"push"}},
{"git stash list passes", []string{"Bash(git stash list:*)"}, nil},
{"bare git stash fails", []string{"Bash(git stash:*)"}, []string{"stash"}},
{"git branch --show-current passes", []string{"Bash(git branch --show-current:*)"}, nil},
{"bare git branch fails", []string{"Bash(git branch:*)"}, []string{"branch"}},
{"git branch -D fails", []string{"Bash(git branch -D:*)"}, []string{"branch"}},
{"non-bash tools ignored", []string{"Read", "Write", "Agent"}, nil},
{"non-git bash ignored", []string{"Bash(rm -rf:*)"}, nil}, // rm is not a git subverb here
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := ScanAllowlistForWriteGitVerbs(tc.allowlist, defaultForbiddenGitVerbs)
if len(got) != len(tc.want) {
t.Fatalf("got %v, want %v", got, tc.want)
}
for i := range tc.want {
if got[i] != tc.want[i] {
t.Errorf("hit %d = %q, want %q", i, got[i], tc.want[i])
}
}
})
}
}
func TestGenerate_RefusesForbiddenVerb(t *testing.T) {
cfg := testConfig(t)
pb := loadHandover(t)
// Poison the playbook: add a write-git capability.
pb.Capabilities = append(pb.Capabilities, Capability{Kind: "bash", Verb: "git commit", Scope: "*"})
if _, err := Generate(cfg, pb, "claude"); err == nil {
t.Fatal("expected Generate to refuse a poisoned playbook")
}
// Nothing written, no ledger.
if _, err := os.Stat(filepath.Join(cfg.UserDir, ".claude", "skills", "handover", "SKILL.md")); err == nil {
t.Error("a SKILL.md was written despite the safety refusal")
}
if _, err := os.Stat(ledgerPath(cfg)); err == nil {
t.Error("a ledger was written despite the safety refusal")
}
}
// testConfig builds a minimal Config whose UserDir/Workspace point inside a
// throwaway temp dir, the only fields the cockpit emit path touches.
func testConfig(t *testing.T) *config.Config {
t.Helper()
root := t.TempDir()
return &config.Config{
UserDir: filepath.Join(root, "tester"),
Workspace: filepath.Join(root, "tester", ".eeco"),
}
}
added internal/cockpit/gate_uniform_test.go
@@ -0,0 +1,42 @@
package cockpit
import "testing"
// poisonedPlaybook declares a forbidden write-git verb (commit) yet lists it
// in the allowlist — the exact thing the uniform safety gate must refuse, on
// every target.
func poisonedPlaybook() Playbook {
return Playbook{
Name: "poison",
Description: "a playbook that grants a forbidden write-git verb",
Intent: Intent{Guarantee: "x", Forbidden: []string{"git commit"}},
Capabilities: []Capability{
{Kind: "tool", Name: "Read"},
{Kind: "bash", Verb: "git commit", Scope: "*"},
},
Steps: []Step{{Index: 0, Title: "go", Body: "do it"}},
OutputFormat: "x",
}
}
// TestUniformGate_RefusesOnEveryTarget: a playbook with a forbidden write-git
// verb in its allowlist is refused by both the per-playbook path (cursor) and
// the aggregate path (agents). Advisory ≠ bypass.
func TestUniformGate_RefusesOnEveryTarget(t *testing.T) {
cfg := testConfig(t)
pb := poisonedPlaybook()
if _, err := Generate(cfg, pb, "cursor"); err == nil {
t.Error("cursor Generate accepted a poisoned playbook")
}
if _, err := GenerateAll(cfg, []Playbook{pb}, "agents"); err == nil {
t.Error("agents GenerateAll accepted a poisoned playbook")
}
if _, err := GenerateAll(cfg, []Playbook{loadHandover(t), pb}, "gemini"); err == nil {
t.Error("gemini GenerateAll accepted a set containing a poisoned playbook")
}
// And the enforced target, for completeness.
if _, err := Generate(cfg, pb, "claude"); err == nil {
t.Error("claude Generate accepted a poisoned playbook")
}
}
added internal/cockpit/gemini.go
@@ -0,0 +1,34 @@
package cockpit
// geminiRenderer emits the whole selected playbook set as one GEMINI.md — the
// Gemini CLI context convention, plain Markdown with no frontmatter. GEMINI.md
// is advisory: Gemini reads it as context but enforces tool permissions in a
// separate `.gemini/settings.json` `tools.core` layer (emitting that is C4,
// not C2), so the file leads with the ADVISORY banner and names where real
// enforcement lives. Aggregate target, like AGENTS.md.
type geminiRenderer struct{}
func (geminiRenderer) Target() string { return "gemini" }
// Enforcement reports that GEMINI.md is advisory only (enforcement lives in a
// separate settings layer). It satisfies the Fidelity interface.
func (geminiRenderer) Enforcement() Enforcement { return EnforcementAdvisory }
// AggRelPath is the single shared artifact path for the whole set.
func (geminiRenderer) AggRelPath() string { return "GEMINI.md" }
// RenderAll renders the set as one GEMINI.md document, deterministic in set
// order (renderAggregateMarkdown sorts by Name).
func (geminiRenderer) RenderAll(ps []Playbook) ([]byte, error) {
return renderAggregateMarkdown(ps,
"GEMINI.md — eeco-generated agent playbooks",
"Gemini reads this file as context. Tool-permission enforcement is NOT here — it lives in "+
"`.gemini/settings.json` (`tools.core` / `tools.exclude`), which eeco does not emit in this slice."), nil
}
// RelPath / Render satisfy Renderer for interface completeness; the aggregate
// emit path (GenerateAll) is what actually drives this target.
func (r geminiRenderer) RelPath(Playbook) string { return r.AggRelPath() }
func (r geminiRenderer) Render(p Playbook) ([]byte, error) {
return r.RenderAll([]Playbook{p})
}
added internal/cockpit/gemini_test.go
@@ -0,0 +1,49 @@
package cockpit
import (
"strings"
"testing"
)
func TestGeminiRender_Structure(t *testing.T) {
out, err := geminiRenderer{}.RenderAll(twoPlaybooks(t))
if err != nil {
t.Fatalf("RenderAll: %v", err)
}
got := string(out)
if strings.HasPrefix(got, "---") {
t.Error("GEMINI.md must not carry YAML frontmatter")
}
for _, want := range []string{"# GEMINI.md", advisoryBanner, "## Fidelity report", ".gemini/settings.json"} {
if !strings.Contains(got, want) {
t.Errorf("GEMINI.md missing %q", want)
}
}
}
func TestGeminiRender_SetOrderStable(t *testing.T) {
a, err := geminiRenderer{}.RenderAll([]Playbook{loadHandover(t), synthPlaybook("zeta")})
if err != nil {
t.Fatal(err)
}
b, err := geminiRenderer{}.RenderAll([]Playbook{synthPlaybook("zeta"), loadHandover(t)})
if err != nil {
t.Fatal(err)
}
if string(a) != string(b) {
t.Error("aggregate render not stable under input reordering")
}
}
func TestGeminiAggRelPath(t *testing.T) {
if got := (geminiRenderer{}).AggRelPath(); got != "GEMINI.md" {
t.Errorf("AggRelPath = %q, want GEMINI.md", got)
}
if !IsAggregateTarget("gemini") {
t.Error("gemini should be an aggregate target")
}
if IsAggregateTarget("claude") || IsAggregateTarget("cursor") {
t.Error("claude/cursor are per-playbook, not aggregate")
}
}
added internal/cockpit/ledger.go
@@ -0,0 +1,139 @@
package cockpit
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/ajhahnde/eeco/internal/config"
)
// ledgerName is the reversibility record for emitted cockpit artifacts,
// inside <workspace>/state. It mirrors internal/hooks/state/hooks.json: a
// record per artifact, sha-stamped, with a backup pointer, so every emit is
// cleanly undoable.
const ledgerName = "cockpit.json"
// record is one emitted artifact's reversibility state. Keyed by
// (Target, Playbook). Created is true when generate created the leaf skill
// directory (so off can prune it); Backup points at a pre-existing file's
// saved copy under <workspace>/state/backups.
type record struct {
Installed bool `json:"installed"`
Target string `json:"target,omitempty"`
Playbook string `json:"playbook,omitempty"`
Path string `json:"path,omitempty"`
SHA256 string `json:"sha256,omitempty"`
Backup string `json:"backup,omitempty"`
Created bool `json:"created,omitempty"`
At string `json:"at,omitempty"`
}
// ledger is the persisted state. A slice so it grows per playbook/target in
// C2 without a shape change.
type ledger struct {
Records []record `json:"records"`
}
// find returns the index of the record for (target, playbook), or -1.
func (l *ledger) find(target, playbook string) int {
for i := range l.Records {
if l.Records[i].Target == target && l.Records[i].Playbook == playbook {
return i
}
}
return -1
}
// upsert stores rec, replacing any existing (target, playbook) record.
func (l *ledger) upsert(rec record) {
if i := l.find(rec.Target, rec.Playbook); i >= 0 {
l.Records[i] = rec
return
}
l.Records = append(l.Records, rec)
}
// hasInstalled reports whether any record is still installed — the
// "cockpit in use here" gate for Sync. init writes a default selection but
// never the ledger, so an empty (or all-removed) ledger means generate
// never produced an artifact and a drift scan has nothing to check.
func (l *ledger) hasInstalled() bool {
for i := range l.Records {
if l.Records[i].Installed {
return true
}
}
return false
}
// clear drops the record for (target, playbook) if present.
func (l *ledger) clear(target, playbook string) {
i := l.find(target, playbook)
if i < 0 {
return
}
l.Records = append(l.Records[:i], l.Records[i+1:]...)
}
// findAgg / upsertAgg / clearAgg are the aggregate-target views of the ledger:
// an aggregate artifact (AGENTS.md, GEMINI.md) is one shared file for the
// whole set, so its record is keyed on the target alone (Playbook==""). Keying
// on target alone is what stops an `off` of one playbook from deleting a file
// shared by the rest (the orphan bug). They reuse the (target, playbook)
// primitives with an empty playbook, so per-playbook and aggregate records
// coexist under distinct keys.
func (l *ledger) findAgg(target string) int { return l.find(target, "") }
func (l *ledger) upsertAgg(rec record) { l.upsert(rec) }
func (l *ledger) clearAgg(target string) { l.clear(target, "") }
func ledgerPath(cfg *config.Config) string {
return filepath.Join(cfg.Workspace, "state", ledgerName)
}
// loadLedger reads the cockpit ledger. A missing or empty file is empty
// state; a corrupt file degrades to empty state rather than wedging the
// tool (on-disk sha verification still guards every removal).
func loadLedger(cfg *config.Config) (ledger, error) {
var l ledger
b, err := os.ReadFile(ledgerPath(cfg))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return l, nil
}
return l, fmt.Errorf("read cockpit ledger: %w", err)
}
if len(b) == 0 {
return l, nil
}
if err := json.Unmarshal(b, &l); err != nil {
return ledger{}, nil
}
return l, nil
}
// saveLedger writes the cockpit ledger with the indent + trailing-newline
// discipline of the hooks ledger.
func saveLedger(cfg *config.Config, l ledger) error {
dir := filepath.Join(cfg.Workspace, "state")
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("cockpit ledger dir: %w", err)
}
b, err := json.MarshalIndent(l, "", " ")
if err != nil {
return err
}
return os.WriteFile(ledgerPath(cfg), append(b, '\n'), 0o644)
}
// sha256hex is the local 3-line dup of internal/hooks.sha256hex (the two
// packages share no exported helper; duplicating it keeps cockpit free of a
// hooks import for a one-liner).
func sha256hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
added internal/cockpit/machinery_test.go
@@ -0,0 +1,30 @@
package cockpit
import (
"path/filepath"
"testing"
"github.com/ajhahnde/eeco/internal/config"
)
func TestMachineryFidelity(t *testing.T) {
if enf, ok := MachineryFidelity("claude"); !ok || enf != EnforcementEnforced {
t.Errorf("claude machinery fidelity = (%v, %v), want (enforced, true)", enf, ok)
}
for _, tgt := range []string{"cursor", "agents", "gemini"} {
if enf, ok := MachineryFidelity(tgt); !ok || enf != EnforcementAdvisory {
t.Errorf("%s machinery fidelity = (%v, %v), want (advisory, true)", tgt, enf, ok)
}
}
if _, ok := MachineryFidelity("nope"); ok {
t.Error("an unknown target should report ok=false")
}
}
func TestSelectionPath(t *testing.T) {
cfg := &config.Config{Workspace: filepath.Join("x", "tester", ".eeco")}
want := filepath.Join("x", "tester", ".eeco", "cockpit.json")
if got := SelectionPath(cfg); got != want {
t.Errorf("SelectionPath = %q, want %q", got, want)
}
}
added internal/cockpit/parity.go
@@ -0,0 +1,275 @@
package cockpit
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// ParityResult is the three-tier structural comparison of an emitted skill
// against a hand-built answer-key SKILL.md. Tiers are independent so a
// failure names exactly which dimension diverged. OK requires all three.
type ParityResult struct {
LayerOK bool // same one-dir-per-skill .claude/skills/<name>/SKILL.md layout
CapOK bool // 3-key frontmatter, >=5 steps, output section, allowlist superset of the answer-key core
SafetyOK bool // zero forbidden write-git verbs in the emitted allowlist; answer-key hits are warnings, not failures
Notes []string // human notes for any failed tier
}
// OK reports whether all three parity tiers held.
func (p ParityResult) OK() bool { return p.LayerOK && p.CapOK && p.SafetyOK }
// skillShape is the comparable structure parsed from a SKILL.md: the
// frontmatter keys present, the allowlist entries, the step count, and
// whether an output section exists. Prose is deliberately ignored —
// parity is structural, the emit is neutral while the answer key is
// project-specific.
type skillShape struct {
frontmatterKeys map[string]bool
allowlist []string
stepCount int
hasOutput bool
}
// ScratchRegenerate renders pb for target into a fresh file under
// scratchRoot and returns its path. It never writes cfg.UserDir or the
// answer key — scratchRoot is a caller-owned throwaway (t.TempDir /
// os.MkdirTemp) — so a parity check can never mutate a real cockpit.
func ScratchRegenerate(pb Playbook, target, scratchRoot string) (string, error) {
r, ok := rendererFor(target)
if !ok {
return "", fmt.Errorf("unknown target %q", target)
}
content, err := r.Render(pb)
if err != nil {
return "", err
}
dst := filepath.Join(scratchRoot, r.RelPath(pb))
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return "", fmt.Errorf("scratch dir: %w", err)
}
if err := os.WriteFile(dst, content, 0o644); err != nil {
return "", fmt.Errorf("write scratch: %w", err)
}
return dst, nil
}
// Parity renders pb for target into a private scratch dir and compares it
// structurally against the answer-key SKILL.md at answerKeyPath. The answer
// key is read-only; the scratch dir is created and removed internally.
func Parity(pb Playbook, target, answerKeyPath string) (ParityResult, error) {
scratch, err := os.MkdirTemp("", "eeco-cockpit-parity-")
if err != nil {
return ParityResult{}, fmt.Errorf("scratch root: %w", err)
}
defer os.RemoveAll(scratch)
emittedPath, err := ScratchRegenerate(pb, target, scratch)
if err != nil {
return ParityResult{}, err
}
emittedBytes, err := os.ReadFile(emittedPath)
if err != nil {
return ParityResult{}, err
}
keyBytes, err := os.ReadFile(answerKeyPath)
if err != nil {
return ParityResult{}, fmt.Errorf("read answer key %s: %w", answerKeyPath, err)
}
r, ok := rendererFor(target)
if !ok {
return ParityResult{}, fmt.Errorf("unknown target %q", target)
}
emitted := parseSkillShape(emittedBytes)
key := parseSkillShape(keyBytes)
var res ParityResult
// Tier 1 — layer: both live at .claude/skills/<name>/SKILL.md.
emittedRel := r.RelPath(pb)
res.LayerOK = isSkillLayout(emittedRel) && isSkillLayout(answerKeyPath)
if !res.LayerOK {
res.Notes = append(res.Notes, fmt.Sprintf("layer: emitted %q / answer key %q not both .claude/skills/<name>/SKILL.md", emittedRel, answerKeyPath))
}
// Tier 2 — capability: 3-key frontmatter, >=5 steps, an output section,
// and the emitted allowlist is a superset of the answer key's portable
// core (FlashOS-only entries ignored).
threeKey := emitted.frontmatterKeys["name"] && emitted.frontmatterKeys["description"] && emitted.frontmatterKeys["allowed-tools"]
enoughSteps := emitted.stepCount >= 5
keyCore := portableAllowlist(key.allowlist)
missing := setDifference(coverageSet(keyCore), coverageSet(emitted.allowlist))
res.CapOK = threeKey && enoughSteps && emitted.hasOutput && len(missing) == 0
if !res.CapOK {
if !threeKey {
res.Notes = append(res.Notes, "capability: emitted frontmatter missing one of name/description/allowed-tools")
}
if !enoughSteps {
res.Notes = append(res.Notes, fmt.Sprintf("capability: emitted has %d steps (<5)", emitted.stepCount))
}
if !emitted.hasOutput {
res.Notes = append(res.Notes, "capability: emitted has no output section")
}
if len(missing) > 0 {
res.Notes = append(res.Notes, "capability: emitted allowlist missing answer-key core: "+strings.Join(missing, ", "))
}
}
// Tier 3 — safety: zero forbidden write-git verbs in the EMITTED allowlist.
// eeco controls only what it emits; the hand-built answer key is a separate
// repo, so an over-grant there is surfaced as a warning Note, not a hard
// failure (the roadmap invariant scopes to "every emitted allowlist"). The
// scan reads the literal emitted scopes, so a broader emitted scope can
// never hide behind the Tier-2 head normalization.
forbidden := pb.Intent.forbiddenVerbs()
emittedHits := ScanAllowlistForWriteGitVerbs(emitted.allowlist, forbidden)
keyHits := ScanAllowlistForWriteGitVerbs(key.allowlist, forbidden)
res.SafetyOK = len(emittedHits) == 0
if len(emittedHits) > 0 {
res.Notes = append(res.Notes, fmt.Sprintf("safety: forbidden write-git verb(s) in emitted allowlist: %v", emittedHits))
}
if len(keyHits) > 0 {
res.Notes = append(res.Notes, fmt.Sprintf("answer key over-grants forbidden verb(s): %v (not eeco-controlled — fix the source skill)", keyHits))
}
return res, nil
}
// isSkillLayout reports whether p ends in the one-dir-per-skill Claude
// layout .claude/skills/<name>/SKILL.md.
func isSkillLayout(p string) bool {
p = filepath.ToSlash(p)
if filepath.Base(p) != "SKILL.md" {
return false
}
return strings.Contains(p, ".claude/skills/")
}
// portableAllowlist drops answer-key entries that are project-specific and
// not expected in a neutral emit: absolute-path bash verbs (e.g.
// /bin/ls) and path-scoped write verbs (e.g. mv into a project dir). What
// remains is the portable core the emitted allowlist must cover.
func portableAllowlist(allowlist []string) []string {
var out []string
for _, e := range allowlist {
verb := bashVerb(e)
if verb != "" {
if strings.Contains(verb, "/") { // absolute-path tool like /bin/ls
continue
}
fields := strings.Fields(verb)
// A non-git write verb pinned to a project path (e.g. "mv
// ajhahnde/...") is FlashOS-specific; a bare/git read verb stays.
if len(fields) >= 2 && fields[0] != "git" {
continue
}
}
out = append(out, e)
}
return out
}
// coverageKey maps an allowlist entry to the token the Tier-2 capability
// coverage check compares on. A git Bash entry collapses to its "git <verb>"
// head (first two tokens) so a scope or sub-verb refinement of the same verb
// (emitted "git branch --show-current" vs key "git branch") is not counted as
// a missing capability. Every other entry — a named tool, a non-git Bash verb
// — compares as its exact string. Safety is unaffected: Tier 3 scans the
// literal emitted scopes, so a broader emitted scope cannot hide here.
func coverageKey(entry string) string {
verb := bashVerb(entry)
fields := strings.Fields(verb)
if len(fields) >= 2 && fields[0] == "git" {
return "git " + fields[1]
}
return entry
}
// coverageSet maps coverageKey over a list, for the Tier-2 set-difference.
func coverageSet(entries []string) []string {
out := make([]string, 0, len(entries))
for _, e := range entries {
out = append(out, coverageKey(e))
}
return out
}
// setDifference returns the entries of want not present in have (set
// semantics, order-insensitive).
func setDifference(want, have []string) []string {
hset := make(map[string]bool, len(have))
for _, h := range have {
hset[h] = true
}
var diff []string
for _, w := range want {
if !hset[w] {
diff = append(diff, w)
}
}
return diff
}
// parseSkillShape extracts the comparable structure from SKILL.md bytes:
// the leading YAML frontmatter's keys + allowed-tools list, the count of
// "## Step " headings, and whether a "## Output" heading is present.
func parseSkillShape(content []byte) skillShape {
shape := skillShape{frontmatterKeys: map[string]bool{}}
text := string(content)
lines := strings.Split(text, "\n")
inFrontmatter := false
frontmatterDone := false
for i, raw := range lines {
line := strings.TrimRight(raw, "\r")
if i == 0 && strings.TrimSpace(line) == "---" {
inFrontmatter = true
continue
}
if inFrontmatter {
if strings.TrimSpace(line) == "---" {
inFrontmatter = false
frontmatterDone = true
continue
}
key, val, ok := strings.Cut(line, ":")
if ok {
key = strings.TrimSpace(key)
shape.frontmatterKeys[key] = true
if key == "allowed-tools" {
shape.allowlist = splitAllowlist(val)
}
}
continue
}
_ = frontmatterDone
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "## Step ") {
shape.stepCount++
}
if trimmed == "## Output" {
shape.hasOutput = true
}
}
return shape
}
// parseAllowedTools returns the allowlist entries from a SKILL.md's
// frontmatter (empty when absent). Shared by Verify's on-disk safety scan.
func parseAllowedTools(content []byte) []string {
return parseSkillShape(content).allowlist
}
// splitAllowlist parses an "allowed-tools:" value into trimmed, non-empty
// entries split on commas.
func splitAllowlist(val string) []string {
var out []string
for _, part := range strings.Split(val, ",") {
if p := strings.TrimSpace(part); p != "" {
out = append(out, p)
}
}
return out
}
added internal/cockpit/parity_test.go
@@ -0,0 +1,158 @@
package cockpit
import (
"os"
"path/filepath"
"strings"
"testing"
)
// flashOSAnswerKey is the operator's hand-built FlashOS handover skill —
// the living structural answer key for C1 dogfood parity. It is read-only
// and may be absent (CI, or a fresh clone), so the parity test skips rather
// than fails when it is missing.
const flashOSAnswerKey = "/Users/antonhahn/FlashOS/ajhahnde/.claude/skills/handover/SKILL.md"
func TestScratchRegenerate_WritesToScratchOnly(t *testing.T) {
pb := loadHandover(t)
scratch := t.TempDir()
path, err := ScratchRegenerate(pb, "claude", scratch)
if err != nil {
t.Fatalf("ScratchRegenerate: %v", err)
}
if filepath.Dir(filepath.Dir(filepath.Dir(filepath.Dir(path)))) != scratch {
t.Errorf("scratch path %q not under scratch root %q", path, scratch)
}
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read scratch artifact: %v", err)
}
shape := parseSkillShape(b)
if shape.stepCount < 5 || !shape.hasOutput {
t.Errorf("scratch artifact shape off: steps=%d output=%v", shape.stepCount, shape.hasOutput)
}
}
func TestParity_FlashOSAnswerKey(t *testing.T) {
if _, err := os.Stat(flashOSAnswerKey); err != nil {
t.Skipf("FlashOS answer key absent (%v) — parity check skipped", err)
}
pb := loadHandover(t)
res, err := Parity(pb, "claude", flashOSAnswerKey)
if err != nil {
t.Fatalf("Parity: %v", err)
}
if !res.LayerOK {
t.Errorf("layer parity failed: %v", res.Notes)
}
if !res.CapOK {
t.Errorf("capability parity failed: %v", res.Notes)
}
if !res.SafetyOK {
t.Errorf("safety parity failed: %v", res.Notes)
}
}
func TestParity_UnknownTarget(t *testing.T) {
pb := loadHandover(t)
if _, err := Parity(pb, "nosuchharness", flashOSAnswerKey); err == nil {
t.Error("expected an error for an unknown target")
}
if _, err := ScratchRegenerate(pb, "nosuchharness", t.TempDir()); err == nil {
t.Error("expected ScratchRegenerate to reject an unknown target")
}
}
func TestParity_SafetyTierWarnsOnOverGrantingKey(t *testing.T) {
// A hand-built answer key that over-grants a write-git verb is NOT eeco's
// artifact, so it no longer hard-fails the safety tier — Tier 3 scopes to
// the emitted allowlist. eeco's emit is clean, so SafetyOK holds; the
// key's over-grant is surfaced as a warning Note instead.
dir := t.TempDir()
key := filepath.Join(dir, ".claude", "skills", "handover", "SKILL.md")
if err := os.MkdirAll(filepath.Dir(key), 0o755); err != nil {
t.Fatal(err)
}
poisoned := "---\nname: handover\ndescription: x\nallowed-tools: Read, Bash(git commit:*)\n---\n# Handover\n"
if err := os.WriteFile(key, []byte(poisoned), 0o644); err != nil {
t.Fatal(err)
}
pb := loadHandover(t)
res, err := Parity(pb, "claude", key)
if err != nil {
t.Fatalf("Parity: %v", err)
}
if !res.SafetyOK {
t.Errorf("over-granting answer key hard-failed the safety tier; should warn (Tier 3 is emitted-only): %v", res.Notes)
}
if !hasNote(res.Notes, "answer key over-grants") {
t.Errorf("expected an over-grant warning Note, got %v", res.Notes)
}
}
func TestParity_SafetyTierFailsOnEmittedForbiddenVerb(t *testing.T) {
// The invariant that matters: a forbidden write-git verb in the EMITTED
// allowlist hard-fails the safety tier. ScratchRegenerate renders without
// the generation gate, so a poisoned playbook reaches the parity scan.
dir := t.TempDir()
key := filepath.Join(dir, ".claude", "skills", "handover", "SKILL.md")
if err := os.MkdirAll(filepath.Dir(key), 0o755); err != nil {
t.Fatal(err)
}
clean := "---\nname: handover\ndescription: x\nallowed-tools: Read\n---\n# Handover\n"
if err := os.WriteFile(key, []byte(clean), 0o644); err != nil {
t.Fatal(err)
}
pb := loadHandover(t)
pb.Capabilities = append(pb.Capabilities, Capability{Kind: "bash", Verb: "git commit", Scope: "*"})
res, err := Parity(pb, "claude", key)
if err != nil {
t.Fatalf("Parity: %v", err)
}
if res.SafetyOK {
t.Error("safety tier passed an emitted allowlist that grants git commit")
}
}
func TestParity_GitHeadCoverageAndKeyOverGrantWarns(t *testing.T) {
// Tier 2: emitted "git branch --show-current" covers an answer key that
// grants the broader "git branch" head — a scope refinement is not a
// capability gap. Tier 3: the bare "git branch" in the key is a forbidden
// over-grant → a warning Note, but SafetyOK stays true because the emitted
// allowlist is clean.
dir := t.TempDir()
key := filepath.Join(dir, ".claude", "skills", "handover", "SKILL.md")
if err := os.MkdirAll(filepath.Dir(key), 0o755); err != nil {
t.Fatal(err)
}
keyBody := "---\nname: handover\ndescription: x\n" +
"allowed-tools: Read, Write, Bash(git status:*), Bash(git log:*), Bash(git diff:*), Bash(git describe:*), Bash(git branch:*)\n" +
"---\n# Handover\n"
if err := os.WriteFile(key, []byte(keyBody), 0o644); err != nil {
t.Fatal(err)
}
pb := loadHandover(t)
res, err := Parity(pb, "claude", key)
if err != nil {
t.Fatalf("Parity: %v", err)
}
if !res.CapOK {
t.Errorf("capability tier failed; head coverage should treat emitted \"git branch --show-current\" as covering key \"git branch\": %v", res.Notes)
}
if !res.SafetyOK {
t.Errorf("safety tier should hold (emitted clean): %v", res.Notes)
}
if !hasNote(res.Notes, "answer key over-grants") {
t.Errorf("expected an over-grant warning for the key's bare git branch, got %v", res.Notes)
}
}
// hasNote reports whether any note contains substr.
func hasNote(notes []string, substr string) bool {
for _, n := range notes {
if strings.Contains(n, substr) {
return true
}
}
return false
}
added internal/cockpit/playbook_sources_test.go
@@ -0,0 +1,73 @@
package cockpit
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// loadAllSources reads every shipped playbook JSON source directly (no
// internal/playbooks import → no cycle), so the cockpit package can assert the
// safety invariant against the real library.
func loadAllSources(t *testing.T) []Playbook {
t.Helper()
dir := filepath.Join("..", "playbooks", "data")
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("read playbook data dir: %v", err)
}
var out []Playbook
for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".json" {
continue
}
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
t.Fatalf("read %s: %v", e.Name(), err)
}
var pb Playbook
if err := json.Unmarshal(b, &pb); err != nil {
t.Fatalf("parse %s: %v", e.Name(), err)
}
out = append(out, pb)
}
if len(out) == 0 {
t.Fatal("no playbook sources found")
}
return out
}
// TestAllSources_NoWriteGitVerb is the S1 trap-door test: every shipped
// playbook's composed allowlist must hold the safety invariant (zero
// write-git verbs), so a `git tag` slipping into a capability fails at test
// time, not at emit time.
func TestAllSources_NoWriteGitVerb(t *testing.T) {
for _, pb := range loadAllSources(t) {
if hits := ScanAllowlistForWriteGitVerbs(composeAllowedTools(pb), pb.Intent.forbiddenVerbs()); len(hits) > 0 {
t.Errorf("playbook %q grants forbidden write-git verb(s): %v", pb.Name, hits)
}
}
}
// TestAllSources_RenderOnEveryTarget: every shipped playbook renders without
// error on the per-playbook targets, and the whole set renders on the
// aggregate targets.
func TestAllSources_RenderOnEveryTarget(t *testing.T) {
set := loadAllSources(t)
for _, target := range []string{"claude", "cursor"} {
r, _ := rendererFor(target)
for _, pb := range set {
if _, err := r.Render(pb); err != nil {
t.Errorf("%s render of %q: %v", target, pb.Name, err)
}
}
}
for _, target := range []string{"agents", "gemini"} {
r, _ := rendererFor(target)
agg, _ := isAggregate(r)
if _, err := agg.RenderAll(set); err != nil {
t.Errorf("%s RenderAll: %v", target, err)
}
}
}
added internal/cockpit/relunder_test.go
@@ -0,0 +1,36 @@
package cockpit
import (
"path/filepath"
"testing"
)
func TestRelUnder_RejectsEscapes(t *testing.T) {
bad := []string{
"",
"/etc/passwd",
"../escape",
"../../etc/passwd",
filepath.Join("a", "..", "..", "b"),
}
for _, p := range bad {
if _, err := relUnder(p); err == nil {
t.Errorf("relUnder(%q) accepted an escaping/absolute path", p)
}
}
}
func TestRelUnder_AcceptsCleanRelative(t *testing.T) {
good := []string{
"AGENTS.md",
"GEMINI.md",
".claude/skills/handover/SKILL.md",
".cursor/rules/handover.mdc",
"a/b/c.md",
}
for _, p := range good {
if _, err := relUnder(p); err != nil {
t.Errorf("relUnder(%q) rejected a clean relative path: %v", p, err)
}
}
}
added internal/cockpit/render.go
@@ -0,0 +1,115 @@
package cockpit
import (
"fmt"
"sort"
"strings"
)
// advisoryBanner is the loud header every advisory artifact (Cursor,
// AGENTS.md, GEMINI.md) carries so the operator can never mistake it for
// harness-enforced config. It is a single package constant shared by the
// renderers and the self-consistency parser, so the honesty notice can never
// silently drift; fidelityBannerPresent asserts it in tests.
const advisoryBanner = "**ADVISORY ONLY — NOT HARNESS-ENFORCED.** This file documents the tool policy " +
"the AI should honor; the harness does not enforce it at runtime. eeco still refuses to emit any " +
"playbook that declares a forbidden write-git verb — the advisory note describes only the harness side."
// Heading texts the self-consistency parser keys on. The renderers and the
// parser reference the same constants so a section can never be renamed in one
// place and silently missed in the other.
const (
headingForbidden = "Forbidden" // section listing the safety denylist
headingAllowed = "Allowed (advisory)" // section listing the composed allowlist
headingStep = "Step " // prefix of a per-step heading's text
headingOutput = "Output" // closing output-format section
)
// renderPlaybookBody appends the shared advisory body for one playbook under
// the given heading depth marker (e.g. "##" for a per-file Cursor doc, "###"
// for a section inside an aggregate AGENTS.md / GEMINI.md). The structure —
// derived safety warning, a Forbidden block enumerating both the git-verb
// denylist (as `git <verb>`) and the human Intent.Forbidden phrases, an
// Allowed (advisory) list from composeAllowedTools, the numbered Steps, and
// the Output section — is identical across advisory targets so one
// self-consistency parser serves them all.
func renderPlaybookBody(b *strings.Builder, p Playbook, depth string) {
b.WriteString(deriveSafetyWarning(p.Intent))
b.WriteString("\n\n")
fmt.Fprintf(b, "%s %s\n", depth, headingForbidden)
for _, v := range p.Intent.forbiddenVerbs() {
fmt.Fprintf(b, "- `git %s`\n", v)
}
for _, ph := range p.Intent.Forbidden {
fmt.Fprintf(b, "- %s\n", ph)
}
b.WriteString("\n")
fmt.Fprintf(b, "%s %s\n", depth, headingAllowed)
for _, a := range composeAllowedTools(p) {
fmt.Fprintf(b, "- %s\n", a)
}
b.WriteString("\n")
for _, s := range p.Steps {
fmt.Fprintf(b, "%s %s%d — %s\n", depth, headingStep, s.Index, s.Title)
if body := strings.TrimRight(s.Body, "\n"); body != "" {
b.WriteString(body)
b.WriteString("\n")
}
if len(s.Runs) > 0 {
b.WriteString("\n```\n")
for _, run := range s.Runs {
b.WriteString(run)
b.WriteString("\n")
}
b.WriteString("```\n")
}
b.WriteString("\n")
}
if out := strings.TrimRight(p.OutputFormat, "\n"); out != "" {
fmt.Fprintf(b, "%s %s\n", depth, headingOutput)
b.WriteString(out)
b.WriteString("\n")
}
}
// renderAggregateMarkdown renders a whole playbook set as one advisory
// Markdown document: an H1 title, the ADVISORY banner, an optional
// target-specific header note, a fidelity report naming the set, then one
// section per playbook. The set is sorted by Name so the output is
// byte-deterministic regardless of input order (re-emit stays sha-idempotent).
// Shared by the AGENTS.md and GEMINI.md renderers.
func renderAggregateMarkdown(ps []Playbook, title, header string) []byte {
set := append([]Playbook(nil), ps...)
sort.Slice(set, func(i, j int) bool { return set[i].Name < set[j].Name })
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", title)
b.WriteString(advisoryBanner)
b.WriteString("\n\n")
if h := strings.TrimSpace(header); h != "" {
b.WriteString(h)
b.WriteString("\n\n")
}
b.WriteString("## Fidelity report\n\n")
b.WriteString("Enforcement: advisory (not harness-enforced). Playbooks in this file:\n\n")
for _, p := range set {
fmt.Fprintf(&b, "- %s\n", p.Name)
}
b.WriteString("\n")
for _, p := range set {
fmt.Fprintf(&b, "## %s\n\n", deriveTitle(p.Name))
if d := strings.TrimSpace(p.Description); d != "" {
b.WriteString(d)
b.WriteString("\n\n")
}
renderPlaybookBody(&b, p, "###")
b.WriteString("\n")
}
return []byte(b.String())
}
added internal/cockpit/reversible_boundary_test.go
@@ -0,0 +1,162 @@
package cockpit
import (
"os"
"path/filepath"
"testing"
)
func TestGenerateOff_Reversible(t *testing.T) {
cfg := testConfig(t)
pb := loadHandover(t)
dst := filepath.Join(cfg.UserDir, ".claude", "skills", "handover", "SKILL.md")
leaf := filepath.Dir(dst)
res, err := Generate(cfg, pb, "claude")
if err != nil {
t.Fatalf("Generate: %v", err)
}
if res.Action != "generated" {
t.Errorf("first generate action = %q, want generated", res.Action)
}
if _, err := os.Stat(dst); err != nil {
t.Fatalf("SKILL.md not written: %v", err)
}
if _, err := os.Stat(ledgerPath(cfg)); err != nil {
t.Fatalf("ledger not written: %v", err)
}
off, err := Off(cfg, pb, "claude")
if err != nil {
t.Fatalf("Off: %v", err)
}
if !off.Changed {
t.Error("Off reported no change for an installed artifact")
}
if _, err := os.Stat(dst); !os.IsNotExist(err) {
t.Error("SKILL.md still present after off")
}
if _, err := os.Stat(leaf); !os.IsNotExist(err) {
t.Error("leaf skill dir not pruned after off")
}
// Record cleared.
l, _ := loadLedger(cfg)
if l.find("claude", "handover") >= 0 {
t.Error("ledger record not cleared after off")
}
}
func TestVerify_DriftAndOffLeavesEdited(t *testing.T) {
cfg := testConfig(t)
pb := loadHandover(t)
dst := filepath.Join(cfg.UserDir, ".claude", "skills", "handover", "SKILL.md")
if _, err := Generate(cfg, pb, "claude"); err != nil {
t.Fatalf("Generate: %v", err)
}
// Clean verify.
vr, err := Verify(cfg, pb, "claude", "")
if err != nil {
t.Fatalf("Verify: %v", err)
}
if !vr.Clean {
t.Errorf("verify not clean on a fresh emit: %s", vr.Detail)
}
// Hand-edit → drift.
if err := os.WriteFile(dst, []byte("hand edited\n"), 0o644); err != nil {
t.Fatal(err)
}
vr, err = Verify(cfg, pb, "claude", "")
if err != nil {
t.Fatalf("Verify after edit: %v", err)
}
if vr.Clean {
t.Error("verify reported clean on a hand-edited artifact")
}
// Off leaves the edited file untouched.
off, err := Off(cfg, pb, "claude")
if err != nil {
t.Fatalf("Off after edit: %v", err)
}
if off.Changed {
t.Error("Off removed a hand-edited artifact")
}
b, err := os.ReadFile(dst)
if err != nil || string(b) != "hand edited\n" {
t.Errorf("hand-edited file not preserved by off: %q (%v)", string(b), err)
}
}
func TestGenerate_Idempotent(t *testing.T) {
cfg := testConfig(t)
pb := loadHandover(t)
dst := filepath.Join(cfg.UserDir, ".claude", "skills", "handover", "SKILL.md")
if _, err := Generate(cfg, pb, "claude"); err != nil {
t.Fatalf("first Generate: %v", err)
}
file1, _ := os.ReadFile(dst)
ledger1, _ := os.ReadFile(ledgerPath(cfg))
res, err := Generate(cfg, pb, "claude")
if err != nil {
t.Fatalf("second Generate: %v", err)
}
if res.Action != "already current" {
t.Errorf("second generate action = %q, want already current", res.Action)
}
file2, _ := os.ReadFile(dst)
ledger2, _ := os.ReadFile(ledgerPath(cfg))
if string(file1) != string(file2) {
t.Error("SKILL.md changed on a no-op re-generate")
}
if string(ledger1) != string(ledger2) {
t.Error("ledger changed on a no-op re-generate")
}
// No backup churn: state/backups should be empty/absent.
backups, _ := os.ReadDir(filepath.Join(cfg.Workspace, "state", "backups"))
if len(backups) != 0 {
t.Errorf("re-generate created %d backup(s), want 0", len(backups))
}
}
func TestLedger_RoundTrip(t *testing.T) {
cfg := testConfig(t)
l := ledger{Records: []record{{
Installed: true, Target: "claude", Playbook: "handover",
Path: "/x/SKILL.md", SHA256: "abc", Created: true, At: "2026-06-05T00:00:00Z",
}}}
if err := saveLedger(cfg, l); err != nil {
t.Fatal(err)
}
b1, _ := os.ReadFile(ledgerPath(cfg))
got, err := loadLedger(cfg)
if err != nil {
t.Fatal(err)
}
if err := saveLedger(cfg, got); err != nil {
t.Fatal(err)
}
b2, _ := os.ReadFile(ledgerPath(cfg))
if string(b1) != string(b2) {
t.Error("ledger save→load→save is not byte-identical")
}
}
func TestStatus_Transitions(t *testing.T) {
cfg := testConfig(t)
pb := loadHandover(t)
if got := Status(cfg)[0]; got != "claude/handover: not emitted" {
t.Errorf("status before generate = %q", got)
}
if _, err := Generate(cfg, pb, "claude"); err != nil {
t.Fatal(err)
}
if got := Status(cfg)[0]; got != "claude/handover: on" {
t.Errorf("status after generate = %q", got)
}
}
added internal/cockpit/selection.go
@@ -0,0 +1,111 @@
package cockpit
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/ajhahnde/eeco/internal/config"
)
// Selection is the operator-managed set of active cockpit targets, persisted
// at <username>/.eeco/cockpit.json — the C0-envisioned selector, separate from
// the emission ledger at <workspace>/state/cockpit.json. Targets is the active
// harness set `eeco cockpit generate` renders by default. Playbooks is
// reserved for future narrowing: absent (or empty) means every registered
// playbook (C2 keeps it implicit-all; the field is present so a later slice
// can narrow without a schema change).
type Selection struct {
Targets []string `json:"targets"`
Playbooks []string `json:"playbooks,omitempty"`
}
// selectionName is the selection store's filename inside the workspace dir
// (<username>/.eeco/). It is intentionally the same base name as the emission
// ledger but at a different path (the ledger is under state/), so the two
// never collide.
const selectionName = "cockpit.json"
func selectionPath(cfg *config.Config) string {
return filepath.Join(cfg.Workspace, selectionName)
}
// SelectionPath returns the absolute path of the selection store
// (<username>/.eeco/cockpit.json). Exported so the contract-watch hook can
// recognize an edit to it without duplicating the path construction.
func SelectionPath(cfg *config.Config) string {
return selectionPath(cfg)
}
// HasSelection reports whether a selection store already exists, so init can
// record the operator's harness choice once without clobbering it on re-run.
func HasSelection(cfg *config.Config) bool {
_, err := os.Stat(selectionPath(cfg))
return err == nil
}
// DefaultSelection is the fail-safe active set: Claude alone, the one enforced
// target. Used when no selection is configured or the stored one is unusable.
func DefaultSelection() Selection {
return Selection{Targets: []string{"claude"}}
}
// LoadSelection reads the active target set. A missing, empty, or corrupt
// file — or one whose targets are all unknown — degrades to DefaultSelection
// rather than wedging the tool (mirrors loadLedger). Unknown target names are
// dropped so a stale entry from a newer binary can't break an older one.
func LoadSelection(cfg *config.Config) Selection {
b, err := os.ReadFile(selectionPath(cfg))
if err != nil || len(b) == 0 {
return DefaultSelection()
}
var s Selection
if err := json.Unmarshal(b, &s); err != nil {
return DefaultSelection()
}
s.Targets = sanitizeTargets(s.Targets)
if len(s.Targets) == 0 {
return DefaultSelection()
}
return s
}
// SaveSelection writes the active target set under the workspace dir, creating
// it if absent. Targets are sanitized (deduped, known-only, order-preserving);
// an empty result falls back to the default so the store is never wedged.
func SaveSelection(cfg *config.Config, s Selection) error {
s.Targets = sanitizeTargets(s.Targets)
if len(s.Targets) == 0 {
s.Targets = DefaultSelection().Targets
}
dir := filepath.Dir(selectionPath(cfg))
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("selection dir: %w", err)
}
out, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
return os.WriteFile(selectionPath(cfg), append(out, '\n'), 0o644)
}
// sanitizeTargets returns the known targets in in, deduplicated and in their
// first-seen order. Unknown or blank names are dropped.
func sanitizeTargets(in []string) []string {
seen := make(map[string]bool, len(in))
var out []string
for _, t := range in {
t = strings.TrimSpace(t)
if t == "" || seen[t] {
continue
}
if _, ok := rendererFor(t); !ok {
continue
}
seen[t] = true
out = append(out, t)
}
return out
}
added internal/cockpit/selection_test.go
@@ -0,0 +1,68 @@
package cockpit
import (
"os"
"reflect"
"testing"
)
func TestSelection_DefaultWhenMissing(t *testing.T) {
cfg := testConfig(t)
if HasSelection(cfg) {
t.Error("fresh workspace should have no selection")
}
sel := LoadSelection(cfg)
if !reflect.DeepEqual(sel.Targets, []string{"claude"}) {
t.Errorf("default targets = %v, want [claude]", sel.Targets)
}
}
func TestSelection_SaveLoadRoundTrip(t *testing.T) {
cfg := testConfig(t)
if err := SaveSelection(cfg, Selection{Targets: []string{"claude", "agents"}}); err != nil {
t.Fatal(err)
}
if !HasSelection(cfg) {
t.Error("HasSelection false after save")
}
got := LoadSelection(cfg)
if !reflect.DeepEqual(got.Targets, []string{"claude", "agents"}) {
t.Errorf("round-trip targets = %v", got.Targets)
}
}
func TestSelection_SaveSanitizes(t *testing.T) {
cfg := testConfig(t)
// Duplicates, unknowns, and blanks are dropped; order preserved.
if err := SaveSelection(cfg, Selection{Targets: []string{"cursor", "cursor", "bogus", "", "claude"}}); err != nil {
t.Fatal(err)
}
got := LoadSelection(cfg)
if !reflect.DeepEqual(got.Targets, []string{"cursor", "claude"}) {
t.Errorf("sanitized targets = %v, want [cursor claude]", got.Targets)
}
}
func TestSelection_EmptyFallsBackToDefault(t *testing.T) {
cfg := testConfig(t)
if err := SaveSelection(cfg, Selection{Targets: []string{"bogus"}}); err != nil {
t.Fatal(err)
}
// All-unknown ⇒ default written.
if got := LoadSelection(cfg); !reflect.DeepEqual(got.Targets, []string{"claude"}) {
t.Errorf("all-unknown selection should fall back to default, got %v", got.Targets)
}
}
func TestSelection_CorruptDegrades(t *testing.T) {
cfg := testConfig(t)
if err := os.MkdirAll(cfg.Workspace, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(selectionPath(cfg), []byte("{not json"), 0o644); err != nil {
t.Fatal(err)
}
if got := LoadSelection(cfg); !reflect.DeepEqual(got.Targets, []string{"claude"}) {
t.Errorf("corrupt selection should degrade to default, got %v", got.Targets)
}
}
added internal/cockpit/selfcheck.go
@@ -0,0 +1,159 @@
package cockpit
import (
"fmt"
"strings"
)
// SelfConsistencyResult is the outcome of an advisory artifact's
// render -> parse-back -> assert check. OK is true only when the rendered
// bytes still surface every playbook's forbidden content, carry the advisory
// banner, keep the step/output structure, and leak no write-git verb into an
// Allowed block. Notes name each failed assertion.
type SelfConsistencyResult struct {
OK bool
Notes []string
}
// CheckSelfConsistency renders pb for a per-playbook advisory target (cursor)
// and asserts the rendered bytes preserve the safety contract. Parity stays
// Claude-only (the new targets have no FlashOS answer key); this is the
// answer-key-free substitute — a renderer that silently dropped the Forbidden
// block or leaked a write verb fails here.
func CheckSelfConsistency(pb Playbook, target string) (SelfConsistencyResult, error) {
r, ok := rendererFor(target)
if !ok {
return SelfConsistencyResult{}, unknownTargetErr(target)
}
out, err := r.Render(pb)
if err != nil {
return SelfConsistencyResult{}, err
}
return checkSelfConsistencyBytes(out, []Playbook{pb}), nil
}
// CheckSelfConsistencyAll renders set for an aggregate advisory target
// (agents, gemini) and asserts the rendered document preserves every
// playbook's safety contract.
func CheckSelfConsistencyAll(set []Playbook, target string) (SelfConsistencyResult, error) {
r, ok := rendererFor(target)
if !ok {
return SelfConsistencyResult{}, unknownTargetErr(target)
}
agg, ok := isAggregate(r)
if !ok {
return SelfConsistencyResult{}, fmt.Errorf("target %q is not aggregate", target)
}
out, err := agg.RenderAll(set)
if err != nil {
return SelfConsistencyResult{}, err
}
return checkSelfConsistencyBytes(out, set), nil
}
// checkSelfConsistencyBytes is the shared assertion core: given rendered bytes
// and the playbook set they should describe, it verifies the banner, every
// forbidden verb/phrase, the step/output structure, and zero leaked write-git
// verbs in any Allowed block. It runs on the literal bytes — at verify time
// the caller passes the on-disk bytes (sub-decision S4) so a hand-edit that
// strips the Forbidden block fails.
func checkSelfConsistencyBytes(out []byte, set []Playbook) SelfConsistencyResult {
text := string(out)
var notes []string
if !strings.Contains(text, advisoryBanner) {
notes = append(notes, "ADVISORY banner missing")
}
for _, pb := range set {
for _, v := range pb.Intent.forbiddenVerbs() {
if !strings.Contains(text, "git "+v) {
notes = append(notes, fmt.Sprintf("%s: forbidden verb %q not surfaced", pb.Name, v))
}
}
for _, ph := range pb.Intent.Forbidden {
if !strings.Contains(text, ph) {
notes = append(notes, fmt.Sprintf("%s: forbidden phrase %q not surfaced", pb.Name, ph))
}
}
}
wantSteps, wantOutputs := 0, 0
for _, pb := range set {
wantSteps += len(pb.Steps)
if strings.TrimSpace(pb.OutputFormat) != "" {
wantOutputs++
}
}
gotSteps, gotOutputs := countHeadings(text)
if gotSteps < wantSteps {
notes = append(notes, fmt.Sprintf("step headings %d < expected %d", gotSteps, wantSteps))
}
if gotOutputs < wantOutputs {
notes = append(notes, fmt.Sprintf("output sections %d < expected %d", gotOutputs, wantOutputs))
}
// Defense-in-depth: re-scan every Allowed block for a leaked write-git
// verb, against the union of the set's denylists.
if hits := ScanAllowlistForWriteGitVerbs(parseAdvisoryAllowed(text), unionForbidden(set)); len(hits) > 0 {
notes = append(notes, "write-git verb leaked into an Allowed block: "+strings.Join(hits, ", "))
}
return SelfConsistencyResult{OK: len(notes) == 0, Notes: notes}
}
// countHeadings counts the Markdown headings (at any depth) whose text starts
// with the step marker, and those equal to the output heading.
func countHeadings(text string) (steps, outputs int) {
for _, raw := range strings.Split(text, "\n") {
line := strings.TrimSpace(strings.TrimRight(raw, "\r"))
if !strings.HasPrefix(line, "#") {
continue
}
h := strings.TrimSpace(strings.TrimLeft(line, "#"))
switch {
case strings.HasPrefix(h, headingStep):
steps++
case h == headingOutput:
outputs++
}
}
return steps, outputs
}
// parseAdvisoryAllowed collects the bullet entries under every "Allowed"
// heading in an advisory artifact (cursor .mdc / aggregate Markdown) so the
// composed allowlist can be re-scanned for leaked write-git verbs. Bullets
// under any other heading (Forbidden, Fidelity report) are ignored.
func parseAdvisoryAllowed(text string) []string {
var out []string
inAllowed := false
for _, raw := range strings.Split(text, "\n") {
trimmed := strings.TrimSpace(strings.TrimRight(raw, "\r"))
if strings.HasPrefix(trimmed, "#") {
h := strings.TrimSpace(strings.TrimLeft(trimmed, "#"))
inAllowed = strings.HasPrefix(h, "Allowed")
continue
}
if inAllowed && strings.HasPrefix(trimmed, "- ") {
out = append(out, strings.TrimSpace(strings.TrimPrefix(trimmed, "- ")))
}
}
return out
}
// unionForbidden returns the de-duplicated union of the set's git-verb
// denylists, for the leaked-verb scan.
func unionForbidden(set []Playbook) []string {
seen := make(map[string]bool)
var out []string
for _, pb := range set {
for _, v := range pb.Intent.forbiddenVerbs() {
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
}
return out
}
added internal/cockpit/selfcheck_test.go
@@ -0,0 +1,70 @@
package cockpit
import (
"strings"
"testing"
)
func TestSelfConsistency_CleanCursor(t *testing.T) {
pb := loadHandover(t)
res, err := CheckSelfConsistency(pb, "cursor")
if err != nil {
t.Fatal(err)
}
if !res.OK {
t.Errorf("clean cursor render should pass self-consistency: %v", res.Notes)
}
}
func TestSelfConsistency_CleanAggregate(t *testing.T) {
res, err := CheckSelfConsistencyAll(twoPlaybooks(t), "agents")
if err != nil {
t.Fatal(err)
}
if !res.OK {
t.Errorf("clean aggregate render should pass: %v", res.Notes)
}
}
// TestSelfConsistency_FailsOnStrippedForbidden: removing the Forbidden block
// lines (the `git <verb>` bullets) makes a denylisted verb disappear, which
// self-consistency must catch.
func TestSelfConsistency_FailsOnStrippedForbidden(t *testing.T) {
pb := loadHandover(t)
out, err := cursorRenderer{}.Render(pb)
if err != nil {
t.Fatal(err)
}
var kept []string
for _, line := range strings.Split(string(out), "\n") {
if strings.Contains(line, "`git ") { // drop the Forbidden-block verb bullets
continue
}
kept = append(kept, line)
}
tampered := []byte(strings.Join(kept, "\n"))
res := checkSelfConsistencyBytes(tampered, []Playbook{pb})
if res.OK {
t.Error("self-consistency passed bytes with the Forbidden block stripped")
}
}
// TestSelfConsistency_FailsOnLeakedWriteVerb: injecting a write-git verb into
// an Allowed block must fail the defense-in-depth scan.
func TestSelfConsistency_FailsOnLeakedWriteVerb(t *testing.T) {
pb := loadHandover(t)
out, err := cursorRenderer{}.Render(pb)
if err != nil {
t.Fatal(err)
}
marker := "## " + headingAllowed + "\n"
idx := strings.Index(string(out), marker)
if idx < 0 {
t.Fatal("Allowed heading not found")
}
injected := string(out[:idx+len(marker)]) + "- Bash(git commit:*)\n" + string(out[idx+len(marker):])
res := checkSelfConsistencyBytes([]byte(injected), []Playbook{pb})
if res.OK {
t.Error("self-consistency passed a leaked write-git verb in the Allowed block")
}
}
added internal/cockpit/spec.go
@@ -0,0 +1,81 @@
// Package cockpit is eeco's neutral, harness-independent model of an AI
// cockpit and the renderers that translate it into harness-specific
// config. A Playbook is a single unit of AI procedure — what it does, the
// structured safety contract it must never violate, the capabilities it is
// allowed, the ordered steps, and the output it produces. The model is
// brand-free; a Renderer (one per harness) is the only thing that knows a
// target's file layout and permission spelling.
//
// The single product-defining guarantee lives here, not in the renderer:
// the safety invariant (ScanAllowlistForWriteGitVerbs, gate.go) is derived
// from the structured Intent, so an emitted artifact can never grant a
// write-capable git verb a Playbook declares forbidden. Generation refuses
// rather than silently drop a forbidden verb.
//
// Dependency direction is fixed to avoid an import cycle: cockpit owns the
// model + renderer + gate + ledger + orchestration; internal/playbooks
// imports cockpit for the Playbook type; cmd/eeco wires the two
// (playbooks.Get -> cockpit.Generate). cockpit never imports playbooks.
package cockpit
// Playbook is the neutral, harness-independent description of one AI
// procedure. The same Playbook renders to any harness target; only a
// Renderer knows a target's file layout and permission spelling.
type Playbook struct {
Name string `json:"name"` // skill dir + frontmatter name
Description string `json:"description"` // single-line: when + what + safety promise
Intent Intent `json:"intent"` // structured safety contract
Capabilities []Capability `json:"capabilities"` // ordered allowlist source
Steps []Step `json:"steps"` // ordered procedure (Step 0..N)
OutputFormat string `json:"output_format"` // closing body section
Params []Param `json:"params,omitempty"` // optional parameterization
MapsToWorkflow string `json:"maps_to_workflow,omitempty"` // deterministic backing (metadata only in C1)
}
// Intent is the structured safety contract. Forbidden seeds the prose
// warning the renderer derives; ForbiddenGitVerbs is the gate's denylist
// (falling back to defaultForbiddenGitVerbs when empty). The renderer
// derives both the allowlist and the warning from this — neither is ever
// hand-written into the body data.
type Intent struct {
Guarantee string `json:"guarantee"` // positive promise (prose seed)
Forbidden []string `json:"forbidden"` // human phrases, e.g. "git commit", "touch any tracked file"
ForbiddenGitVerbs []string `json:"forbidden_git_verbs,omitempty"` // git write/mutate denylist (gate input)
}
// forbiddenVerbs returns the git write/mutate denylist the gate scans
// against: the Playbook's own list when set, otherwise the package default.
func (in Intent) forbiddenVerbs() []string {
if len(in.ForbiddenGitVerbs) > 0 {
return in.ForbiddenGitVerbs
}
return defaultForbiddenGitVerbs
}
// Capability is one entry of the allowlist source. Kind is "tool" (a named
// harness tool) or "bash" (a shell command head with an optional arg
// scope). The renderer walks Capabilities in declared order — the JSON is
// the single source of truth, so there is no silent reorder or dedupe.
type Capability struct {
Kind string `json:"kind"` // "tool" | "bash"
Name string `json:"name,omitempty"` // tool name when kind="tool"
Verb string `json:"verb,omitempty"` // command head when kind="bash" ("git status", "date")
Scope string `json:"scope,omitempty"` // arg glob when kind="bash"; default "*"
}
// Step is one ordered instruction. Runs lists optional read-only commands
// shown in a fenced block under the step body.
type Step struct {
Index int `json:"index"`
Title string `json:"title"`
Body string `json:"body"`
Runs []string `json:"runs,omitempty"`
}
// Param is one optional parameter the procedure accepts. Reserved for the
// parameterized-general playbooks (C2); unused by the C1 handover source.
type Param struct {
Name string `json:"name"`
Description string `json:"description"`
Default string `json:"default,omitempty"`
}
added internal/cockpit/status_test.go
@@ -0,0 +1,69 @@
package cockpit
import (
"strings"
"testing"
)
// TestStatus_FidelityLabels: Status flags advisory per-playbook targets with
// "(advisory)", aggregate records with "(aggregate, ADVISORY)", and leaves the
// enforced Claude line unsuffixed.
func TestStatus_FidelityLabels(t *testing.T) {
cfg := testConfig(t)
pb := loadHandover(t)
if _, err := Generate(cfg, pb, "claude"); err != nil {
t.Fatal(err)
}
if _, err := Generate(cfg, pb, "cursor"); err != nil {
t.Fatal(err)
}
if _, err := GenerateAll(cfg, []Playbook{pb}, "agents"); err != nil {
t.Fatal(err)
}
joined := strings.Join(Status(cfg), "\n")
for _, want := range []string{
"claude/handover: on",
"cursor/handover: on (advisory)",
"agents: on (aggregate, ADVISORY)",
} {
if !strings.Contains(joined, want) {
t.Errorf("status missing %q:\n%s", want, joined)
}
}
// The enforced line must NOT carry an advisory suffix.
for _, line := range Status(cfg) {
if strings.HasPrefix(line, "claude/handover:") && strings.Contains(line, "advisory") {
t.Errorf("enforced claude line wrongly marked advisory: %q", line)
}
}
}
// TestCursorGenerateOff_Reversible: a per-playbook advisory target emits and
// reverses cleanly through the unchanged emit machinery.
func TestCursorGenerateOff_Reversible(t *testing.T) {
cfg := testConfig(t)
pb := loadHandover(t)
res, err := Generate(cfg, pb, "cursor")
if err != nil {
t.Fatalf("Generate cursor: %v", err)
}
if res.Fidelity != EnforcementAdvisory || !strings.Contains(res.Message(), "advisory") {
t.Errorf("cursor result not flagged advisory: %q", res.Message())
}
vr, err := Verify(cfg, pb, "cursor", "")
if err != nil {
t.Fatal(err)
}
if !vr.Clean {
t.Errorf("fresh cursor emit should verify clean: %q", vr.Detail)
}
off, err := Off(cfg, pb, "cursor")
if err != nil {
t.Fatalf("Off cursor: %v", err)
}
if !off.Changed {
t.Error("Off cursor reported no change")
}
}
added internal/cockpit/sync.go
@@ -0,0 +1,166 @@
package cockpit
import (
"fmt"
"sort"
"strings"
"github.com/ajhahnde/eeco/internal/config"
)
// SyncReport is the result of a read-only drift scan over the whole emitted
// cockpit. Clean is true exactly when Findings is empty.
type SyncReport struct {
Findings []SyncFinding
Clean bool
}
// SyncFinding is one stale-artifact finding. Kind is "drifted" (on-disk
// bytes no longer match a fresh render — a hand-edit or an eeco upgrade that
// changed the neutral source), "missing" (an active target/playbook was
// never emitted), "orphan" (a deselected target's artifact remains), or
// "safety" (the on-disk allowlist now grants a forbidden write-git verb).
// Playbook is empty for aggregate and orphan findings, which key on the
// target alone. Detail is the human line, reused verbatim from the
// underlying VerifyResult where one exists.
type SyncFinding struct {
Target string
Playbook string
Kind string
Detail string
}
// IsGenerated reports whether the cockpit has been generated in this workspace
// — the ledger carries at least one installed artifact record. It is the cheap
// "is the cockpit in use here" gate (the same empty-ledger signal Sync uses),
// exported for callers outside the package such as the session-start doc-drift
// nudge's time backstop, which must stay silent where the cockpit was never
// generated.
func IsGenerated(cfg *config.Config) bool {
l, err := loadLedger(cfg)
if err != nil {
return false
}
return l.hasInstalled()
}
// Sync is the one read-only drift engine behind both `eeco cockpit verify`
// (no scoping flags) and the cockpit-sync builtin. It never writes anything.
//
// The load-bearing gate is the ledger: with zero installed records the
// cockpit was never generated here (init writes a default selection but
// never the ledger), so Sync returns a silent clean — that is what keeps the
// post-merge builtin a no-op on a repo that does not use the cockpit. With
// at least one installed record it verifies every active target against a
// fresh render and scans the ledger for orphaned (deselected) targets,
// deduped by target so a per-playbook target's K records collapse to one
// finding.
func Sync(cfg *config.Config, all []Playbook) (SyncReport, error) {
l, err := loadLedger(cfg)
if err != nil {
return SyncReport{}, err
}
if !l.hasInstalled() {
return SyncReport{Clean: true}, nil
}
sel := LoadSelection(cfg)
resolved := resolvePlaybookSet(all, sel.Playbooks)
var findings []SyncFinding
for _, tg := range sel.Targets {
if IsAggregateTarget(tg) {
// Aggregate targets emit one shared file for the whole set, so
// verify the same resolved set generate emitted (a narrowed
// selection must verify its narrowed bytes, not all).
res, verr := VerifyAll(cfg, resolved, tg)
if verr != nil {
return SyncReport{}, verr
}
if !res.Clean {
findings = append(findings, SyncFinding{
Target: tg, Kind: classifySync(res.Detail), Detail: res.Detail,
})
}
continue
}
for _, pb := range resolved {
res, verr := Verify(cfg, pb, tg, "")
if verr != nil {
return SyncReport{}, verr
}
if !res.Clean {
findings = append(findings, SyncFinding{
Target: tg, Playbook: pb.Name, Kind: classifySync(res.Detail), Detail: res.Detail,
})
}
}
}
findings = append(findings, orphanFindings(l, sel.Targets)...)
return SyncReport{Findings: findings, Clean: len(findings) == 0}, nil
}
// resolvePlaybookSet returns the playbook subset a selection emits: every
// playbook in all when the selection does not narrow them (the C3 default),
// otherwise the members of all whose names appear in narrow. Filtering all
// (already Name-ordered) keeps the result deterministic and an unknown name
// in narrow is simply skipped (LoadSelection never stores one).
func resolvePlaybookSet(all []Playbook, narrow []string) []Playbook {
if len(narrow) == 0 {
return all
}
want := make(map[string]bool, len(narrow))
for _, n := range narrow {
want[n] = true
}
out := make([]Playbook, 0, len(narrow))
for _, pb := range all {
if want[pb.Name] {
out = append(out, pb)
}
}
return out
}
// classifySync coarsely buckets a not-clean VerifyResult.Detail into a Sync
// Kind. The phrases are the stable ones emit.go/emit_aggregate.go produce.
func classifySync(detail string) string {
switch {
case strings.Contains(detail, "SAFETY VIOLATION"):
return "safety"
case strings.Contains(detail, "not emitted"):
return "missing"
default:
return "drifted"
}
}
// orphanFindings returns one finding per ledger target that is still
// installed but no longer in the active set, deduped by target and sorted
// for deterministic output.
func orphanFindings(l ledger, active []string) []SyncFinding {
activeSet := make(map[string]bool, len(active))
for _, t := range active {
activeSet[t] = true
}
seen := make(map[string]bool)
var orphans []string
for _, rec := range l.Records {
if !rec.Installed || activeSet[rec.Target] || seen[rec.Target] {
continue
}
seen[rec.Target] = true
orphans = append(orphans, rec.Target)
}
sort.Strings(orphans)
out := make([]SyncFinding, 0, len(orphans))
for _, t := range orphans {
out = append(out, SyncFinding{
Target: t,
Kind: "orphan",
Detail: fmt.Sprintf("%s: deselected but artifact remains — run `eeco cockpit off --target %s`", t, t),
})
}
return out
}
added internal/cockpit/sync_test.go
@@ -0,0 +1,191 @@
package cockpit
import (
"bytes"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// syncSet is a small, stable playbook set for Sync tests: the real handover
// source plus a synthetic one, so multi-playbook behavior is observable.
func syncSet(t *testing.T) []Playbook {
t.Helper()
return []Playbook{loadHandover(t), synthPlaybook("zeta")}
}
func findKind(rep SyncReport, kind string) *SyncFinding {
for i := range rep.Findings {
if rep.Findings[i].Kind == kind {
return &rep.Findings[i]
}
}
return nil
}
// TestSync_EmptyLedgerClean: a cockpit that was never generated (no ledger,
// only a default selection) returns a silent clean — the load-bearing
// empty-ledger gate that keeps the post-merge builtin a no-op.
func TestSync_EmptyLedgerClean(t *testing.T) {
cfg := testConfig(t)
rep, err := Sync(cfg, syncSet(t))
if err != nil {
t.Fatal(err)
}
if !rep.Clean || len(rep.Findings) != 0 {
t.Fatalf("empty-ledger Sync = %+v, want clean with no findings", rep)
}
}
// TestSync_DriftedAfterHandEdit: a hand-edit to an emitted artifact is
// reported as a drifted finding.
func TestSync_DriftedAfterHandEdit(t *testing.T) {
cfg := testConfig(t)
set := syncSet(t)
if err := SaveSelection(cfg, Selection{Targets: []string{"claude"}}); err != nil {
t.Fatal(err)
}
for _, pb := range set {
if _, err := Generate(cfg, pb, "claude"); err != nil {
t.Fatalf("generate %s: %v", pb.Name, err)
}
}
dst := filepath.Join(cfg.UserDir, claudeRenderer{}.RelPath(set[0]))
if err := os.WriteFile(dst, []byte("edited\n"), 0o644); err != nil {
t.Fatal(err)
}
rep, err := Sync(cfg, set)
if err != nil {
t.Fatal(err)
}
if rep.Clean {
t.Fatal("Sync reported clean over a hand-edited artifact")
}
if findKind(rep, "drifted") == nil {
t.Fatalf("no drifted finding: %+v", rep.Findings)
}
}
// TestSync_MissingAfterTargetAdd: activating a target without generating it
// surfaces a missing finding for that target.
func TestSync_MissingAfterTargetAdd(t *testing.T) {
cfg := testConfig(t)
set := syncSet(t)
for _, pb := range set {
if _, err := Generate(cfg, pb, "claude"); err != nil {
t.Fatal(err)
}
}
if err := SaveSelection(cfg, Selection{Targets: []string{"claude", "cursor"}}); err != nil {
t.Fatal(err)
}
rep, err := Sync(cfg, set)
if err != nil {
t.Fatal(err)
}
if rep.Clean {
t.Fatal("Sync clean despite an un-emitted active target")
}
missing := false
for _, f := range rep.Findings {
if f.Target == "cursor" && f.Kind == "missing" {
missing = true
}
}
if !missing {
t.Fatalf("no cursor missing finding: %+v", rep.Findings)
}
}
// TestSync_OrphanDedupByTarget: a deselected per-playbook target with K
// ledger records collapses to exactly one orphan finding.
func TestSync_OrphanDedupByTarget(t *testing.T) {
cfg := testConfig(t)
set := syncSet(t)
if err := SaveSelection(cfg, Selection{Targets: []string{"cursor"}}); err != nil {
t.Fatal(err)
}
for _, pb := range set {
if _, err := Generate(cfg, pb, "cursor"); err != nil {
t.Fatalf("generate cursor/%s: %v", pb.Name, err)
}
}
// Deselect cursor; its K artifacts are now orphaned.
if err := SaveSelection(cfg, Selection{Targets: []string{"claude"}}); err != nil {
t.Fatal(err)
}
rep, err := Sync(cfg, set)
if err != nil {
t.Fatal(err)
}
orphans := 0
for _, f := range rep.Findings {
if f.Kind == "orphan" {
orphans++
if f.Target != "cursor" {
t.Errorf("orphan finding target = %q, want cursor", f.Target)
}
}
}
if orphans != 1 {
t.Fatalf("orphan findings = %d, want exactly 1 (dedup by target); findings = %+v", orphans, rep.Findings)
}
}
var rfc3339Date = regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:`)
// TestRenderersDeterministic_NoHostStrings is the derive-at-fire guard:
// every renderer produces byte-identical output on a repeat render and bakes
// no host-specific or volatile value into the artifact (which would make
// verify drift forever).
func TestRenderersDeterministic_NoHostStrings(t *testing.T) {
set := []Playbook{synthPlaybook("alpha"), synthPlaybook("beta")}
for _, target := range Targets() {
r, ok := rendererFor(target)
if !ok {
t.Fatalf("no renderer for %q", target)
}
var out []byte
if agg, isAgg := isAggregate(r); isAgg {
b1, err := agg.RenderAll(set)
if err != nil {
t.Fatalf("%s RenderAll: %v", target, err)
}
b2, err := agg.RenderAll(set)
if err != nil {
t.Fatalf("%s RenderAll: %v", target, err)
}
if !bytes.Equal(b1, b2) {
t.Errorf("%s RenderAll is not deterministic", target)
}
out = b1
} else {
b1, err := r.Render(set[0])
if err != nil {
t.Fatalf("%s Render: %v", target, err)
}
b2, err := r.Render(set[0])
if err != nil {
t.Fatalf("%s Render: %v", target, err)
}
if !bytes.Equal(b1, b2) {
t.Errorf("%s Render is not deterministic", target)
}
out = b1
}
s := string(out)
for _, bad := range []string{"/Users/", "/home/", "$USER"} {
if strings.Contains(s, bad) {
t.Errorf("%s output contains host string %q (derive-at-fire violated)", target, bad)
}
}
if rfc3339Date.MatchString(s) {
t.Errorf("%s output contains a baked timestamp (derive-at-fire violated)", target)
}
}
}
added internal/cockpit/target.go
@@ -0,0 +1,179 @@
package cockpit
import (
"errors"
"fmt"
"path/filepath"
"strings"
)
// Renderer translates a neutral Playbook into one harness target's config
// bytes. A Renderer is the only component that knows a target's file layout
// (RelPath) and permission spelling (Render). Render must be deterministic:
// the same Playbook yields byte-identical output, so re-emit is idempotent
// and drift detection is a sha comparison.
//
// A per-playbook target (Claude, Cursor) emits one file per Playbook. An
// aggregate target (AGENTS.md, GEMINI.md) emits one shared file for the whole
// selected set and additionally implements AggregateRenderer; the per-playbook
// Render/RelPath on an aggregate renderer are defined for interface
// completeness but are not on the emit path (Generate rejects aggregate
// targets — see emit.go).
type Renderer interface {
Target() string // the target name, e.g. "claude"
RelPath(p Playbook) string // artifact path relative to the user dir
Render(p Playbook) ([]byte, error) // deterministic config bytes
}
// AggregateRenderer is the optional contract for a target whose config is a
// single shared file describing the whole selected playbook set (AGENTS.md,
// GEMINI.md) rather than one file per playbook. It is discovered by
// type-assertion (isAggregate); a renderer that does not implement it is
// per-playbook. RenderAll must be deterministic for a given set regardless of
// input order (it sorts by Name internally) so re-emit stays sha-idempotent.
type AggregateRenderer interface {
Renderer
AggRelPath() string // single shared artifact path, playbook-independent
RenderAll(ps []Playbook) ([]byte, error) // deterministic bytes for the whole set
}
// isAggregate reports whether r emits one shared file for a set rather than
// one file per playbook, returning the aggregate view when it does.
func isAggregate(r Renderer) (AggregateRenderer, bool) {
a, ok := r.(AggregateRenderer)
return a, ok
}
// Enforcement describes the harness-runtime side of a target's safety
// guarantee: whether the harness itself enforces the playbook's allowlist
// (Claude's allowed-tools) or the emitted artifact is only advisory text the
// AI is asked to honor (AGENTS.md, GEMINI.md, Cursor rules). It says nothing
// about eeco's own generate-time gate, which is always enforced for every
// target — "advisory" never relaxes the safety invariant, it only describes
// what the downstream harness does with the file.
type Enforcement int
const (
// EnforcementEnforced means the harness enforces the allowlist at runtime.
EnforcementEnforced Enforcement = iota
// EnforcementAdvisory means the artifact is advisory text only — the
// harness does not enforce tool permissions from it.
EnforcementAdvisory
)
// String renders the enforcement level for banners, fidelity lines, and
// status output.
func (e Enforcement) String() string {
if e == EnforcementEnforced {
return "enforced"
}
return "advisory"
}
// Fidelity is the optional contract a Renderer implements to declare its
// harness-runtime enforcement. A renderer that does not implement it is
// treated as advisory (fail-honest default — never claim enforcement that
// isn't there).
type Fidelity interface {
Enforcement() Enforcement
}
// fidelityOf returns r's declared enforcement, defaulting to advisory for a
// renderer that does not declare one (fail-honest).
func fidelityOf(r Renderer) Enforcement {
if f, ok := r.(Fidelity); ok {
return f.Enforcement()
}
return EnforcementAdvisory
}
// relUnder validates that a renderer-supplied artifact path stays inside the
// user dir before it is joined to cfg.UserDir — the write-scope-floor guard.
// It rejects an absolute path or one that escapes via "..", mirroring the
// path guards in internal/config, and returns the cleaned relative path.
func relUnder(rel string) (string, error) {
if strings.TrimSpace(rel) == "" {
return "", errors.New("empty artifact path")
}
// A leading "/" or "\" is not absolute on Windows (Go needs a volume
// name), so guard the rooted forms explicitly alongside filepath.IsAbs.
if filepath.IsAbs(rel) || strings.HasPrefix(rel, "/") || strings.HasPrefix(rel, `\`) {
return "", fmt.Errorf("artifact path %q must be relative to the user dir", rel)
}
clean := filepath.Clean(rel)
if slash := filepath.ToSlash(clean); slash == ".." || strings.HasPrefix(slash, "../") {
return "", fmt.Errorf("artifact path %q escapes the user dir", rel)
}
return clean, nil
}
// rendererFor returns the renderer for a target name. Claude is enforced and
// per-playbook; Cursor is advisory and per-playbook; AGENTS.md and GEMINI.md
// are advisory and aggregate.
func rendererFor(target string) (Renderer, bool) {
switch target {
case "claude":
return claudeRenderer{}, true
case "cursor":
return cursorRenderer{}, true
case "agents":
return agentsRenderer{}, true
case "gemini":
return geminiRenderer{}, true
default:
return nil, false
}
}
// Targets lists the renderer target names eeco can emit, for usage and error
// messages. Order is stable (Claude first as the enforced reference target).
func Targets() []string {
return []string{"claude", "cursor", "agents", "gemini"}
}
// unknownTargetErr is the shared "unknown target" error naming the known set,
// so every entry point words it identically.
func unknownTargetErr(target string) error {
return fmt.Errorf("unknown target %q (known: %s)", target, strings.Join(Targets(), ", "))
}
// IsAggregateTarget reports whether target emits one shared file for the whole
// set (AGENTS.md, GEMINI.md) rather than one file per playbook. Unknown
// targets report false. The cmd layer uses it to route generate/verify/off
// down the per-playbook or aggregate path.
func IsAggregateTarget(target string) bool {
r, ok := rendererFor(target)
if !ok {
return false
}
_, agg := isAggregate(r)
return agg
}
// TargetFidelity returns target's harness-runtime enforcement and whether the
// target is known, for `eeco cockpit target list` and status.
func TargetFidelity(target string) (Enforcement, bool) {
r, ok := rendererFor(target)
if !ok {
return EnforcementAdvisory, false
}
return fidelityOf(r), true
}
// MachineryFidelity reports whether target can host the auto-firing
// deterministic machinery — the PreToolUse git-write guard and the other
// runtime hook events the cockpit emits. It is EnforcementEnforced only for a
// harness with a real runtime hook channel (claude), EnforcementAdvisory
// otherwise; the second return is false for an unknown target. It is the
// machinery analog of TargetFidelity (which describes the emitted playbook
// allowlist), letting the cmd layer print honest fidelity without the hooks
// package importing target logic.
func MachineryFidelity(target string) (Enforcement, bool) {
if _, ok := rendererFor(target); !ok {
return EnforcementAdvisory, false
}
if target == "claude" {
return EnforcementEnforced, true
}
return EnforcementAdvisory, true
}
added internal/config/attribution_test.go
@@ -0,0 +1,38 @@
package config
import (
"path/filepath"
"reflect"
"strings"
"testing"
)
func TestLoad_AttributionPatternRepeatable(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
write(t, wsDir, "config.local", strings.Join([]string{
"attribution_pattern=FIRST-MARK",
`attribution_pattern = "SECOND MARK"`,
"attribution_pattern=", // empty value ignored, cannot disable the gate
}, "\n"))
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
want := []string{"FIRST-MARK", "SECOND MARK"}
if !reflect.DeepEqual(cfg.AttributionPatterns, want) {
t.Errorf("AttributionPatterns = %v, want %v", cfg.AttributionPatterns, want)
}
}
func TestLoad_AttributionPatternDefaultEmpty(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if len(cfg.AttributionPatterns) != 0 {
t.Errorf("default AttributionPatterns = %v, want empty", cfg.AttributionPatterns)
}
}
added internal/config/automation_test.go
@@ -0,0 +1,104 @@
package config
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func TestLoad_AutomationDefault(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.Automation != DefaultAutomation {
t.Errorf("Automation = %q, want %q", cfg.Automation, DefaultAutomation)
}
if cfg.AIBudget != DefaultAIBudget {
t.Errorf("AIBudget = %d, want %d", cfg.AIBudget, DefaultAIBudget)
}
if cfg.AICommand != nil {
t.Errorf("AICommand = %v, want nil", cfg.AICommand)
}
}
func TestLoad_AutomationLevels(t *testing.T) {
cases := map[string]Automation{
"manual": AutomationManual,
"propose": AutomationPropose,
"scaffold": AutomationScaffold,
"auto": AutomationAuto,
// Unknown / future value is tolerated and falls back to default.
"galaxy-brain": DefaultAutomation,
"": DefaultAutomation,
}
for in, want := range cases {
t.Run(in, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "automation="+in+"\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatalf("unknown automation must not error: %v", err)
}
if cfg.Automation != want {
t.Errorf("automation=%q -> %q, want %q", in, cfg.Automation, want)
}
})
}
}
func TestAutomation_ConsentAndScaffold(t *testing.T) {
if !AutomationAuto.ImpliesAIConsent() {
t.Error("auto must imply AI consent")
}
for _, a := range []Automation{AutomationManual, AutomationPropose, AutomationScaffold} {
if a.ImpliesAIConsent() {
t.Errorf("%q must not imply AI consent", a)
}
}
if !AutomationScaffold.ScaffoldsWorkflows() || !AutomationAuto.ScaffoldsWorkflows() {
t.Error("scaffold and auto must scaffold workflows")
}
if AutomationManual.ScaffoldsWorkflows() || AutomationPropose.ScaffoldsWorkflows() {
t.Error("manual/propose must not scaffold workflows")
}
}
func TestLoad_AICommandAndBudget(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "ai_command=my tool --flag\nai_budget=3\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(cfg.AICommand, []string{"my", "tool", "--flag"}) {
t.Errorf("AICommand = %v", cfg.AICommand)
}
if cfg.AIBudget != 3 {
t.Errorf("AIBudget = %d, want 3", cfg.AIBudget)
}
}
func TestLoad_AIBudgetMalformed(t *testing.T) {
for _, body := range []string{"ai_budget=nope\n", "ai_budget=-1\n"} {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Errorf("expected error for %q", body)
}
}
}
added internal/config/config.go
@@ -0,0 +1,1115 @@
// Package config detects the target repository root and project profile
// and resolves the eeco workspace configuration.
//
// The package is deliberately small: it answers "where is the repo
// root", "what kind of project is this", "who owns the workspace",
// "where is the workspace", and "what config.local overrides apply".
// Its only internal dependency is gitx, for the read-only git-identity
// lookup that scopes the workspace under <repo>/<username>/. Workspace
// creation lives alongside in init.go.
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/ajhahnde/eeco/internal/gitx"
)
// Profile identifies the kind of project a repository contains. It
// drives the default parse/build gate command and may shape workflow
// behaviour later.
type Profile string
const (
ProfileGo Profile = "go"
ProfileZig Profile = "zig"
ProfileRust Profile = "rust"
ProfileNode Profile = "node"
ProfilePython Profile = "python"
ProfileGeneric Profile = "generic"
)
// DefaultWorkspace is the default engine workspace directory name. It
// is a hidden directory scaffolded inside the per-user workspace dir
// (<repo>/<username>/.eeco).
const DefaultWorkspace = ".eeco"
// UsernameEnv, when set, overrides the username used to scope the
// workspace under <repo>/<username>/. It is the non-interactive
// injection point — CI, tests, and the future `eeco init --username`
// flag — and takes precedence over `git config user.name`.
const UsernameEnv = "EECO_USERNAME"
// FallbackUsername is the last-resort workspace owner directory name,
// used only when no username can be resolved from the environment or
// git. It keeps Load total: the workspace always has a valid path even
// on a machine with no git identity configured.
const FallbackUsername = "eeco-workspace"
// DefaultStaleDays is the default age in days after which a
// reference-type memory fact is considered stale by garbage collection.
const DefaultStaleDays = 30
// Automation selects how much housekeeping eeco performs on its own.
// Raising the level lets eeco prepare more without prompting; it never
// lets eeco act on the tracked tree on its own (the floor invariants in
// PLAN.md hold at every level).
type Automation string
const (
// AutomationManual: no background analysis; new workflows only via
// `eeco new`.
AutomationManual Automation = "manual"
// AutomationPropose (default): analysis only on explicit run / --ai;
// a drafted workflow becomes a queue proposal.
AutomationPropose Automation = "propose"
// AutomationScaffold: as propose, but a drafted workflow is written
// inactive into the workspace and queued "ready to activate".
AutomationScaffold Automation = "scaffold"
// AutomationAuto: analysis may run automatically within the budget
// cap (this setting is the AI consent); workflows as scaffold.
AutomationAuto Automation = "auto"
)
// DefaultAutomation is the conservative default automation level.
const DefaultAutomation = AutomationPropose
// DefaultAIBudget caps the number of gated passes a single eeco
// invocation may spend. A tool-using pass may make several model calls
// but counts as one. One pass is enough for the builtins; 0 disables AI
// entirely (every pass is then parked).
const DefaultAIBudget = 1
// DefaultAIAPIKeyEnv is the environment-variable NAME the native
// provider reads its API key from when `ai_api_key_env` is unset. Only
// the variable name is configured; the secret itself is never stored.
const DefaultAIAPIKeyEnv = "ANTHROPIC_API_KEY"
// DefaultBugReportDir is the workspace-relative directory where
// `eeco report-bug` writes per-invocation Markdown records. Overridable
// via the `bug_report_dir` key in config.local.
const DefaultBugReportDir = "bug-reports"
// DefaultContextPath is the workspace-relative file `eeco go --write`
// renders the project brief into. Overridable via the `context_path`
// key in config.local.
const DefaultContextPath = "context.md"
// DefaultContextBudget is the default byte cap on the file `eeco go
// --write` renders. 0 means no cap — the full brief is written as-is.
// Overridable via the `context_budget` key in config.local.
const DefaultContextBudget = 0
// DefaultBriefIncludeNotes is the default for whether `eeco go` adds a
// "Recent notes" section to the brief. False keeps bare `eeco go`
// byte-identical to the notes-free brief; opt in via the
// `brief_include_notes` key in config.local.
const DefaultBriefIncludeNotes = false
// DefaultSessionStartMailbox is the default filename of the
// repo-root mailbox the bundled session-start hook surfaces when it has
// unprocessed content. Overridable via `session_start_mailbox` in
// config.local; an empty override disables the mailbox block.
const DefaultSessionStartMailbox = "Ideas.md"
// DefaultSessionStartPinnedBodies is the default for whether the
// bundled session-start hook composes a fourth block that emits the
// FULL BODY of every `pin: true` memory fact alongside the existing
// three blocks. False keeps the hook's output byte-identical to the
// three-block behaviour; opt in via the `session_start_pinned_bodies`
// key in config.local OR the `--with-pinned-bodies` flag on
// `eeco hooks session-emit`. Useful when an AI assistant treats hook
// output as a system-reminder so a pinned policy memory (for example
// no-AI-attribution) is in the model's context from session start.
const DefaultSessionStartPinnedBodies = false
// DefaultSessionStartRoadmapGlob is the default glob, relative to the
// repo root, used to discover the live planning surface the bundled
// session-start hook appends to the reading routine. The
// most-recently-modified match wins. Overridable via
// `session_start_roadmap_glob`; an empty override disables roadmap
// discovery.
const DefaultSessionStartRoadmapGlob = "roadmap*.md"
// DefaultPreCommitWorkflows returns the builtin workflow names wired
// into the eeco-managed pre-commit hook when the operator has not
// overridden them via `pre_commit_workflows`. The default is the
// gate-family pair that is safe to run on every commit: `leak-guard`
// (the long-standing default wiring) and `version-sync` (silent on
// projects with no `version_locations`, so opt-in per project).
// `comment-hygiene` is omitted: it is opinionated about prose in the
// tracked tree and would surprise a fresh adopter on first install.
// Callers receive a fresh slice; mutating it does not affect the
// default returned by a subsequent call.
func DefaultPreCommitWorkflows() []string {
return []string{"leak-guard", "version-sync"}
}
// DefaultPostMergeWorkflows returns the builtin workflow names wired
// into the eeco-managed post-merge hook when the operator has not
// overridden them via `post_merge_workflows`. The default is the
// drift-detection pair: `memory-drift` and `doc-drift`. A merge (a
// `git pull` / `git merge`) is the moment another author's changes land
// in the tree, so it is the natural trigger to re-check whether eeco's
// recorded state has drifted from the code. Both are silent no-ops on a
// project that carries no memory `ref:` facts and no CHANGELOG/tags, so
// they are safe to wire by default. `manifest-refresh` joins them: a merge
// can add or remove files in the knowledge dirs, so it is the natural moment
// to rebuild the deterministic .ai.json skeletons; it is a no-op on a repo
// with no knowledge dirs. `cockpit-sync` joins them too: a merge can ship new
// playbook content (an eeco upgrade) that leaves every generated cockpit
// artifact behind its source, so a merge is the natural moment to flag the
// drift; it is a silent no-op on a repo where the cockpit was never generated
// (its empty-ledger gate). Callers receive a fresh slice; mutating it does not
// affect the default returned by a subsequent call.
func DefaultPostMergeWorkflows() []string {
return []string{"memory-drift", "doc-drift", "manifest-refresh", "cockpit-sync"}
}
// normalizeAutomation maps any value to a known level. An unknown or
// future value is tolerated and falls back to the default rather than
// failing (PLAN.md §Automation level).
func normalizeAutomation(v string) Automation {
switch Automation(v) {
case AutomationManual, AutomationPropose, AutomationScaffold, AutomationAuto:
return Automation(v)
default:
return DefaultAutomation
}
}
// ImpliesAIConsent reports whether the level is itself standing consent
// for a gated, budget-capped AI pass. Only `auto` is: every other level
// requires an explicit --ai (PLAN.md §AI providers).
func (a Automation) ImpliesAIConsent() bool { return a == AutomationAuto }
// ScaffoldsWorkflows reports whether a drafted workflow is written
// (inactive) into the workspace rather than only proposed via the queue.
func (a Automation) ScaffoldsWorkflows() bool {
return a == AutomationScaffold || a == AutomationAuto
}
// ReconcilesCockpit reports whether eeco may regenerate a drifted or missing
// cockpit artifact on its own — a deterministic render→write into the
// gitignored workspace tree, performed by the cockpit-sync workflow. Only
// `auto` is. It is a file-write consent, distinct from ImpliesAIConsent (no
// model spend is involved), so it has its own predicate rather than overloading
// that one. Orphan removal stays operator-in-the-loop even at auto (deleting a
// file is destructive).
func (a Automation) ReconcilesCockpit() bool { return a == AutomationAuto }
// WorkspaceHistory selects whether eeco keeps a private, local git
// repository inside its own workspace directory (<repo>/<username>/) to
// version the knowledge layer — memory, queue, decisions, manifests —
// over time, and how often it commits. It is a different axis from
// Automation (which governs background analysis), so it has its own key.
type WorkspaceHistory string
const (
// WorkspaceHistoryOff: no private repo. `eeco init` does not create
// one; `eeco history` reports it is off. The durable opt-out.
WorkspaceHistoryOff WorkspaceHistory = "off"
// WorkspaceHistoryManual (default): the private repo exists, but eeco
// commits to it only on an explicit `eeco history snapshot`.
WorkspaceHistoryManual WorkspaceHistory = "manual"
// WorkspaceHistoryAuto: as manual, plus eeco commits automatically
// after each mutating verb (the cmd layer calls maybeAutoCommit at every
// write site; see cmd/eeco/historygit.go). Still local-only — no remote,
// no push.
WorkspaceHistoryAuto WorkspaceHistory = "auto"
)
// DefaultWorkspaceHistory is the safe-default floor: the repo is created
// at init, but nothing is committed without an explicit snapshot. The
// floor is manual, never auto.
const DefaultWorkspaceHistory = WorkspaceHistoryManual
// normalizeWorkspaceHistory maps any value to a known level. An unknown
// or future value is tolerated and falls back to the default rather than
// failing config load (floor invariant, mirroring normalizeAutomation).
func normalizeWorkspaceHistory(v string) WorkspaceHistory {
switch WorkspaceHistory(v) {
case WorkspaceHistoryOff, WorkspaceHistoryManual, WorkspaceHistoryAuto:
return WorkspaceHistory(v)
default:
return DefaultWorkspaceHistory
}
}
// Enabled reports whether a private workspace-history repo should exist
// (every level except off).
func (h WorkspaceHistory) Enabled() bool { return h != WorkspaceHistoryOff }
// Auto reports whether eeco should commit automatically after each
// mutating verb (only the auto level).
func (h WorkspaceHistory) Auto() bool { return h == WorkspaceHistoryAuto }
// Config is the resolved configuration for an eeco invocation.
type Config struct {
// RepoRoot is the absolute path to the repository root (the directory
// containing .git).
RepoRoot string
// Username is the slugged identity that owns the workspace. It scopes
// the workspace under <RepoRoot>/<Username>/ and is the single
// directory Init adds to .gitignore. Resolved by resolveUsername from
// UsernameEnv, `git config user.name`, $USER, or FallbackUsername; it
// is never empty.
Username string
// UserDir is the absolute path to the per-user workspace parent,
// <RepoRoot>/<Username>/. It holds the engine workspace (the .eeco
// directory) plus the project-type-aware knowledge directories.
UserDir string
// WorkspaceName is the engine workspace directory name, relative to
// UserDir (the leaf component of Workspace, e.g. ".eeco").
WorkspaceName string
// Workspace is the absolute path to the workspace directory.
Workspace string
// Profile is the detected project profile.
Profile Profile
// Gate is the project's parse/build gate: an ordered chain of
// commands (each an argv slice) run in sequence, stopping at the
// first failure. The default is a single-step chain from the
// detected profile; the repeatable `gate` key in config.local
// overrides it — the first occurrence resets the profile default,
// each occurrence appends one step. Empty (a lone `gate=`, or the
// generic profile) means no gate. The `gate` builtin workflow runs
// the chain.
Gate [][]string
// StaleDays controls when reference-type memory facts age out of
// the store. Overridable via the `stale_days` key in config.local.
StaleDays int
// AttributionPatterns holds operator-supplied extra regexes appended
// to the built-in AI-attribution denylist. Populated from repeatable
// `attribution_pattern` keys in config.local; empty by default.
AttributionPatterns []string
// Automation is the resolved automation level. Overridable via the
// `automation` key in config.local; defaults to DefaultAutomation.
Automation Automation
// WorkspaceHistory selects whether eeco keeps a private, local git
// repository inside UserDir to version the knowledge layer over time,
// and how often it commits (off | manual | auto). Overridable via the
// `workspace_history` key in config.local; defaults to
// DefaultWorkspaceHistory (manual). An unknown value falls back to the
// default (floor invariant). The private repo is local-only — no
// remote, no push — and confined to the gitignored workspace; the
// engine never writes to it (all private-repo git lives in the cmd
// layer, cmd/eeco/historygit.go).
WorkspaceHistory WorkspaceHistory
// AICommand is the argv for the wired CLI-based AI provider, taken
// from the `ai_command` key (whitespace-split). Empty means no
// provider is configured: every AI pass is parked, never failed.
AICommand []string
// AIBudget caps gated passes per invocation (a tool-using pass may
// make several model calls but counts as one). From the `ai_budget`
// key; defaults to DefaultAIBudget. 0 disables AI (passes are parked).
AIBudget int
// AIProvider selects the provider implementation. From the
// `ai_provider` key; `cli` selects the CLI provider, and any other
// value (empty/auto, or the legacy `anthropic`) falls back to
// auto-select: an explicit `ai_command` picks the CLI provider, else
// the not-configured stub. An unknown value is tolerated and never an
// error (floor invariant).
AIProvider string
// AIModel is an opaque model identifier carried in config. From the
// `ai_model` key. Inert passthrough: the CLI provider ignores it.
AIModel string
// AIAPIKeyEnv is the NAME of an environment variable for an API key,
// from the `ai_api_key_env` key; defaults to DefaultAIAPIKeyEnv. Inert
// passthrough since the in-binary API provider was retired. The key
// value itself is never read from disk or stored (secret-handling floor).
AIAPIKeyEnv string
// SessionSettingsPath is the absolute path to the AI CLI's
// user-global JSON settings file that the opt-in session-start hook
// edits. From the `session_settings_path` key, or the
// EECO_SESSION_SETTINGS environment variable when the key is unset.
// Empty means session-start wiring is not configured: `eeco hooks
// session-start on` reports that and touches nothing. No brand path
// is baked in (Constraint 4).
SessionSettingsPath string
// BugReportDir is the workspace-relative directory where `eeco
// report-bug` writes per-invocation Markdown records. From the
// `bug_report_dir` key in config.local; defaults to
// DefaultBugReportDir. The value is held to the workspace by the
// write-scope guard; absolute paths and `..` traversal are rejected
// at parse time.
BugReportDir string
// ContextPath is the workspace-relative file `eeco go --write`
// renders the project brief into. From the `context_path` key in
// config.local; defaults to DefaultContextPath. The value is held to
// the workspace by the write-scope guard; absolute paths and `..`
// traversal are rejected at parse time.
ContextPath string
// ContextBudget is the byte cap on the file `eeco go --write`
// renders. From the `context_budget` key in config.local; defaults
// to DefaultContextBudget (0, no cap). When positive, `eeco go
// --write` trims the brief down a deterministic ladder until it
// fits the budget. Negative values are rejected at parse time.
ContextBudget int
// BriefIncludeNotes opts the brief into a "Recent notes" section
// drawn from <workspace>/notes/. From the `brief_include_notes` key
// in config.local; defaults to DefaultBriefIncludeNotes (false), so
// bare `eeco go` stays byte-identical to the notes-free brief. The
// notes section appears in Markdown output only; the JSON brief's
// nine frozen top-level keys are unchanged.
BriefIncludeNotes bool
// SessionStartPinnedBodies opts the bundled session-start hook into
// a fourth block that lists the full body of every `pin: true`
// memory fact, separated by markdown dividers. From the
// `session_start_pinned_bodies` key in config.local; defaults to
// DefaultSessionStartPinnedBodies (false), so the three-block output
// stays byte-identical. The `--with-pinned-bodies` flag on
// `eeco hooks session-emit` sets this for one invocation, taking
// precedence over the config value.
SessionStartPinnedBodies bool
// SessionStartDocs is the explicit reading-routine the bundled
// session-start hook surfaces, in order. Populated from repeatable
// `session_start_docs` keys in config.local (one path per
// occurrence, repo-relative). When empty the hook falls back to
// auto-detection over a list of common docs.
SessionStartDocs []string
// SessionStartMailbox is the repo-relative filename of the mailbox
// the bundled session-start hook checks for unprocessed content.
// From the `session_start_mailbox` key in config.local; defaults to
// DefaultSessionStartMailbox. An empty override disables the
// mailbox block; absolute paths and `..` traversal are rejected at
// parse time.
SessionStartMailbox string
// SessionStartRoadmapGlob is the glob, relative to the repo root,
// used by the bundled session-start hook to discover the live
// planning surface. The most-recently-modified match is appended to
// the reading routine. From `session_start_roadmap_glob`; defaults
// to DefaultSessionStartRoadmapGlob. Empty disables discovery.
SessionStartRoadmapGlob string
// HandoverGlob is an optional glob, relative to the repo root, the
// cockpit's SessionStart orient block uses to find the newest handover /
// resume note (the session's resume point). The most-recently-modified
// match wins, mirroring SessionStartRoadmapGlob. From the `handover_glob`
// key in config.local; empty (the default) falls back to the newest note
// under <workspace>/notes/.
HandoverGlob string
// VersionLocations is the operator-declared list of `path:regex`
// entries the `version-sync` builtin reads to detect drift between
// version strings inside the repository. Each entry is split on the
// first colon; the path is repo-relative and the regex must declare
// at least one capture group (group 1 captures the version string).
// Populated from repeatable `version_locations` keys in
// config.local; empty disables the gate (`version-sync` exits 0).
// Absolute paths and `..` traversal are rejected at parse time.
//
// The reserved value `version_locations=auto` switches `version-sync`
// to auto-detect a fixed set of common version files instead of an
// explicit list. It cannot be mixed with `path:regex` entries; when
// declared, this slice is exactly the single element "auto".
VersionLocations []string
// VersionAnchor selects the source of truth `version-sync` compares
// declared `version_locations` against. Three modes:
//
// "" (unset, default) — consistency-only. The first declared
// location is the anchor; the rest must match it. Slice-1
// behaviour, preserved for backward compatibility.
// "tag" — tag-anchor. The latest semver-shaped reachable git tag
// (via gitx.LatestSemverTag) is the expected version. Declared
// locations must be semver-greater-or-equal to it so a release
// commit can bump declared locations ahead of the tag (the tag
// is pushed after the commit). Backward-drift fails. If no
// semver-shaped tag is reachable yet, falls back to
// consistency-only.
// "<path>:<regex>" — designated-file. The path:regex pair is
// parsed like a `version_locations` entry; the captured value
// is the expected version. Declared locations must strict-equal
// it. A missing path exits 2 (blocked).
//
// Populated from the `version_anchor` key in config.local; empty
// keeps the default. Absolute paths and `..` traversal in the
// designated-file form are rejected at parse time.
VersionAnchor string
// PreCommitWorkflows is the ordered list of builtin workflow names
// the eeco-managed pre-commit hook runs, in declared sequence,
// stopping at the first non-zero exit. Populated from repeatable
// `pre_commit_workflows` keys in config.local; the first occurrence
// in the file resets the default, subsequent occurrences append.
// When config.local declares the key with an empty value the list
// is cleared and `eeco hooks pre-commit on` refuses to install.
// When config.local does not declare the key at all, the default
// from DefaultPreCommitWorkflows is used. Names are not validated
// here (the config package cannot import the workflow registry
// without a cycle); validation happens at hook-install time.
PreCommitWorkflows []string
// PostMergeWorkflows is the ordered list of builtin workflow names
// the eeco-managed post-merge hook runs after a `git merge` /
// `git pull`. Populated from repeatable `post_merge_workflows` keys
// in config.local with the same reset-then-append semantics as
// PreCommitWorkflows: the first occurrence resets the default,
// subsequent occurrences append, an empty value clears the list and
// `eeco hooks post-merge on` refuses to install. When the key is not
// declared the default from DefaultPostMergeWorkflows is used. Names
// are validated at hook-install time, not here (registry cycle).
PostMergeWorkflows []string
// SessionFiles is the operator-declared list of paths where the
// session-start hook maintains a marker block carrying the same
// content `eeco hooks session-emit` prints. Each entry is one
// delivery target — either repo-relative (held inside the repo by
// the same path-traversal guard `session_start_docs` uses) or
// absolute (matching the precedent set by `session_settings_path`).
// Populated from repeatable `session_files` keys in config.local;
// empty by default. Together with the JSON-settings channel keyed
// by `session_settings_path`, this is the brand-free second delivery
// channel for assistants that read a plain text or markdown file
// at session start (e.g. Cursor's `.cursorrules`, OpenAI Codex's
// `AGENTS.md`, a repo-level `CLAUDE.md`). Either channel is enough
// for `eeco hooks session-start on` to succeed; both compose.
SessionFiles []string
// KnowledgeDirs is the project-type-aware set of knowledge
// directories Init scaffolds inside UserDir, alongside the engine
// workspace. It is empty for a Config produced by Load: the dir set
// is resolved by `eeco init` from the project-type detector
// (internal/projecttype) and assigned before the Init call, so Load
// stays a pure, non-interactive configuration read. Names are held
// to a single safe path component by Init.
KnowledgeDirs []string
// InitDetectionThreshold is the deterministic-confidence floor at or
// above which `eeco init` accepts the project-type marker scan
// without an interactive prompt. From the `init_detection_threshold`
// key in config.local; 0 (the default) means "use the detector's
// built-in default". Values are constrained to [0,1] at parse time.
InitDetectionThreshold float64
}
// ErrNotInRepo is returned when no enclosing git repository can be
// located by walking upwards from the start directory.
var ErrNotInRepo = errors.New("not inside a git repository")
// FindRepoRoot walks upwards from start until it finds a directory that
// contains a `.git` entry (a directory in a normal clone, or a file in
// a worktree). The start path may be relative; the returned path is
// always absolute and cleaned.
func FindRepoRoot(start string) (string, error) {
abs, err := filepath.Abs(start)
if err != nil {
return "", fmt.Errorf("resolve start path: %w", err)
}
dir := abs
for {
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", fmt.Errorf("%w: searched up from %s", ErrNotInRepo, abs)
}
dir = parent
}
}
// DetectProfile inspects marker files at the repository root and
// returns the best-matching profile. When several markers are present,
// the first match in the documented order wins (go, zig, rust, node,
// python). It never returns an error: an unrecognised tree is generic.
func DetectProfile(repoRoot string) Profile {
exists := func(name string) bool {
_, err := os.Stat(filepath.Join(repoRoot, name))
return err == nil
}
hasGlob := func(pattern string) bool {
matches, err := filepath.Glob(filepath.Join(repoRoot, pattern))
return err == nil && len(matches) > 0
}
switch {
case exists("go.mod"):
return ProfileGo
case exists("build.zig"):
return ProfileZig
case exists("Cargo.toml"):
return ProfileRust
case exists("package.json"):
return ProfileNode
case exists("pyproject.toml"), exists(".venv"), hasGlob("requirements*.txt"):
return ProfilePython
default:
return ProfileGeneric
}
}
// resolveUsername picks the directory name that owns the workspace,
// scoping it under <repo>/<username>/. Resolution order: UsernameEnv,
// then `git config user.name`, then $USER / $USERNAME, then
// FallbackUsername. Each candidate is slugged to a safe single path
// component; the first non-empty slug wins. It never returns empty and
// never fails: Load runs on every command and must stay non-interactive
// (the interactive "pick a username" prompt belongs to `eeco init`).
func resolveUsername(root string) string {
candidates := []string{os.Getenv(UsernameEnv)}
if name, err := gitx.UserName(root); err == nil {
candidates = append(candidates, name)
}
candidates = append(candidates, os.Getenv("USER"), os.Getenv("USERNAME"))
for _, c := range candidates {
if s := slugUsername(c); s != "" {
return s
}
}
return FallbackUsername
}
// slugUsername reduces an arbitrary identity string to a safe single
// path component: it keeps ASCII letters, digits, '-', '_', and '.',
// maps spaces to '-', drops everything else, and trims leading/trailing
// dots so the result can never be "." or "..". An empty result signals
// "no usable name" to resolveUsername.
func slugUsername(s string) string {
var b strings.Builder
for _, r := range strings.TrimSpace(s) {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9',
r == '-', r == '_', r == '.':
b.WriteRune(r)
case r == ' ':
b.WriteRune('-')
}
}
return strings.Trim(b.String(), ".")
}
// GateFor returns the default parse/build gate for a profile as a
// single-step chain. The generic profile has no gate and yields nil.
// The returned value is a fresh copy and safe to mutate.
func GateFor(p Profile) [][]string {
var cmd []string
switch p {
case ProfileGo:
cmd = []string{"go", "vet", "./..."}
case ProfileZig:
cmd = []string{"zig", "build", "--summary", "none"}
case ProfileRust:
cmd = []string{"cargo", "check", "--quiet"}
case ProfileNode:
cmd = []string{"npm", "run", "--if-present", "typecheck"}
case ProfilePython:
cmd = []string{"python3", "-m", "compileall", "-q", "."}
default:
return nil
}
step := make([]string, len(cmd))
copy(step, cmd)
return [][]string{step}
}
// GateSteps renders each step of a gate chain as a single joined
// command string (its argv joined by spaces). The result is a non-nil
// (possibly empty) slice — one element per step — suitable for display
// or, joined by " && ", for a one-line summary of the whole chain.
func GateSteps(chain [][]string) []string {
out := make([]string, 0, len(chain))
for _, step := range chain {
out = append(out, strings.Join(step, " "))
}
return out
}
// Load resolves the configuration for the given working directory and
// workspace name. It detects the repo root, the profile, fills in the
// default gate, then applies any overrides from
// <workspace>/config.local. The workspace itself does not need to
// exist yet — Load is safe to call before `eeco init`.
//
// Pass an empty workspaceName to use DefaultWorkspace.
func Load(cwd, workspaceName string) (*Config, error) {
if workspaceName == "" {
workspaceName = DefaultWorkspace
}
if err := validateWorkspaceName(workspaceName); err != nil {
return nil, err
}
root, err := FindRepoRoot(cwd)
if err != nil {
return nil, err
}
username := resolveUsername(root)
cfg := &Config{
RepoRoot: root,
Username: username,
UserDir: filepath.Join(root, username),
WorkspaceName: workspaceName,
Workspace: filepath.Join(root, username, workspaceName),
Profile: DetectProfile(root),
StaleDays: DefaultStaleDays,
Automation: DefaultAutomation,
WorkspaceHistory: DefaultWorkspaceHistory,
AIBudget: DefaultAIBudget,
AIAPIKeyEnv: DefaultAIAPIKeyEnv,
BugReportDir: DefaultBugReportDir,
ContextPath: DefaultContextPath,
ContextBudget: DefaultContextBudget,
BriefIncludeNotes: DefaultBriefIncludeNotes,
SessionStartPinnedBodies: DefaultSessionStartPinnedBodies,
SessionStartMailbox: DefaultSessionStartMailbox,
SessionStartRoadmapGlob: DefaultSessionStartRoadmapGlob,
PreCommitWorkflows: DefaultPreCommitWorkflows(),
PostMergeWorkflows: DefaultPostMergeWorkflows(),
// Env is the default; a config.local key overrides it below.
SessionSettingsPath: os.Getenv("EECO_SESSION_SETTINGS"),
}
cfg.Gate = GateFor(cfg.Profile)
if err := applyLocal(cfg); err != nil {
return nil, fmt.Errorf("read config.local: %w", err)
}
return cfg, nil
}
// validateWorkspaceName rejects names that would escape the repo root
// or otherwise misbehave as a relative path component.
func validateWorkspaceName(name string) error {
if name == "" {
return errors.New("workspace name is empty")
}
if name != filepath.Clean(name) {
return fmt.Errorf("workspace name %q is not a clean path component", name)
}
if filepath.IsAbs(name) || strings.ContainsAny(name, `/\`) {
return fmt.Errorf("workspace name %q must be a single path component", name)
}
if name == "." || name == ".." {
return fmt.Errorf("workspace name %q is not allowed", name)
}
return nil
}
// applyLocal reads <workspace>/config.local and overrides Profile and
// Gate when matching keys are present. The file is optional. Format is
// a flat KEY=VALUE list, one entry per line; blank lines and lines
// starting with `#` are ignored. Values may be wrapped in matching
// single or double quotes. Multi-word `gate` is split on whitespace
// into one chain step; the `gate` key is repeatable, each occurrence
// adding a step.
func applyLocal(cfg *Config) error {
info, err := os.Stat(cfg.Workspace)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
if !info.IsDir() {
// Workspace path exists but is not a directory. Init will
// surface that; don't fail config loading here.
return nil
}
path := filepath.Join(cfg.Workspace, "config.local")
b, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
// sawPreCommitWorkflows and sawGate track whether the operator has
// declared the `pre_commit_workflows` / `gate` key at least once in
// this file. The first occurrence of each resets the binary or
// profile default so the operator-declared list fully replaces it;
// subsequent occurrences append.
var sawPreCommitWorkflows bool
var sawPostMergeWorkflows bool
var sawGate bool
for lineNo, raw := range strings.Split(string(b), "\n") {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
k, v, ok := strings.Cut(line, "=")
if !ok {
return fmt.Errorf("%s:%d: missing '='", path, lineNo+1)
}
key := strings.TrimSpace(k)
val := unquote(strings.TrimSpace(v))
switch key {
case "profile":
cfg.Profile = Profile(val)
cfg.Gate = GateFor(cfg.Profile)
sawGate = false
case "gate":
// Repeatable: the first occurrence resets the profile
// default so the operator-declared chain fully replaces it;
// each occurrence appends one step (a whitespace-split argv
// command). An empty value contributes no step, so a lone
// `gate=` clears the chain and disables the gate.
if !sawGate {
cfg.Gate = nil
sawGate = true
}
if fields := strings.Fields(val); len(fields) > 0 {
cfg.Gate = append(cfg.Gate, fields)
}
case "stale_days":
n, err := strconv.Atoi(val)
if err != nil {
return fmt.Errorf("%s:%d: stale_days: %w", path, lineNo+1, err)
}
if n < 0 {
return fmt.Errorf("%s:%d: stale_days must be >= 0 (got %d)", path, lineNo+1, n)
}
cfg.StaleDays = n
case "attribution_pattern":
// Repeatable: each occurrence appends one extra regex to the
// attribution denylist. An empty value is ignored so a blank
// override line cannot disable the gate.
if val != "" {
cfg.AttributionPatterns = append(cfg.AttributionPatterns, val)
}
case "automation":
// An unknown or future level is tolerated and falls back to
// the default (floor invariant — never fail on this key).
cfg.Automation = normalizeAutomation(val)
case "workspace_history":
// off | manual | auto. An empty value resets to the default
// (manual); an unknown or future value is tolerated and falls
// back to the default (floor invariant — never fail on this
// key, mirroring `automation`).
if val == "" {
cfg.WorkspaceHistory = DefaultWorkspaceHistory
} else {
cfg.WorkspaceHistory = normalizeWorkspaceHistory(val)
}
case "ai_command":
// Whitespace-split argv for the wired CLI provider. Empty
// leaves the provider unconfigured (passes are parked).
if val == "" {
cfg.AICommand = nil
} else {
cfg.AICommand = strings.Fields(val)
}
case "ai_budget":
n, err := strconv.Atoi(val)
if err != nil {
return fmt.Errorf("%s:%d: ai_budget: %w", path, lineNo+1, err)
}
if n < 0 {
return fmt.Errorf("%s:%d: ai_budget must be >= 0 (got %d)", path, lineNo+1, n)
}
cfg.AIBudget = n
case "ai_provider":
// Provider selector: `cli` | `anthropic`, or empty for auto.
// An unknown value is tolerated and treated as auto (floor
// invariant — never fail config loading on this key).
cfg.AIProvider = val
case "ai_model":
// Opaque model identifier passed through to the provider.
// Empty resets to the provider's own default.
cfg.AIModel = val
case "ai_api_key_env":
// NAME of the env var holding the API key (never the value).
// Empty resets to the default env-var name.
if val == "" {
cfg.AIAPIKeyEnv = DefaultAIAPIKeyEnv
} else {
cfg.AIAPIKeyEnv = val
}
case "session_settings_path":
// Absolute path to the AI CLI's user-global settings file.
// An empty value clears it (session-start stays unconfigured);
// a relative value is rejected so the hook never edits a path
// resolved against an unexpected working directory.
if val == "" {
cfg.SessionSettingsPath = ""
} else if !filepath.IsAbs(val) {
return fmt.Errorf("%s:%d: session_settings_path must be absolute (got %q)", path, lineNo+1, val)
} else {
cfg.SessionSettingsPath = val
}
case "session_start_docs":
// Repeatable: each occurrence appends one repo-relative path
// to the reading routine, in declared order. Absolute paths
// and `..` traversal are rejected at parse time so the hook
// only reads inside the repo. An empty value is ignored so a
// blank override line does not produce a phantom entry.
if val == "" {
continue
}
if filepath.IsAbs(val) || strings.HasPrefix(val, "/") || strings.HasPrefix(val, `\`) {
return fmt.Errorf("%s:%d: session_start_docs must be repo-relative (got %q)", path, lineNo+1, val)
}
cleanDoc := filepath.ToSlash(filepath.Clean(val))
if cleanDoc == ".." || strings.HasPrefix(cleanDoc, "../") {
return fmt.Errorf("%s:%d: session_start_docs escapes the repo (got %q)", path, lineNo+1, val)
}
cfg.SessionStartDocs = append(cfg.SessionStartDocs, cleanDoc)
case "session_files":
// Repeatable: each occurrence appends one delivery target the
// session-start hook writes a marker block to. An entry is
// either repo-relative (held inside the repo by the same
// path-traversal guard `session_start_docs` uses) or absolute
// (mirrors the precedent set by `session_settings_path` for
// the AI CLI's user-global file). An empty value is ignored
// so a blank override line does not produce a phantom entry.
if val == "" {
continue
}
if strings.ContainsAny(val, " \t") {
return fmt.Errorf("%s:%d: session_files: value %q must not contain whitespace", path, lineNo+1, val)
}
if filepath.IsAbs(val) {
cfg.SessionFiles = append(cfg.SessionFiles, val)
} else {
if strings.HasPrefix(val, "/") || strings.HasPrefix(val, `\`) {
return fmt.Errorf("%s:%d: session_files must be repo-relative or absolute (got %q)", path, lineNo+1, val)
}
cleanRel := filepath.ToSlash(filepath.Clean(val))
if cleanRel == ".." || strings.HasPrefix(cleanRel, "../") {
return fmt.Errorf("%s:%d: session_files escapes the repo (got %q)", path, lineNo+1, val)
}
cfg.SessionFiles = append(cfg.SessionFiles, cleanRel)
}
case "session_start_mailbox":
// Repo-relative filename of the mailbox; empty disables the
// block. Absolute paths and `..` traversal are rejected.
if val == "" {
cfg.SessionStartMailbox = ""
} else if filepath.IsAbs(val) || strings.HasPrefix(val, "/") || strings.HasPrefix(val, `\`) {
return fmt.Errorf("%s:%d: session_start_mailbox must be repo-relative (got %q)", path, lineNo+1, val)
} else {
cleanMb := filepath.ToSlash(filepath.Clean(val))
if cleanMb == ".." || strings.HasPrefix(cleanMb, "../") {
return fmt.Errorf("%s:%d: session_start_mailbox escapes the repo (got %q)", path, lineNo+1, val)
}
cfg.SessionStartMailbox = cleanMb
}
case "session_start_roadmap_glob":
// Glob relative to the repo root for live-planning discovery.
// Empty disables discovery. The glob pattern itself is not
// path-validated here; filepath.Glob will return no matches
// for anything that escapes the repo.
cfg.SessionStartRoadmapGlob = val
case "handover_glob":
// Glob relative to the repo root for the cockpit orient block's
// newest handover note. Empty (the default) falls back to the
// workspace notes dir. Not path-validated here; filepath.Glob
// returns no match for anything that escapes the repo (mirrors
// session_start_roadmap_glob).
cfg.HandoverGlob = val
case "bug_report_dir":
// Workspace-relative directory for `eeco report-bug` records.
// An empty value falls back to the default. Absolute paths
// and `..` traversal are rejected so the write-scope guard
// (Constraint 1) holds at parse time, not just at write time.
// The unix-style-prefix check catches `/abs/path` even on
// Windows, where filepath.IsAbs returns false without a
// drive letter.
if val == "" {
cfg.BugReportDir = DefaultBugReportDir
} else if filepath.IsAbs(val) || strings.HasPrefix(val, "/") || strings.HasPrefix(val, `\`) {
return fmt.Errorf("%s:%d: bug_report_dir must be relative (got %q)", path, lineNo+1, val)
} else {
clean := filepath.ToSlash(filepath.Clean(val))
if clean == ".." || strings.HasPrefix(clean, "../") {
return fmt.Errorf("%s:%d: bug_report_dir escapes the workspace (got %q)", path, lineNo+1, val)
}
cfg.BugReportDir = clean
}
case "pre_commit_workflows":
// Repeatable: the first occurrence in the file resets the
// binary default; each non-empty occurrence appends one
// builtin workflow name. An empty value clears the list so
// `eeco hooks pre-commit on` refuses to install (the
// operator's explicit opt-out). Whitespace inside a value is
// rejected so a stray `name1 name2` line is caught here
// rather than silently producing one workflow with a broken
// name. Workflow-name validity is checked at hook-install
// time so the config package does not depend on the workflow
// registry (cycle).
if !sawPreCommitWorkflows {
cfg.PreCommitWorkflows = nil
sawPreCommitWorkflows = true
}
if val == "" {
continue
}
if strings.ContainsAny(val, " \t") {
return fmt.Errorf("%s:%d: pre_commit_workflows: name %q must not contain whitespace", path, lineNo+1, val)
}
cfg.PreCommitWorkflows = append(cfg.PreCommitWorkflows, val)
case "post_merge_workflows":
// Repeatable, mirroring pre_commit_workflows: the first
// occurrence resets the binary default, each non-empty
// occurrence appends one builtin workflow name, an empty value
// clears the list so `eeco hooks post-merge on` refuses to
// install. Whitespace inside a value is rejected. Workflow-name
// validity is checked at hook-install time (registry cycle).
if !sawPostMergeWorkflows {
cfg.PostMergeWorkflows = nil
sawPostMergeWorkflows = true
}
if val == "" {
continue
}
if strings.ContainsAny(val, " \t") {
return fmt.Errorf("%s:%d: post_merge_workflows: name %q must not contain whitespace", path, lineNo+1, val)
}
cfg.PostMergeWorkflows = append(cfg.PostMergeWorkflows, val)
case "version_locations":
// Repeatable: each occurrence appends one `path:regex` entry
// the `version-sync` builtin reads to detect drift. The path
// is repo-relative; absolute paths and `..` traversal are
// rejected here so the gate never reads outside the repo. The
// regex syntax is RE2 (Go's `regexp`) and must declare at
// least one capture group; the workflow validates that at run
// time. An empty value is ignored so a blank override line
// does not produce a phantom entry. The reserved value `auto`
// switches the gate to auto-detect; it must stand alone —
// mixing it with explicit `path:regex` entries (in either
// order, or declaring it twice) is rejected here.
if val == "" {
continue
}
autoDeclared := len(cfg.VersionLocations) == 1 && cfg.VersionLocations[0] == "auto"
if val == "auto" {
if len(cfg.VersionLocations) > 0 {
return fmt.Errorf("%s:%d: version_locations=auto must be the only version_locations entry", path, lineNo+1)
}
cfg.VersionLocations = []string{"auto"}
continue
}
if autoDeclared {
return fmt.Errorf("%s:%d: version_locations=auto must be the only version_locations entry", path, lineNo+1)
}
relPart, _, hasColon := strings.Cut(val, ":")
if !hasColon || relPart == "" {
return fmt.Errorf("%s:%d: version_locations: expected \"path:regex\" or \"auto\" (got %q)", path, lineNo+1, val)
}
if filepath.IsAbs(relPart) || strings.HasPrefix(relPart, "/") || strings.HasPrefix(relPart, `\`) {
return fmt.Errorf("%s:%d: version_locations path must be repo-relative (got %q)", path, lineNo+1, relPart)
}
cleanRel := filepath.ToSlash(filepath.Clean(relPart))
if cleanRel == ".." || strings.HasPrefix(cleanRel, "../") {
return fmt.Errorf("%s:%d: version_locations path escapes the repo (got %q)", path, lineNo+1, relPart)
}
cfg.VersionLocations = append(cfg.VersionLocations, val)
case "version_anchor":
// Single-valued. Three modes: empty (consistency-only,
// default), "tag" (latest semver-shaped reachable git tag is
// the source of truth), or "<path>:<regex>" (designated file).
// The designated-file form is path-validated here (same reject
// table as `version_locations` and the other repo-relative
// keys); regex validity is enforced at workflow run time.
switch val {
case "":
cfg.VersionAnchor = ""
case "tag":
cfg.VersionAnchor = "tag"
default:
relPart, regexPart, hasColon := strings.Cut(val, ":")
if !hasColon || relPart == "" || regexPart == "" {
return fmt.Errorf("%s:%d: version_anchor: expected \"tag\" or \"path:regex\" (got %q)", path, lineNo+1, val)
}
if filepath.IsAbs(relPart) || strings.HasPrefix(relPart, "/") || strings.HasPrefix(relPart, `\`) {
return fmt.Errorf("%s:%d: version_anchor path must be repo-relative (got %q)", path, lineNo+1, relPart)
}
cleanRel := filepath.ToSlash(filepath.Clean(relPart))
if cleanRel == ".." || strings.HasPrefix(cleanRel, "../") {
return fmt.Errorf("%s:%d: version_anchor path escapes the repo (got %q)", path, lineNo+1, relPart)
}
cfg.VersionAnchor = val
}
case "context_path":
// Workspace-relative file `eeco go --write` renders the brief
// into. An empty value falls back to the default. Absolute
// paths and `..` traversal are rejected so the write-scope
// guard (Constraint 1) holds at parse time, not just at write
// time. The unix-style-prefix check catches `/abs/path` even
// on Windows, where filepath.IsAbs returns false without a
// drive letter.
if val == "" {
cfg.ContextPath = DefaultContextPath
} else if filepath.IsAbs(val) || strings.HasPrefix(val, "/") || strings.HasPrefix(val, `\`) {
return fmt.Errorf("%s:%d: context_path must be relative (got %q)", path, lineNo+1, val)
} else {
clean := filepath.ToSlash(filepath.Clean(val))
if clean == ".." || strings.HasPrefix(clean, "../") {
return fmt.Errorf("%s:%d: context_path escapes the workspace (got %q)", path, lineNo+1, val)
}
cfg.ContextPath = clean
}
case "context_budget":
// Byte cap on the file `eeco go --write` renders. An empty
// value falls back to the default (0, no cap); a negative
// value is rejected. 0 keeps the full brief.
if val == "" {
cfg.ContextBudget = DefaultContextBudget
} else {
n, err := strconv.Atoi(val)
if err != nil {
return fmt.Errorf("%s:%d: context_budget: %w", path, lineNo+1, err)
}
if n < 0 {
return fmt.Errorf("%s:%d: context_budget must be >= 0 (got %d)", path, lineNo+1, n)
}
cfg.ContextBudget = n
}
case "brief_include_notes":
// Opt into a "Recent notes" section in the `eeco go` brief.
// Boolean, default false. An empty value falls back to the
// default; any value strconv.ParseBool does not accept
// ("true"/"false"/"1"/"0"/"t"/"f" in either case) is rejected
// at parse time rather than silently defaulting, so a typo
// surfaces immediately.
if val == "" {
cfg.BriefIncludeNotes = DefaultBriefIncludeNotes
} else {
b, err := strconv.ParseBool(val)
if err != nil {
return fmt.Errorf("%s:%d: brief_include_notes: %w", path, lineNo+1, err)
}
cfg.BriefIncludeNotes = b
}
case "session_start_pinned_bodies":
// Opt into a fourth "pinned memory bodies" block on the
// bundled session-start hook output. Boolean, default false.
// Same parse contract as brief_include_notes — empty falls
// back to the default, typos are loud.
if val == "" {
cfg.SessionStartPinnedBodies = DefaultSessionStartPinnedBodies
} else {
b, err := strconv.ParseBool(val)
if err != nil {
return fmt.Errorf("%s:%d: session_start_pinned_bodies: %w", path, lineNo+1, err)
}
cfg.SessionStartPinnedBodies = b
}
case "init_detection_threshold":
// Confidence floor `eeco init` uses to accept the project-type
// marker scan without prompting. An empty value falls back to
// the default (0, which the detector reads as "use my built-in
// default"). The value must be a fraction in [0,1]; anything
// outside that range is rejected at parse time rather than
// silently clamped, so a typo surfaces immediately.
if val == "" {
cfg.InitDetectionThreshold = 0
} else {
f, err := strconv.ParseFloat(val, 64)
if err != nil {
return fmt.Errorf("%s:%d: init_detection_threshold: %w", path, lineNo+1, err)
}
if f < 0 || f > 1 {
return fmt.Errorf("%s:%d: init_detection_threshold must be in [0,1] (got %s)", path, lineNo+1, val)
}
cfg.InitDetectionThreshold = f
}
default:
// Unknown keys are tolerated for forward-compatibility.
}
}
return nil
}
func unquote(s string) string {
if len(s) >= 2 {
first, last := s[0], s[len(s)-1]
if (first == '"' || first == '\'') && first == last {
return s[1 : len(s)-1]
}
}
return s
}
added internal/config/config_c4b_test.go
@@ -0,0 +1,35 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestReconcilesCockpit(t *testing.T) {
if !AutomationAuto.ReconcilesCockpit() {
t.Error("auto should reconcile the cockpit")
}
for _, a := range []Automation{AutomationManual, AutomationPropose, AutomationScaffold} {
if a.ReconcilesCockpit() {
t.Errorf("%s should not reconcile the cockpit", a)
}
}
}
func TestHandoverGlobParse(t *testing.T) {
ws := filepath.Join(t.TempDir(), ".eeco")
if err := os.MkdirAll(ws, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(ws, "config.local"), []byte("handover_glob=ajhahnde/RESUME*.md\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg := &Config{Workspace: ws}
if err := applyLocal(cfg); err != nil {
t.Fatal(err)
}
if cfg.HandoverGlob != "ajhahnde/RESUME*.md" {
t.Errorf("HandoverGlob = %q, want ajhahnde/RESUME*.md", cfg.HandoverGlob)
}
}
added internal/config/config_test.go
@@ -0,0 +1,1880 @@
package config
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// TestMain pins the workspace owner so Load resolves a deterministic
// username across machines instead of picking up the dev box's
// `git config user.name`. Every Load in this package then scopes the
// workspace under <root>/tester/.eeco.
func TestMain(m *testing.M) {
os.Setenv("EECO_USERNAME", "tester")
os.Exit(m.Run())
}
func TestFindRepoRoot_WalksUpToDotGit(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil {
t.Fatal(err)
}
deep := filepath.Join(root, "a", "b", "c")
if err := os.MkdirAll(deep, 0o755); err != nil {
t.Fatal(err)
}
got, err := FindRepoRoot(deep)
if err != nil {
t.Fatalf("FindRepoRoot(%q) error: %v", deep, err)
}
wantRoot, _ := filepath.EvalSymlinks(root)
gotRoot, _ := filepath.EvalSymlinks(got)
if gotRoot != wantRoot {
t.Fatalf("FindRepoRoot = %q, want %q", gotRoot, wantRoot)
}
}
func TestFindRepoRoot_AcceptsGitFile(t *testing.T) {
// Worktrees use a `.git` *file* with a gitdir pointer; FindRepoRoot
// must accept that too.
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".git"), []byte("gitdir: x\n"), 0o644); err != nil {
t.Fatal(err)
}
got, err := FindRepoRoot(root)
if err != nil {
t.Fatal(err)
}
wantRoot, _ := filepath.EvalSymlinks(root)
gotRoot, _ := filepath.EvalSymlinks(got)
if gotRoot != wantRoot {
t.Fatalf("FindRepoRoot = %q, want %q", gotRoot, wantRoot)
}
}
func TestFindRepoRoot_ErrorsOutsideRepo(t *testing.T) {
// A fresh temp directory should not be inside any git repo on any
// sane test machine; if it is, the test environment is broken.
dir := t.TempDir()
if _, err := FindRepoRoot(dir); err == nil {
t.Fatal("expected error outside repo, got nil")
}
}
func TestDetectProfile(t *testing.T) {
cases := []struct {
name string
seed map[string]string // path -> contents
want Profile
}{
{"go", map[string]string{"go.mod": "module x\n"}, ProfileGo},
{"zig", map[string]string{"build.zig": ""}, ProfileZig},
{"rust", map[string]string{"Cargo.toml": "[package]\n"}, ProfileRust},
{"node", map[string]string{"package.json": "{}"}, ProfileNode},
{"python-pyproject", map[string]string{"pyproject.toml": ""}, ProfilePython},
{"python-requirements", map[string]string{"requirements.txt": ""}, ProfilePython},
{"python-requirements-dev", map[string]string{"requirements-dev.txt": ""}, ProfilePython},
{"python-venv", map[string]string{".venv/bin/python": ""}, ProfilePython},
{"generic-empty", map[string]string{}, ProfileGeneric},
{"generic-random", map[string]string{"some-file.txt": "x"}, ProfileGeneric},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
for path, content := range tc.seed {
full := filepath.Join(dir, path)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
got := DetectProfile(dir)
if got != tc.want {
t.Fatalf("DetectProfile = %q, want %q", got, tc.want)
}
})
}
}
func TestDetectProfile_GoWinsOverPython(t *testing.T) {
// A polyglot repo with both go.mod and pyproject.toml resolves to
// the documented precedence order (Go first).
dir := t.TempDir()
write(t, dir, "go.mod", "module x\n")
write(t, dir, "pyproject.toml", "")
if got := DetectProfile(dir); got != ProfileGo {
t.Fatalf("DetectProfile polyglot = %q, want %q", got, ProfileGo)
}
}
func TestGateFor(t *testing.T) {
cases := map[Profile][][]string{
ProfileGo: {{"go", "vet", "./..."}},
ProfileZig: {{"zig", "build", "--summary", "none"}},
ProfileRust: {{"cargo", "check", "--quiet"}},
ProfileNode: {{"npm", "run", "--if-present", "typecheck"}},
ProfilePython: {{"python3", "-m", "compileall", "-q", "."}},
ProfileGeneric: nil,
}
for p, want := range cases {
got := GateFor(p)
if !reflect.DeepEqual(got, want) {
t.Errorf("GateFor(%q) = %v, want %v", p, got, want)
}
}
}
func TestGateFor_ReturnsFreshSlice(t *testing.T) {
a := GateFor(ProfileGo)
b := GateFor(ProfileGo)
a[0][0] = "MUTATED"
if b[0][0] == "MUTATED" {
t.Fatal("GateFor returned a shared backing array; expected a fresh copy")
}
}
func TestLoad_DefaultsAndRepoRoot(t *testing.T) {
root := newRepo(t)
write(t, root, "go.mod", "module x\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if got, want := cfg.WorkspaceName, DefaultWorkspace; got != want {
t.Errorf("workspace name = %q, want %q", got, want)
}
wantWS := filepath.Join(root, "tester", DefaultWorkspace)
gotWS, _ := filepath.EvalSymlinks(filepath.Dir(cfg.Workspace))
wantWSDir, _ := filepath.EvalSymlinks(filepath.Dir(wantWS))
if gotWS != wantWSDir {
t.Errorf("workspace parent = %q, want %q", gotWS, wantWSDir)
}
if cfg.Profile != ProfileGo {
t.Errorf("profile = %q, want %q", cfg.Profile, ProfileGo)
}
if !reflect.DeepEqual(cfg.Gate, [][]string{{"go", "vet", "./..."}}) {
t.Errorf("gate = %v", cfg.Gate)
}
}
func TestLoad_RejectsBadWorkspaceName(t *testing.T) {
root := newRepo(t)
cases := []string{"..", ".", "foo/bar", "/abs", `back\slash`}
for _, name := range cases {
if _, err := Load(root, name); err == nil {
t.Errorf("Load(%q) succeeded; expected error", name)
}
}
}
func TestLoad_ConfigLocalOverride(t *testing.T) {
root := newRepo(t)
write(t, root, "go.mod", "module x\n")
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", strings.Join([]string{
"# comment line",
"",
`profile = "generic"`,
"gate=make check",
"unknown_key=ignored",
}, "\n"))
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.Profile != ProfileGeneric {
t.Errorf("profile override = %q, want generic", cfg.Profile)
}
if !reflect.DeepEqual(cfg.Gate, [][]string{{"make", "check"}}) {
t.Errorf("gate override = %v, want [[make check]]", cfg.Gate)
}
}
func TestLoad_ConfigLocalProfileResetsGate(t *testing.T) {
root := newRepo(t)
write(t, root, "go.mod", "module x\n")
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "profile=rust\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.Profile != ProfileRust {
t.Fatalf("profile = %q, want rust", cfg.Profile)
}
if !reflect.DeepEqual(cfg.Gate, [][]string{{"cargo", "check", "--quiet"}}) {
t.Fatalf("gate after profile override = %v", cfg.Gate)
}
}
func TestLoad_ConfigLocalEmptyGateDisablesIt(t *testing.T) {
root := newRepo(t)
write(t, root, "go.mod", "module x\n")
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "gate=\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.Gate != nil {
t.Fatalf("gate = %v, want nil", cfg.Gate)
}
}
func TestLoad_ConfigLocalMultiStepGate(t *testing.T) {
root := newRepo(t)
write(t, root, "go.mod", "module x\n")
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
// Three `gate=` lines: the first resets the profile default, all
// three append, so the chain runs in declared order.
write(t, wsDir, "config.local", strings.Join([]string{
"gate=go vet ./...",
"gate=staticcheck ./...",
"gate=go test ./...",
}, "\n"))
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
want := [][]string{
{"go", "vet", "./..."},
{"staticcheck", "./..."},
{"go", "test", "./..."},
}
if !reflect.DeepEqual(cfg.Gate, want) {
t.Fatalf("gate chain = %v, want %v", cfg.Gate, want)
}
}
func TestLoad_DefaultStaleDays(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.StaleDays != DefaultStaleDays {
t.Errorf("StaleDays = %d, want %d", cfg.StaleDays, DefaultStaleDays)
}
}
func TestLoad_ConfigLocalStaleDaysOverride(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "stale_days=7\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.StaleDays != 7 {
t.Errorf("StaleDays = %d, want 7", cfg.StaleDays)
}
}
func TestLoad_ConfigLocalStaleDaysMalformed(t *testing.T) {
cases := []string{"stale_days=abc\n", "stale_days=-3\n"}
for _, body := range cases {
t.Run(body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
func TestLoad_ConfigLocalInitDetectionThreshold(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "init_detection_threshold=0.85\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.InitDetectionThreshold != 0.85 {
t.Errorf("InitDetectionThreshold = %v, want 0.85", cfg.InitDetectionThreshold)
}
}
func TestLoad_ConfigLocalInitDetectionThresholdMalformed(t *testing.T) {
cases := []string{
"init_detection_threshold=abc\n",
"init_detection_threshold=2\n",
"init_detection_threshold=-0.1\n",
}
for _, body := range cases {
t.Run(body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
func TestLoad_ConfigLocalMalformed(t *testing.T) {
root := newRepo(t)
write(t, root, "go.mod", "module x\n")
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "no-equals-sign-here\n")
if _, err := Load(root, ""); err == nil {
t.Fatal("expected malformed config.local to error")
}
}
func TestLoad_SessionSettingsPath(t *testing.T) {
t.Run("unset default is empty", func(t *testing.T) {
t.Setenv("EECO_SESSION_SETTINGS", "")
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.SessionSettingsPath != "" {
t.Errorf("SessionSettingsPath = %q, want empty", cfg.SessionSettingsPath)
}
})
t.Run("env supplies the default", func(t *testing.T) {
t.Setenv("EECO_SESSION_SETTINGS", "/abs/env/settings.json")
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.SessionSettingsPath != "/abs/env/settings.json" {
t.Errorf("SessionSettingsPath = %q, want the env value", cfg.SessionSettingsPath)
}
})
t.Run("config.local overrides env", func(t *testing.T) {
envPath := filepath.Join(t.TempDir(), "env-settings.json")
localPath := filepath.Join(t.TempDir(), "local-settings.json")
t.Setenv("EECO_SESSION_SETTINGS", envPath)
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "session_settings_path="+localPath+"\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.SessionSettingsPath != localPath {
t.Errorf("SessionSettingsPath = %q, want the config.local value", cfg.SessionSettingsPath)
}
})
t.Run("empty value clears the env default", func(t *testing.T) {
t.Setenv("EECO_SESSION_SETTINGS", "/abs/env/settings.json")
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "session_settings_path=\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.SessionSettingsPath != "" {
t.Errorf("SessionSettingsPath = %q, want empty after explicit clear", cfg.SessionSettingsPath)
}
})
t.Run("relative path is rejected", func(t *testing.T) {
t.Setenv("EECO_SESSION_SETTINGS", "")
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "session_settings_path=rel/settings.json\n")
if _, err := Load(root, ""); err == nil {
t.Fatal("expected a relative session_settings_path to error")
}
})
}
func TestLoad_DefaultBugReportDir(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.BugReportDir != DefaultBugReportDir {
t.Errorf("BugReportDir = %q, want %q", cfg.BugReportDir, DefaultBugReportDir)
}
}
func TestLoad_ConfigLocalBugReportDirOverride(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "bug_report_dir=my-bugs\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.BugReportDir != "my-bugs" {
t.Errorf("BugReportDir = %q, want my-bugs", cfg.BugReportDir)
}
}
func TestLoad_ConfigLocalBugReportDirEmptyResetsToDefault(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "bug_report_dir=\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.BugReportDir != DefaultBugReportDir {
t.Errorf("BugReportDir = %q, want default %q", cfg.BugReportDir, DefaultBugReportDir)
}
}
func TestLoad_ConfigLocalBugReportDirRejected(t *testing.T) {
cases := []string{
"bug_report_dir=/abs/path\n",
"bug_report_dir=..\n",
"bug_report_dir=../escape\n",
"bug_report_dir=sub/../../escape\n",
}
for _, body := range cases {
t.Run(body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
func TestLoad_DefaultContextPath(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.ContextPath != DefaultContextPath {
t.Errorf("ContextPath = %q, want %q", cfg.ContextPath, DefaultContextPath)
}
}
func TestLoad_ConfigLocalContextPathOverride(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "context_path=brief/project.md\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.ContextPath != "brief/project.md" {
t.Errorf("ContextPath = %q, want brief/project.md", cfg.ContextPath)
}
}
func TestLoad_ConfigLocalContextPathEmptyResetsToDefault(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "context_path=\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.ContextPath != DefaultContextPath {
t.Errorf("ContextPath = %q, want default %q", cfg.ContextPath, DefaultContextPath)
}
}
func TestLoad_ConfigLocalContextPathRejected(t *testing.T) {
cases := []string{
"context_path=/abs/path.md\n",
"context_path=..\n",
"context_path=../escape.md\n",
"context_path=sub/../../escape.md\n",
}
for _, body := range cases {
t.Run(body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
func TestLoad_DefaultContextBudget(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.ContextBudget != DefaultContextBudget {
t.Errorf("ContextBudget = %d, want %d", cfg.ContextBudget, DefaultContextBudget)
}
}
func TestLoad_ConfigLocalContextBudgetOverride(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "context_budget=800\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.ContextBudget != 800 {
t.Errorf("ContextBudget = %d, want 800", cfg.ContextBudget)
}
}
func TestLoad_ConfigLocalContextBudgetEmptyResetsToDefault(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "context_budget=\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.ContextBudget != DefaultContextBudget {
t.Errorf("ContextBudget = %d, want default %d", cfg.ContextBudget, DefaultContextBudget)
}
}
func TestLoad_ConfigLocalContextBudgetRejected(t *testing.T) {
cases := []string{
"context_budget=-1\n",
"context_budget=notanumber\n",
}
for _, body := range cases {
t.Run(body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
func TestLoad_DefaultBriefIncludeNotes(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.BriefIncludeNotes != DefaultBriefIncludeNotes {
t.Errorf("BriefIncludeNotes = %v, want %v", cfg.BriefIncludeNotes, DefaultBriefIncludeNotes)
}
}
func TestLoad_ConfigLocalBriefIncludeNotesAccepted(t *testing.T) {
cases := []struct {
body string
want bool
}{
{"brief_include_notes=true\n", true},
{"brief_include_notes=false\n", false},
{"brief_include_notes=1\n", true},
{"brief_include_notes=0\n", false},
{"brief_include_notes=\n", DefaultBriefIncludeNotes},
}
for _, tc := range cases {
t.Run(tc.body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", tc.body)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.BriefIncludeNotes != tc.want {
t.Errorf("BriefIncludeNotes = %v, want %v", cfg.BriefIncludeNotes, tc.want)
}
})
}
}
func TestLoad_ConfigLocalBriefIncludeNotesRejected(t *testing.T) {
cases := []string{
"brief_include_notes=yes\n",
"brief_include_notes=no\n",
"brief_include_notes=notabool\n",
}
for _, body := range cases {
t.Run(body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
func TestLoad_DefaultSessionStartPinnedBodies(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.SessionStartPinnedBodies != DefaultSessionStartPinnedBodies {
t.Errorf("SessionStartPinnedBodies = %v, want %v",
cfg.SessionStartPinnedBodies, DefaultSessionStartPinnedBodies)
}
}
func TestLoad_ConfigLocalSessionStartPinnedBodiesAccepted(t *testing.T) {
cases := []struct {
body string
want bool
}{
{"session_start_pinned_bodies=true\n", true},
{"session_start_pinned_bodies=false\n", false},
{"session_start_pinned_bodies=1\n", true},
{"session_start_pinned_bodies=0\n", false},
{"session_start_pinned_bodies=\n", DefaultSessionStartPinnedBodies},
}
for _, tc := range cases {
t.Run(tc.body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", tc.body)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.SessionStartPinnedBodies != tc.want {
t.Errorf("SessionStartPinnedBodies = %v, want %v",
cfg.SessionStartPinnedBodies, tc.want)
}
})
}
}
func TestLoad_ConfigLocalSessionStartPinnedBodiesRejected(t *testing.T) {
cases := []string{
"session_start_pinned_bodies=yes\n",
"session_start_pinned_bodies=no\n",
"session_start_pinned_bodies=notabool\n",
}
for _, body := range cases {
t.Run(body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
func TestLoad_DefaultVersionLocationsEmpty(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if len(cfg.VersionLocations) != 0 {
t.Errorf("VersionLocations = %v, want empty", cfg.VersionLocations)
}
}
func TestLoad_ConfigLocalVersionLocationsRepeatable(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", strings.Join([]string{
`version_locations=CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`version_locations=VERSION:^v(\d+\.\d+\.\d+)`,
"",
"version_locations=", // blank — ignored, no phantom entry
}, "\n")+"\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
want := []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`VERSION:^v(\d+\.\d+\.\d+)`,
}
if !reflect.DeepEqual(cfg.VersionLocations, want) {
t.Fatalf("VersionLocations = %v, want %v", cfg.VersionLocations, want)
}
}
func TestLoad_ConfigLocalVersionLocationsRejected(t *testing.T) {
cases := []string{
"version_locations=no-colon-here\n",
"version_locations=:no-path\n",
"version_locations=/abs/path:v(\\d+)\n",
"version_locations=..:v(\\d+)\n",
"version_locations=../escape.md:v(\\d+)\n",
"version_locations=sub/../../escape.md:v(\\d+)\n",
}
for _, body := range cases {
t.Run(body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
func TestLoad_ConfigLocalVersionLocationsAuto(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "version_locations=auto\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
want := []string{"auto"}
if !reflect.DeepEqual(cfg.VersionLocations, want) {
t.Fatalf("VersionLocations = %v, want %v", cfg.VersionLocations, want)
}
}
func TestLoad_ConfigLocalVersionLocationsAutoRejectsMix(t *testing.T) {
cases := map[string]string{
"auto then explicit": "version_locations=auto\n" +
`version_locations=VERSION:v(\d+\.\d+\.\d+)` + "\n",
"explicit then auto": `version_locations=VERSION:v(\d+\.\d+\.\d+)` + "\n" +
"version_locations=auto\n",
"auto twice": "version_locations=auto\nversion_locations=auto\n",
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
func TestLoad_DefaultVersionAnchorEmpty(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.VersionAnchor != "" {
t.Errorf("VersionAnchor default = %q, want empty", cfg.VersionAnchor)
}
}
func TestLoad_ConfigLocalVersionAnchorTag(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "version_anchor=tag\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.VersionAnchor != "tag" {
t.Errorf("VersionAnchor = %q, want %q", cfg.VersionAnchor, "tag")
}
}
func TestLoad_ConfigLocalVersionAnchorFile(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
body := `version_anchor=VERSION:^v(\d+\.\d+\.\d+)` + "\n"
write(t, wsDir, "config.local", body)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
want := `VERSION:^v(\d+\.\d+\.\d+)`
if cfg.VersionAnchor != want {
t.Errorf("VersionAnchor = %q, want %q", cfg.VersionAnchor, want)
}
}
func TestLoad_ConfigLocalVersionAnchorEmptyResetsToDefault(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "version_anchor=\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.VersionAnchor != "" {
t.Errorf("VersionAnchor = %q, want empty", cfg.VersionAnchor)
}
}
func TestLoad_ConfigLocalVersionAnchorRejected(t *testing.T) {
cases := []string{
"version_anchor=no-colon-here\n",
"version_anchor=:no-path\n",
"version_anchor=VERSION:\n",
"version_anchor=/abs/path:v(\\d+)\n",
"version_anchor=..:v(\\d+)\n",
"version_anchor=../escape.md:v(\\d+)\n",
"version_anchor=sub/../../escape.md:v(\\d+)\n",
}
for _, body := range cases {
t.Run(body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
func TestLoad_DefaultPreCommitWorkflows(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
want := []string{"leak-guard", "version-sync"}
if !reflect.DeepEqual(cfg.PreCommitWorkflows, want) {
t.Errorf("PreCommitWorkflows default = %v, want %v", cfg.PreCommitWorkflows, want)
}
}
func TestLoad_ConfigLocalPreCommitWorkflowsReplacesDefault(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", strings.Join([]string{
"pre_commit_workflows=leak-guard",
"pre_commit_workflows=comment-hygiene",
"",
"pre_commit_workflows=", // blank — ignored, no phantom entry
}, "\n")+"\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
want := []string{"leak-guard", "comment-hygiene"}
if !reflect.DeepEqual(cfg.PreCommitWorkflows, want) {
t.Fatalf("PreCommitWorkflows = %v, want %v", cfg.PreCommitWorkflows, want)
}
}
func TestLoad_ConfigLocalPreCommitWorkflowsEmptyDisables(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "pre_commit_workflows=\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if len(cfg.PreCommitWorkflows) != 0 {
t.Errorf("PreCommitWorkflows = %v, want empty (default disabled)", cfg.PreCommitWorkflows)
}
}
func TestLoad_ConfigLocalPreCommitWorkflowsRejectsWhitespace(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "pre_commit_workflows=leak-guard version-sync\n")
if _, err := Load(root, ""); err == nil {
t.Fatal("expected error on whitespace-containing workflow name")
}
}
func TestLoad_DefaultPostMergeWorkflows(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
want := []string{"memory-drift", "doc-drift", "manifest-refresh", "cockpit-sync"}
if !reflect.DeepEqual(cfg.PostMergeWorkflows, want) {
t.Errorf("PostMergeWorkflows default = %v, want %v", cfg.PostMergeWorkflows, want)
}
}
func TestLoad_ConfigLocalPostMergeWorkflowsReplacesDefault(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", strings.Join([]string{
"post_merge_workflows=memory-drift",
"post_merge_workflows=", // blank — ignored, no phantom entry
}, "\n")+"\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
want := []string{"memory-drift"}
if !reflect.DeepEqual(cfg.PostMergeWorkflows, want) {
t.Fatalf("PostMergeWorkflows = %v, want %v", cfg.PostMergeWorkflows, want)
}
}
func TestLoad_ConfigLocalPostMergeWorkflowsEmptyDisables(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "post_merge_workflows=\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if len(cfg.PostMergeWorkflows) != 0 {
t.Errorf("PostMergeWorkflows = %v, want empty (default disabled)", cfg.PostMergeWorkflows)
}
}
func TestLoad_ConfigLocalPostMergeWorkflowsRejectsWhitespace(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "post_merge_workflows=memory-drift doc-drift\n")
if _, err := Load(root, ""); err == nil {
t.Fatal("expected error on whitespace-containing workflow name")
}
}
func TestLoad_DefaultSessionFiles(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if len(cfg.SessionFiles) != 0 {
t.Errorf("SessionFiles default = %v, want empty", cfg.SessionFiles)
}
}
func TestLoad_ConfigLocalSessionFilesRepoRelative(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", strings.Join([]string{
"session_files=CLAUDE.md",
"session_files=AGENTS.md",
"",
}, "\n"))
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
want := []string{"CLAUDE.md", "AGENTS.md"}
if !reflect.DeepEqual(cfg.SessionFiles, want) {
t.Errorf("SessionFiles = %v, want %v", cfg.SessionFiles, want)
}
}
func TestLoad_ConfigLocalSessionFilesAbsoluteAccepted(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
abs := filepath.Join(t.TempDir(), "cursor-rules.md")
write(t, wsDir, "config.local", "session_files="+abs+"\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if len(cfg.SessionFiles) != 1 || cfg.SessionFiles[0] != abs {
t.Errorf("SessionFiles = %v, want [%q]", cfg.SessionFiles, abs)
}
}
func TestLoad_ConfigLocalSessionFilesMixed(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
abs := filepath.Join(t.TempDir(), "cursor-rules.md")
write(t, wsDir, "config.local", strings.Join([]string{
"session_files=CLAUDE.md",
"session_files=" + abs,
"",
}, "\n"))
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
want := []string{"CLAUDE.md", abs}
if !reflect.DeepEqual(cfg.SessionFiles, want) {
t.Errorf("SessionFiles = %v, want %v", cfg.SessionFiles, want)
}
}
func TestLoad_ConfigLocalSessionFilesRejected(t *testing.T) {
cases := []string{
"session_files=..\n",
"session_files=../escape.md\n",
"session_files=sub/../../escape.md\n",
"session_files=has space.md\n",
}
for _, body := range cases {
t.Run(body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
func TestWriteLocalKeys_UpsertPreservesAndRoundTrips(t *testing.T) {
root := newRepo(t)
write(t, root, "go.mod", "module x\n")
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", strings.Join([]string{
"# keep me",
"stale_days=7",
"unknown_key=keep",
}, "\n")+"\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if err := WriteLocalKeys(cfg, map[string]string{
"stale_days": "9", // replace in place
"automation": "scaffold", // append
"ai_budget": "3", // append
}); err != nil {
t.Fatal(err)
}
b, _ := os.ReadFile(filepath.Join(wsDir, "config.local"))
got := string(b)
if !strings.Contains(got, "# keep me") || !strings.Contains(got, "unknown_key=keep") {
t.Errorf("comments / unknown keys not preserved:\n%s", got)
}
if strings.Contains(got, "stale_days=7") || !strings.Contains(got, "stale_days=9") {
t.Errorf("stale_days not replaced in place:\n%s", got)
}
// The override must round-trip through Load.
cfg2, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg2.StaleDays != 9 {
t.Errorf("StaleDays = %d, want 9", cfg2.StaleDays)
}
if cfg2.Automation != AutomationScaffold {
t.Errorf("Automation = %q, want scaffold", cfg2.Automation)
}
if cfg2.AIBudget != 3 {
t.Errorf("AIBudget = %d, want 3", cfg2.AIBudget)
}
}
func TestWriteLocalKeys_CreatesFileWhenMissing(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if err := WriteLocalKeys(cfg, map[string]string{"automation": "auto"}); err != nil {
t.Fatal(err)
}
cfg2, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg2.Automation != AutomationAuto {
t.Errorf("Automation = %q, want auto", cfg2.Automation)
}
}
func TestWriteLocalKeys_RequiresInitialisedWorkspace(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if err := WriteLocalKeys(cfg, map[string]string{"automation": "auto"}); err == nil {
t.Fatal("expected an error when the workspace is not initialised")
}
}
// --- helpers ---
func newRepo(t *testing.T) string {
t.Helper()
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil {
t.Fatal(err)
}
return root
}
func write(t *testing.T, dir, name, content string) {
t.Helper()
full := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestLoad_DefaultWorkspaceHistory(t *testing.T) {
root := newRepo(t)
write(t, root, "go.mod", "module x\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.WorkspaceHistory != DefaultWorkspaceHistory {
t.Errorf("WorkspaceHistory = %q, want %q (default)", cfg.WorkspaceHistory, DefaultWorkspaceHistory)
}
if DefaultWorkspaceHistory != WorkspaceHistoryManual {
t.Errorf("DefaultWorkspaceHistory = %q, want manual (safe-default floor)", DefaultWorkspaceHistory)
}
}
func TestLoad_ConfigLocalWorkspaceHistory(t *testing.T) {
cases := []struct {
val string
want WorkspaceHistory
}{
{"off", WorkspaceHistoryOff},
{"manual", WorkspaceHistoryManual},
{"auto", WorkspaceHistoryAuto},
{"", WorkspaceHistoryManual}, // empty resets to default
{"nonsense", WorkspaceHistoryManual}, // unknown → default (floor)
}
for _, tc := range cases {
t.Run(tc.val, func(t *testing.T) {
root := newRepo(t)
write(t, root, "go.mod", "module x\n")
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "workspace_history="+tc.val+"\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.WorkspaceHistory != tc.want {
t.Errorf("workspace_history=%q → %q, want %q", tc.val, cfg.WorkspaceHistory, tc.want)
}
})
}
}
func TestWorkspaceHistory_EnabledAuto(t *testing.T) {
cases := []struct {
h WorkspaceHistory
enabled bool
auto bool
}{
{WorkspaceHistoryOff, false, false},
{WorkspaceHistoryManual, true, false},
{WorkspaceHistoryAuto, true, true},
}
for _, tc := range cases {
if got := tc.h.Enabled(); got != tc.enabled {
t.Errorf("%q.Enabled() = %v, want %v", tc.h, got, tc.enabled)
}
if got := tc.h.Auto(); got != tc.auto {
t.Errorf("%q.Auto() = %v, want %v", tc.h, got, tc.auto)
}
}
}
// --- H1.2: branch/edge coverage deepening (test-only) ---
// Group A — pure/exported functions (no fixtures, direct in-package calls).
// TestGateSteps covers GateSteps (config.go:586-591), which was 0%: the
// nil-chain non-nil-empty contract plus single/multi-step joining.
func TestGateSteps(t *testing.T) {
t.Run("nil chain yields non-nil empty", func(t *testing.T) {
got := GateSteps(nil)
if got == nil {
t.Fatal("GateSteps(nil) = nil, want non-nil empty slice")
}
if len(got) != 0 {
t.Errorf("GateSteps(nil) = %v, want empty", got)
}
})
cases := []struct {
name string
in [][]string
want []string
}{
{"single multi-arg step", [][]string{{"go", "vet", "./..."}}, []string{"go vet ./..."}},
{"two steps", [][]string{{"go", "vet"}, {"staticcheck", "./..."}}, []string{"go vet", "staticcheck ./..."}},
{"single word step", [][]string{{"make"}}, []string{"make"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := GateSteps(tc.in); !reflect.DeepEqual(got, tc.want) {
t.Errorf("GateSteps(%v) = %v, want %v", tc.in, got, tc.want)
}
})
}
}
// TestValidateWorkspaceName covers the empty (config.go:647-649) and
// not-clean (650-652) reject arms by calling the validator directly: Load
// maps "" to DefaultWorkspace before validating, so the empty arm is only
// reachable here.
func TestValidateWorkspaceName(t *testing.T) {
cases := []struct {
name string
wantErr bool
}{
{"", true},
{"a//b", true},
{"./x", true},
{"/abs", true},
{"a/b", true},
{`a\b`, true},
{".", true},
{"..", true},
{".eeco", false},
{"workspace", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := validateWorkspaceName(tc.name); (err != nil) != tc.wantErr {
t.Errorf("validateWorkspaceName(%q) err = %v, wantErr = %v", tc.name, err, tc.wantErr)
}
})
}
}
// TestSlugUsername covers the space->dash arm (config.go:551-552) and the
// drop/trim edges, including a result that trims to empty.
func TestSlugUsername(t *testing.T) {
cases := []struct {
in string
want string
}{
{"Jane Doe", "Jane-Doe"},
{" ada ", "ada"},
{"a@b!c", "abc"},
{"...x...", "x"},
{"日本語", ""},
{".", ""},
{"", ""},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
if got := slugUsername(tc.in); got != tc.want {
t.Errorf("slugUsername(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// Group B — per-key config.local edge tables (driven via Load). This is
// the "garbage in config.local -> documented default/error, never crash"
// exit, one function per typed key not already covered.
// TestLoad_ConfigLocalAICommand covers the ai_command split/empty arms
// (config.go:760-762).
func TestLoad_ConfigLocalAICommand(t *testing.T) {
cases := []struct {
name string
body string
want []string
}{
{"multi-arg", "ai_command=my tool --flag\n", []string{"my", "tool", "--flag"}},
{"empty leaves nil", "ai_command=\n", nil},
{"single", "ai_command=solo\n", []string{"solo"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", tc.body)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(cfg.AICommand, tc.want) {
t.Errorf("AICommand = %v, want %v", cfg.AICommand, tc.want)
}
})
}
}
// TestLoad_ConfigLocalAIProvider covers the ai_provider passthrough
// (config.go:778), including the floor invariant that an unknown value is
// stored verbatim without error.
func TestLoad_ConfigLocalAIProvider(t *testing.T) {
cases := []struct {
name string
body string
want string
}{
{"cli", "ai_provider=cli\n", "cli"},
{"anthropic", "ai_provider=anthropic\n", "anthropic"},
{"empty", "ai_provider=\n", ""},
{"unknown tolerated", "ai_provider=galaxy-brain\n", "galaxy-brain"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", tc.body)
cfg, err := Load(root, "")
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.AIProvider != tc.want {
t.Errorf("AIProvider = %q, want %q", cfg.AIProvider, tc.want)
}
})
}
}
// TestLoad_ConfigLocalAIModel covers the ai_model passthrough
// (config.go:782): an opaque identifier is stored verbatim, empty resets.
func TestLoad_ConfigLocalAIModel(t *testing.T) {
cases := []struct {
name string
body string
want string
}{
{"identifier", "ai_model=claude-x\n", "claude-x"},
{"empty", "ai_model=\n", ""},
{"opaque chars", "ai_model=anything/with:chars\n", "anything/with:chars"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", tc.body)
cfg, err := Load(root, "")
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.AIModel != tc.want {
t.Errorf("AIModel = %q, want %q", cfg.AIModel, tc.want)
}
})
}
}
// TestLoad_ConfigLocalAIAPIKeyEnv covers ai_api_key_env (config.go:786-790):
// a name is taken verbatim, empty falls back to the default env-var name.
func TestLoad_ConfigLocalAIAPIKeyEnv(t *testing.T) {
cases := []struct {
name string
body string
want string
}{
{"custom name", "ai_api_key_env=MY_VAR\n", "MY_VAR"},
{"empty falls back to default", "ai_api_key_env=\n", DefaultAIAPIKeyEnv},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", tc.body)
cfg, err := Load(root, "")
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.AIAPIKeyEnv != tc.want {
t.Errorf("AIAPIKeyEnv = %q, want %q", cfg.AIAPIKeyEnv, tc.want)
}
})
}
}
// TestLoad_ConfigLocalSessionStartDocs covers session_start_docs accept
// (config.go:809-810 empty-skip, 815 clean, 819 append) and reject
// (812 absolute, 816 escape) arms.
func TestLoad_ConfigLocalSessionStartDocs(t *testing.T) {
t.Run("accepted with empty-skip and clean", func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", strings.Join([]string{
"session_start_docs=docs/a.md",
"session_start_docs=b.md",
"session_start_docs=", // empty value: skipped, no phantom entry
"session_start_docs=sub/./c.md",
"",
}, "\n"))
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
want := []string{"docs/a.md", "b.md", "sub/c.md"}
if !reflect.DeepEqual(cfg.SessionStartDocs, want) {
t.Errorf("SessionStartDocs = %v, want %v", cfg.SessionStartDocs, want)
}
})
for _, body := range []string{
"session_start_docs=/abs/x.md\n",
"session_start_docs=..\n",
"session_start_docs=../escape.md\n",
"session_start_docs=sub/../../escape.md\n",
} {
t.Run("rejected "+body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
// TestLoad_ConfigLocalSessionFilesEmptyAndSlash covers session_files
// empty-skip (config.go:828-829) and the backslash-prefix reject (837-839),
// which is reachable on unix because filepath.IsAbs(`\x`) is false there
// but the HasPrefix(`\`) guard still fires.
func TestLoad_ConfigLocalSessionFilesEmptyAndSlash(t *testing.T) {
t.Run("empty value skipped", func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", strings.Join([]string{
"session_files=CLAUDE.md",
"session_files=",
"",
}, "\n"))
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if len(cfg.SessionFiles) != 1 || cfg.SessionFiles[0] != "CLAUDE.md" {
t.Errorf("SessionFiles = %v, want [CLAUDE.md]", cfg.SessionFiles)
}
})
t.Run("backslash-prefix rejected", func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", `session_files=\x`+"\n")
if _, err := Load(root, ""); err == nil {
t.Fatal(`expected error for session_files=\x`)
}
})
}
// TestLoad_ConfigLocalSessionStartMailbox covers session_start_mailbox
// accept/empty (config.go:849-850 disable, 858 clean) and reject
// (851 absolute, 855 escape) arms.
func TestLoad_ConfigLocalSessionStartMailbox(t *testing.T) {
accept := []struct {
name string
body string
want string
}{
{"simple name", "session_start_mailbox=Inbox.md\n", "Inbox.md"},
{"empty disables", "session_start_mailbox=\n", ""},
{"clean relative", "session_start_mailbox=sub/./M.md\n", "sub/M.md"},
}
for _, tc := range accept {
t.Run(tc.name, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", tc.body)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.SessionStartMailbox != tc.want {
t.Errorf("SessionStartMailbox = %q, want %q", cfg.SessionStartMailbox, tc.want)
}
})
}
for _, body := range []string{
"session_start_mailbox=/abs/M.md\n",
"session_start_mailbox=..\n",
"session_start_mailbox=../escape.md\n",
} {
t.Run("rejected "+body, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", body)
if _, err := Load(root, ""); err == nil {
t.Fatalf("expected error for %q", body)
}
})
}
}
// TestLoad_ConfigLocalSessionStartRoadmapGlob covers the
// session_start_roadmap_glob passthrough (config.go:865): the glob is
// stored verbatim, empty disables discovery.
func TestLoad_ConfigLocalSessionStartRoadmapGlob(t *testing.T) {
cases := []struct {
name string
body string
want string
}{
{"glob verbatim", "session_start_roadmap_glob=plan*.md\n", "plan*.md"},
{"empty disables", "session_start_roadmap_glob=\n", ""},
{"other glob verbatim", "session_start_roadmap_glob=ROADMAP-*.md\n", "ROADMAP-*.md"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", tc.body)
cfg, err := Load(root, "")
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.SessionStartRoadmapGlob != tc.want {
t.Errorf("SessionStartRoadmapGlob = %q, want %q", cfg.SessionStartRoadmapGlob, tc.want)
}
})
}
}
// TestLoad_ConfigLocalInitDetectionThresholdEmpty covers the empty-value
// arm of init_detection_threshold (config.go:1061-1063); the 0.85 and
// malformed cases are covered elsewhere.
func TestLoad_ConfigLocalInitDetectionThresholdEmpty(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, "config.local", "init_detection_threshold=\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if cfg.InitDetectionThreshold != 0 {
t.Errorf("InitDetectionThreshold = %v, want 0", cfg.InitDetectionThreshold)
}
}
// Group C — nil guards + IsInitialized depth (local.go arms; the init.go
// nil guards live in init_test.go).
// TestWriteLocalKeys_NilConfig covers local.go:25-27.
func TestWriteLocalKeys_NilConfig(t *testing.T) {
if err := WriteLocalKeys(nil, map[string]string{"x": "y"}); err == nil {
t.Fatal("WriteLocalKeys(nil, ...) = nil, want error")
}
}
// TestWriteLocalKeys_EmptyMap covers local.go:28-30: an empty map is a
// no-op that returns nil and creates no config.local.
func TestWriteLocalKeys_EmptyMap(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if err := WriteLocalKeys(cfg, nil); err != nil {
t.Fatalf("WriteLocalKeys(cfg, nil) = %v, want nil", err)
}
if _, err := os.Stat(filepath.Join(wsDir, LocalFilename)); !os.IsNotExist(err) {
t.Errorf("config.local stat err = %v, want IsNotExist", err)
}
}
// Group D — filesystem I/O error branches, NO seam (dir-/file-in-the-way).
// Assertions target the package's own wrap text, never the OS errno, so
// they are portable (EISDIR/ENOTDIR are both non-os.ErrNotExist).
// TestApplyLocal_ErrorsWhenConfigLocalIsADirectory covers the applyLocal
// ReadFile non-NotExist arm (config.go:687-688) and the Load read wrap
// (638-639): the workspace stat succeeds (IsDir true), then ReadFile on a
// directory fails.
func TestApplyLocal_ErrorsWhenConfigLocalIsADirectory(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(wsDir, "config.local"), 0o755); err != nil {
t.Fatal(err)
}
_, err := Load(root, "")
if err == nil {
t.Fatal("expected Load to error when config.local is a directory")
}
if !strings.Contains(err.Error(), "read config.local") {
t.Errorf("error = %q, want it to contain %q", err.Error(), "read config.local")
}
}
// TestWriteLocalKeys_ErrorsWhenConfigLocalIsADirectory covers
// local.go:38-40 (ReadFile non-NotExist). WriteLocalKeys reads only
// cfg.Workspace, so a minimal hand-built Config suffices.
func TestWriteLocalKeys_ErrorsWhenConfigLocalIsADirectory(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(wsDir, LocalFilename), 0o755); err != nil {
t.Fatal(err)
}
cfg := &Config{Workspace: wsDir}
err := WriteLocalKeys(cfg, map[string]string{"automation": "auto"})
if err == nil {
t.Fatal("expected WriteLocalKeys to error when config.local is a directory")
}
if !strings.Contains(err.Error(), "read config.local") {
t.Errorf("error = %q, want it to contain %q", err.Error(), "read config.local")
}
}
// TestWriteLocalKeys_PreservesRawNonKVLine covers the no-'=' raw
// passthrough in WriteLocalKeys (local.go:52-54): a malformed line and a
// comment survive an upsert untouched.
func TestWriteLocalKeys_PreservesRawNonKVLine(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
write(t, wsDir, LocalFilename, strings.Join([]string{
"# a comment",
"raw-line-without-equals",
"stale_days=7",
}, "\n")+"\n")
// WriteLocalKeys reads only cfg.Workspace and parses config.local
// itself; a hand-built Config avoids Load rejecting the malformed line.
cfg := &Config{Workspace: wsDir}
if err := WriteLocalKeys(cfg, map[string]string{"automation": "manual"}); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(wsDir, LocalFilename))
if err != nil {
t.Fatal(err)
}
got := string(b)
if !strings.Contains(got, "raw-line-without-equals") {
t.Errorf("raw non-kv line not preserved:\n%s", got)
}
if !strings.Contains(got, "# a comment") {
t.Errorf("comment not preserved:\n%s", got)
}
}
// Group E — resolveUsername fallback + empty-username Init (the Init half
// lives in init_test.go).
// TestResolveUsername_FallbackWhenNoIdentity covers the final fallback
// return (config.go:536). Every identity source is nulled: EECO_USERNAME
// slugs to empty, USER/USERNAME are empty, and GIT_CONFIG_GLOBAL/SYSTEM
// point at nonexistent files so the host's own git user.name cannot leak
// (mirrors the H1.1 gitx isolation). The bare-.git fixture makes
// gitx.UserName return ("", nil).
func TestResolveUsername_FallbackWhenNoIdentity(t *testing.T) {
t.Setenv(UsernameEnv, "!!!") // slugs to "" -> candidate skipped (overrides TestMain "tester")
t.Setenv("USER", "")
t.Setenv("USERNAME", "")
t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(t.TempDir(), "nope-global"))
t.Setenv("GIT_CONFIG_SYSTEM", filepath.Join(t.TempDir(), "nope-system"))
root := newRepo(t)
if got := resolveUsername(root); got != FallbackUsername {
t.Errorf("resolveUsername = %q, want %q", got, FallbackUsername)
}
}
added internal/config/init.go
@@ -0,0 +1,251 @@
package config
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// workspaceSubdirs are the directories scaffolded inside the
// workspace by Init. The order is deterministic for predictable
// reports.
var workspaceSubdirs = []string{"engine", "memory", "workflows", "state", "docs"}
// InitReport summarises the result of an Init call. It is safe to use
// for both the first run (everything created) and idempotent re-runs
// (nothing changed).
type InitReport struct {
RepoRoot string
Username string
Workspace string
WorkspaceName string
Profile Profile
Gate [][]string
CreatedDirs []string
// CreatedKnowledgeDirs lists the project-type knowledge dirs created
// this run inside UserDir (siblings of the engine workspace). Empty
// when cfg carried no KnowledgeDirs or all already existed.
CreatedKnowledgeDirs []string
WroteReadme bool
GitignoreChanged bool
GitignorePath string
AlreadyInit bool
}
// Init scaffolds the workspace described by cfg. It is idempotent: a
// second call against an initialised tree is a no-op except for
// re-reporting state. Init does not touch any file outside RepoRoot
// and creates only files inside the workspace plus a possible
// modification to <repo>/.gitignore (the documented opt-in
// modification).
func Init(cfg *Config) (InitReport, error) {
if cfg == nil {
return InitReport{}, errors.New("config is nil")
}
rep := InitReport{
RepoRoot: cfg.RepoRoot,
Username: cfg.Username,
Workspace: cfg.Workspace,
WorkspaceName: cfg.WorkspaceName,
Profile: cfg.Profile,
Gate: append([][]string(nil), cfg.Gate...),
}
rep.AlreadyInit = IsInitialized(cfg)
if err := ensureDir(cfg.Workspace); err != nil {
return rep, err
}
for _, sub := range workspaceSubdirs {
p := filepath.Join(cfg.Workspace, sub)
created, err := ensureDirCreated(p)
if err != nil {
return rep, err
}
if created {
rep.CreatedDirs = append(rep.CreatedDirs, sub)
}
}
// Scaffold the project-type knowledge dirs as siblings of the engine
// workspace, inside UserDir. They are resolved by `eeco init` from
// the project-type detector and carried on cfg.KnowledgeDirs; a
// Config built by Load alone has none, so this loop is a no-op there.
// UserDir already exists (the workspace was created under it above).
if cfg.UserDir != "" {
for _, dir := range cfg.KnowledgeDirs {
if !safeDirComponent(dir) {
continue
}
created, err := ensureDirCreated(filepath.Join(cfg.UserDir, dir))
if err != nil {
return rep, err
}
if created {
rep.CreatedKnowledgeDirs = append(rep.CreatedKnowledgeDirs, dir)
}
}
}
rep.GitignorePath = filepath.Join(cfg.RepoRoot, ".gitignore")
// The whole per-user directory is gitignored — it holds the engine
// workspace plus the knowledge dirs. A Config not produced by Load
// (no Username) falls back to ignoring just the workspace name.
ignoreName := cfg.Username
if ignoreName == "" {
ignoreName = cfg.WorkspaceName
}
changed, err := ensureIgnored(rep.GitignorePath, ignoreName)
if err != nil {
return rep, fmt.Errorf("update .gitignore: %w", err)
}
rep.GitignoreChanged = changed
wrote, err := writeReadme(cfg)
if err != nil {
return rep, fmt.Errorf("write workspace README: %w", err)
}
rep.WroteReadme = wrote
return rep, nil
}
// IsInitialized reports whether the workspace described by cfg looks
// scaffolded. The check is structural: all canonical subdirectories
// must exist as directories.
func IsInitialized(cfg *Config) bool {
if cfg == nil {
return false
}
for _, sub := range workspaceSubdirs {
info, err := os.Stat(filepath.Join(cfg.Workspace, sub))
if err != nil || !info.IsDir() {
return false
}
}
return true
}
// safeDirComponent reports whether name is usable as a single,
// non-escaping path component for a scaffolded knowledge dir. It rejects
// empty names, absolute paths, multi-segment paths, and the "." / ".."
// specials so a malformed catalog entry can never write outside UserDir.
func safeDirComponent(name string) bool {
if name == "" || name == "." || name == ".." {
return false
}
if name != filepath.Clean(name) {
return false
}
if filepath.IsAbs(name) || strings.ContainsAny(name, `/\`) {
return false
}
return true
}
func ensureDir(path string) error {
info, err := os.Stat(path)
if err == nil {
if !info.IsDir() {
return fmt.Errorf("%s exists and is not a directory", path)
}
return nil
}
if !errors.Is(err, os.ErrNotExist) {
return err
}
return os.MkdirAll(path, 0o755)
}
func ensureDirCreated(path string) (bool, error) {
info, err := os.Stat(path)
if err == nil {
if !info.IsDir() {
return false, fmt.Errorf("%s exists and is not a directory", path)
}
return false, nil
}
if !errors.Is(err, os.ErrNotExist) {
return false, err
}
if err := os.MkdirAll(path, 0o755); err != nil {
return false, err
}
return true, nil
}
// ensureIgnored adds `/<name>/` to the .gitignore file at gitignorePath
// if no equivalent existing entry is present. It returns true if the
// file was created or modified. Equivalent entries are exact-line
// matches against `<name>`, `<name>/`, `/<name>`, or `/<name>/`. The
// check ignores comment lines and surrounding whitespace.
func ensureIgnored(gitignorePath, name string) (bool, error) {
target := "/" + name + "/"
existing, err := os.ReadFile(gitignorePath)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return false, err
}
equiv := map[string]struct{}{
name: {},
name + "/": {},
"/" + name: {},
"/" + name + "/": {},
}
for _, raw := range strings.Split(string(existing), "\n") {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if _, ok := equiv[line]; ok {
return false, nil
}
}
var buf bytes.Buffer
if len(existing) > 0 && !bytes.HasSuffix(existing, []byte("\n")) {
buf.WriteByte('\n')
}
buf.WriteString(target)
buf.WriteByte('\n')
f, err := os.OpenFile(gitignorePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return false, err
}
defer f.Close()
if _, err := f.Write(buf.Bytes()); err != nil {
return false, err
}
return true, nil
}
// writeReadme drops a short, neutral README at the workspace root the
// first time Init runs. Subsequent runs leave any existing README
// untouched.
func writeReadme(cfg *Config) (bool, error) {
p := filepath.Join(cfg.Workspace, "README.md")
if _, err := os.Stat(p); err == nil {
return false, nil
} else if !errors.Is(err, os.ErrNotExist) {
return false, err
}
content := fmt.Sprintf(`eeco workspace
This directory is the private workspace for the repository at
%s. It is gitignored and must not be committed.
Detected profile: %s
Subdirectories:
engine/ engine-side state and templates
memory/ fact store, one file per fact
workflows/ user-scaffolded workflows (builtins are embedded)
state/ queue and other mutable runtime state
docs/ per-repo documentation and handover notes
Edit config.local in this directory to override the detected profile
or the parse/build gate command.
`, cfg.RepoRoot, cfg.Profile)
return true, os.WriteFile(p, []byte(content), 0o644)
}
added internal/config/init_test.go
@@ -0,0 +1,418 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestInit_CreatesWorkspaceTree(t *testing.T) {
root := newRepo(t)
write(t, root, "go.mod", "module x\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
rep, err := Init(cfg)
if err != nil {
t.Fatal(err)
}
for _, sub := range workspaceSubdirs {
info, err := os.Stat(filepath.Join(root, "tester", DefaultWorkspace, sub))
if err != nil {
t.Errorf("subdir %s missing: %v", sub, err)
continue
}
if !info.IsDir() {
t.Errorf("subdir %s is not a directory", sub)
}
}
if !rep.WroteReadme {
t.Error("expected WroteReadme=true on first init")
}
if !rep.GitignoreChanged {
t.Error("expected GitignoreChanged=true on first init")
}
if rep.AlreadyInit {
t.Error("expected AlreadyInit=false on first init")
}
if len(rep.CreatedDirs) != len(workspaceSubdirs) {
t.Errorf("CreatedDirs = %v, want %d entries", rep.CreatedDirs, len(workspaceSubdirs))
}
}
func TestInit_Idempotent(t *testing.T) {
root := newRepo(t)
write(t, root, "go.mod", "module x\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if _, err := Init(cfg); err != nil {
t.Fatal(err)
}
rep, err := Init(cfg)
if err != nil {
t.Fatal(err)
}
if !rep.AlreadyInit {
t.Error("expected AlreadyInit=true on re-init")
}
if rep.WroteReadme {
t.Error("expected WroteReadme=false on re-init")
}
if rep.GitignoreChanged {
t.Error("expected GitignoreChanged=false on re-init")
}
if len(rep.CreatedDirs) != 0 {
t.Errorf("CreatedDirs on re-init = %v, want empty", rep.CreatedDirs)
}
}
func TestInit_AppendsToExistingGitignore(t *testing.T) {
root := newRepo(t)
write(t, root, ".gitignore", "node_modules/\n*.log\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if _, err := Init(cfg); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(root, ".gitignore"))
if err != nil {
t.Fatal(err)
}
s := string(b)
// Init now ignores the per-user dir (cfg.Username = "tester"), not the
// workspace leaf, so the appended line is /tester/.
wantLines := []string{"node_modules/", "*.log", "/tester/"}
for _, l := range wantLines {
if !strings.Contains(s, l+"\n") {
t.Errorf(".gitignore missing line %q. got:\n%s", l, s)
}
}
}
func TestInit_GitignoreNoTrailingNewline(t *testing.T) {
root := newRepo(t)
write(t, root, ".gitignore", "node_modules/")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if _, err := Init(cfg); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(root, ".gitignore"))
if err != nil {
t.Fatal(err)
}
want := "node_modules/\n/tester/\n"
if string(b) != want {
t.Errorf(".gitignore =\n%q\nwant\n%q", string(b), want)
}
}
func TestInit_CreatesGitignoreWhenMissing(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if _, err := Init(cfg); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(root, ".gitignore"))
if err != nil {
t.Fatal(err)
}
if string(b) != "/tester/\n" {
t.Errorf("created .gitignore =\n%q\nwant exactly /tester/\\n", string(b))
}
}
func TestInit_RecognisesExistingIgnoreVariants(t *testing.T) {
// Init ignores the per-user dir (cfg.Username = "tester"), so an
// existing equivalent line is one of the tester variants.
cases := []string{
"tester",
"tester/",
"/tester",
"/tester/",
}
for _, variant := range cases {
t.Run(variant, func(t *testing.T) {
root := newRepo(t)
write(t, root, ".gitignore", "# preamble\n"+variant+"\n")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
rep, err := Init(cfg)
if err != nil {
t.Fatal(err)
}
if rep.GitignoreChanged {
t.Errorf("variant %q caused gitignore append", variant)
}
// gitignore content must be unchanged
b, _ := os.ReadFile(filepath.Join(root, ".gitignore"))
if string(b) != "# preamble\n"+variant+"\n" {
t.Errorf("gitignore mutated:\n%s", string(b))
}
})
}
}
func TestInit_HonoursCustomWorkspaceName(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, ".workshop")
if err != nil {
t.Fatal(err)
}
if _, err := Init(cfg); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(root, "tester", ".workshop", "memory")); err != nil {
t.Errorf("custom workspace not created: %v", err)
}
b, _ := os.ReadFile(filepath.Join(root, ".gitignore"))
// Init ignores the per-user dir, not the workspace leaf, so even a
// custom workspace name yields /tester/.
if !strings.Contains(string(b), "/tester/\n") {
t.Errorf("gitignore missing /tester/: %s", string(b))
}
}
func TestInit_ErrorsWhenWorkspacePathIsAFile(t *testing.T) {
root := newRepo(t)
write(t, root, filepath.Join("tester", DefaultWorkspace), "i am a file, not a dir")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if _, err := Init(cfg); err == nil {
t.Fatal("expected Init to error when workspace path is a file")
}
}
func TestIsInitialized(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if IsInitialized(cfg) {
t.Error("expected IsInitialized=false before Init")
}
if _, err := Init(cfg); err != nil {
t.Fatal(err)
}
if !IsInitialized(cfg) {
t.Error("expected IsInitialized=true after Init")
}
}
func TestInit_PreservesExistingReadme(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
if err := os.MkdirAll(wsDir, 0o755); err != nil {
t.Fatal(err)
}
custom := "user wrote this README themselves"
write(t, wsDir, "README.md", custom)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
rep, err := Init(cfg)
if err != nil {
t.Fatal(err)
}
if rep.WroteReadme {
t.Error("expected WroteReadme=false when README already exists")
}
b, _ := os.ReadFile(filepath.Join(wsDir, "README.md"))
if string(b) != custom {
t.Errorf("README overwritten:\n%s", string(b))
}
}
func TestInit_ScaffoldsKnowledgeDirs(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
// A safe set plus one unsafe component that must be skipped, never
// written outside UserDir.
cfg.KnowledgeDirs = []string{"frontend", "backend", "../evil", "docs"}
rep, err := Init(cfg)
if err != nil {
t.Fatal(err)
}
for _, d := range []string{"frontend", "backend", "docs"} {
info, err := os.Stat(filepath.Join(root, "tester", d))
if err != nil || !info.IsDir() {
t.Errorf("knowledge dir %s not created under UserDir: %v", d, err)
}
}
if _, err := os.Stat(filepath.Join(root, "evil")); err == nil {
t.Error("unsafe knowledge dir \"../evil\" escaped UserDir")
}
if got := strings.Join(rep.CreatedKnowledgeDirs, ","); got != "frontend,backend,docs" {
t.Errorf("CreatedKnowledgeDirs = %q, want frontend,backend,docs", got)
}
// Idempotent: a second Init creates nothing new.
rep2, err := Init(cfg)
if err != nil {
t.Fatal(err)
}
if len(rep2.CreatedKnowledgeDirs) != 0 {
t.Errorf("re-init CreatedKnowledgeDirs = %v, want empty", rep2.CreatedKnowledgeDirs)
}
}
// --- H1.2: branch/edge coverage deepening (test-only) ---
// TestSafeDirComponent covers the empty/"."/".." reject (init.go:136-138)
// and the not-clean reject (139-141) arms by calling the validator directly.
func TestSafeDirComponent(t *testing.T) {
cases := []struct {
name string
want bool
}{
{"", false},
{".", false},
{"..", false},
{"a/b", false},
{"./x", false},
{"/abs", false},
{`a\b`, false},
{"frontend", true},
{"a.b", true},
{"a-b_c", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := safeDirComponent(tc.name); got != tc.want {
t.Errorf("safeDirComponent(%q) = %v, want %v", tc.name, got, tc.want)
}
})
}
}
// TestInit_NilConfig covers the nil guard (init.go:45-47).
func TestInit_NilConfig(t *testing.T) {
if _, err := Init(nil); err == nil {
t.Fatal("Init(nil) = nil error, want error")
}
}
// TestIsInitialized_NilConfig covers the nil guard (init.go:119-121).
func TestIsInitialized_NilConfig(t *testing.T) {
if IsInitialized(nil) {
t.Fatal("IsInitialized(nil) = true, want false")
}
}
// TestIsInitialized_IncompleteWorkspace covers the false arm of the
// subdir-missing check (init.go:124): a workspace missing one canonical
// subdir is not initialised.
func TestIsInitialized_IncompleteWorkspace(t *testing.T) {
root := newRepo(t)
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
if _, err := Init(cfg); err != nil {
t.Fatal(err)
}
if !IsInitialized(cfg) {
t.Fatal("expected IsInitialized=true after Init")
}
if err := os.RemoveAll(filepath.Join(cfg.Workspace, "memory")); err != nil {
t.Fatal(err)
}
if IsInitialized(cfg) {
t.Error("expected IsInitialized=false with a subdir removed")
}
}
// TestInit_ErrorsWhenSubdirPathIsAFile covers the loop error propagation
// (init.go:64-66) and the ensureDirCreated non-dir arm (165-167): a file
// occupying a canonical subdir path surfaces a clear error.
func TestInit_ErrorsWhenSubdirPathIsAFile(t *testing.T) {
root := newRepo(t)
wsDir := filepath.Join(root, "tester", DefaultWorkspace)
// "engine" is created first and succeeds; "memory" is the file in the way.
write(t, wsDir, "memory", "i am a file")
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
_, err = Init(cfg)
if err == nil {
t.Fatal("expected Init to error when a subdir path is a file")
}
if !strings.Contains(err.Error(), "exists and is not a directory") {
t.Errorf("error = %q, want it to contain %q", err.Error(), "exists and is not a directory")
}
}
// TestInit_ErrorsWhenGitignoreIsADirectory covers the ensureIgnored
// ReadFile non-NotExist arm (init.go:187-189) and the Init wrap (101-103):
// the subdirs are created before .gitignore is touched, so a directory at
// the .gitignore path fails the ReadFile cleanly.
func TestInit_ErrorsWhenGitignoreIsADirectory(t *testing.T) {
root := newRepo(t)
if err := os.Mkdir(filepath.Join(root, ".gitignore"), 0o755); err != nil {
t.Fatal(err)
}
cfg, err := Load(root, "")
if err != nil {
t.Fatal(err)
}
_, err = Init(cfg)
if err == nil {
t.Fatal("expected Init to error when .gitignore is a directory")
}
if !strings.Contains(err.Error(), "update .gitignore") {
t.Errorf("error = %q, want it to contain %q", err.Error(), "update .gitignore")
}
}
// TestInit_EmptyUsernameIgnoresWorkspaceName covers the empty-username
// fallback (init.go:97-99), only reachable with Username=="" (which Load
// never produces). Built directly, not via a seam. UserDir is empty so the
// knowledge loop is skipped; ensureIgnored receives WorkspaceName.
func TestInit_EmptyUsernameIgnoresWorkspaceName(t *testing.T) {
root := newRepo(t)
cfg := &Config{
RepoRoot: root,
Username: "",
WorkspaceName: DefaultWorkspace,
Workspace: filepath.Join(root, DefaultWorkspace),
Profile: ProfileGeneric,
}
rep, err := Init(cfg)
if err != nil {
t.Fatalf("Init: %v", err)
}
b, err := os.ReadFile(rep.GitignorePath)
if err != nil {
t.Fatal(err)
}
want := "/" + DefaultWorkspace + "/"
if !strings.Contains(string(b), want) {
t.Errorf(".gitignore = %q, want it to contain %q", string(b), want)
}
}
added internal/config/local.go
@@ -0,0 +1,78 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// LocalFilename is the per-workspace override file name.
const LocalFilename = "config.local"
// WriteLocalKeys upserts key=value pairs into <workspace>/config.local,
// preserving comments, blank lines, unknown keys, and line order. An
// existing non-comment line whose key matches is replaced in place; a
// key not yet present is appended (appended keys in sorted order for a
// deterministic file). The workspace directory must already exist —
// settings are an initialised-workspace operation, like gc and new.
//
// It only edits config.local inside the gitignored workspace; it never
// touches the tracked tree.
func WriteLocalKeys(cfg *Config, kv map[string]string) error {
if cfg == nil {
return errors.New("WriteLocalKeys: nil config")
}
if len(kv) == 0 {
return nil
}
info, err := os.Stat(cfg.Workspace)
if err != nil || !info.IsDir() {
return fmt.Errorf("workspace %s is not initialised", cfg.Workspace)
}
path := filepath.Join(cfg.Workspace, LocalFilename)
existing, err := os.ReadFile(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("read %s: %w", LocalFilename, err)
}
written := map[string]bool{}
var out []string
if len(existing) > 0 {
for _, raw := range strings.Split(strings.TrimRight(string(existing), "\n"), "\n") {
trimmed := strings.TrimSpace(raw)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
out = append(out, raw)
continue
}
k, _, ok := strings.Cut(trimmed, "=")
if !ok {
out = append(out, raw)
continue
}
key := strings.TrimSpace(k)
if v, replace := kv[key]; replace && !written[key] {
out = append(out, key+"="+v)
written[key] = true
continue
}
out = append(out, raw)
}
}
var fresh []string
for k := range kv {
if !written[k] {
fresh = append(fresh, k)
}
}
sort.Strings(fresh)
for _, k := range fresh {
out = append(out, k+"="+kv[k])
}
return os.WriteFile(path, []byte(strings.Join(out, "\n")+"\n"), 0o644)
}
added internal/docs/compact.go
@@ -0,0 +1,403 @@
package docs
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// Marker spellings for `eeco docs compact`. Fixed in slice 1; a future
// slice can introduce a config knob for custom markers if a user needs
// it.
const (
startMarker = "<!-- eeco:archive:start -->"
endMarker = "<!-- eeco:archive:end -->"
)
// CompactRegion records one marked region that was (or would be, in
// dry-run) moved to the archive. Line numbers are 1-based and inclusive,
// covering the start marker line through the end marker line.
type CompactRegion struct {
StartLine int
EndLine int
}
// CompactReport summarises a compact run. It is returned in both the
// dry-run and write paths so a CLI caller can render the same summary
// either way.
type CompactReport struct {
Source string
Archive string
Regions []CompactRegion
ArchiveExists bool
DryRun bool
}
// Compact moves every region of source delimited by
// `<!-- eeco:archive:start -->` / `<!-- eeco:archive:end -->` into
// archive, leaving a pointer stub in place at the source (marker mode).
// It is a thin wrapper over the shared compact engine; the regions to
// move are discovered from explicit markers. The public signature is
// unchanged.
func Compact(source, archive string, dryRun bool) (CompactReport, error) {
return compact(source, archive, dryRun, func(raw []byte) ([]CompactRegion, error) {
return scanArchiveRegions(raw)
})
}
// CompactKeepLast moves heading-delimited regions of source into archive
// (heading mode). prefix is a heading-line prefix such as "## Snapshot"
// whose `#` run fixes the section level; the keepLast most-recent
// matching sections (newest first, top of file) are kept and everything
// older is archived. It shares every move mechanic with Compact via the
// compact engine — only region discovery differs. Heading mode refuses
// to run on a source that still carries explicit archive markers (the
// two modes are mutually exclusive).
func CompactKeepLast(source, archive string, dryRun bool, prefix string, keepLast int) (CompactReport, error) {
return compact(source, archive, dryRun, func(raw []byte) ([]CompactRegion, error) {
return scanHeadingRegions(raw, prefix, keepLast)
})
}
// compact is the shared engine behind Compact (marker mode) and
// CompactKeepLast (heading mode). find discovers the regions to move;
// everything downstream — the archive stat, the dry-run / no-region
// early return, splitRegions, appendArchive, and the source rewrite — is
// identical across both modes. Both paths are absolute. The
// repo-relativity check belongs in the CLI layer where the repo root is
// known; this function trusts both paths.
//
// Behaviour:
// - Markers / headings inside fenced code blocks (``` or ~~~) are ignored.
// - Unmatched, nested, or out-of-order markers return an error.
// - With dryRun=true, nothing is written; the report still names every
// region that would move.
// - Re-running with no discoverable regions is an idempotent no-op
// (returns an empty Regions slice and writes nothing).
// - The archive file is created on first run and appended to on later
// runs; the appended content is a deterministic concatenation of the
// cut regions, each preceded by a one-line provenance header.
// - The source-doc trailing newline is preserved exactly.
//
// The header carries no date or wall-clock content so byte output is
// reproducible across runs.
func compact(source, archive string, dryRun bool, find func([]byte) ([]CompactRegion, error)) (CompactReport, error) {
report := CompactReport{
Source: source,
Archive: archive,
DryRun: dryRun,
}
raw, err := os.ReadFile(source)
if err != nil {
return report, fmt.Errorf("read source: %w", err)
}
regions, err := find(raw)
if err != nil {
return report, err
}
report.Regions = regions
if _, err := os.Stat(archive); err == nil {
report.ArchiveExists = true
} else if !errors.Is(err, os.ErrNotExist) {
return report, fmt.Errorf("stat archive: %w", err)
}
if len(regions) == 0 || dryRun {
return report, nil
}
// Source path is given as an absolute path; the archive header
// records the source by basename to keep the header short and avoid
// leaking the operator's local layout. The stub references the
// archive by its path relative to the source's directory so a
// reader can follow the pointer without guessing where the archive
// lives.
sourceTag := filepath.Base(source)
stubTarget, relErr := filepath.Rel(filepath.Dir(source), archive)
if relErr != nil {
stubTarget = filepath.Base(archive)
}
stubTarget = filepath.ToSlash(stubTarget)
archiveAddition, sourceRewrite := splitRegions(raw, regions, sourceTag, stubTarget)
if err := appendArchive(archive, archiveAddition, !report.ArchiveExists); err != nil {
return report, fmt.Errorf("write archive: %w", err)
}
if err := os.WriteFile(source, sourceRewrite, 0o644); err != nil {
return report, fmt.Errorf("rewrite source: %w", err)
}
return report, nil
}
// scanArchiveRegions walks src line-by-line tracking fenced-code state
// and returns every paired start/end region. Markers inside a fence are
// ignored. A start without a matching end, an end without an open start,
// or a second start before the first end is a hard error.
func scanArchiveRegions(src []byte) ([]CompactRegion, error) {
var regions []CompactRegion
inFence := false
openStart := 0 // 1-based line number of the open start marker; 0 = no open start
lines := splitLinesKeepEOL(src)
for i, line := range lines {
lineNo := i + 1
trimmed := strings.TrimRight(line, "\r\n")
// Track fenced code boundaries. The trim handles indented fences too
// (a fence may carry leading whitespace).
stripped := strings.TrimLeft(trimmed, " \t")
if strings.HasPrefix(stripped, "```") || strings.HasPrefix(stripped, "~~~") {
inFence = !inFence
continue
}
if inFence {
continue
}
marker := strings.TrimSpace(trimmed)
switch marker {
case startMarker:
if openStart != 0 {
return nil, fmt.Errorf("%s line %d: nested start marker (previous still open at line %d)", "compact", lineNo, openStart)
}
openStart = lineNo
case endMarker:
if openStart == 0 {
return nil, fmt.Errorf("%s line %d: end marker with no matching start", "compact", lineNo)
}
regions = append(regions, CompactRegion{StartLine: openStart, EndLine: lineNo})
openStart = 0
}
}
if openStart != 0 {
return nil, fmt.Errorf("compact line %d: start marker with no matching end", openStart)
}
return regions, nil
}
// headingSection is one matched heading-mode section: the 1-based line of
// the matched heading and the 1-based line of the boundary that
// terminates it (exclusive — the section spans [start, end)). At EOF the
// terminating boundary is len(lines)+1.
type headingSection struct {
start int
end int
}
// headingLevel returns the ATX-heading level of line (the number of
// leading `#` characters) when line is a heading, or 0 when it is not. A
// heading is a run of one or more `#` at the start of the line (after
// optional leading whitespace) followed by a space or the line end. The
// trailing newline is ignored.
func headingLevel(line string) int {
s := strings.TrimLeft(strings.TrimRight(line, "\r\n"), " \t")
n := 0
for n < len(s) && s[n] == '#' {
n++
}
if n == 0 || (n < len(s) && s[n] != ' ') {
return 0
}
return n
}
// scanHeadingRegions discovers archivable regions by heading rather than
// by explicit markers. prefix is a heading-line prefix such as
// "## Snapshot"; its `#` run fixes the section level L. A *matched*
// section opens at a heading of exactly level L whose trimmed text has
// the given prefix, and runs until the next *boundary* heading (any
// heading of level <= L) or EOF — so a section can never swallow a later
// same-or-higher heading such as a live "## Next session" tail. The N
// most-recent matched sections (newest first, i.e. topmost in the file)
// are kept; everything older is archivable, and adjacent archivable
// sections coalesce into one CompactRegion per contiguous run. Headings
// inside fenced code blocks are ignored, mirroring scanArchiveRegions.
//
// Heading mode is mutually exclusive with explicit markers: if the
// source already contains a paired archive-marker region, this returns
// an error rather than silently mixing the two schemes.
func scanHeadingRegions(src []byte, prefix string, keepLast int) ([]CompactRegion, error) {
level := headingLevel(prefix)
if level == 0 {
return nil, fmt.Errorf("compact: --heading %q is not a markdown heading (expected a leading '#' run, e.g. \"## Snapshot\")", prefix)
}
if keepLast < 0 {
return nil, fmt.Errorf("compact: --keep-last must be >= 0 (got %d)", keepLast)
}
// Any explicit archive markers (a complete pair, or even a malformed
// unmatched/nested one) mean the source is set up for marker mode;
// refuse rather than silently mix the two schemes. Inline prose
// mentions are unaffected — scanArchiveRegions only matches standalone
// marker lines.
if markers, err := scanArchiveRegions(src); err != nil || len(markers) > 0 {
return nil, errors.New("source contains explicit archive markers; remove them or drop --keep-last")
}
wantPrefix := strings.TrimSpace(prefix)
lines := splitLinesKeepEOL(src)
var matched []headingSection
openStart := 0 // 1-based line of the currently open matched section; 0 = none
inFence := false
for i, line := range lines {
lineNo := i + 1
stripped := strings.TrimLeft(strings.TrimRight(line, "\r\n"), " \t")
if strings.HasPrefix(stripped, "```") || strings.HasPrefix(stripped, "~~~") {
inFence = !inFence
continue
}
if inFence {
continue
}
lvl := headingLevel(line)
if lvl == 0 || lvl > level {
continue // body line (a deeper heading does not split the section)
}
// A boundary heading (lvl <= level) closes any open matched section.
if openStart != 0 {
matched = append(matched, headingSection{start: openStart, end: lineNo})
openStart = 0
}
// The boundary is itself a new matched section only when it is at
// exactly level L and carries the prefix.
if lvl == level && strings.HasPrefix(strings.TrimSpace(line), wantPrefix) {
openStart = lineNo
}
}
if openStart != 0 {
matched = append(matched, headingSection{start: openStart, end: len(lines) + 1})
}
if keepLast >= len(matched) {
return nil, nil // nothing older than the kept window — idempotent no-op
}
archivable := matched[keepLast:] // newest-on-top: keep the first keepLast
// Coalesce adjacent archivable sections (section_i.end == the next
// section's start) into maximal contiguous runs; each run is one
// region whose EndLine is the last line before its terminating
// boundary.
var regions []CompactRegion
for i := 0; i < len(archivable); {
runStart := archivable[i].start
runEnd := archivable[i].end
j := i + 1
for j < len(archivable) && archivable[j].start == runEnd {
runEnd = archivable[j].end
j++
}
regions = append(regions, CompactRegion{StartLine: runStart, EndLine: runEnd - 1})
i = j
}
return regions, nil
}
// splitRegions partitions src into (archiveBytes, sourceBytes) using the
// pre-validated regions. Each cut region (markers + body) is appended to
// archiveBytes after a one-line provenance header. The same region is
// replaced in sourceBytes with a single-line pointer stub that names the
// archive destination.
func splitRegions(src []byte, regions []CompactRegion, sourceTag, stubTarget string) (archiveAddition, sourceRewrite []byte) {
lines := splitLinesKeepEOL(src)
newline := dominantNewline(lines)
stub := fmt.Sprintf("> _archived to `%s` (eeco docs compact)._%s", stubTarget, newline)
var archive bytes.Buffer
var out bytes.Buffer
cursor := 0
for _, r := range regions {
for ; cursor < r.StartLine-1; cursor++ {
out.WriteString(lines[cursor])
}
out.WriteString(stub)
archive.WriteString("<!-- archived from ")
archive.WriteString(sourceTag)
archive.WriteString(" -->")
archive.WriteString(newline)
for j := r.StartLine - 1; j < r.EndLine; j++ {
archive.WriteString(lines[j])
}
// Guarantee a blank line between consecutive archive blocks. If
// the cut content already ended with a newline (the end-marker
// line normally does), one extra newline is enough; if it did
// not, add two.
last := lines[r.EndLine-1]
if !strings.HasSuffix(last, "\n") {
archive.WriteString(newline)
}
archive.WriteString(newline)
cursor = r.EndLine
}
for ; cursor < len(lines); cursor++ {
out.WriteString(lines[cursor])
}
return archive.Bytes(), out.Bytes()
}
// dominantNewline picks the newline style used most often in lines, with
// a "\n" fallback for files with no newlines at all.
func dominantNewline(lines []string) string {
crlf, lf := 0, 0
for _, line := range lines {
switch {
case strings.HasSuffix(line, "\r\n"):
crlf++
case strings.HasSuffix(line, "\n"):
lf++
}
}
if crlf > lf {
return "\r\n"
}
return "\n"
}
// splitLinesKeepEOL returns the lines of src with their trailing newline
// (LF or CRLF) preserved. An unterminated final line is returned as-is.
func splitLinesKeepEOL(src []byte) []string {
var lines []string
for len(src) > 0 {
i := bytes.IndexByte(src, '\n')
if i < 0 {
lines = append(lines, string(src))
break
}
lines = append(lines, string(src[:i+1]))
src = src[i+1:]
}
return lines
}
// appendArchive appends content to archive, creating the file (and any
// parent directories) on first write. When the archive already exists,
// a single blank line is written between the prior content and the new
// content so successive runs do not glue blocks together visually.
func appendArchive(archive string, content []byte, createNew bool) error {
if createNew {
if err := os.MkdirAll(filepath.Dir(archive), 0o755); err != nil {
return err
}
return os.WriteFile(archive, content, 0o644)
}
existing, err := os.ReadFile(archive)
if err != nil {
return err
}
var buf bytes.Buffer
buf.Write(existing)
if len(existing) > 0 && !bytes.HasSuffix(existing, []byte("\n")) {
buf.WriteByte('\n')
}
if len(existing) > 0 {
buf.WriteByte('\n')
}
buf.Write(content)
return os.WriteFile(archive, buf.Bytes(), 0o644)
}
added internal/docs/compact_test.go
@@ -0,0 +1,562 @@
package docs
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func readFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func TestCompact_NoMarkers_NoOp(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "doc.md")
archive := filepath.Join(dir, "doc.archive.md")
original := "# Title\n\nNothing marked here.\n"
writeFile(t, source, original)
rep, err := Compact(source, archive, false)
if err != nil {
t.Fatalf("Compact: %v", err)
}
if len(rep.Regions) != 0 {
t.Errorf("regions = %v, want none", rep.Regions)
}
if got := readFile(t, source); got != original {
t.Errorf("source mutated:\nwant: %q\ngot: %q", original, got)
}
if _, err := os.Stat(archive); !os.IsNotExist(err) {
t.Errorf("archive should not exist on a no-op run, got err=%v", err)
}
}
func TestCompact_OnePair_MovesAndStubs(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "doc.md")
archive := filepath.Join(dir, "doc.archive.md")
writeFile(t, source, "# Title\n\nlive line\n<!-- eeco:archive:start -->\nold content\n<!-- eeco:archive:end -->\ntail\n")
rep, err := Compact(source, archive, false)
if err != nil {
t.Fatalf("Compact: %v", err)
}
if len(rep.Regions) != 1 {
t.Fatalf("regions = %d, want 1", len(rep.Regions))
}
if rep.Regions[0].StartLine != 4 || rep.Regions[0].EndLine != 6 {
t.Errorf("region = %+v, want {4,6}", rep.Regions[0])
}
wantSource := "# Title\n\nlive line\n> _archived to `doc.archive.md` (eeco docs compact)._\ntail\n"
if got := readFile(t, source); got != wantSource {
t.Errorf("source:\nwant: %q\ngot: %q", wantSource, got)
}
wantArchive := "<!-- archived from doc.md -->\n<!-- eeco:archive:start -->\nold content\n<!-- eeco:archive:end -->\n\n"
if got := readFile(t, archive); got != wantArchive {
t.Errorf("archive:\nwant: %q\ngot: %q", wantArchive, got)
}
}
func TestCompact_MultiplePairs(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "doc.md")
archive := filepath.Join(dir, "doc.archive.md")
writeFile(t, source, "a\n<!-- eeco:archive:start -->\nA1\n<!-- eeco:archive:end -->\nb\n<!-- eeco:archive:start -->\nB1\nB2\n<!-- eeco:archive:end -->\nc\n")
rep, err := Compact(source, archive, false)
if err != nil {
t.Fatalf("Compact: %v", err)
}
if len(rep.Regions) != 2 {
t.Fatalf("regions = %d, want 2", len(rep.Regions))
}
gotSource := readFile(t, source)
if strings.Count(gotSource, "> _archived to") != 2 {
t.Errorf("source should have 2 stub lines:\n%s", gotSource)
}
if strings.Contains(gotSource, "A1") || strings.Contains(gotSource, "B1") {
t.Errorf("source still carries archived body:\n%s", gotSource)
}
gotArchive := readFile(t, archive)
if strings.Count(gotArchive, "<!-- archived from doc.md -->") != 2 {
t.Errorf("archive should have 2 provenance headers:\n%s", gotArchive)
}
if !strings.Contains(gotArchive, "A1") || !strings.Contains(gotArchive, "B2") {
t.Errorf("archive missing region body:\n%s", gotArchive)
}
}
func TestCompact_UnmatchedStart(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "doc.md")
archive := filepath.Join(dir, "doc.archive.md")
writeFile(t, source, "<!-- eeco:archive:start -->\nbody\n")
if _, err := Compact(source, archive, false); err == nil {
t.Fatal("expected error for unmatched start")
} else if !strings.Contains(err.Error(), "no matching end") {
t.Errorf("error should name the issue, got %q", err)
}
if _, err := os.Stat(archive); !os.IsNotExist(err) {
t.Errorf("archive should not be created on error, got err=%v", err)
}
}
func TestCompact_UnmatchedEnd(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "doc.md")
archive := filepath.Join(dir, "doc.archive.md")
writeFile(t, source, "body\n<!-- eeco:archive:end -->\n")
_, err := Compact(source, archive, false)
if err == nil {
t.Fatal("expected error for unmatched end")
}
if !strings.Contains(err.Error(), "no matching start") {
t.Errorf("error should name the issue, got %q", err)
}
}
func TestCompact_NestedStart(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "doc.md")
archive := filepath.Join(dir, "doc.archive.md")
writeFile(t, source, "<!-- eeco:archive:start -->\n<!-- eeco:archive:start -->\nx\n<!-- eeco:archive:end -->\n<!-- eeco:archive:end -->\n")
_, err := Compact(source, archive, false)
if err == nil {
t.Fatal("expected error for nested start")
}
if !strings.Contains(err.Error(), "nested start") {
t.Errorf("error should name the issue, got %q", err)
}
}
func TestCompact_MarkerInsideFenceIgnored(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "doc.md")
archive := filepath.Join(dir, "doc.archive.md")
original := "show a fenced example:\n\n```\n<!-- eeco:archive:start -->\nnot a real marker\n<!-- eeco:archive:end -->\n```\n\ntrue marker here:\n<!-- eeco:archive:start -->\nreal old block\n<!-- eeco:archive:end -->\ntail\n"
writeFile(t, source, original)
rep, err := Compact(source, archive, false)
if err != nil {
t.Fatalf("Compact: %v", err)
}
if len(rep.Regions) != 1 {
t.Fatalf("regions = %d, want 1 (markers inside fence ignored)", len(rep.Regions))
}
gotSource := readFile(t, source)
if !strings.Contains(gotSource, "not a real marker") {
t.Errorf("source should still carry the fenced example body")
}
if strings.Contains(gotSource, "real old block") {
t.Errorf("source should not carry the moved real block")
}
}
func TestCompact_ArchiveAppends(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "doc.md")
archive := filepath.Join(dir, "doc.archive.md")
writeFile(t, archive, "prior archive content\n")
writeFile(t, source, "<!-- eeco:archive:start -->\nfresh\n<!-- eeco:archive:end -->\n")
rep, err := Compact(source, archive, false)
if err != nil {
t.Fatalf("Compact: %v", err)
}
if !rep.ArchiveExists {
t.Error("report should flag archive as pre-existing")
}
got := readFile(t, archive)
if !strings.HasPrefix(got, "prior archive content\n") {
t.Errorf("prior content must stay at top: %q", got)
}
if !strings.Contains(got, "<!-- archived from doc.md -->") {
t.Errorf("archive missing new header: %q", got)
}
if !strings.Contains(got, "fresh") {
t.Errorf("archive missing new body: %q", got)
}
}
func TestCompact_DryRun(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "doc.md")
archive := filepath.Join(dir, "doc.archive.md")
original := "head\n<!-- eeco:archive:start -->\nbody\n<!-- eeco:archive:end -->\ntail\n"
writeFile(t, source, original)
rep, err := Compact(source, archive, true)
if err != nil {
t.Fatalf("Compact dry-run: %v", err)
}
if !rep.DryRun {
t.Error("report should flag dry-run")
}
if len(rep.Regions) != 1 {
t.Errorf("regions = %d, want 1 (dry-run still scans)", len(rep.Regions))
}
if got := readFile(t, source); got != original {
t.Errorf("dry-run mutated source: %q", got)
}
if _, err := os.Stat(archive); !os.IsNotExist(err) {
t.Errorf("dry-run created archive: err=%v", err)
}
}
func TestCompact_Idempotent(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "doc.md")
archive := filepath.Join(dir, "doc.archive.md")
writeFile(t, source, "<!-- eeco:archive:start -->\nold\n<!-- eeco:archive:end -->\n")
if _, err := Compact(source, archive, false); err != nil {
t.Fatalf("first Compact: %v", err)
}
afterFirst := readFile(t, source)
archiveAfterFirst := readFile(t, archive)
rep, err := Compact(source, archive, false)
if err != nil {
t.Fatalf("second Compact: %v", err)
}
if len(rep.Regions) != 0 {
t.Errorf("second run should find 0 regions, got %d", len(rep.Regions))
}
if got := readFile(t, source); got != afterFirst {
t.Errorf("second run mutated source")
}
if got := readFile(t, archive); got != archiveAfterFirst {
t.Errorf("second run mutated archive")
}
}
func TestCompact_CRLFPreserved(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "doc.md")
archive := filepath.Join(dir, "doc.archive.md")
writeFile(t, source, "head\r\n<!-- eeco:archive:start -->\r\nbody\r\n<!-- eeco:archive:end -->\r\ntail\r\n")
if _, err := Compact(source, archive, false); err != nil {
t.Fatalf("Compact: %v", err)
}
gotSource := readFile(t, source)
if !strings.Contains(gotSource, "\r\n") {
t.Errorf("source CRLF lost: %q", gotSource)
}
if !strings.Contains(gotSource, "(eeco docs compact)._\r\n") {
t.Errorf("stub did not use CRLF newline: %q", gotSource)
}
}
func TestCompact_StubReferencesArchivePath(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "sub", "doc.md")
archive := filepath.Join(dir, "archives", "doc.archive.md")
writeFile(t, source, "<!-- eeco:archive:start -->\nbody\n<!-- eeco:archive:end -->\n")
if _, err := Compact(source, archive, false); err != nil {
t.Fatalf("Compact: %v", err)
}
got := readFile(t, source)
wantPath := "../archives/doc.archive.md"
if !strings.Contains(got, wantPath) {
t.Errorf("stub should reference archive at %q:\n%s", wantPath, got)
}
}
func TestCompact_SourceMissing(t *testing.T) {
dir := t.TempDir()
_, err := Compact(filepath.Join(dir, "nope.md"), filepath.Join(dir, "a.md"), false)
if err == nil {
t.Fatal("expected error reading missing source")
}
}
// --- heading mode (--keep-last) ---
func TestHeadingLevel(t *testing.T) {
cases := []struct {
line string
want int
}{
{"# Title\n", 1},
{"## Snapshot — session 3\n", 2},
{"### sub\n", 3},
{"###\n", 3}, // hashes then EOL is a heading
{"#nospace", 0}, // no space after the run
{"plain text", 0},
{" ## indented\n", 2}, // leading whitespace allowed
{"", 0},
{"## Snapshot", 2}, // no trailing newline
}
for _, c := range cases {
if got := headingLevel(c.line); got != c.want {
t.Errorf("headingLevel(%q) = %d, want %d", c.line, got, c.want)
}
}
}
// threeSnapshots is the canonical fixture: newest snapshot on top, a live
// "## Next session" tail after the oldest. Line numbers (1-based):
//
// 1 # Doc
// 2 (blank)
// 3 ## Snapshot — session 3
// 4 c3 content
// 5 (blank)
// 6 ## Snapshot — session 2
// 7 c2 content
// 8 (blank)
// 9 ## Snapshot — session 1
// 10 c1 content
// 11 (blank)
// 12 ## Next session
// 13 live tail
const threeSnapshots = "# Doc\n\n" +
"## Snapshot — session 3\nc3 content\n\n" +
"## Snapshot — session 2\nc2 content\n\n" +
"## Snapshot — session 1\nc1 content\n\n" +
"## Next session\nlive tail\n"
func regionsEqual(a, b []CompactRegion) bool {
return slices.Equal(a, b)
}
func TestScanHeadingRegions_KeepWindow(t *testing.T) {
cases := []struct {
name string
keepLast int
want []CompactRegion
}{
// keep 2 of 3 → only the oldest section, live tail excluded.
{"keep<count", 2, []CompactRegion{{StartLine: 9, EndLine: 11}}},
// keep 1 → the two oldest merge into one contiguous run.
{"keep<count-merge", 1, []CompactRegion{{StartLine: 6, EndLine: 11}}},
// keep 0 → all three sections merge into one run; live tail excluded.
{"keep=0", 0, []CompactRegion{{StartLine: 3, EndLine: 11}}},
// keep == count → nothing to archive.
{"keep==count", 3, nil},
// keep > count → nothing to archive.
{"keep>count", 5, nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := scanHeadingRegions([]byte(threeSnapshots), "## Snapshot", c.keepLast)
if err != nil {
t.Fatalf("scanHeadingRegions: %v", err)
}
if !regionsEqual(got, c.want) {
t.Errorf("regions = %+v, want %+v", got, c.want)
}
})
}
}
func TestScanHeadingRegions_FenceIgnored(t *testing.T) {
// A fenced "## Snapshot …" line must not be treated as a heading and
// must not split the section that contains it.
src := "## Snapshot — session 2\nc2\n\nintro\n```\n## Snapshot — session fake\nnot a heading\n```\nmore c2\n\n" +
"## Snapshot — session 1\nc1\n\n## Next session\ntail\n"
got, err := scanHeadingRegions([]byte(src), "## Snapshot", 1)
if err != nil {
t.Fatalf("scanHeadingRegions: %v", err)
}
// session 1 starts at line 11, "## Next session" is line 14 → region [11,13].
want := []CompactRegion{{StartLine: 11, EndLine: 13}}
if !regionsEqual(got, want) {
t.Errorf("regions = %+v, want %+v (fenced fake heading should not split)", got, want)
}
}
func TestScanHeadingRegions_DeeperHeadingDoesNotSplit(t *testing.T) {
src := "## Snapshot — session 2\nc2\n### sub\ndeeper\nmore\n\n" +
"## Snapshot — session 1\nc1\n\n## Next session\ntail\n"
got, err := scanHeadingRegions([]byte(src), "## Snapshot", 1)
if err != nil {
t.Fatalf("scanHeadingRegions: %v", err)
}
// session 1 starts at line 7, "## Next session" is line 10 → region [7,9].
want := []CompactRegion{{StartLine: 7, EndLine: 9}}
if !regionsEqual(got, want) {
t.Errorf("regions = %+v, want %+v (### should not split the section)", got, want)
}
}
func TestScanHeadingRegions_NonMatchingSameLevelTerminates(t *testing.T) {
// A non-matching same-level "## Interlude" terminates a section and is
// itself never archived; non-adjacent archivable sections stay split.
src := "## Snapshot — session 2\nc2\n\n## Interlude\nnot a snapshot\n\n" +
"## Snapshot — session 1\nc1\n\n## Next session\ntail\n"
got, err := scanHeadingRegions([]byte(src), "## Snapshot", 0)
if err != nil {
t.Fatalf("scanHeadingRegions: %v", err)
}
// session 2 = [1,3]; "## Interlude" (lines 4-6) stays; session 1 = [7,9].
want := []CompactRegion{{StartLine: 1, EndLine: 3}, {StartLine: 7, EndLine: 9}}
if !regionsEqual(got, want) {
t.Errorf("regions = %+v, want %+v (interlude must split + survive)", got, want)
}
}
func TestScanHeadingRegions_PrefixNotHeading(t *testing.T) {
_, err := scanHeadingRegions([]byte(threeSnapshots), "Snapshot", 1)
if err == nil {
t.Fatal("expected error for non-heading prefix")
}
if !strings.Contains(err.Error(), "not a markdown heading") {
t.Errorf("error should name the issue, got %q", err)
}
}
func TestScanHeadingRegions_MarkersConflict(t *testing.T) {
src := "head\n<!-- eeco:archive:start -->\nx\n<!-- eeco:archive:end -->\n## Snapshot — session 1\nc1\n"
_, err := scanHeadingRegions([]byte(src), "## Snapshot", 0)
if err == nil {
t.Fatal("expected error when explicit markers are present in heading mode")
}
if !strings.Contains(err.Error(), "explicit archive markers") {
t.Errorf("error should name the conflict, got %q", err)
}
}
func TestScanHeadingRegions_MalformedMarkersConflict(t *testing.T) {
// An unmatched start marker (no end) also signals marker-mode intent;
// heading mode refuses rather than letting the stray marker survive.
src := "head\n<!-- eeco:archive:start -->\nx\n## Snapshot — session 1\nc1\n"
_, err := scanHeadingRegions([]byte(src), "## Snapshot", 0)
if err == nil {
t.Fatal("expected error for malformed (unmatched) markers in heading mode")
}
if !strings.Contains(err.Error(), "explicit archive markers") {
t.Errorf("error should name the conflict, got %q", err)
}
}
func TestCompactKeepLast_MovesOldest(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "RESUME.md")
archive := filepath.Join(dir, "RESUME.archive.md")
writeFile(t, source, threeSnapshots)
rep, err := CompactKeepLast(source, archive, false, "## Snapshot", 2)
if err != nil {
t.Fatalf("CompactKeepLast: %v", err)
}
if len(rep.Regions) != 1 {
t.Fatalf("regions = %d, want 1", len(rep.Regions))
}
gotSource := readFile(t, source)
for _, keep := range []string{"## Snapshot — session 3", "## Snapshot — session 2", "## Next session", "live tail"} {
if !strings.Contains(gotSource, keep) {
t.Errorf("source dropped a kept block %q:\n%s", keep, gotSource)
}
}
for _, gone := range []string{"## Snapshot — session 1", "c1 content"} {
if strings.Contains(gotSource, gone) {
t.Errorf("source still carries archived block %q:\n%s", gone, gotSource)
}
}
if n := strings.Count(gotSource, "> _archived to"); n != 1 {
t.Errorf("source should have exactly one stub, got %d:\n%s", n, gotSource)
}
gotArchive := readFile(t, archive)
if !strings.Contains(gotArchive, "## Snapshot — session 1") || !strings.Contains(gotArchive, "c1 content") {
t.Errorf("archive missing the moved oldest section:\n%s", gotArchive)
}
if !strings.Contains(gotArchive, "<!-- archived from RESUME.md -->") {
t.Errorf("archive missing provenance header:\n%s", gotArchive)
}
}
func TestCompactKeepLast_Idempotent(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "RESUME.md")
archive := filepath.Join(dir, "RESUME.archive.md")
writeFile(t, source, threeSnapshots)
if _, err := CompactKeepLast(source, archive, false, "## Snapshot", 2); err != nil {
t.Fatalf("first run: %v", err)
}
afterFirst := readFile(t, source)
archiveAfterFirst := readFile(t, archive)
rep, err := CompactKeepLast(source, archive, false, "## Snapshot", 2)
if err != nil {
t.Fatalf("second run: %v", err)
}
if len(rep.Regions) != 0 {
t.Errorf("second run should find 0 regions (only 2 sections remain, keep 2), got %d", len(rep.Regions))
}
if readFile(t, source) != afterFirst {
t.Error("second run mutated source")
}
if readFile(t, archive) != archiveAfterFirst {
t.Error("second run mutated archive")
}
}
func TestCompactKeepLast_DryRun(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "RESUME.md")
archive := filepath.Join(dir, "RESUME.archive.md")
writeFile(t, source, threeSnapshots)
rep, err := CompactKeepLast(source, archive, true, "## Snapshot", 2)
if err != nil {
t.Fatalf("CompactKeepLast dry-run: %v", err)
}
if len(rep.Regions) != 1 {
t.Errorf("dry-run regions = %d, want 1 (still scans)", len(rep.Regions))
}
if readFile(t, source) != threeSnapshots {
t.Error("dry-run mutated source")
}
if _, err := os.Stat(archive); !os.IsNotExist(err) {
t.Errorf("dry-run created archive: err=%v", err)
}
}
func TestCompactKeepLast_MarkersConflict(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "RESUME.md")
archive := filepath.Join(dir, "RESUME.archive.md")
writeFile(t, source, "head\n<!-- eeco:archive:start -->\nx\n<!-- eeco:archive:end -->\n## Snapshot — s1\nc1\n")
_, err := CompactKeepLast(source, archive, false, "## Snapshot", 0)
if err == nil {
t.Fatal("expected error when markers present in heading mode")
}
if !strings.Contains(err.Error(), "explicit archive markers") {
t.Errorf("error should name the conflict, got %q", err)
}
if _, err := os.Stat(archive); !os.IsNotExist(err) {
t.Errorf("conflict run should not create archive: err=%v", err)
}
}
added internal/docs/docs.go
@@ -0,0 +1,139 @@
// Package docs renders eeco's tracked-tree documentation scaffolds.
//
// It backs `eeco docs new <target>` — a one-shot, deterministic write
// of a tracked-tree doc (today: VISION.md) at the repository root,
// invoked explicitly by the operator. Reuses the precedent set by
// `eeco init` for the workspace `.gitignore` line: eeco may write into
// the tracked tree on explicit invocation, the operator stages and
// commits (Constraint 6).
//
// The render is template-driven and project-shape-aware: placeholders
// fill in the project basename, the eeco version, and which of the
// usual companion docs already exist so the scaffold's "See also"
// reflects the project rather than a generic stub.
package docs
import (
"bytes"
"embed"
"errors"
"fmt"
"os"
"path/filepath"
"text/template"
)
//go:embed templates/*.tmpl
var templatesFS embed.FS
// Target enumerates the tracked-tree docs the scaffolder can produce.
// Each target maps to one filename at the repository root and one
// embedded template file under templates/.
type Target string
const (
// TargetVision scaffolds VISION.md.
TargetVision Target = "vision"
// TargetReadme scaffolds README.md.
TargetReadme Target = "readme"
)
// AllTargets returns every supported target in the order presented in
// usage messages.
func AllTargets() []Target {
return []Target{TargetVision, TargetReadme}
}
// Filename returns the tracked-tree filename this target scaffolds to.
// An empty string means the target is not recognised.
func (t Target) Filename() string {
switch t {
case TargetVision:
return "VISION.md"
case TargetReadme:
return "README.md"
}
return ""
}
// Params carries the substitutions a template can reference. Fields
// are kept small and deterministic; nothing here depends on wall-clock
// time so a re-render of the same project yields byte-identical bytes.
type Params struct {
// Project is the repository basename (e.g. "eeco").
Project string
// Version is the eeco version string the binary was built with.
Version string
// HasReadme reports whether README.md exists at the repository root.
HasReadme bool
// HasUsage reports whether docs/USAGE.md exists.
HasUsage bool
// HasArch reports whether docs/ARCHITECTURE.md exists.
HasArch bool
}
// Scaffold renders target's template into repoRoot/<target-filename>
// and returns the repo-relative path written. If the file already
// exists and overwrite is false, Scaffold refuses and returns an error
// whose message names the file and the override flag.
func Scaffold(target Target, repoRoot string, overwrite bool, p Params) (string, error) {
name := target.Filename()
if name == "" {
return "", fmt.Errorf("unknown target %q", string(target))
}
// Filenames are hardcoded per target; this is belt-and-braces against
// a future target accidentally introducing an escape.
if filepath.IsAbs(name) || filepath.Clean(name) != name {
return "", fmt.Errorf("target filename %q is not a safe relative path", name)
}
full := filepath.Join(repoRoot, name)
if !overwrite {
if _, err := os.Stat(full); err == nil {
return "", fmt.Errorf("%s already exists at the repo root; pass --overwrite to replace", name)
} else if !errors.Is(err, os.ErrNotExist) {
return "", err
}
}
tmplPath := "templates/" + string(target) + ".md.tmpl"
raw, err := templatesFS.ReadFile(tmplPath)
if err != nil {
return "", fmt.Errorf("read template: %w", err)
}
tmpl, err := template.New(string(target)).Parse(string(raw))
if err != nil {
return "", fmt.Errorf("parse template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, p); err != nil {
return "", fmt.Errorf("render template: %w", err)
}
if err := os.WriteFile(full, buf.Bytes(), 0o644); err != nil {
return "", err
}
return name, nil
}
// Render returns the rendered template bytes for target without
// touching the filesystem. Useful for tests and any future caller that
// wants to inspect the scaffold without writing it.
func Render(target Target, p Params) (string, error) {
name := target.Filename()
if name == "" {
return "", fmt.Errorf("unknown target %q", string(target))
}
tmplPath := "templates/" + string(target) + ".md.tmpl"
raw, err := templatesFS.ReadFile(tmplPath)
if err != nil {
return "", fmt.Errorf("read template: %w", err)
}
tmpl, err := template.New(string(target)).Parse(string(raw))
if err != nil {
return "", fmt.Errorf("parse template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, p); err != nil {
return "", fmt.Errorf("render template: %w", err)
}
return buf.String(), nil
}
added internal/docs/docs_test.go
@@ -0,0 +1,285 @@
package docs
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func TestAllTargets_ContainsVisionAndReadme(t *testing.T) {
got := AllTargets()
if len(got) == 0 {
t.Fatal("AllTargets returned no targets")
}
if !slices.Contains(got, TargetVision) {
t.Errorf("AllTargets missing TargetVision; got %v", got)
}
if !slices.Contains(got, TargetReadme) {
t.Errorf("AllTargets missing TargetReadme; got %v", got)
}
}
func TestTarget_Filename(t *testing.T) {
cases := []struct {
in Target
want string
}{
{TargetVision, "VISION.md"},
{TargetReadme, "README.md"},
{Target("bogus"), ""},
}
for _, tc := range cases {
if got := tc.in.Filename(); got != tc.want {
t.Errorf("Filename(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestRender_VisionAllCompanionsPresent(t *testing.T) {
p := Params{
Project: "demo",
Version: "v1.10.0",
HasReadme: true,
HasUsage: true,
HasArch: true,
}
got, err := Render(TargetVision, p)
if err != nil {
t.Fatalf("Render: %v", err)
}
for _, want := range []string{
"# demo — vision",
"Scaffolded by `eeco docs new vision` (eeco v1.10.0)",
"## What it gives you",
"## Non-goals",
"## Roadmap",
"## See also",
"[README.md](README.md)",
"[docs/USAGE.md](docs/USAGE.md)",
"[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)",
} {
if !strings.Contains(got, want) {
t.Errorf("Render missing %q:\n%s", want, got)
}
}
}
func TestRender_VisionNoCompanions(t *testing.T) {
got, err := Render(TargetVision, Params{Project: "lonely", Version: "v0.0.0-dev"})
if err != nil {
t.Fatalf("Render: %v", err)
}
for _, absent := range []string{
"[README.md](README.md)",
"[docs/USAGE.md](docs/USAGE.md)",
"[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)",
} {
if strings.Contains(got, absent) {
t.Errorf("Render included %q when companion was absent:\n%s", absent, got)
}
}
if !strings.Contains(got, "(no related docs detected") {
t.Errorf("Render missing empty-See-also placeholder:\n%s", got)
}
}
func TestRender_Deterministic(t *testing.T) {
p := Params{Project: "demo", Version: "v1.10.0", HasReadme: true}
a, err := Render(TargetVision, p)
if err != nil {
t.Fatalf("Render a: %v", err)
}
b, err := Render(TargetVision, p)
if err != nil {
t.Fatalf("Render b: %v", err)
}
if a != b {
t.Errorf("Render is not deterministic across calls with the same Params")
}
}
func TestRender_UnknownTarget(t *testing.T) {
if _, err := Render(Target("nope"), Params{}); err == nil {
t.Fatal("expected error for unknown target")
}
}
func TestScaffold_WritesVision(t *testing.T) {
root := t.TempDir()
p := Params{Project: filepath.Base(root), Version: "v1.10.0", HasReadme: true}
written, err := Scaffold(TargetVision, root, false, p)
if err != nil {
t.Fatalf("Scaffold: %v", err)
}
if written != "VISION.md" {
t.Errorf("written path = %q, want VISION.md", written)
}
body, err := os.ReadFile(filepath.Join(root, "VISION.md"))
if err != nil {
t.Fatalf("read written file: %v", err)
}
if !strings.Contains(string(body), "— vision") {
t.Errorf("written file missing vision heading:\n%s", body)
}
}
func TestScaffold_RefusesExisting(t *testing.T) {
root := t.TempDir()
full := filepath.Join(root, "VISION.md")
if err := os.WriteFile(full, []byte("operator content\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := Scaffold(TargetVision, root, false, Params{Project: "demo"})
if err == nil {
t.Fatal("Scaffold should refuse to overwrite an existing file")
}
if !strings.Contains(err.Error(), "already exists") || !strings.Contains(err.Error(), "--overwrite") {
t.Errorf("error should name file + flag, got %q", err)
}
// The pre-existing content must be intact.
body, err := os.ReadFile(full)
if err != nil {
t.Fatal(err)
}
if string(body) != "operator content\n" {
t.Errorf("Scaffold mutated the existing file: %q", body)
}
}
func TestScaffold_OverwriteReplaces(t *testing.T) {
root := t.TempDir()
full := filepath.Join(root, "VISION.md")
if err := os.WriteFile(full, []byte("stale content\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Scaffold(TargetVision, root, true, Params{Project: "demo", Version: "v1.10.0"}); err != nil {
t.Fatalf("Scaffold --overwrite: %v", err)
}
body, err := os.ReadFile(full)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), "demo — vision") {
t.Errorf("overwrite did not render new template:\n%s", body)
}
}
func TestScaffold_UnknownTarget(t *testing.T) {
root := t.TempDir()
if _, err := Scaffold(Target("nope"), root, false, Params{}); err == nil {
t.Fatal("expected error for unknown target")
}
}
func TestRender_ReadmeAllCompanionsPresent(t *testing.T) {
p := Params{
Project: "demo",
Version: "v2.6.0",
HasReadme: true,
HasUsage: true,
HasArch: true,
}
got, err := Render(TargetReadme, p)
if err != nil {
t.Fatalf("Render: %v", err)
}
for _, want := range []string{
"# demo",
"Scaffolded by `eeco docs new readme` (eeco v2.6.0)",
"## Quick start",
"## How it works",
"## See also",
"[docs/USAGE.md](docs/USAGE.md)",
"[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)",
} {
if !strings.Contains(got, want) {
t.Errorf("Render missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "[README.md](README.md)") {
t.Errorf("readme template must not self-link to README.md:\n%s", got)
}
}
func TestRender_ReadmeNoCompanions(t *testing.T) {
got, err := Render(TargetReadme, Params{Project: "lonely", Version: "v0.0.0-dev"})
if err != nil {
t.Fatalf("Render: %v", err)
}
for _, absent := range []string{
"[docs/USAGE.md](docs/USAGE.md)",
"[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)",
} {
if strings.Contains(got, absent) {
t.Errorf("Render included %q when companion was absent:\n%s", absent, got)
}
}
if !strings.Contains(got, "(no related docs detected") {
t.Errorf("Render missing empty-See-also placeholder:\n%s", got)
}
}
func TestScaffold_WritesReadme(t *testing.T) {
root := t.TempDir()
p := Params{Project: filepath.Base(root), Version: "v2.6.0", HasUsage: true}
written, err := Scaffold(TargetReadme, root, false, p)
if err != nil {
t.Fatalf("Scaffold: %v", err)
}
if written != "README.md" {
t.Errorf("written path = %q, want README.md", written)
}
body, err := os.ReadFile(filepath.Join(root, "README.md"))
if err != nil {
t.Fatalf("read written file: %v", err)
}
if !strings.Contains(string(body), "## Quick start") {
t.Errorf("written file missing Quick start heading:\n%s", body)
}
}
func TestScaffold_ReadmeRefusesExisting(t *testing.T) {
root := t.TempDir()
full := filepath.Join(root, "README.md")
if err := os.WriteFile(full, []byte("operator content\n"), 0o644); err != nil {
t.Fatal(err)
}
_, err := Scaffold(TargetReadme, root, false, Params{Project: "demo"})
if err == nil {
t.Fatal("Scaffold should refuse to overwrite an existing README.md")
}
if !strings.Contains(err.Error(), "already exists") || !strings.Contains(err.Error(), "--overwrite") {
t.Errorf("error should name file + flag, got %q", err)
}
body, err := os.ReadFile(full)
if err != nil {
t.Fatal(err)
}
if string(body) != "operator content\n" {
t.Errorf("Scaffold mutated the existing file: %q", body)
}
}
func TestScaffold_ReadmeOverwriteReplaces(t *testing.T) {
root := t.TempDir()
full := filepath.Join(root, "README.md")
if err := os.WriteFile(full, []byte("stale content\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Scaffold(TargetReadme, root, true, Params{Project: "demo", Version: "v2.6.0"}); err != nil {
t.Fatalf("Scaffold --overwrite: %v", err)
}
body, err := os.ReadFile(full)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), "## Quick start") {
t.Errorf("overwrite did not render new template:\n%s", body)
}
}
added internal/docs/refresh.go
@@ -0,0 +1,221 @@
package docs
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
// Marker spellings for `eeco docs refresh`. Mirrors the `archive` and
// `session` schemes; HTML-comment so a Markdown renderer hides them while
// still treating the body between as content. Fixed in slice 1; a future
// slice can introduce a config knob if a user needs custom markers.
const (
docsStartMarker = "<!-- eeco:docs:start -->"
docsEndMarker = "<!-- eeco:docs:end -->"
)
// RefreshAction names the change Refresh made to the target file.
type RefreshAction string
const (
// RefreshReplaced means the existing in-place docs block was rewritten.
RefreshReplaced RefreshAction = "replaced"
// RefreshAppended means no marker pair was present; a freshly-rendered
// block was appended at EOF. The operator is expected to remove the
// prior in-place content manually.
RefreshAppended RefreshAction = "appended"
// RefreshNoop means the file's bytes did not change (rendered block
// already matched).
RefreshNoop RefreshAction = "noop"
)
// RefreshReport summarises a refresh run.
type RefreshReport struct {
Path string
Action RefreshAction
}
// Refresh re-renders the project-state-derived block of target's file at
// repoRoot/<target-filename>, leaving operator prose outside the marker
// pair untouched. The marker pair is `<!-- eeco:docs:start -->` /
// `<!-- eeco:docs:end -->`, fence-aware like `eeco docs compact`.
//
// Behaviour:
// - Marker pair found: bytes between the markers are replaced with the
// freshly-rendered block extracted from the template.
// - Marker pair absent (legacy scaffold): the freshly-rendered block,
// marker-wrapped, is appended at EOF with a blank-line separator.
// The operator removes the prior in-place block manually.
// - File missing: refuse with a hint pointing to `eeco docs new`.
// - Malformed markers (unmatched, nested, out-of-order): refuse with a
// parse error naming the offending line; the file is not touched.
//
// eeco writes the file but never stages or commits it (Constraint 6).
func Refresh(target Target, repoRoot string, p Params) (RefreshReport, error) {
name := target.Filename()
if name == "" {
return RefreshReport{}, fmt.Errorf("unknown target %q", string(target))
}
if filepath.IsAbs(name) || filepath.Clean(name) != name {
return RefreshReport{}, fmt.Errorf("target filename %q is not a safe relative path", name)
}
full := filepath.Join(repoRoot, name)
existing, err := os.ReadFile(full)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return RefreshReport{Path: name}, fmt.Errorf("%s does not exist; run `eeco docs new %s` first", name, string(target))
}
return RefreshReport{Path: name}, err
}
rendered, err := Render(target, p)
if err != nil {
return RefreshReport{Path: name}, err
}
renderedBlock, err := extractDocsBlock([]byte(rendered))
if err != nil {
return RefreshReport{Path: name}, fmt.Errorf("template for %s has no docs marker pair — refresh cannot operate", string(target))
}
startByte, endByte, found, err := findDocsBlock(existing)
if err != nil {
return RefreshReport{Path: name}, err
}
lines := splitLinesKeepEOL(existing)
newline := dominantNewline(lines)
var out []byte
var action RefreshAction
if found {
var buf bytes.Buffer
buf.Write(existing[:startByte])
buf.WriteString(docsStartMarker)
buf.WriteString(newline)
buf.Write(renderedBlock)
if len(renderedBlock) > 0 && !bytes.HasSuffix(renderedBlock, []byte("\n")) {
buf.WriteString(newline)
}
buf.WriteString(docsEndMarker)
buf.WriteString(newline)
buf.Write(existing[endByte:])
out = buf.Bytes()
action = RefreshReplaced
} else {
var buf bytes.Buffer
buf.Write(existing)
if len(existing) > 0 {
if !bytes.HasSuffix(existing, []byte("\n")) {
buf.WriteString(newline)
}
buf.WriteString(newline)
}
buf.WriteString(docsStartMarker)
buf.WriteString(newline)
buf.Write(renderedBlock)
if len(renderedBlock) > 0 && !bytes.HasSuffix(renderedBlock, []byte("\n")) {
buf.WriteString(newline)
}
buf.WriteString(docsEndMarker)
buf.WriteString(newline)
out = buf.Bytes()
action = RefreshAppended
}
if bytes.Equal(out, existing) {
return RefreshReport{Path: name, Action: RefreshNoop}, nil
}
if err := os.WriteFile(full, out, 0o644); err != nil {
return RefreshReport{Path: name}, err
}
return RefreshReport{Path: name, Action: action}, nil
}
// extractDocsBlock returns the bytes between the docs markers in src,
// exclusive of the marker lines themselves. Returns an error when the
// marker pair is missing or malformed.
func extractDocsBlock(src []byte) ([]byte, error) {
startByte, endByte, found, err := findDocsBlock(src)
if err != nil {
return nil, err
}
if !found {
return nil, fmt.Errorf("no docs markers")
}
// Body begins at the byte after the start-marker line's newline.
startTail := src[startByte:]
startNL := bytes.IndexByte(startTail, '\n')
if startNL < 0 {
return []byte{}, nil
}
bodyStart := startByte + startNL + 1
// Body ends at the newline that terminates the last body line, which
// is the previous newline before the end-marker line. Search inside
// the block, walking back from one byte before its end so the
// end-marker line's own trailing newline (if any) is not picked up.
if endByte <= bodyStart {
return []byte{}, nil
}
prevNL := bytes.LastIndexByte(src[bodyStart:endByte-1], '\n')
if prevNL < 0 {
return []byte{}, nil
}
bodyEnd := bodyStart + prevNL + 1
return src[bodyStart:bodyEnd], nil
}
// findDocsBlock walks src once and returns the byte offsets of the
// start-marker line and the end-marker line for the single eeco docs
// block. Markers inside fenced code blocks are ignored. found=false when
// no markers (or only one) are present at top level. An unmatched,
// nested, or out-of-order marker pair is reported as an error so the
// caller refuses to write rather than guess. Mirrors
// `internal/hooks/sessiondelivery.go:findSessionBlock` byte-for-byte in
// posture; only the marker spellings differ.
func findDocsBlock(src []byte) (startByte, endByte int, found bool, err error) {
lines := splitLinesKeepEOL(src)
inFence := false
startIdx, endIdx := -1, -1
for i, line := range lines {
trimmed := strings.TrimRight(line, "\r\n")
stripped := strings.TrimLeft(trimmed, " \t")
if strings.HasPrefix(stripped, "```") || strings.HasPrefix(stripped, "~~~") {
inFence = !inFence
continue
}
if inFence {
continue
}
marker := strings.TrimSpace(trimmed)
switch marker {
case docsStartMarker:
if startIdx != -1 {
return 0, 0, false, fmt.Errorf("docs line %d: nested start marker (previous still open at line %d)", i+1, startIdx+1)
}
startIdx = i
case docsEndMarker:
if startIdx == -1 {
return 0, 0, false, fmt.Errorf("docs line %d: end marker with no matching start", i+1)
}
endIdx = i
startByte = 0
for j := 0; j < startIdx; j++ {
startByte += len(lines[j])
}
endByte = startByte
for j := startIdx; j <= endIdx; j++ {
endByte += len(lines[j])
}
return startByte, endByte, true, nil
}
}
if startIdx != -1 {
return 0, 0, false, fmt.Errorf("docs line %d: start marker with no matching end", startIdx+1)
}
return 0, 0, false, nil
}
added internal/docs/refresh_test.go
@@ -0,0 +1,170 @@
package docs
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestRefresh_ReplacesMarkerBlock(t *testing.T) {
root := t.TempDir()
p := Params{Project: filepath.Base(root), Version: "v2.8.0", HasUsage: true}
if _, err := Scaffold(TargetReadme, root, false, p); err != nil {
t.Fatalf("Scaffold: %v", err)
}
full := filepath.Join(root, "README.md")
before, err := os.ReadFile(full)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(before), "[docs/USAGE.md](docs/USAGE.md)") {
t.Fatalf("scaffold missing initial USAGE link:\n%s", before)
}
if !strings.Contains(string(before), "<!-- eeco:docs:start -->") {
t.Fatalf("scaffold missing start marker:\n%s", before)
}
// Add an operator-edited paragraph below the markers; refresh must
// preserve it byte-identically.
const operatorAddition = "\nOperator's free-form prose stays here.\n"
if err := os.WriteFile(full, append(before, []byte(operatorAddition)...), 0o644); err != nil {
t.Fatal(err)
}
// Refresh with HasArch flipped on — the See also list grows.
p2 := Params{Project: filepath.Base(root), Version: "v2.8.0", HasUsage: true, HasArch: true}
rep, err := Refresh(TargetReadme, root, p2)
if err != nil {
t.Fatalf("Refresh: %v", err)
}
if rep.Action != RefreshReplaced {
t.Errorf("Action = %q, want %q", rep.Action, RefreshReplaced)
}
after, err := os.ReadFile(full)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(after), "[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)") {
t.Errorf("refreshed body missing new ARCHITECTURE link:\n%s", after)
}
if !strings.HasSuffix(string(after), operatorAddition) {
t.Errorf("refresh discarded operator addition; tail:\n%s", after[max(0, len(after)-200):])
}
}
func TestRefresh_AutoInitOnLegacyScaffold(t *testing.T) {
root := t.TempDir()
full := filepath.Join(root, "README.md")
const legacy = "# legacy README\n\nOlder operator content; no markers.\n"
if err := os.WriteFile(full, []byte(legacy), 0o644); err != nil {
t.Fatal(err)
}
rep, err := Refresh(TargetReadme, root, Params{Project: "demo", Version: "v2.8.0", HasUsage: true})
if err != nil {
t.Fatalf("Refresh: %v", err)
}
if rep.Action != RefreshAppended {
t.Errorf("Action = %q, want %q", rep.Action, RefreshAppended)
}
body, err := os.ReadFile(full)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(string(body), legacy) {
t.Errorf("legacy prefix mutated:\n%s", body)
}
if !strings.Contains(string(body), "<!-- eeco:docs:start -->") {
t.Errorf("auto-init missing start marker:\n%s", body)
}
if !strings.Contains(string(body), "<!-- eeco:docs:end -->") {
t.Errorf("auto-init missing end marker:\n%s", body)
}
if !strings.Contains(string(body), "[docs/USAGE.md](docs/USAGE.md)") {
t.Errorf("auto-init missing rendered body:\n%s", body)
}
}
func TestRefresh_MissingFileRefuses(t *testing.T) {
root := t.TempDir()
_, err := Refresh(TargetReadme, root, Params{Project: "demo", Version: "v2.8.0"})
if err == nil {
t.Fatal("Refresh on missing file should error")
}
if !strings.Contains(err.Error(), "does not exist") {
t.Errorf("error should hint at missing file, got %q", err)
}
if !strings.Contains(err.Error(), "eeco docs new") {
t.Errorf("error should point to docs new, got %q", err)
}
}
func TestRefresh_MalformedMarkersRefuse(t *testing.T) {
root := t.TempDir()
full := filepath.Join(root, "README.md")
const bad = "# header\n<!-- eeco:docs:start -->\nbody\n<!-- eeco:docs:start -->\n"
if err := os.WriteFile(full, []byte(bad), 0o644); err != nil {
t.Fatal(err)
}
before, err := os.ReadFile(full)
if err != nil {
t.Fatal(err)
}
_, err = Refresh(TargetReadme, root, Params{Project: "demo", Version: "v2.8.0"})
if err == nil {
t.Fatal("Refresh on malformed markers should error")
}
if !strings.Contains(err.Error(), "nested") {
t.Errorf("error should name the parse failure, got %q", err)
}
after, err := os.ReadFile(full)
if err != nil {
t.Fatal(err)
}
if string(after) != string(before) {
t.Errorf("Refresh mutated file on parse error:\nbefore: %q\nafter: %q", before, after)
}
}
func TestRefresh_IgnoresFencedMarkers(t *testing.T) {
root := t.TempDir()
full := filepath.Join(root, "README.md")
// Real marker pair after a fenced block that mentions the markers.
body := "# header\n\n```\n<!-- eeco:docs:start -->\nfenced\n<!-- eeco:docs:end -->\n```\n\n<!-- eeco:docs:start -->\noriginal body\n<!-- eeco:docs:end -->\n\ntrailing operator note\n"
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
rep, err := Refresh(TargetReadme, root, Params{Project: "demo", Version: "v2.8.0", HasUsage: true})
if err != nil {
t.Fatalf("Refresh: %v", err)
}
if rep.Action != RefreshReplaced {
t.Errorf("Action = %q, want %q", rep.Action, RefreshReplaced)
}
after, err := os.ReadFile(full)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(after), "```\n<!-- eeco:docs:start -->\nfenced\n<!-- eeco:docs:end -->\n```") {
t.Errorf("fenced markers were touched:\n%s", after)
}
if !strings.Contains(string(after), "trailing operator note") {
t.Errorf("trailing operator note discarded:\n%s", after)
}
if strings.Contains(string(after), "original body") {
t.Errorf("real block not replaced:\n%s", after)
}
}
func TestRefresh_UnknownTarget(t *testing.T) {
root := t.TempDir()
if _, err := Refresh(Target("nope"), root, Params{}); err == nil {
t.Fatal("expected error for unknown target")
}
}
added internal/docs/templates/readme.md.tmpl
@@ -0,0 +1,48 @@
<!-- Scaffolded by `eeco docs new readme` (eeco {{.Version}}).
Edit freely; delete this comment once the doc is yours. -->
# {{.Project}}
One-line tagline: what {{.Project}} does, in plain language. Aim for a
sentence a stranger can repeat after one read.
Two short paragraphs explaining what {{.Project}} is, who it is for,
and why it exists. Lead with the problem it solves; close with the
shape of the solution. Keep it concrete — link out to a longer guide
for detail rather than restating one here.
## Quick start
Install:
```
# replace with the install recipe that fits this project
```
Run:
```
# the one command a new reader should try first
```
## How it works
Two or three sentences on the moving parts: where the code lives, what
it talks to, and what state it keeps. The goal is to orient a reader
who is about to open the source tree, not to replace an architecture
doc.
<!-- eeco:docs:start -->
## See also
{{- if .HasUsage}}
- [docs/USAGE.md](docs/USAGE.md) — full user reference
{{- end}}
{{- if .HasArch}}
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — architecture overview
{{- end}}
{{- if and (not .HasUsage) (not .HasArch)}}
- (no related docs detected — link out as the project grows)
{{- end}}
<!-- eeco:docs:end -->
added internal/docs/templates/vision.md.tmpl
@@ -0,0 +1,48 @@
<!-- Scaffolded by `eeco docs new vision` (eeco {{.Version}}).
Edit freely; delete this comment once the doc is yours. -->
# {{.Project}} — vision
A short statement of what {{.Project}} is for and why it exists. Write
this in your own voice: one or two paragraphs that a stranger can
understand without prior context.
## What it gives you
Three or four concrete capabilities {{.Project}} delivers today. Keep
each to one line; link out to the user-facing guide for detail.
- …
- …
- …
## Non-goals
What {{.Project}} deliberately is not. Naming the non-goals here saves
future readers a round-trip.
- …
- …
## Roadmap
The shape of the next stretch of work, in one paragraph. Link to the
live planning surface rather than restating it.
<!-- eeco:docs:start -->
## See also
{{- if .HasReadme}}
- [README.md](README.md) — quick start and install
{{- end}}
{{- if .HasUsage}}
- [docs/USAGE.md](docs/USAGE.md) — full user reference
{{- end}}
{{- if .HasArch}}
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — architecture overview
{{- end}}
{{- if and (not .HasReadme) (not .HasUsage) (not .HasArch)}}
- (no related docs detected — link out as the project grows)
{{- end}}
<!-- eeco:docs:end -->
added internal/gates/attribution.go
@@ -0,0 +1,271 @@
// Package gates runs cross-cutting policy gates that compose multiple
// scans over a project tree. Today the only gate is check-attribution,
// which combines a tracked-file scan (delegated to
// internal/workflow.Detector — the same primitive comment-hygiene uses)
// with a commit-body scan applying a stricter, trailer-anchored pattern
// set. The package is consumed by the eeco gates CLI verb; it depends
// on git being on PATH for the commit-body scan and the tracked-files
// enumeration.
package gates
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"github.com/ajhahnde/eeco/internal/workflow"
)
// Pattern fragments are assembled at runtime so this source stays
// self-clean for eeco's own comment-hygiene scan (Constraint 3 —
// mirrors the discipline in internal/workflow/attribution.go and
// internal/hooks/commitmsg.go).
var (
gateCoAuthored = "[Cc]o-" + "[Aa]uthored-" + "[Bb]y"
gateGenVerb = "[Gg]enerated"
gateRobotEmoji = "\\x{1F916}"
)
// strictTrailerPatterns is the commit-body pattern set — same shape as
// internal/hooks/commitmsg.go. Trailer-anchored Co-Authored-By rules so
// a docs commit subject like "remove the Co-Authored-By trailer" does
// not false-fire, plus the robot-emoji Generated-with signature.
var strictTrailerPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?im)^` + gateCoAuthored + `:.*claude`),
regexp.MustCompile(`(?im)^` + gateCoAuthored + `:.*anthropic`),
regexp.MustCompile(`(?im)^` + gateCoAuthored + `:.*noreply@anthropic`),
regexp.MustCompile(gateRobotEmoji + `[^\n]{0,20}` + gateGenVerb),
}
// textExtensions is the default extension allowlist for the file scan
// — same set the existing scripts/check_comment_hygiene.sh in
// downstream consumers uses, extended with the Go-side extensions a
// Go project carries.
var textExtensions = map[string]bool{
".md": true,
".sh": true,
".go": true,
".zig": true,
".S": true,
".inc": true,
".zon": true,
".yml": true,
".yaml": true,
".txt": true,
".ld": true,
".json": true,
".toml": true,
}
// Options governs CheckAttribution scope. Zero value scans nothing —
// callers must set at least one of ScanFiles / ScanCommits.
type Options struct {
// Paths overrides the default tracked-files enumeration when set.
// Each entry is repo-relative.
Paths []string
// Range is the commit-body git range (e.g. "origin/main..HEAD"). An
// empty value selects the default: origin/main..HEAD when
// resolvable, otherwise HEAD~10..HEAD with a notice.
Range string
// ScanFiles enables the tracked-tree file scan.
ScanFiles bool
// ScanCommits enables the commit-body scan.
ScanCommits bool
// Excludes are additional repo-relative paths to skip during the
// file scan; the gate's own source is already excluded.
Excludes []string
}
// Finding is one policy hit. Path/Line/Excerpt set for file hits;
// Commit/Line/Excerpt set for commit-body hits.
type Finding struct {
Path string
Line int
Commit string
Excerpt string
}
// Result groups findings with non-fatal notices the caller should
// surface to stderr (for example the HEAD~10 range fallback).
type Result struct {
Findings []Finding
Notices []string
}
// CheckAttribution runs the configured scans against workdir (a git
// repository). Returns the combined Result and a non-nil error only on
// infrastructure failure (workdir is not a repo, git is unavailable).
// A clean result is Result{} with both slices nil; a finding-only
// outcome returns the populated Result and a nil error so callers can
// distinguish "ran and found things" from "could not run".
func CheckAttribution(workdir string, opts Options) (Result, error) {
var res Result
if opts.ScanFiles {
fs, err := scanFiles(workdir, opts)
if err != nil {
return res, err
}
res.Findings = append(res.Findings, fs...)
}
if opts.ScanCommits {
cs, notices, err := scanCommits(workdir, opts)
if err != nil {
return res, err
}
res.Findings = append(res.Findings, cs...)
res.Notices = append(res.Notices, notices...)
}
return res, nil
}
func scanFiles(workdir string, opts Options) ([]Finding, error) {
paths := opts.Paths
if len(paths) == 0 {
out, err := runGit(workdir, "ls-files")
if err != nil {
return nil, fmt.Errorf("git ls-files: %w", err)
}
for p := range strings.SplitSeq(strings.TrimRight(out, "\n"), "\n") {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if !isTextExtension(p) {
continue
}
paths = append(paths, p)
}
}
excluded := make(map[string]bool, len(opts.Excludes)+1)
excluded["internal/gates/attribution.go"] = true
for _, e := range opts.Excludes {
excluded[filepath.ToSlash(e)] = true
}
det, err := workflow.NewDetector(nil)
if err != nil {
return nil, fmt.Errorf("build detector: %w", err)
}
var findings []Finding
for _, rel := range paths {
if excluded[rel] {
continue
}
full := filepath.Join(workdir, rel)
b, err := os.ReadFile(full)
if err != nil {
continue
}
// Cheap binary sniff so a JSON-like blob with a NUL skips.
if bytes.IndexByte(b[:min(len(b), 8000)], 0) != -1 {
continue
}
for _, hit := range det.Scan(rel, string(b)) {
excerpt := readLine(b, hit.Line)
findings = append(findings, Finding{
Path: rel,
Line: hit.Line,
Excerpt: excerpt,
})
}
}
return findings, nil
}
func scanCommits(workdir string, opts Options) ([]Finding, []string, error) {
var notices []string
rng := opts.Range
if rng == "" {
if _, err := runGit(workdir, "rev-parse", "--verify", "--quiet", "origin/main"); err == nil {
rng = "origin/main..HEAD"
} else {
rng = "HEAD~10..HEAD"
notices = append(notices, "origin/main not resolvable; commit-body scan range falls back to "+rng)
}
}
out, err := runGit(workdir, "rev-list", rng)
if err != nil {
// Empty range (e.g. HEAD has no ancestor for HEAD~10..HEAD in a
// shallow repo): treat as no commits, not an infrastructure
// failure. The notice already names the fallback range.
return nil, notices, nil
}
var findings []Finding
for sha := range strings.FieldsSeq(out) {
body, err := runGit(workdir, "log", "-1", "--format=%B", sha)
if err != nil {
continue
}
for _, p := range strictTrailerPatterns {
loc := p.FindStringIndex(body)
if loc == nil {
continue
}
line := strings.Count(body[:loc[0]], "\n") + 1
excerpt := strings.TrimRight(body[loc[0]:loc[1]], "\r\n")
findings = append(findings, Finding{
Commit: shortSHA(sha),
Line: line,
Excerpt: excerpt,
})
break // one hit per commit is enough — keep reports terse
}
}
return findings, notices, nil
}
func runGit(workdir string, args ...string) (string, error) {
cmd := exec.Command("git", args...)
cmd.Dir = workdir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), strings.TrimSpace(stderr.String()))
}
return "", fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
}
return stdout.String(), nil
}
func shortSHA(sha string) string {
if len(sha) >= 7 {
return sha[:7]
}
return sha
}
func isTextExtension(path string) bool {
ext := filepath.Ext(path)
return textExtensions[strings.ToLower(ext)]
}
// readLine returns the 1-indexed line of b, with trailing CR/LF
// stripped. An out-of-range line returns "".
func readLine(b []byte, n int) string {
if n <= 0 {
return ""
}
cur := 1
start := 0
for i, c := range b {
if c != '\n' {
continue
}
if cur == n {
return strings.TrimRight(string(b[start:i]), "\r")
}
cur++
start = i + 1
}
if cur == n {
return strings.TrimRight(string(b[start:]), "\r")
}
return ""
}
added internal/gates/attribution_test.go
@@ -0,0 +1,208 @@
package gates
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// initRepo creates a fresh git repo in t.TempDir() with one seed commit
// so HEAD~N..HEAD ranges resolve. Returns the workdir path.
func initRepo(t *testing.T) string {
t.Helper()
dir := t.TempDir()
runOrFail(t, dir, "git", "init", "-q", "-b", "main")
runOrFail(t, dir, "git", "config", "user.email", "[email protected]")
runOrFail(t, dir, "git", "config", "user.name", "test")
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("# seed\n"), 0o644); err != nil {
t.Fatal(err)
}
runOrFail(t, dir, "git", "add", "README.md")
runOrFail(t, dir, "git", "commit", "-q", "-m", "seed")
return dir
}
func runOrFail(t *testing.T, dir, name string, args ...string) {
t.Helper()
cmd := exec.Command(name, args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("%s %s: %v\n%s", name, strings.Join(args, " "), err, out)
}
}
func writeAndCommit(t *testing.T, dir, rel, body, msg string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, rel), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
runOrFail(t, dir, "git", "add", rel)
runOrFail(t, dir, "git", "commit", "-q", "-m", msg)
}
func TestCheckAttribution_CleanTree(t *testing.T) {
dir := initRepo(t)
res, err := CheckAttribution(dir, Options{ScanFiles: true, ScanCommits: true, Range: "HEAD~0..HEAD"})
if err != nil {
t.Fatalf("CheckAttribution: %v", err)
}
if len(res.Findings) != 0 {
t.Errorf("clean tree has findings: %+v", res.Findings)
}
}
func TestCheckAttribution_FileHitDetectedByDetector(t *testing.T) {
dir := initRepo(t)
// A simulated leak in a tracked file: a Co-Authored-By line.
leaked := "feat\n\n" + "Co-" + "Authored-" + "By: Whoever <[email protected]>\n"
writeAndCommit(t, dir, "NOTES.md", leaked, "add notes")
res, err := CheckAttribution(dir, Options{ScanFiles: true})
if err != nil {
t.Fatalf("CheckAttribution: %v", err)
}
if len(res.Findings) == 0 {
t.Fatal("expected a file hit for the seeded trailer")
}
hit := res.Findings[0]
if hit.Path != "NOTES.md" {
t.Errorf("hit path = %q, want NOTES.md", hit.Path)
}
if hit.Commit != "" {
t.Errorf("file hit must not carry a commit SHA, got %q", hit.Commit)
}
}
func TestCheckAttribution_BodyHitOnCommitMessage(t *testing.T) {
dir := initRepo(t)
// Author a commit whose MESSAGE carries a Co-Authored-By:Claude
// trailer; the working-tree change itself is innocuous.
if err := os.WriteFile(filepath.Join(dir, "x.md"), []byte("x\n"), 0o644); err != nil {
t.Fatal(err)
}
runOrFail(t, dir, "git", "add", "x.md")
runOrFail(t, dir, "git", "commit", "-q", "-m",
"feat: x\n\n"+"Co-"+"Authored-"+"By: Claude <[email protected]>")
res, err := CheckAttribution(dir, Options{ScanCommits: true, Range: "HEAD~1..HEAD"})
if err != nil {
t.Fatalf("CheckAttribution: %v", err)
}
if len(res.Findings) == 0 {
t.Fatal("expected a commit-body hit")
}
hit := res.Findings[0]
if hit.Commit == "" {
t.Errorf("body hit must carry a commit SHA, got empty")
}
if !strings.Contains(strings.ToLower(hit.Excerpt), "claude") {
t.Errorf("hit excerpt should name claude, got %q", hit.Excerpt)
}
}
func TestCheckAttribution_BodyScanIgnoresPolicyDiscussion(t *testing.T) {
dir := initRepo(t)
// A docs commit whose SUBJECT and BODY mention the forbidden tokens
// in prose — not as an actual trailer. Strict trailer-anchored
// patterns must let this pass.
if err := os.WriteFile(filepath.Join(dir, "docs.md"), []byte("policy\n"), 0o644); err != nil {
t.Fatal(err)
}
runOrFail(t, dir, "git", "add", "docs.md")
runOrFail(t, dir, "git", "commit", "-q", "-m",
"docs: discuss the Co-"+"Authored-"+"By trailer policy\n\n"+
"The claude and anthropic tokens used to leak via the trailer; "+
"the noreply@anthropic email is also blocked.")
res, err := CheckAttribution(dir, Options{ScanCommits: true, Range: "HEAD~1..HEAD"})
if err != nil {
t.Fatalf("CheckAttribution: %v", err)
}
if len(res.Findings) != 0 {
t.Errorf("body scan flagged a policy-discussion commit: %+v", res.Findings)
}
}
func TestCheckAttribution_RangeFallbackEmitsNotice(t *testing.T) {
// Fresh repo has no origin/main remote — exercise the fallback.
dir := initRepo(t)
res, err := CheckAttribution(dir, Options{ScanCommits: true})
if err != nil {
t.Fatalf("CheckAttribution: %v", err)
}
if len(res.Notices) == 0 {
t.Fatal("expected a fallback notice")
}
if !strings.Contains(res.Notices[0], "HEAD~10..HEAD") {
t.Errorf("notice = %q, want HEAD~10..HEAD mention", res.Notices[0])
}
}
func TestCheckAttribution_NoCommitsSkipsBodyScan(t *testing.T) {
dir := initRepo(t)
// Stage a body-leak commit; ScanCommits=false should skip it.
runOrFail(t, dir, "git", "commit", "--allow-empty", "-q", "-m",
"feat: x\n\n"+"Co-"+"Authored-"+"By: Claude <[email protected]>")
res, err := CheckAttribution(dir, Options{ScanFiles: true, ScanCommits: false})
if err != nil {
t.Fatalf("CheckAttribution: %v", err)
}
for _, f := range res.Findings {
if f.Commit != "" {
t.Errorf("body finding leaked through ScanCommits=false: %+v", f)
}
}
}
func TestCheckAttribution_PathsOverrideLimitsScope(t *testing.T) {
dir := initRepo(t)
// Seed a file with a real trailer; the override paths point at a
// different (clean) file so the scan returns nothing.
leaked := "feat\n\n" + "Co-" + "Authored-" + "By: Whoever <[email protected]>\n"
writeAndCommit(t, dir, "DIRTY.md", leaked, "add dirty")
writeAndCommit(t, dir, "CLEAN.md", "just text\n", "add clean")
res, err := CheckAttribution(dir, Options{
ScanFiles: true,
Paths: []string{"CLEAN.md"},
})
if err != nil {
t.Fatalf("CheckAttribution: %v", err)
}
if len(res.Findings) != 0 {
t.Errorf("override scope leaked DIRTY.md hit: %+v", res.Findings)
}
}
func TestCheckAttribution_ExcludeSkipsPath(t *testing.T) {
dir := initRepo(t)
leaked := "feat\n\n" + "Co-" + "Authored-" + "By: Whoever <[email protected]>\n"
writeAndCommit(t, dir, "DIRTY.md", leaked, "add dirty")
res, err := CheckAttribution(dir, Options{
ScanFiles: true,
Excludes: []string{"DIRTY.md"},
})
if err != nil {
t.Fatalf("CheckAttribution: %v", err)
}
if len(res.Findings) != 0 {
t.Errorf("exclude failed to skip DIRTY.md: %+v", res.Findings)
}
}
func TestCheckAttribution_RobotEmojiInBody(t *testing.T) {
dir := initRepo(t)
robot := string([]rune{0x1F916})
body := "feat: thing\n\n" + robot + " " + "Generated" + " with the assistant\n"
if err := os.WriteFile(filepath.Join(dir, "x.md"), []byte("x\n"), 0o644); err != nil {
t.Fatal(err)
}
runOrFail(t, dir, "git", "add", "x.md")
runOrFail(t, dir, "git", "commit", "-q", "-m", body)
res, err := CheckAttribution(dir, Options{ScanCommits: true, Range: "HEAD~1..HEAD"})
if err != nil {
t.Fatalf("CheckAttribution: %v", err)
}
if len(res.Findings) == 0 {
t.Fatal("expected a body hit for robot-emoji Generated signature")
}
}
added internal/gitx/gitx.go
@@ -0,0 +1,314 @@
// Package gitx provides the read-only git helpers eeco needs. It never
// commits, pushes, or mutates a repository: every function here only
// inspects. Anything that would write to git history is deliberately
// absent so the engine cannot do it even by mistake (Constraint 6).
package gitx
import (
"errors"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
// ErrUnavailable is returned when the git binary cannot be found. A
// caller that needs git should treat this as "blocked" (contract code
// 2), not as a failure.
var ErrUnavailable = errors.New("git not available on PATH")
// Available reports whether a usable git binary is on PATH.
func Available() bool {
_, err := exec.LookPath("git")
return err == nil
}
// UserName returns the configured `git config user.name` at root,
// trimmed of surrounding whitespace. An unset user.name is not an
// error: `git config <key>` exits non-zero when the key is missing, and
// that case returns "" with a nil error so callers can fall back to
// another source. A missing git binary returns ErrUnavailable. The call
// is strictly read-only.
func UserName(root string) (string, error) {
if !Available() {
return "", ErrUnavailable
}
cmd := exec.Command("git", "config", "user.name")
cmd.Dir = root
out, err := cmd.Output()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
return "", nil
}
return "", wrap("git config user.name", err)
}
return strings.TrimSpace(string(out)), nil
}
// TrackedFiles returns every path git tracks at root, repo-relative and
// slash-separated. It runs `git ls-files -z` with root as the working
// directory and is strictly read-only.
func TrackedFiles(root string) ([]string, error) {
if !Available() {
return nil, ErrUnavailable
}
cmd := exec.Command("git", "ls-files", "-z")
cmd.Dir = root
out, err := cmd.Output()
if err != nil {
return nil, wrap("git ls-files", err)
}
var files []string
for _, p := range strings.Split(string(out), "\x00") {
if p == "" {
continue
}
files = append(files, filepath.ToSlash(p))
}
return files, nil
}
// HeadSHA returns the current commit SHA at root. It is read-only.
func HeadSHA(root string) (string, error) {
if !Available() {
return "", ErrUnavailable
}
cmd := exec.Command("git", "rev-parse", "HEAD")
cmd.Dir = root
out, err := cmd.Output()
if err != nil {
return "", wrap("git rev-parse", err)
}
return strings.TrimSpace(string(out)), nil
}
// ChangesSince returns a one-line commit log and a diffstat for the
// range since..HEAD. It is strictly read-only (log + diff, no write
// surface). An empty since yields the changes for HEAD alone.
func ChangesSince(root, since string) (log, stat string, err error) {
if !Available() {
return "", "", ErrUnavailable
}
rng := "HEAD"
if since != "" {
rng = since + "..HEAD"
}
logCmd := exec.Command("git", "log", "--oneline", rng)
logCmd.Dir = root
lo, lerr := logCmd.Output()
if lerr != nil {
return "", "", wrap("git log", lerr)
}
statCmd := exec.Command("git", "diff", "--stat", rng)
statCmd.Dir = root
so, serr := statCmd.Output()
if serr != nil {
return "", "", wrap("git diff", serr)
}
return strings.TrimSpace(string(lo)), strings.TrimSpace(string(so)), nil
}
// RemoteTags returns the tag names advertised by a remote, via `git
// ls-remote --tags`. root sets the working directory; remote, when
// non-empty, is the explicit remote name or URL to query (empty uses
// the repository's default remote). It is strictly read-only (no fetch,
// no write surface) and reaches the network only to list refs. Peeled
// tag entries (the `refs/tags/x^{}` dereference lines) are collapsed to
// the bare tag name, and the result is de-duplicated. An empty result
// with a nil error means the remote advertised no tags; ErrUnavailable
// means git itself is missing.
func RemoteTags(root, remote string) ([]string, error) {
if !Available() {
return nil, ErrUnavailable
}
args := []string{"ls-remote", "--tags"}
if remote != "" {
args = append(args, remote)
}
cmd := exec.Command("git", args...)
cmd.Dir = root
out, err := cmd.Output()
if err != nil {
return nil, wrap("git ls-remote", err)
}
seen := map[string]struct{}{}
var tags []string
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
_, ref, ok := strings.Cut(line, "\t")
if !ok {
continue
}
name, ok := strings.CutPrefix(strings.TrimSpace(ref), "refs/tags/")
if !ok {
continue
}
name = strings.TrimSuffix(name, "^{}")
if _, dup := seen[name]; dup || name == "" {
continue
}
seen[name] = struct{}{}
tags = append(tags, name)
}
return tags, nil
}
// LatestSemverTag returns the most recent semver-shaped tag reachable
// from HEAD at root, as `vX.Y.Z`. The match is restricted to tags
// matching the strict shape `v` + three dot-separated unsigned integers;
// pre-release / build-metadata suffixes are skipped. Ordering uses git's
// own `--sort=-v:refname` (descending semver). Returns an empty string
// with a nil error when no semver-shaped tag is reachable (a fresh repo
// or one carrying only foreign tags). ErrUnavailable means git itself
// is missing.
func LatestSemverTag(root string) (string, error) {
if !Available() {
return "", ErrUnavailable
}
cmd := exec.Command("git", "tag", "--list", "v*", "--merged", "HEAD", "--sort=-v:refname")
cmd.Dir = root
out, err := cmd.Output()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
msg := strings.ToLower(strings.TrimSpace(string(ee.Stderr)))
if strings.Contains(msg, "head") || strings.Contains(msg, "no such ref") {
return "", nil
}
}
return "", wrap("git tag", err)
}
re := regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if re.MatchString(line) {
return line, nil
}
}
return "", nil
}
// SemverTags returns every semver-shaped tag reachable from HEAD at
// root, as `vX.Y.Z`, ordered descending (newest first). The match is
// restricted to the strict shape `v` + three dot-separated unsigned
// integers; pre-release / build-metadata suffixes are skipped, exactly
// as LatestSemverTag does. Ordering uses git's own `--sort=-v:refname`.
// Returns an empty slice with a nil error when no semver-shaped tag is
// reachable (a fresh repo, or one carrying only foreign tags).
// ErrUnavailable means git itself is missing. The call is read-only.
func SemverTags(root string) ([]string, error) {
if !Available() {
return nil, ErrUnavailable
}
cmd := exec.Command("git", "tag", "--list", "v*", "--merged", "HEAD", "--sort=-v:refname")
cmd.Dir = root
out, err := cmd.Output()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
msg := strings.ToLower(strings.TrimSpace(string(ee.Stderr)))
if strings.Contains(msg, "head") || strings.Contains(msg, "no such ref") {
return nil, nil
}
}
return nil, wrap("git tag", err)
}
re := regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
var tags []string
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if re.MatchString(line) {
tags = append(tags, line)
}
}
return tags, nil
}
// LastCommitDate returns the committer date of the most recent commit
// at root that touched relPath, via `git log -1 --format=%cI`. relPath
// is repo-relative and slash-separated. ok is false — with a nil error —
// when relPath has no commit history at all (untracked, or never
// committed), so the caller has nothing to date it against. The call is
// strictly read-only (a log query, no write surface). ErrUnavailable
// means the git binary itself is missing.
func LastCommitDate(root, relPath string) (date time.Time, ok bool, err error) {
if !Available() {
return time.Time{}, false, ErrUnavailable
}
cmd := exec.Command("git", "log", "-1", "--format=%cI", "--", relPath)
cmd.Dir = root
out, oerr := cmd.Output()
if oerr != nil {
return time.Time{}, false, wrap("git log", oerr)
}
s := strings.TrimSpace(string(out))
if s == "" {
return time.Time{}, false, nil
}
t, perr := time.Parse(time.RFC3339, s)
if perr != nil {
return time.Time{}, false, errors.New("git log: parse commit date: " + perr.Error())
}
return t, true, nil
}
// IsDirty reports whether the working tree at root carries uncommitted work —
// staged or unstaged changes to tracked files, or untracked files — via the
// `git status --porcelain` non-empty test. It is strictly read-only.
// ErrUnavailable means the git binary itself is missing.
func IsDirty(root string) (bool, error) {
if !Available() {
return false, ErrUnavailable
}
cmd := exec.Command("git", "status", "--porcelain")
cmd.Dir = root
out, err := cmd.Output()
if err != nil {
return false, wrap("git status", err)
}
return strings.TrimSpace(string(out)) != "", nil
}
// LastCommitTime returns the committer time of HEAD at root. ok is false (with
// a nil error) when the repository has no commits yet (a fresh repo with no
// HEAD), so the caller has nothing to date against. It is strictly read-only (a
// log query, no write surface). ErrUnavailable means the git binary itself is
// missing.
func LastCommitTime(root string) (date time.Time, ok bool, err error) {
if !Available() {
return time.Time{}, false, ErrUnavailable
}
cmd := exec.Command("git", "log", "-1", "--format=%cI")
cmd.Dir = root
out, oerr := cmd.Output()
if oerr != nil {
var ee *exec.ExitError
if errors.As(oerr, &ee) {
// No commits / no HEAD yet: not an error for the caller.
return time.Time{}, false, nil
}
return time.Time{}, false, wrap("git log", oerr)
}
s := strings.TrimSpace(string(out))
if s == "" {
return time.Time{}, false, nil
}
t, perr := time.Parse(time.RFC3339, s)
if perr != nil {
return time.Time{}, false, errors.New("git log: parse commit date: " + perr.Error())
}
return t, true, nil
}
func wrap(what string, err error) error {
var ee *exec.ExitError
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
return errors.New(what + ": " + strings.TrimSpace(string(ee.Stderr)))
}
return errors.New(what + ": " + err.Error())
}
added internal/gitx/gitx_dirty_test.go
@@ -0,0 +1,88 @@
package gitx
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
// gitInitCommit creates a real git repo with one commit and returns its root.
// Skips when git is unavailable.
func gitInitCommit(t *testing.T) string {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
root := t.TempDir()
for _, args := range [][]string{
{"init", "-q"}, {"config", "user.email", "t@x"}, {"config", "user.name", "t"},
} {
c := exec.Command("git", args...)
c.Dir = root
if out, err := c.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
if err := os.WriteFile(filepath.Join(root, "f.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
for _, args := range [][]string{{"add", "-A"}, {"commit", "-qm", "seed"}} {
c := exec.Command("git", args...)
c.Dir = root
if out, err := c.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
return root
}
func TestIsDirty(t *testing.T) {
root := gitInitCommit(t)
dirty, err := IsDirty(root)
if err != nil {
t.Fatal(err)
}
if dirty {
t.Error("freshly committed tree reported dirty")
}
if err := os.WriteFile(filepath.Join(root, "new.txt"), []byte("y"), 0o644); err != nil {
t.Fatal(err)
}
dirty, err = IsDirty(root)
if err != nil {
t.Fatal(err)
}
if !dirty {
t.Error("tree with an untracked file reported clean")
}
}
func TestLastCommitTime(t *testing.T) {
root := gitInitCommit(t)
ts, ok, err := LastCommitTime(root)
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("LastCommitTime ok=false on a repo with a commit")
}
if ts.IsZero() {
t.Error("LastCommitTime returned a zero time")
}
}
func TestLastCommitTime_NoCommits(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
root := t.TempDir()
c := exec.Command("git", "init", "-q")
c.Dir = root
if out, err := c.CombinedOutput(); err != nil {
t.Fatalf("git init: %v\n%s", err, out)
}
if _, ok, err := LastCommitTime(root); err != nil || ok {
t.Errorf("LastCommitTime on an empty repo = (ok=%v, err=%v), want (false, nil)", ok, err)
}
}
added internal/gitx/gitx_test.go
@@ -0,0 +1,569 @@
package gitx
import (
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
)
func TestAvailable(t *testing.T) {
_, look := exec.LookPath("git")
if Available() != (look == nil) {
t.Errorf("Available()=%v but LookPath err=%v", Available(), look)
}
}
func TestTrackedFiles(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
root := t.TempDir()
for _, f := range []string{"a.txt", "sub/b.txt"} {
p := filepath.Join(root, f)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
for _, args := range [][]string{
{"init", "-q"}, {"config", "user.email", "t@x"}, {"config", "user.name", "t"}, {"add", "-A"},
} {
c := exec.Command("git", args...)
c.Dir = root
if out, err := c.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
// Untracked file must not appear.
if err := os.WriteFile(filepath.Join(root, "c.txt"), []byte("y"), 0o644); err != nil {
t.Fatal(err)
}
got, err := TrackedFiles(root)
if err != nil {
t.Fatal(err)
}
sort.Strings(got)
want := []string{"a.txt", "sub/b.txt"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Errorf("TrackedFiles = %v, want %v", got, want)
}
}
func TestLatestSemverTag(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
t.Run("no-tags", func(t *testing.T) {
work := t.TempDir()
runGit(t, work, "init", "-q")
runGit(t, work, "config", "user.email", "t@x")
runGit(t, work, "config", "user.name", "t")
if err := os.WriteFile(filepath.Join(work, "f.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, work, "add", "-A")
runGit(t, work, "commit", "-q", "-m", "init")
got, err := LatestSemverTag(work)
if err != nil {
t.Fatal(err)
}
if got != "" {
t.Errorf("LatestSemverTag with no tags = %q, want empty", got)
}
})
t.Run("ranks-semver-tags", func(t *testing.T) {
work := t.TempDir()
runGit(t, work, "init", "-q")
runGit(t, work, "config", "user.email", "t@x")
runGit(t, work, "config", "user.name", "t")
if err := os.WriteFile(filepath.Join(work, "f.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, work, "add", "-A")
runGit(t, work, "commit", "-q", "-m", "init")
// Create tags out of insertion order; --sort=-v:refname must
// rank v1.10.0 > v1.9.1 > v0.1.0 (string-sort would put 0.1.0
// last but 1.10.0 before 1.9.1 — only v:refname gets the order
// right).
runGit(t, work, "tag", "v0.1.0")
runGit(t, work, "tag", "v1.9.1")
runGit(t, work, "tag", "v1.10.0")
got, err := LatestSemverTag(work)
if err != nil {
t.Fatal(err)
}
if got != "v1.10.0" {
t.Errorf("LatestSemverTag = %q, want v1.10.0", got)
}
})
t.Run("skips-non-semver-shaped", func(t *testing.T) {
work := t.TempDir()
runGit(t, work, "init", "-q")
runGit(t, work, "config", "user.email", "t@x")
runGit(t, work, "config", "user.name", "t")
if err := os.WriteFile(filepath.Join(work, "f.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, work, "add", "-A")
runGit(t, work, "commit", "-q", "-m", "init")
runGit(t, work, "tag", "v1.0.0")
runGit(t, work, "tag", "v1.0.0-rc1")
runGit(t, work, "tag", "v2024.05.22")
got, err := LatestSemverTag(work)
if err != nil {
t.Fatal(err)
}
// v2024.05.22 sorts high under -v:refname but is shape-valid
// (three dot-separated unsigned ints), so it wins. The pre-
// release tag v1.0.0-rc1 is filtered out.
if got != "v2024.05.22" {
t.Errorf("LatestSemverTag = %q, want v2024.05.22 (vX.Y.Z shape, pre-release filtered)", got)
}
})
t.Run("no-commit", func(t *testing.T) {
// No HEAD yet: `git tag --list --merged HEAD` exits non-zero with a
// "malformed object name HEAD" stderr, which the ExitError branch
// treats as "no semver tag reachable" — empty result, nil error.
work := t.TempDir()
runGit(t, work, "init", "-q")
got, err := LatestSemverTag(work)
if err != nil {
t.Fatalf("LatestSemverTag on a no-commit repo: %v", err)
}
if got != "" {
t.Errorf("LatestSemverTag = %q, want empty on a no-commit repo", got)
}
})
}
func TestSemverTags(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
commit := func(t *testing.T) string {
t.Helper()
work := t.TempDir()
runGit(t, work, "init", "-q")
runGit(t, work, "config", "user.email", "t@x")
runGit(t, work, "config", "user.name", "t")
if err := os.WriteFile(filepath.Join(work, "f.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, work, "add", "-A")
runGit(t, work, "commit", "-q", "-m", "init")
return work
}
t.Run("no-tags", func(t *testing.T) {
got, err := SemverTags(commit(t))
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Errorf("SemverTags with no tags = %v, want empty", got)
}
})
t.Run("ranks-and-filters", func(t *testing.T) {
work := commit(t)
// Insertion order deliberately scrambled; --sort=-v:refname must
// rank v1.10.0 > v1.9.1 > v0.1.0. The pre-release and the foreign
// non-semver-shaped tag are filtered out.
runGit(t, work, "tag", "v1.9.1")
runGit(t, work, "tag", "v0.1.0")
runGit(t, work, "tag", "v1.10.0")
runGit(t, work, "tag", "v1.0.0-rc1")
runGit(t, work, "tag", "nightly")
got, err := SemverTags(work)
if err != nil {
t.Fatal(err)
}
want := []string{"v1.10.0", "v1.9.1", "v0.1.0"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] {
t.Errorf("SemverTags = %v, want %v (descending, non-semver filtered)", got, want)
}
})
t.Run("no-commit", func(t *testing.T) {
// Same ExitError early-return as LatestSemverTag: no HEAD → empty
// slice, nil error.
work := t.TempDir()
runGit(t, work, "init", "-q")
got, err := SemverTags(work)
if err != nil {
t.Fatalf("SemverTags on a no-commit repo: %v", err)
}
if len(got) != 0 {
t.Errorf("SemverTags = %v, want empty on a no-commit repo", got)
}
})
}
func TestLastCommitDate(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
work := t.TempDir()
runGit(t, work, "init", "-q")
runGit(t, work, "config", "user.email", "t@x")
runGit(t, work, "config", "user.name", "t")
if err := os.WriteFile(filepath.Join(work, "a.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, work, "add", "a.txt")
runGit(t, work, "commit", "-q", "-m", "add a.txt")
t.Run("committed-file", func(t *testing.T) {
date, ok, err := LastCommitDate(work, "a.txt")
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("ok = false for a committed file, want true")
}
if date.IsZero() {
t.Error("date is zero for a committed file")
}
})
t.Run("untracked-file", func(t *testing.T) {
if err := os.WriteFile(filepath.Join(work, "b.txt"), []byte("y"), 0o644); err != nil {
t.Fatal(err)
}
_, ok, err := LastCommitDate(work, "b.txt")
if err != nil {
t.Fatal(err)
}
if ok {
t.Error("ok = true for an untracked file, want false")
}
})
t.Run("nonexistent-path", func(t *testing.T) {
_, ok, err := LastCommitDate(work, "never/existed.txt")
if err != nil {
t.Fatal(err)
}
if ok {
t.Error("ok = true for a path with no history, want false")
}
})
}
// runGit is a small local helper shared by the LatestSemverTag and
// RemoteTags tests.
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
c := exec.Command("git", args...)
c.Dir = dir
if out, err := c.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
func TestRemoteTags(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
bare := t.TempDir()
work := t.TempDir()
run := func(dir string, args ...string) {
t.Helper()
c := exec.Command("git", args...)
c.Dir = dir
if out, err := c.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
run(bare, "init", "-q", "--bare")
run(work, "init", "-q")
run(work, "config", "user.email", "t@x")
run(work, "config", "user.name", "t")
if err := os.WriteFile(filepath.Join(work, "f.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
run(work, "add", "-A")
run(work, "commit", "-q", "-m", "init")
// A lightweight and an annotated tag: the annotated one emits a
// peeled `^{}` ref that must collapse to the bare name.
run(work, "tag", "v0.1.0")
run(work, "tag", "-a", "v0.2.0", "-m", "release")
run(work, "remote", "add", "origin", bare)
run(work, "push", "-q", "origin", "HEAD", "--tags")
tags, err := RemoteTags(work, "")
if err != nil {
t.Fatalf("RemoteTags: %v", err)
}
sort.Strings(tags)
want := []string{"v0.1.0", "v0.2.0"}
if len(tags) != len(want) || tags[0] != want[0] || tags[1] != want[1] {
t.Errorf("RemoteTags = %v, want %v (peeled refs must dedupe)", tags, want)
}
// An explicit remote (here the bare repo path) is queried directly,
// independent of the working directory's default remote.
explicit, err := RemoteTags(t.TempDir(), bare)
if err != nil {
t.Fatalf("RemoteTags explicit: %v", err)
}
sort.Strings(explicit)
if len(explicit) != len(want) || explicit[0] != want[0] || explicit[1] != want[1] {
t.Errorf("RemoteTags(explicit) = %v, want %v", explicit, want)
}
}
// shaRE matches a full 40-char lowercase-hex git object name.
var shaRE = regexp.MustCompile(`^[0-9a-f]{40}$`)
// gitOut runs git at dir and returns trimmed-of-nothing stdout, failing the
// test on any error. It is the read-back companion to runGit.
func gitOut(t *testing.T, dir string, args ...string) string {
t.Helper()
c := exec.Command("git", args...)
c.Dir = dir
out, err := c.Output()
if err != nil {
t.Fatalf("git %v: %v", args, err)
}
return string(out)
}
// initRepo creates a fresh temp repo with an identity and one commit, and
// returns its root. Built on the existing runGit helper.
func initRepo(t *testing.T) string {
t.Helper()
root := t.TempDir()
runGit(t, root, "init", "-q")
runGit(t, root, "config", "user.email", "t@x")
runGit(t, root, "config", "user.name", "t")
commitFile(t, root, "base.txt", "base", "init")
return root
}
// commitFile writes name=content under root, stages everything, commits with
// msg, and returns the new HEAD sha.
func commitFile(t *testing.T, root, name, content, msg string) string {
t.Helper()
if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
runGit(t, root, "add", "-A")
runGit(t, root, "commit", "-q", "-m", msg)
return headSHA(t, root)
}
// headSHA returns the trimmed test-side HEAD sha at root.
func headSHA(t *testing.T, root string) string {
t.Helper()
return strings.TrimSpace(gitOut(t, root, "rev-parse", "HEAD"))
}
// fingerprint captures six read-only state fields as one comparable string.
// Each field changes only if a git call mutated the repo, so comparing the
// fingerprint before vs after a batch of gitx calls proves the calls were
// read-only (host-config differences cancel because both sides see the same
// config). `config` is pinned to --local so global config never leaks in.
func fingerprint(t *testing.T, root string) string {
t.Helper()
var b strings.Builder
for _, args := range [][]string{
{"rev-parse", "HEAD"},
{"status", "--porcelain"},
{"tag", "--list"},
{"rev-list", "--all", "--count"},
{"config", "--list", "--local"},
{"stash", "list"},
} {
b.WriteString(strings.Join(args, " "))
b.WriteString("\x1f")
b.WriteString(gitOut(t, root, args...))
b.WriteString("\x1e")
}
return b.String()
}
func TestUserName(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
t.Run("set", func(t *testing.T) {
root := initRepo(t)
runGit(t, root, "config", "user.name", "Ada Lovelace")
got, err := UserName(root)
if err != nil {
t.Fatalf("UserName: %v", err)
}
if got != "Ada Lovelace" {
t.Errorf("UserName = %q, want %q", got, "Ada Lovelace")
}
})
t.Run("unset", func(t *testing.T) {
// Null out global+system config so the host's own user.name cannot
// leak in: an unset key must exit non-zero, which the ExitError
// branch reports as "", nil (not an error).
t.Setenv("GIT_CONFIG_GLOBAL", filepath.Join(t.TempDir(), "noglobal"))
t.Setenv("GIT_CONFIG_SYSTEM", filepath.Join(t.TempDir(), "nosystem"))
root := t.TempDir()
runGit(t, root, "init", "-q")
got, err := UserName(root)
if err != nil {
t.Fatalf("UserName with no user.name set returned an error: %v", err)
}
if got != "" {
t.Errorf("UserName = %q, want empty when user.name is unset", got)
}
})
t.Run("dir-does-not-exist", func(t *testing.T) {
// A nonexistent cmd.Dir fails the chdir in Start() with a non-
// ExitError, exercising wrap's else branch.
root := filepath.Join(t.TempDir(), "nope")
_, err := UserName(root)
if err == nil {
t.Fatal("UserName on a nonexistent dir: want an error, got nil")
}
if !strings.Contains(err.Error(), "git config user.name") {
t.Errorf("err = %q, want it to contain %q", err.Error(), "git config user.name")
}
})
}
func TestHeadSHA(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
t.Run("committed", func(t *testing.T) {
root := initRepo(t)
got, err := HeadSHA(root)
if err != nil {
t.Fatalf("HeadSHA: %v", err)
}
if !shaRE.MatchString(got) {
t.Errorf("HeadSHA = %q, want a 40-char lowercase-hex sha", got)
}
})
t.Run("empty-repo", func(t *testing.T) {
// `git rev-parse HEAD` with no commits exits 128 with stderr,
// exercising wrap's stderr branch.
root := t.TempDir()
runGit(t, root, "init", "-q")
_, err := HeadSHA(root)
if err == nil {
t.Fatal("HeadSHA on an empty repo: want an error, got nil")
}
if !strings.Contains(err.Error(), "git rev-parse") {
t.Errorf("err = %q, want it to contain %q", err.Error(), "git rev-parse")
}
})
}
func TestChangesSince(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
t.Run("since-sha", func(t *testing.T) {
root := initRepo(t)
first := headSHA(t, root)
// A second commit with different content so first..HEAD yields a
// non-empty diffstat as well as a log line.
commitFile(t, root, "f.txt", "v2", "second")
log, stat, err := ChangesSince(root, first)
if err != nil {
t.Fatalf("ChangesSince: %v", err)
}
if !strings.Contains(log, "second") {
t.Errorf("log = %q, want it to contain %q", log, "second")
}
if stat == "" {
t.Error("stat is empty, want a non-empty diffstat for two differing commits")
}
})
t.Run("since-empty", func(t *testing.T) {
root := initRepo(t)
log, _, err := ChangesSince(root, "")
if err != nil {
t.Fatalf("ChangesSince: %v", err)
}
if log == "" {
t.Error("log is empty for the HEAD range, want non-empty")
}
})
t.Run("bad-dir", func(t *testing.T) {
root := filepath.Join(t.TempDir(), "nope")
_, _, err := ChangesSince(root, "")
if err == nil {
t.Fatal("ChangesSince on a nonexistent dir: want an error, got nil")
}
if !strings.Contains(err.Error(), "git log") {
t.Errorf("err = %q, want it to contain %q", err.Error(), "git log")
}
})
}
// TestReadOnly_StateFingerprintUnchanged is the H1.6 trust-boundary keystone
// seed: it proves the engine's git helpers never mutate a repository. Every
// public gitx function is called against a populated fixture, and a six-field
// read-only state snapshot taken before and after must be byte-identical. A
// future change that smuggles in a mutating subcommand trips this test. H1.6
// extends this suite — keep it named.
func TestReadOnly_StateFingerprintUnchanged(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
work := initRepo(t)
beforeSHA := headSHA(t, work)
// A lightweight and an annotated tag, plus a bare remote with the refs
// pushed, so RemoteTags and the semver-tag queries have real refs to read.
runGit(t, work, "tag", "v0.1.0")
runGit(t, work, "tag", "-a", "v0.2.0", "-m", "release")
bare := t.TempDir()
runGit(t, bare, "init", "-q", "--bare")
runGit(t, work, "remote", "add", "origin", bare)
runGit(t, work, "push", "-q", "origin", "HEAD", "--tags")
before := fingerprint(t, work)
// Exercise every public function purely for its read side-effect.
Available()
if _, err := UserName(work); err != nil {
t.Fatalf("UserName: %v", err)
}
if _, err := TrackedFiles(work); err != nil {
t.Fatalf("TrackedFiles: %v", err)
}
if _, err := HeadSHA(work); err != nil {
t.Fatalf("HeadSHA: %v", err)
}
if _, _, err := ChangesSince(work, ""); err != nil {
t.Fatalf("ChangesSince empty: %v", err)
}
if _, _, err := ChangesSince(work, beforeSHA); err != nil {
t.Fatalf("ChangesSince sha: %v", err)
}
if _, err := RemoteTags(work, ""); err != nil {
t.Fatalf("RemoteTags default: %v", err)
}
if _, err := RemoteTags(work, bare); err != nil {
t.Fatalf("RemoteTags explicit: %v", err)
}
if _, err := LatestSemverTag(work); err != nil {
t.Fatalf("LatestSemverTag: %v", err)
}
if _, err := SemverTags(work); err != nil {
t.Fatalf("SemverTags: %v", err)
}
if _, _, err := LastCommitDate(work, "base.txt"); err != nil {
t.Fatalf("LastCommitDate: %v", err)
}
after := fingerprint(t, work)
if before != after {
t.Errorf("read-only invariant violated: repo state changed across gitx calls\nbefore:\n%q\nafter:\n%q", before, after)
}
}
added internal/guide/guide.go
@@ -0,0 +1,68 @@
// Package guide ships eeco's in-binary user manual. It embeds a
// verbatim mirror of docs/USAGE.md so any installation of eeco can
// read the full user-facing reference offline, regardless of install
// route or network access. The embedded copy is drift-gated against
// docs/USAGE.md (see TestUsageMirrorsDocs).
package guide
import (
_ "embed"
"io"
"os"
"os/exec"
"strings"
)
//go:embed usage.md
var usage string
// Text returns the embedded guide content. Byte-identical to
// docs/USAGE.md at the tag the binary was built from.
func Text() string {
return usage
}
// Dump writes the embedded guide to w verbatim, byte-identical to
// docs/USAGE.md. The non-TTY / piped entry point — machine consumers
// get raw Markdown with no ANSI.
func Dump(w io.Writer) error {
_, err := io.WriteString(w, usage)
return err
}
// DumpRendered writes the prettified guide (see Render) to w. The
// interactive fallback used when no pager is available, so a terminal
// user still gets box-drawing tables and styled headings.
func DumpRendered(w io.Writer, colour bool) error {
_, err := io.WriteString(w, Render(colour))
return err
}
// PagerCommand builds a paginator *exec.Cmd that reads content from
// stdin. It honours $PAGER (split on whitespace), falls back to
// `less -R`, and returns nil when neither is available. The returned
// command leaves Stdout / Stderr unset so the caller can attach the
// real terminal file descriptors. lookPath / getenv are injectable for
// tests.
func PagerCommand(content string, getenv func(string) string, lookPath func(string) (string, error)) *exec.Cmd {
if getenv == nil {
getenv = os.Getenv
}
if lookPath == nil {
lookPath = exec.LookPath
}
if p := strings.TrimSpace(getenv("PAGER")); p != "" {
fields := strings.Fields(p)
if resolved, err := lookPath(fields[0]); err == nil {
cmd := exec.Command(resolved, fields[1:]...)
cmd.Stdin = strings.NewReader(content)
return cmd
}
}
if resolved, err := lookPath("less"); err == nil {
cmd := exec.Command(resolved, "-R")
cmd.Stdin = strings.NewReader(content)
return cmd
}
return nil
}
added internal/guide/guide_test.go
@@ -0,0 +1,149 @@
package guide
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestText_NonEmptyAndHeader(t *testing.T) {
s := Text()
if len(s) == 0 {
t.Fatal("Text() is empty; usage.md missing from embed?")
}
if !strings.HasPrefix(s, `<div align="center">`) {
t.Errorf("Text() does not start with the cross-repo-fingerprint header block; got %q", firstLine(s))
}
if !strings.Contains(s, "<h1>Usage</h1>") {
t.Errorf("Text() is missing the <h1>Usage</h1> page title")
}
}
// TestUsageMirrorsDocs is the drift gate. The in-package copy at
// internal/guide/usage.md must stay byte-identical to docs/USAGE.md;
// any USAGE.md edit that forgets to re-sync the mirror fails here in
// CI. Re-sync by re-running `make sync-guide` or copying the file by
// hand.
func TestUsageMirrorsDocs(t *testing.T) {
root := repoRoot(t)
want, err := os.ReadFile(filepath.Join(root, "docs", "USAGE.md"))
if err != nil {
t.Fatalf("read docs/USAGE.md: %v", err)
}
if !bytes.Equal([]byte(Text()), want) {
t.Errorf("internal/guide/usage.md drifted from docs/USAGE.md — re-sync the mirror (cp docs/USAGE.md internal/guide/usage.md)")
}
}
func TestDump_WritesEmbeddedText(t *testing.T) {
var buf bytes.Buffer
if err := Dump(&buf); err != nil {
t.Fatalf("Dump returned %v", err)
}
if buf.String() != Text() {
t.Error("Dump output != Text() — content diverged")
}
}
func TestPagerCommand_HonoursPAGER(t *testing.T) {
getenv := func(k string) string {
if k == "PAGER" {
return "mypager --quiet"
}
return ""
}
lookPath := func(name string) (string, error) {
if name == "mypager" {
return "/usr/local/bin/mypager", nil
}
return "", errors.New("not found")
}
cmd := PagerCommand("body", getenv, lookPath)
if cmd == nil {
t.Fatal("PagerCommand returned nil with PAGER set")
}
if cmd.Path != "/usr/local/bin/mypager" {
t.Errorf("cmd.Path = %q, want %q", cmd.Path, "/usr/local/bin/mypager")
}
if len(cmd.Args) < 2 || cmd.Args[1] != "--quiet" {
t.Errorf("cmd.Args = %v, want PAGER args parsed", cmd.Args)
}
}
func TestPagerCommand_FallsBackToLess(t *testing.T) {
getenv := func(string) string { return "" }
lookPath := func(name string) (string, error) {
if name == "less" {
return "/usr/bin/less", nil
}
return "", errors.New("not found")
}
cmd := PagerCommand("body", getenv, lookPath)
if cmd == nil {
t.Fatal("PagerCommand returned nil with less on PATH")
}
if cmd.Path != "/usr/bin/less" {
t.Errorf("cmd.Path = %q, want %q", cmd.Path, "/usr/bin/less")
}
if len(cmd.Args) != 2 || cmd.Args[1] != "-R" {
t.Errorf("cmd.Args = %v, want less -R", cmd.Args)
}
}
func TestPagerCommand_ReturnsNilWhenNoPager(t *testing.T) {
getenv := func(string) string { return "" }
lookPath := func(string) (string, error) {
return "", errors.New("not found")
}
if cmd := PagerCommand("body", getenv, lookPath); cmd != nil {
t.Errorf("PagerCommand = %v, want nil when no pager available", cmd)
}
}
func TestPagerCommand_IgnoresUnresolvablePAGER(t *testing.T) {
getenv := func(k string) string {
if k == "PAGER" {
return "no-such-binary"
}
return ""
}
lookPath := func(name string) (string, error) {
if name == "less" {
return "/usr/bin/less", nil
}
return "", errors.New("not found")
}
cmd := PagerCommand("body", getenv, lookPath)
if cmd == nil || cmd.Path != "/usr/bin/less" {
t.Errorf("expected fallback to less when PAGER unresolvable; got %v", cmd)
}
}
// repoRoot walks up from the test file's working directory until it
// finds the repo's go.mod, returning the absolute path.
func repoRoot(t *testing.T) string {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
dir := wd
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatalf("go.mod not found above %q", wd)
}
dir = parent
}
}
func firstLine(s string) string {
head, _, _ := strings.Cut(s, "\n")
return head
}
added internal/guide/render.go
@@ -0,0 +1,365 @@
package guide
import (
"regexp"
"strings"
"unicode/utf8"
)
// ANSI styling. Kept to two attributes — bold for headings, table
// headers, and **bold** spans; faint for rule lines, code spans, link
// URLs, and fenced bodies. Layout (boxes, indents, rules) is identical
// with or without colour; only these escapes are added or omitted.
const (
ansiBold = "\x1b[1m"
ansiFaint = "\x1b[2m"
ansiReset = "\x1b[0m"
)
var (
reCode = regexp.MustCompile("`([^`]+)`")
reBold = regexp.MustCompile(`\*\*([^*]+)\*\*`)
reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
reAutolink = regexp.MustCompile(`<(https?://[^>]+)>`)
reBullet = regexp.MustCompile(`^(\s*)[-*] (.*)$`)
)
// Render transforms the embedded Markdown manual into a terminal-
// friendly form: box-drawing tables, styled headings, and rendered
// inline markup. colour toggles ANSI styling. Layout is identical with
// or without colour, so stripping the escapes from Render(true) yields
// Render(false). Unrecognised lines pass through verbatim, so a future
// docs/USAGE.md edit can never break the guide — at worst a new
// construct renders as plain text. The source embed is untouched;
// Text() still returns the byte-identical mirror.
func Render(colour bool) string {
return renderManual(usage, colour)
}
func renderManual(src string, colour bool) string {
// Normalise line endings so a CRLF checkout (Windows) renders
// identically to an LF one — the golden is LF.
src = strings.ReplaceAll(src, "\r\n", "\n")
lines := strings.Split(src, "\n")
lines = stripTopHTMLBlock(lines)
lines = stripTrailingNavFooter(lines)
out := make([]string, 0, len(lines))
inFence := false
for i := 0; i < len(lines); i++ {
line := lines[i]
trimmed := strings.TrimSpace(line)
// Fence markers toggle verbatim mode and are dropped; bodies
// are emitted as indented blocks with no inline processing.
if strings.HasPrefix(trimmed, "```") {
inFence = !inFence
continue
}
if inFence {
out = append(out, renderFenceLine(line, colour))
continue
}
if h, ok := renderHeading(line, colour); ok {
out = append(out, h...)
continue
}
if isTableHeader(lines, i) {
block, consumed := renderTable(lines, i, colour)
out = append(out, block...)
i += consumed - 1
continue
}
out = append(out, renderProse(line, colour))
}
return strings.Join(out, "\n")
}
// stripTopHTMLBlock drops the GitHub-only header block that every
// cross-repo-fingerprint doc opens with — the centred logo + page
// title + horizontal doc-nav bar inside a `<div align="center">` —
// plus its trailing `---` separator. The block is structural noise in
// a terminal renderer (raw HTML tags would otherwise pass through as
// plain text). Anything before the first non-empty line is kept
// untouched; if the first non-empty line is not the marker, the input
// is returned unchanged.
func stripTopHTMLBlock(lines []string) []string {
first := -1
for i, l := range lines {
if strings.TrimSpace(l) != "" {
first = i
break
}
}
if first < 0 {
return lines
}
if strings.TrimSpace(lines[first]) != `<div align="center">` {
return lines
}
end := -1
for i := first + 1; i < len(lines); i++ {
if strings.TrimSpace(lines[i]) == `</div>` {
end = i
break
}
}
if end < 0 {
return lines
}
tail := end + 1
for tail < len(lines) && strings.TrimSpace(lines[tail]) == "" {
tail++
}
if tail < len(lines) && strings.TrimSpace(lines[tail]) == "---" {
tail++
}
return append(lines[:first:first], lines[tail:]...)
}
// stripTrailingNavFooter drops the GitHub-only `---` + `[← Prev: X] ·
// [Next: Y →]` footer the cross-repo-fingerprint doc set closes with.
// It is structural noise in the terminal renderer (the same `eeco
// guide` user has nothing to click). The check looks at the last
// non-empty content line: if it matches the Prev/Next pattern, drop
// it together with the preceding `---` rule and any blank lines.
func stripTrailingNavFooter(lines []string) []string {
last := len(lines) - 1
for last >= 0 && strings.TrimSpace(lines[last]) == "" {
last--
}
if last < 0 {
return lines
}
footer := strings.TrimSpace(lines[last])
if !strings.Contains(footer, "Prev:") && !strings.Contains(footer, "Next:") && !strings.Contains(footer, "Back to start") {
return lines
}
cut := last
for cut-1 >= 0 && strings.TrimSpace(lines[cut-1]) == "" {
cut--
}
if cut-1 >= 0 && strings.TrimSpace(lines[cut-1]) == "---" {
cut--
}
return lines[:cut]
}
// renderHeading styles an ATX heading. Level 1 and 2 gain a rule line
// underneath sized to the title; level 3+ is bold only. Returns false
// for any line that is not a `# ` heading.
func renderHeading(line string, colour bool) ([]string, bool) {
level := 0
for level < len(line) && line[level] == '#' {
level++
}
if level == 0 || level >= len(line) || line[level] != ' ' {
return nil, false
}
text := visible(strings.TrimSpace(line[level+1:]))
styled := text
if colour {
styled = ansiBold + text + ansiReset
}
switch level {
case 1:
return []string{styled, dim(strings.Repeat("═", utf8.RuneCountInString(text)), colour)}, true
case 2:
return []string{styled, dim(strings.Repeat("─", utf8.RuneCountInString(text)), colour)}, true
default:
return []string{styled}, true
}
}
// renderProse renders a non-heading, non-table, non-fenced line:
// bullet markers become a `•` glyph (indent preserved) and inline
// markup is applied. Blank and unrecognised lines pass through.
func renderProse(line string, colour bool) string {
if m := reBullet.FindStringSubmatch(line); m != nil {
return m[1] + "• " + renderInline(m[2], colour)
}
return renderInline(line, colour)
}
// renderFenceLine emits a fenced-block line verbatim, indented four
// spaces and faint when colour is on. A blank line stays blank.
func renderFenceLine(line string, colour bool) string {
if strings.TrimSpace(line) == "" {
return ""
}
indented := " " + line
if colour {
return ansiFaint + indented + ansiReset
}
return indented
}
// renderInline applies inline Markdown: `code`, **bold**, [text](url),
// and <autolink>. Code is rendered first so markup inside a code span
// is left literal.
func renderInline(s string, colour bool) string {
s = reCode.ReplaceAllStringFunc(s, func(m string) string {
inner := m[1 : len(m)-1]
if colour {
return ansiFaint + inner + ansiReset
}
return inner
})
s = reBold.ReplaceAllStringFunc(s, func(m string) string {
inner := m[2 : len(m)-2]
if colour {
return ansiBold + inner + ansiReset
}
return inner
})
s = reLink.ReplaceAllStringFunc(s, func(m string) string {
sub := reLink.FindStringSubmatch(m)
text, url := sub[1], sub[2]
if colour {
return text + " " + ansiFaint + "(" + url + ")" + ansiReset
}
return text + " (" + url + ")"
})
return reAutolink.ReplaceAllString(s, "$1")
}
// visible returns the plain visible text of an inline-markup string —
// renderInline with colour off — used for width measurement.
func visible(s string) string {
return renderInline(s, false)
}
func dim(s string, colour bool) string {
if colour {
return ansiFaint + s + ansiReset
}
return s
}
// isTableHeader reports whether line i begins a GitHub-style table: a
// pipe row immediately followed by a `| --- |` separator.
func isTableHeader(lines []string, i int) bool {
return i+1 < len(lines) && isTableRow(lines[i]) && isTableSep(lines[i+1])
}
func isTableRow(s string) bool {
t := strings.TrimSpace(s)
return len(t) >= 2 && strings.HasPrefix(t, "|") && strings.HasSuffix(t, "|")
}
func isTableSep(s string) bool {
t := strings.TrimSpace(s)
if !strings.HasPrefix(t, "|") || !strings.ContainsRune(t, '-') {
return false
}
for _, r := range t {
switch r {
case '|', '-', ':', ' ', '\t':
default:
return false
}
}
return true
}
// renderTable redraws a table block starting at lines[start] as a
// box-drawing grid. Returns the rendered lines and how many source
// lines it consumed (header + separator + body rows).
func renderTable(lines []string, start int, colour bool) ([]string, int) {
header := parseRow(lines[start])
rows := [][]string{header}
consumed := 2 // header + separator
for j := start + 2; j < len(lines) && isTableRow(lines[j]); j++ {
rows = append(rows, parseRow(lines[j]))
consumed++
}
ncols := 0
for _, r := range rows {
if len(r) > ncols {
ncols = len(r)
}
}
widths := make([]int, ncols)
for _, r := range rows {
for c := 0; c < ncols; c++ {
if c < len(r) {
if w := utf8.RuneCountInString(visible(r[c])); w > widths[c] {
widths[c] = w
}
}
}
}
border := func(left, mid, right string) string {
var b strings.Builder
b.WriteString(left)
for c := 0; c < ncols; c++ {
b.WriteString(strings.Repeat("─", widths[c]+2))
if c < ncols-1 {
b.WriteString(mid)
}
}
b.WriteString(right)
return b.String()
}
row := func(cells []string, bold bool) string {
var b strings.Builder
b.WriteString("│")
for c := 0; c < ncols; c++ {
cell := ""
if c < len(cells) {
cell = cells[c]
}
disp := renderInline(cell, colour)
if bold && colour {
disp = ansiBold + disp + ansiReset
}
padding := widths[c] - utf8.RuneCountInString(visible(cell))
b.WriteString(" " + disp + strings.Repeat(" ", padding) + " │")
}
return b.String()
}
out := []string{border("┌", "┬", "┐"), row(rows[0], true), border("├", "┼", "┤")}
for _, r := range rows[1:] {
out = append(out, row(r, false))
}
out = append(out, border("└", "┴", "┘"))
return out, consumed
}
// parseRow splits a table row into trimmed cells, honouring `\|` as a
// literal pipe rather than a column separator.
func parseRow(line string) []string {
t := strings.TrimSpace(line)
t = strings.TrimPrefix(t, "|")
t = strings.TrimSuffix(t, "|")
cells := splitUnescapedPipe(t)
for i := range cells {
cells[i] = strings.ReplaceAll(strings.TrimSpace(cells[i]), `\|`, "|")
}
return cells
}
func splitUnescapedPipe(s string) []string {
var cells []string
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+1 < len(s) && s[i+1] == '|' {
b.WriteByte('\\')
b.WriteByte('|')
i++
continue
}
if s[i] == '|' {
cells = append(cells, b.String())
b.Reset()
continue
}
b.WriteByte(s[i])
}
return append(cells, b.String())
}
added internal/guide/render_test.go
@@ -0,0 +1,129 @@
package guide
import (
"flag"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
var updateGolden = flag.Bool("update", false, "rewrite the rendered golden file")
var reANSI = regexp.MustCompile("\x1b\\[[0-9;]*m")
func stripANSI(s string) string { return reANSI.ReplaceAllString(s, "") }
// TestRender_Golden locks the full rendered manual (no colour) so any
// drift in the renderer is visible in review. Re-run with -update to
// refresh after an intentional change.
func TestRender_Golden(t *testing.T) {
got := Render(false)
golden := filepath.Join("testdata", "usage.rendered.golden")
if *updateGolden {
if err := os.WriteFile(golden, []byte(got), 0o644); err != nil {
t.Fatalf("write golden: %v", err)
}
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("read golden: %v (run with -update to create it)", err)
}
if got != string(want) {
t.Errorf("Render(false) drifted from testdata/usage.rendered.golden — re-run with -update if intended")
}
}
// TestRender_ColourStripsToPlain is the colour invariant: Render only
// adds ANSI escapes on top of the plain layout, so stripping them from
// Render(true) must yield Render(false).
func TestRender_ColourStripsToPlain(t *testing.T) {
coloured := Render(true)
if !strings.Contains(coloured, "\x1b[") {
t.Fatal("Render(true) emitted no ANSI escapes")
}
if stripANSI(coloured) != Render(false) {
t.Error("stripANSI(Render(true)) != Render(false) — colour changed the layout")
}
}
func TestRender_Heading(t *testing.T) {
got := renderManual("## Builtin workflows", false)
lines := strings.Split(got, "\n")
if lines[0] != "Builtin workflows" {
t.Errorf("heading text = %q, want stripped of ##", lines[0])
}
if lines[1] != strings.Repeat("─", len("Builtin workflows")) {
t.Errorf("heading rule = %q, want ─ sized to the title", lines[1])
}
}
func TestRender_TableBox(t *testing.T) {
src := "| A | Bee |\n| - | --- |\n| 1 | two |"
got := renderManual(src, false)
want := strings.Join([]string{
"┌───┬─────┐",
"│ A │ Bee │",
"├───┼─────┤",
"│ 1 │ two │",
"└───┴─────┘",
}, "\n")
if got != want {
t.Errorf("table render mismatch:\n got:\n%s\nwant:\n%s", got, want)
}
}
func TestRender_TableEscapedPipe(t *testing.T) {
src := "| Key | Note |\n| --- | --- |\n| `a\\|b` | one cell |"
got := renderManual(src, false)
if !strings.Contains(got, "a|b") {
t.Errorf("escaped pipe not preserved as a literal cell value:\n%s", got)
}
// Three rows of three columns would mean the escape leaked a split;
// the body row must stay two columns (one ┼ junction in the rule).
for line := range strings.SplitSeq(got, "\n") {
if strings.HasPrefix(line, "├") && strings.Count(line, "┼") != 1 {
t.Errorf("expected a 2-column body, got rule %q", line)
}
}
}
func TestRender_InlineCodeAndBold(t *testing.T) {
plain := renderManual("run `eeco go` and read the **brief**.", false)
if plain != "run eeco go and read the brief." {
t.Errorf("plain inline = %q", plain)
}
coloured := renderManual("run `eeco go` and read the **brief**.", true)
if !strings.Contains(coloured, ansiFaint+"eeco go"+ansiReset) {
t.Errorf("code span not faint: %q", coloured)
}
if !strings.Contains(coloured, ansiBold+"brief"+ansiReset) {
t.Errorf("bold span not bold: %q", coloured)
}
}
func TestRender_FenceVerbatim(t *testing.T) {
src := "```\n**not bold** `not code`\n```"
got := renderManual(src, false)
if !strings.Contains(got, "**not bold** `not code`") {
t.Errorf("fenced body should stay literal, got %q", got)
}
if !strings.HasPrefix(got, " ") {
t.Errorf("fenced body should be indented, got %q", got)
}
}
func TestRender_Bullet(t *testing.T) {
got := renderManual("- a point", false)
if got != "• a point" {
t.Errorf("bullet render = %q, want • glyph", got)
}
}
func TestRender_UnknownPassThrough(t *testing.T) {
got := renderManual("just some prose with no markup", false)
if got != "just some prose with no markup" {
t.Errorf("plain prose changed: %q", got)
}
}
added internal/guide/testdata/usage.rendered.golden
@@ -0,0 +1,1432 @@
Everything eeco can do. The runtime story, directory layout, trust
boundaries, and frozen public surface are documented in
ARCHITECTURE.md (ARCHITECTURE.md).
1. Install
──────────
Single static binary, zero runtime dependencies. Four install routes:
pre-built binary, Homebrew, Scoop, from source.
1.1 Pre-built binary (recommended)
Download the archive for your platform from the
releases page (https://github.com/ajhahnde/eeco/releases), verify it
against SHA256SUMS, extract the eeco binary, and put it on your
PATH.
┌─────────┬───────┬────────────────────────────────────┐
│ OS │ Arch │ Archive │
├─────────┼───────┼────────────────────────────────────┤
│ darwin │ amd64 │ eeco_<version>_darwin_amd64.tar.gz │
│ darwin │ arm64 │ eeco_<version>_darwin_arm64.tar.gz │
│ linux │ amd64 │ eeco_<version>_linux_amd64.tar.gz │
│ linux │ arm64 │ eeco_<version>_linux_arm64.tar.gz │
│ windows │ amd64 │ eeco_<version>_windows_amd64.zip │
│ windows │ arm64 │ eeco_<version>_windows_arm64.zip │
└─────────┴───────┴────────────────────────────────────┘
Checksum verification:
shasum -a 256 -c SHA256SUMS
Signature and provenance (recommended). SHA256SUMS is signed
keylessly with cosign — no key to fetch or trust on file; the signing
identity is the release workflow itself. Verify it:
cosign verify-blob \
--certificate SHA256SUMS.pem \
--certificate-identity-regexp \
'^https://github.com/ajhahnde/eeco/\.github/workflows/release\.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--signature SHA256SUMS.sig \
SHA256SUMS
Each archive also carries GitHub build provenance. Verify a downloaded
archive came from this repository's release workflow:
gh attestation verify eeco_<version>_<os>_<arch>.<ext> --repo ajhahnde/eeco
macOS Gatekeeper. The darwin binaries are distributed unsigned by
design; their integrity is established by the SHA256SUMS signature
and provenance above rather than Apple's notary service. After
extracting, clear the quarantine attribute the browser sets on the
download:
xattr -d com.apple.quarantine ./eeco
The cosign + provenance route above is the supported integrity check
on macOS; the darwin binaries are intentionally distributed without an
Apple Developer ID signature.
1.2 From source
Requires Go 1.24+.
Released archives are produced by GitHub Actions on every v* tag and
uploaded to the releases page (https://github.com/ajhahnde/eeco/releases).
Running make release locally is an alternate path — useful for
offline rebuilds and for verifying that a published release is
byte-identical to a fresh build from the tag.
Reproducing the released binaries byte-for-byte requires the same Go
toolchain CI used. go.mod pins it; when your installed Go differs,
set GOTOOLCHAIN=go1.24.2 so the build fetches and uses the pinned
version.
git clone https://github.com/ajhahnde/eeco
cd eeco
make build # ./eeco with injected version
make release VERSION=vX.Y.Z # cross-build the published matrix into dist/
make verify # go build ./... && go vet ./... && go test ./...
make build produces ./eeco with version, commit, and buildDate
injected via -ldflags so eeco version reports the real build
identity. make release writes archives plus SHA256SUMS into dist/;
make packaging writes eeco.rb and eeco.json from the checksums.
1.3 Homebrew (macOS, Linux)
brew install ajhahnde/eeco/eeco
Installs the binary from the latest tagged release via the
ajhahnde/eeco tap. brew upgrade eeco follows new tags. The formula
is generated per release and published with the release assets as
eeco.rb.
1.4 Scoop (Windows)
scoop bucket add eeco https://github.com/ajhahnde/scoop-eeco
scoop install eeco
scoop update eeco follows new tags. The manifest is published with
the release assets as eeco.json.
2. First use in a project — eeco init
─────────────────────────────────────
cd /path/to/your/repo
eeco init
eeco init:
• creates a private, per-user workspace at <repo>/<owner>/.eeco/
and adds /<owner>/ to .gitignore if it is not already ignored, so
nothing eeco does can leak into your tracked history. <owner> is your
git user.name; override it with --username NAME or the
EECO_USERNAME environment variable (resolution order: flag, env, git
user.name, then a prompt). --workspace NAME renames the inner
.eeco engine directory;
• scaffolds the engine, memory store, builtin workflows, and state
inside that directory;
• detects the project profile (zig, python, node, go, generic) and
the matching parse/build gate, and classifies the project type to
choose the knowledge directories it scaffolds. Detection is a
deterministic marker scan; init_detection_threshold (a value in
[0,1] in config.local) is the confidence at or above which init
accepts the scan without prompting (0, the default, uses the
detector's own default). --type CATEGORY forces a type and skips
detection; --ai opts into a single gated AI pass for an ambiguous
tree.
The one commit eeco makes. When init adds the .gitignore line it
stages *only that file*, commits it as eeco init, and pushes — the
single sanctioned write to your git history (everything else eeco writes
stays in the gitignored workspace). --no-commit skips the commit and
push; --no-push commits locally without pushing. On a non-git or
not-yet-real repo the step is skipped with a manual hint, and any git
failure is a warning — init never fails because of it.
Migrating an older workspace. If a legacy <repo>/.eeco workspace
from before the per-user layout is found, init reports it and offers to
run eeco migrate v1 (§4) before continuing; declining makes no changes.
This is also the "new project, professional setup" path: one command
gives a repo its private workspace plus the whole ecosystem.
3. The control center (TUI) — eeco
──────────────────────────────────
Run eeco with no arguments to open the control center. Not a terminal
(piped/CI)? It prints a one-screen status digest and exits 0 instead.
The control center opens on a home screen: a centred eeco logo at the
top, the version and one rotating usage tip below it, a hint above the
input, and a thin dim status footer (repo, profile, gate, automation,
memory and queue counts, hook state, last run) below the input. Every
command is discoverable by typing / (the slash-command palette) or
pressing ? (the shortcut overlay), so the home screen stays
uncluttered. Typing or running a command collapses the home view to
input + footer so streamed output reads cleanly above; clearing the
input recovers the home view. Output stays in the normal terminal
scrollback after you quit — no full-screen takeover.
Typing / opens a slash-command palette: a live dropdown of every
command with its one-line purpose, between the input and the footer.
Filter it by typing (/h narrows to /help and /hooks), move the
highlight with Up/Down, and accept the selection with Tab or Enter; the
palette closes on a space, a newline, or when the input clears.
The input is a multi-line composer: type or paste a request that spans
several lines. Enter submits; Alt+Enter or Ctrl+J inserts a newline
(Ctrl+J is the fallback where a terminal swallows Alt+Enter). The box
grows with the draft up to eight rows and then scrolls internally,
shrinking back when you submit or clear.
Free text (no leading slash) no longer opens an in-binary chat. eeco
configures the harness that runs the AI (see eeco cockpit), so the
control center stays a deterministic command surface rather than a
second-rate agent: a free-text line prints a dim one-line reminder to
use a slash command (/run, /memory, …) or ? for help, and spends
nothing. The in-binary chat turn, its read-only tool loop, and /clear
were retired in the move to the cockpit model.
Input accepts:
┌─────────────────────┬─────────────────────────────────────────────────────────┐
│ Input │ What it does │
├─────────────────────┼─────────────────────────────────────────────────────────┤
│ /run [--ai] <wf> │ run a workflow (--ai opts into one gated pass) │
│ /queue │ show items awaiting a decision │
│ /memory │ list stored facts │
│ /gc │ run memory garbage collection │
│ /new <wf> │ scaffold a new workflow │
│ /hooks [<n> on|off] │ show or toggle the opt-in reversible hooks │
│ /settings [<k> <v>] │ view or set the AI config (persists to config.local) │
│ /help │ command and key reference │
│ /quit │ leave the control center │
│ plain text │ a dim reminder to use a slash command (chat is retired) │
└─────────────────────┴─────────────────────────────────────────────────────────┘
Keys: Up/Down browse command history at the top and bottom of the
composer (and move the cursor within a multi-line draft otherwise, or
move the palette highlight when it is open), Tab completes a command or
workflow name, Alt+Enter / Ctrl+J insert a newline, ? toggles a
shortcut overlay, Esc interrupts a running task — an animated spinner in
the footer marks one in flight. q (on an empty line), /quit, and
Ctrl-C always leave the terminal in a sane state. NO_COLOR is honoured.
Plain text never spends silently: each conversation turn is consented and
budget-capped, and parked-and-queued when it cannot run (the conversation
does not advance on a parked turn). Commands never require AI.
4. Commands
───────────
eeco open the control center (digest if non-TTY)
eeco init [--no-track] bootstrap the ecosystem in this repo (--no-track skips the private workspace-history repo)
eeco migrate v1 [--yes] move a legacy <repo>/.eeco workspace under <username>/
eeco run <workflow> run one workflow (read-only / safe by default)
eeco run --ai <workflow> allow this run's gated, budget-capped AI pass
eeco new <workflow> scaffold a new workflow from the template
eeco gc run memory garbage collection
eeco queue print the workspace queue (resolve by editing queue.md)
eeco stats print cumulative AI usage from the call ledger (real token counts)
eeco hooks <name> on|off toggle an opt-in hook (reversible; names: pre-commit, post-merge, session-start, commit-msg, commit-guard)
eeco hooks session-start refresh re-render every session_files block from current project state
eeco hooks <name> refresh rewrite an installed hook (pre-commit, post-merge, commit-msg, commit-guard) with the current eeco binary path
eeco gates check-attribution [flags] scan tracked files + commit bodies for AI-attribution fingerprints
eeco update [--apply] check for a newer release; --apply verifies + swaps
eeco doctor run workspace and config diagnostics
eeco go [--brief] [--metrics] [--write|--json|--copy] print an AI-ready brief; --brief trims it; --metrics adds a stderr readout; --json emits JSON, --write saves, --copy clipboards
eeco ask [--limit N] [--json] "<question>" answer a question with ranked file:line pointers (no AI)
eeco docs new [--overwrite] <target> scaffold a tracked-tree doc (targets: vision, readme)
eeco docs refresh <target> re-render a scaffolded doc's marker-wrapped block (targets: vision, readme)
eeco docs compact [--keep-last N --heading <prefix>] [--archive <path>] [--dry-run] <path> archive old regions of <path> (markers, or heading-discovered with --keep-last) into a sibling
eeco history show the private workspace-history log (recent commits)
eeco history snapshot [-m <msg>] commit the current workspace state into the private history repo
eeco history compact [--dry-run] [--yes] squash the private history log into one commit (reflog-recoverable)
eeco guide page the in-binary user manual through the host pager
eeco uninstall [--yes] write a handoff summary, print the removal command, and de-init the private history repo (--yes skips the confirm)
eeco report-bug [--submit] file a structured bug report (--submit opens the pre-filled issue URL in your browser)
eeco add note "<text>" append a free-form note to the workspace
eeco add fact --description "<s>" [flags] "<body>" record a durable memory fact
eeco add task "<title>" append an item to the workspace queue
eeco adaptations <name> on|off toggle an AI-adaptation fact on or off
eeco workflows [<name> on|off] list scaffolded workflows or toggle one on/off
eeco show notes list the workspace notes, newest first
eeco show adaptations list AI-adaptation facts with their on/off state
eeco show prompt [name] list the prompt library or print one prompt body
eeco refresh-manifest [<dir>] rebuild the .ai.json manifests in the knowledge dirs
eeco version print the version
eeco help command reference
Exit codes (also the workflow contract): 0 clean ·
1 finding/failure · 2 blocked (a required tool is missing) ·
3 AI pass deferred (no --ai).
5. Builtin workflows
────────────────────
┌──────────────────┬──────────────────────────────────────┬─────────────────┬─────┐
│ Workflow │ Inspects │ Writes │ AI │
├──────────────────┼──────────────────────────────────────┼─────────────────┼─────┤
│ comment-hygiene │ source/docs for tooling fingerprints │ nothing │ no │
│ leak-guard │ staged + tracked tree, commit msg │ nothing │ no │
│ version-sync │ declared version_locations │ nothing │ no │
│ gate │ the declared gate command chain │ nothing │ no │
│ bug-sweep │ code, statically │ a triage ledger │ opt │
│ handover-refresh │ code/docs/git history │ queue proposal │ opt │
│ evolve │ run history + repeated actions │ queue │ opt │
│ memory-drift │ memory facts with a ref │ queue │ no │
│ doc-drift │ CHANGELOG.md vs git tags │ queue │ no │
│ manifest-refresh │ knowledge dirs (paths + kinds) │ .ai.json │ no │
└──────────────────┴──────────────────────────────────────┴─────────────────┴─────┘
• comment-hygiene fails if any shippable file carries an
AI-attribution / tooling string.
• leak-guard blocks a commit that would leak an attribution
string, a Co-Authored-By trailer, or a private-workspace path into
tracked files.
• version-sync reports drift between the version strings declared
in <workspace>/config.local's version_locations list. Each entry
is a path:regex pair (split on the first colon); the path is
repo-relative and the regex must declare at least one capture group
(group 1 captures the version string). With no version_locations
entries the workflow exits 0 (the gate is opt-in per project). Use
the multiline flag (?m) when the regex anchors on the start of a
line further down a file — Go's ^ is start-of-string by default.
Set version_locations=auto instead of an explicit list to let
version-sync discover the version locations itself. In auto mode it
scans a fixed, high-precision set of common version files — VERSION,
CHANGELOG.md (the first ## [vX.Y.Z] heading), package.json,
pyproject.toml, and Cargo.toml — and reports drift across
whichever of them the project actually has. A file that is absent, or
present but carrying no version-shaped string, is skipped. auto
cannot be mixed with explicit path:regex entries — it is the whole
list or none of it — and composes with version_anchor the same way
an explicit list does.
The version_anchor config key selects the source of truth declared
locations are checked against. Three modes:
• Unset (default) — consistency-only. The first declared location
is the anchor; the rest must match it. Exits 0 when every declared
location agrees, 1 when any drifts, and 2 when a declared path is
missing on disk. The slice-1 behaviour, preserved for backward
compatibility.
• version_anchor=tag — tag-anchor. The latest semver-shaped
(vX.Y.Z) tag reachable from HEAD is the source of truth.
Declared locations must be semver->= the tag, so a release commit
can bump declared locations ahead of the not-yet-pushed tag (the
tag is pushed after the commit). Backward-drift still fails. If no
semver-shaped tag is reachable yet, falls back to the
consistency-only behaviour with a note in the summary.
• version_anchor=<path>:<regex> — designated-file mode. Same
shape as a version_locations entry; the captured version is the
source of truth and every declared location must strict-equal it.
A missing path exits 2 (blocked) so a typo surfaces rather than
silently falling through to consistency-only.
Worked example. Declare two entries that both must carry the latest
released tag — the CHANGELOG.md heading and the matching footer
compare-link, which often drift when a release bumps one but
forgets the other — and enable tag-anchor so a release commit's
bump catches a forgotten location:
version_locations=CHANGELOG.md:(?m)^## \[v(\d+\.\d+\.\d+)\]
version_locations=CHANGELOG.md:(?m)^\[v(\d+\.\d+\.\d+)\]: https://github
version_anchor=tag
Once version_locations is declared, wire the gate into the
pre-commit hook so drift is caught before the commit lands rather
than only under make gates: eeco hooks pre-commit on installs a
.git/hooks/pre-commit that runs the default workflow chain
(leak-guard + version-sync) and blocks the commit on a non-zero
exit. See §9 for the override knob and the byte-identical reversal
contract.
• gate runs the project's declared parse/build gate — the ordered
command chain in <workspace>/config.local's gate key — step by
step, with the repository root as the working directory, stopping at
the first failure. The gate key is repeatable: each occurrence adds
one step (a whitespace-split command), and the first occurrence resets
the profile default so the operator-declared chain fully replaces it.
A bare single-command gate is a one-step chain; a project with no
gate (the generic profile, or a lone gate=) is a clean no-op, so the
gate is opt-in per project. Every step's command is checked on PATH
before the first step runs, so a chain that cannot complete exits 2
(blocked); a step that exits non-zero exits 1 and stops the chain.
Run it with eeco run gate, or add gate to pre_commit_workflows
(§9) to run the whole validation chain before each commit.
Worked example. A Go project that wants vet, a static linter, and the
unit tests as one gate, run in order and stopping at the first to
fail:
gate=go vet ./...
gate=staticcheck ./...
gate=go test ./...
• bug-sweep does a static read and maintains an append-only triage
ledger (open findings → resolved log); with --ai it performs the
reasoning pass, otherwise it reports and parks the prompt.
• handover-refresh drafts a dated handover plus a "what's now
dead" diff and queues it for your approval — it never overwrites a
note directly.
• evolve watches for repetition and *proposes* new workflows into
the queue. It never activates one on its own. The
proposal pass is two-stage: a deterministic signal extraction
scans the most recent 40 commit subjects for repeated
conventional-commit types (<type>(<scope>)?!?: …) and surfaces one
workflow candidate per type that appears at least three times (cap
five candidates per run, sorted count-desc then key-asc). Each
candidate becomes its own evolve queue item — the operator
resolves or dismisses one at a time. The gated AI pass becomes
optional enrichment layered on top: --ai (or automation=auto)
opts into it; on no consent or any provider skip eeco still exits 0
when the deterministic pass found at least one candidate, and only
preserves the exit-3 (AI deferred) contract when there is genuinely
nothing to report — no consent and no deterministic candidates.
It also maintains a repetition ledger at
<workspace>/state/evolve-history.json. Each surfaced candidate
writes one record (signal kind + key, count at proposal, the queue
row's kind + title, the proposal timestamp); a re-run suppresses any
candidate whose (signal_kind, signal_key) is already in the
ledger, so a recurring signal is proposed exactly once in its
lifetime even after the queue item is ticked. Each run also
reconciles unresolved records against the queue: a record whose
queue row is now checked flips to resolved: true with a
resolved_at timestamp. Reconciliation runs once per evolve
invocation — the queue is not watched live, so an operator who
ticks a row between runs sees the ledger update on the next
eeco run evolve. The re-propose-on-signal-recurrence knob is a
follow-on slice; today a resolved record still suppresses. A
future slice may add a file-touch signal alongside the commit-type
one (deferred because it needs a new gitx helper).
• memory-drift flags a memory fact whose ref: file has been
committed on a later calendar day than the fact's own created: date
— a sign the fact may now describe code that has moved on. eeco gc
already catches a ref: that no longer exists on disk; memory-drift
catches the complementary case where the file is still there but has
changed since the fact was written. One review item per stale fact is
routed to the queue — eeco flags the drift, you reconcile the fact. It
needs git on PATH (it exits 2, blocked, without it) and exits 0 when
every ref-carrying fact is still current. Run it with `eeco run
memory-drift`, or wire it to run automatically after a merge with the
post-merge hook (§9), where it is in the default workflow list.
• doc-drift flags drift between the release sections in
CHANGELOG.md and the project's git tags. Two cases: a vX.Y.Z git
tag with no ## [vX.Y.Z] CHANGELOG section (a release that was never
documented), and a ## [vX.Y.Z] section with no matching tag (a
release documented but never tagged). The newest section is exempt: a
release commit adds the ## [vX.Y.Z] heading before the vX.Y.Z tag
is pushed, so a section strictly ahead of the latest tag is the
expected release-in-progress state, not drift. One review item per
drift is routed to the queue — eeco flags it, you reconcile the
CHANGELOG.md or the tag. It needs git on PATH (it exits 2,
blocked, without it) and exits 0 when CHANGELOG.md is absent, no
tags exist yet, or every tag and section agree. Run it with `eeco run
doc-drift, or let the post-merge` hook (§9) run it automatically — it
is in that hook's default workflow list alongside memory-drift and
manifest-refresh.
• manifest-refresh rebuilds the per-directory .ai.json manifests
across the knowledge dirs — the project-type directories scaffolded
under <repo>/<owner>/ as siblings of the engine workspace. Each
manifest is a deterministic skeleton — a JSON document `{"dir",
"purpose"?, "items":[{"path","kind"}…]} with kind either "file"`
or "dir", sorted by path; the optional desc/find_when fields are
reserved for a future enrichment pass and stay empty in the
deterministic walk. The .ai.json file is skipped in its own walk, so
a re-run over unchanged dirs reproduces byte-identical output. Writes
land in the gitignored per-user area, never the tracked tree; it needs
no AI and never blocks. Run it on demand with `eeco refresh-manifest
[<dir>]` (the same deterministic rebuild, optionally scoped to one
knowledge dir), or let the post-merge hook (§9) run it.
Run any of them from the TUI or eeco run <name>.
These ten are the complete builtin set. Additional builtin workflows
are tracked as a post-v0.1.0 item.
Scaffolding a workflow with eeco new
eeco new <name> scaffolds a user workflow under
<workspace>/workflows/<name>/ from a per-profile template. The
scaffold layout is picked from the project's detected profile:
• go profile — run PATH-checks go and runs go vet ./... as
the inert default.
• python profile — run PATH-checks python3 and runs
python3 -m compileall -q . as the inert default.
• generic profile (and any profile without a dedicated template:
zig, rust, node) — run is a bare sh stub that prints a
placeholder line and exits 0.
In every case the scaffolded run is a starting point — replace the
default body with the real check this workflow is meant to perform.
The chosen template is internal to eeco; the verb shape `eeco new
<name>` is unchanged.
Toggling a scaffolded workflow on or off
Once scaffolded, a workflow is on by default; eeco runs it
whenever it is asked to (a manual eeco run <name>, a hook chain
that names it, the TUI). To pause a workflow without deleting it:
eeco workflows <name> off # mark <name> as disabled
eeco workflows <name> on # bring <name> back online
eeco workflows # list every scaffolded workflow + state
off plants an empty sentinel marker file at
<workspace>/workflows/<name>/disabled; the loader sees it and
reports the workflow as blocked (exit 2) without running it. on
removes the marker. Repeating the same action is a clean no-op.
Builtin workflows are unaffected by this toggle — their contract is
part of the frozen public surface. To remove a scaffolded workflow
for good, delete its directory.
6. Memory and garbage collection
────────────────────────────────
The store holds one fact per file (flat frontmatter: name, description,
type, dates, optional ref:/expires:, pin:), with a regenerated
MEMORY.md index and [[links]] between facts. Types: user,
feedback, project, reference, finding.
eeco gc (or the Memory screen) keeps the store honest:
• a memory whose ref: path is gone, that has expired, a resolved
finding, or an unused reference past the stale window is archived
to the recoverable attic;
• anything load-bearing (project/feedback/user) is **queued for
your decision**, never silently deleted;
• pin: true exempts a fact permanently.
This is automated and conservative — the algorithm never loses a fact
that still matters; it asks you instead.
Re-running eeco gc (for example through the post-merge hook chain)
no longer files duplicate gc-review items for an unresolved finding:
an open queue row for the same fact + reason is recognised and the
second filing is skipped. Resolving the row (checking its box) lets a
future trigger file again, so the queue is never wedged on a stale
recommendation.
eeco add fact records a fact without hand-authoring the frontmatter:
eeco add fact --description "<summary>" [--type <type>] [--name <slug>] [--ref <path>] [--pin] [--provenance <text>] [--agent <name>] "<body>"
It writes one frontmatter file under <workspace>/memory/ and
regenerates MEMORY.md. --description is the one-line summary that
drives relevance matching and is required. --type is one of user,
feedback, project, reference, finding and defaults to project
— the safe default, since project/user/feedback facts are queued
for review rather than archived. --name is the lower-kebab-case
filename stem; when omitted it is derived from --description. --ref
records a repo-relative path the fact documents (garbage collection
validates it); --pin exempts the fact from collection.
--provenance "<text>" records the snippet that triggered the fact
(source: in frontmatter, capped at 120 characters); --agent <name>
records which assistant filed it (agent:). Both are **required for
--type feedback and --type user** — those types are durable
adaptations to the operator, and an unauthored adaptation is the silent
drift eeco show adaptations exists to surface. Hand-authored facts
remain permissive: an older fact file without the new fields still
loads.
eeco refuses to overwrite an existing fact — pass a different --name,
or edit the file directly. The command needs an initialised workspace;
it writes only inside it (Constraint 1) and never stages or commits
(Constraint 6). Editing a fact later is `$EDITOR
<workspace>/memory/<name>.md; removing one is rm`.
Auditing AI adaptations
A feedback- or user-typed memory fact is a durable adaptation to
the operator — every future assistant briefed by eeco go honours it.
eeco show adaptations lists every adaptation newest-first with its
provenance and on/off state, so the operator can audit *what the AI is
doing automatically now* without grepping the memory directory:
eeco show adaptations
eeco adaptations <name> on|off flips a disabled: flag on one fact
in place and regenerates the MEMORY.md index so the change shows at
once. A disabled fact is preserved on disk but hidden from
eeco go briefs and eeco ask rankings, so re-enabling is one CLI
flip:
eeco adaptations terse-feedback off # mute it without deleting
eeco adaptations terse-feedback on # restore it
eeco gc skips disabled facts so a deliberately muted adaptation
never gets routed for staleness review.
A disabled fact is hidden from the AI-facing surfaces (eeco go,
eeco ask, the TUI relevance ranker) but stays **visible — marked —
on the human audit surfaces**: the regenerated MEMORY.md index lists
it under a ## disabled heading, and the TUI /memory screen tags its
line [off]. This holds for a fact of any type, so a disabled
project or reference fact is never silently lost from view.
eeco show adaptations is the audit surface; the fact file under
<workspace>/memory/<name>.md is the source of truth. Constraints 1 +
6 hold (writes inside the workspace; nothing staged or committed).
7. The queue — the only thing that interrupts you
─────────────────────────────────────────────────
Workflows and GC never act unilaterally on a meaningful change. They
append an item to the queue. View it with eeco queue (or /queue in
the TUI); the status digest shows the open count. The queue file is
yours — you resolve an item by ticking its checkbox ([ ] → [x]) or
deleting the row in <workspace>/state/queue.md. Nothing else nags you;
nothing runs unless you invoke it.
You can also file a queue item yourself — or have an assistant file one
for you — with eeco add task:
eeco add task "wire the man page into brew/scoop"
eeco add task --kind review --detail "check the brew formula" "audit packaging"
The title is the checklist row; --kind is the short queue tag (default
task); --detail is an optional elaboration printed beneath the row.
The item is appended to <workspace>/state/queue.md and shows up
alongside workflow- and GC-filed items in eeco queue (or /queue) and
the status digest's queue count. eeco add task writes only inside the
workspace (Constraint 1) and never stages or commits (Constraint 6); it
requires an initialised workspace. Like eeco add fact, it closes the
read/write loop — an assistant briefed by eeco go can route a decision
back into eeco's one decision channel instead of letting it evaporate.
8. AI configuration
───────────────────
AI is opt-in (--ai, or an automation level that grants standing
consent) and budget-capped. eeco no longer runs an in-binary model
client — it configures the harness that runs the AI. The one inference
path it keeps for its own chores is a CLI provider that shells any
program of your choice (prompt on stdin, reply on stdout); with none
configured, every AI pass is parked, never failed. Configure in
<workspace>/config.local, or set the common keys from the control
center with /settings:
automation = propose # manual | propose | scaffold | auto
ai_budget = 1 # gated passes per invocation; 0 disables AI
# CLI provider — any program, prompt on stdin, reply on stdout:
ai_command = yourcli --print
ai_provider = cli # cli | none; empty/unknown auto-selects
• Auto-select (default). With ai_provider empty or unset — or an
unknown/legacy value such as anthropic — a configured ai_command
picks the CLI provider; otherwise every AI pass is **parked and
queued**, never failed — the deterministic, non-AI result still
stands. ai_provider = none is also accepted; nothing here is ever a
config error.
• ai_model and ai_api_key_env are inert legacy keys: read and passed
through, but ignored by the CLI provider (there is no native API path).
They stay only so an old config.local loads unchanged.
• Only automation=auto is itself standing AI consent; every other
level needs an explicit --ai on the run.
• The same gating — consent, budget cap, and prompt-parking — wraps
every provider call, so a run is always safe and never an uncontrolled
spend. Every attempt is recorded to the AI-call ledger
(state/ai-calls.json): label, provider, resolved model, prompt and
response hashes, and token usage.
/settings (no arguments) shows the current values; `/settings <key>
<value> writes one to config.local`. A change applies the next time
eeco starts; a long-lived session keeps the budget cap it began with.
Cumulative AI usage — eeco stats
eeco stats aggregates that ledger (state/ai-calls.json) into a
one-glance readout of how much AI eeco has actually used on this project:
eeco stats
eeco stats: 12 AI calls (9 ran, 3 parked) from 2026-05-21 to 2026-05-30
tokens: 18432 input · 4096 cached · 6210 output (28738 total)
by provider: cli 9 · none 3
It reports the total number of gated AI calls, how many actually ran
versus were parked, the cumulative token counts, the per-provider call
breakdown, and the recorded date range. Unlike eeco go --metrics,
whose token figures are bytes/4 estimates marked with ≈, these token
totals are the real provider counts the ledger stored, so they carry
no ≈. The command is read-only and makes no AI call; an
uninitialised workspace or an empty ledger reports `no AI calls recorded
yet` and still exits 0. The exact wording and the displayed figures are a
human-readable readout and are not part of the frozen surface.
9. Hooks (opt-in, reversible)
─────────────────────────────
Off by default. Inspect with eeco hooks (or /hooks); toggle with
eeco hooks <name> on|off (or /hooks <name> on|off). Names:
pre-commit, post-merge, session-start, commit-msg,
commit-guard.
• pre-commit — a local .git/hooks/pre-commit (untracked,
repo-scoped) that runs the configured builtin workflows in declared
order and stops at the first non-zero exit, blocking the commit when
any step fails. Defaults to leak-guard (blocks a leaking commit)
followed by version-sync (silent on projects that have not declared
version_locations, so opt-in per project). Override the list with
one or more pre_commit_workflows keys in config.local; the first
occurrence resets the binary default, subsequent occurrences append.
An empty pre_commit_workflows= value clears the list and `eeco
hooks pre-commit on` refuses to install. The hook is installed only
if no pre-commit hook already exists, and removed only when the
on-disk script is byte-identical to what eeco wrote; a foreign or
hand-edited hook is always left untouched. After upgrading the eeco
binary (for example a brew upgrade that moves the binary) or editing
pre_commit_workflows, run eeco hooks pre-commit refresh to rewrite
the installed script with the current binary path and workflow set.
• post-merge — a local .git/hooks/post-merge (untracked,
repo-scoped) that runs the configured drift-detection workflows after
a git merge / git pull — the moment another author's changes land,
the natural time to re-check whether eeco's recorded state has drifted
from the code. Defaults to memory-drift followed by doc-drift (both
silent no-ops on a project with no memory ref: facts and no
CHANGELOG/tags). Unlike pre-commit it does not block: the merge has
already completed, so a finding or a missing tool surfaces as a queue
item, never as a hook failure (each step's exit is swallowed and the
hook does not use set -e). The drift workflows file at most one open
queue item per finding — a repeated run does not pile up duplicates of
a finding still awaiting a decision. Override the list with one or more
post_merge_workflows keys in config.local (same reset-then-append
semantics as pre_commit_workflows; an empty post_merge_workflows=
clears the list and eeco hooks post-merge on refuses to install).
Install / removal / refresh behave exactly as for pre-commit: a foreign
or hand-edited hook is always left untouched.
• session-start — composes up to three blocks at session start
and stays silent when every block is empty:
1. A reading routine — repo-relative paths to read before
substantive work. Auto-detected from a built-in list
(docs/PUBLIC_API.md, docs/ARCHITECTURE.md, CHANGELOG.md,
ARCHITECTURE.md, docs/USAGE.md, README.md); existing
entries surface, missing ones are skipped. The
most-recently-modified match of roadmap*.md is appended as
the live planning surface. Override the doc list with one or
more session_start_docs keys in config.local; change the
roadmap pattern with session_start_roadmap_glob (empty
disables discovery).
2. A mailbox warning — surfaces when Ideas.md at the repo root
has content beyond its empty template (the parser skips the
file header and any HTML comment blocks). Change the filename
with session_start_mailbox in config.local; an empty value
disables the block. The hook does not create the file — it
surfaces only when the operator opted in by creating it.
3. A one-line queue reminder — eeco: N items awaiting a decision
when the queue is non-empty.
4. (opt-in) Pinned memory bodies — the full body of every
pin: true memory fact under <workspace>/memory/, separated by
Markdown dividers. Off by default, so the hook output is
byte-identical until enabled. Opt in via
session_start_pinned_bodies=true in
config.local (workspace default) or the --with-pinned-bodies
flag on eeco hooks session-emit (one invocation only). Useful
when an AI assistant treats the hook output as a system-reminder
so a pinned policy memory (for example a no-AI-attribution rule)
lands in the model's context at session start.
The session-start hook ships with two brand-free delivery channels;
use either alone or compose both:
• JSON settings file — an exact-match-removable entry in an AI
CLI's user-global JSON settings file (the Claude Code shape). Set
the file location with session_settings_path in config.local
or the EECO_SESSION_SETTINGS environment variable. The file is
backed up (inside the workspace) before the edit and re-validated
after; an invalid result is rolled back.
• Marker block in a text/markdown file — eeco maintains a
delimited block of the same content the JSON channel renders, in
one or more files the assistant already reads at session start
(CLAUDE.md for Claude Code, GEMINI.md for Gemini CLI,
AGENTS.md for OpenAI Codex, .cursorrules for Cursor,
~/.config/<tool>/..., etc.). Declare one path per session_files= line in
config.local; each value is either repo-relative (held inside
the repo) or absolute. The block is fenced by HTML comments —
<!-- eeco:session:start --> / <!-- eeco:session:end --> —
and replaces in place on re-on or refresh. The block content
is whatever eeco hooks session-emit prints. eeco never edits
bytes outside the marker pair, and a block whose contents have
been hand-edited since install is left untouched on off. Files
eeco itself created are removed when off would leave them
empty; files that already existed are restored byte-identical to
their pre-on state when the block is at end-of-file.
Neither channel is configured by default: `eeco hooks session-start
on` reports "not configured" until at least one of the two is set.
When the file channel is wired, run eeco hooks session-start refresh
to re-render the block — the JSON channel does not need it (it
carries a command eeco runs at session start, so it is always
fresh).
• commit-msg — a local .git/hooks/commit-msg (untracked,
repo-scoped) that rejects commit messages carrying an AI-attribution
trailer. The policy is universal and lives inside the eeco binary:
the on-disk script is a one-line wrapper that execs
eeco hooks commit-msg-check "$1", so the pattern set refreshes with
every brew upgrade eeco. Matches the trailer-anchored forms
(Co-Authored-By: lines naming claude, anthropic, or
noreply@anthropic) plus the Claude Code robot-emoji (U+1F916)
"Generated-with" signature; deliberately stricter than the broad
repo-tree scan so a
docs commit that *discusses* the policy ("remove Co-Authored-By
trailer") still passes. Exit code 1 prints the matched line and names
--no-verify explicitly as the bypass; conscious overrides remain
available without surprise. Install / removal behave exactly as for
pre-commit and post-merge: foreign hooks are never modified. After a
brew upgrade eeco rewires the eeco binary path, run `eeco hooks
commit-msg refresh` to rewrite the on-disk wrapper without an
off-then-on toggle.
• commit-guard — the harness-layer companion to commit-msg. Where
the git commit-msg hook guards eeco's own repository, the
commit-guard installs a Claude Code PreToolUse hook that denies any
pending git commit carrying AI attribution — in any repository the
assistant drives, and not bypassable by git commit --no-verify (the
hook sits above git). It runs the same attribution detector
leak-guard uses, so the patterns stay one source of truth (the foreign
repo's own attribution_pattern entries are honoured when its config
loads). Default off; enable with eeco hooks commit-guard on, which
installs a PreToolUse group into the JSON settings file named by
session_settings_path (or EECO_SESSION_SETTINGS) — the same file the
session-start JSON channel uses, backed up and re-validated the same way;
it reports "not configured" when neither is set. The runner detects a
real git commit token-by-token (so git status and echo "git commit"
never fire), scans the assembled message (-m/-F/COMMIT_EDITMSG),
the staged diff, and the raw command, and **denies only on a positive
finding — any parse or infrastructure uncertainty degrades open**
(the commit proceeds), so a session is never wedged; the git pre-commit
/ commit-msg hooks and CI remain the hard gates. off removes only
eeco's group (foreign PreToolUse groups are preserved); after a
brew upgrade eeco run eeco hooks commit-guard refresh to rewrite the
binary path.
Every action is recorded in <workspace>/state/hooks.json so it can
be cleanly undone. The git-hook scripts and the JSON-settings writes —
the session-start and commit-guard entries plus any session_files
blocks — are the only touches eeco ever makes outside the gitignored
workspace.
9a. Gates — composed read-only policy checks
────────────────────────────────────────────
eeco gates check-attribution is a CI-facing companion to the
commit-msg hook (§9). The hook blocks at the source on a developer
machine; the gate is the CI backstop that catches a leak when a
developer bypassed the hook (--no-verify) or never installed it. Both
share the same trailer-anchored pattern set so prose discussing the
policy never false-fires.
Scope:
• Tracked-file scan — broad: delegates to the same detector
comment-hygiene uses (line-anchored Co-Authored-By:, the
Generated with/by <ai-ish> co-marketing line, and the robot-emoji
Generated signature). Enumerates the file list from git ls-files
and filters to a built-in text-extension allowlist (.md, .sh,
.go, .zig, .S, .inc, .zon, .yml, .yaml, .txt, .ld,
.json, .toml). Hits print as path:line: excerpt.
• Commit-body scan — strict trailer-anchored: Co-Authored-By:
lines naming claude, anthropic, or noreply@anthropic, plus the
robot-emoji "Generated" signature. Default range is
origin/main..HEAD; falls back to HEAD~10..HEAD with a stderr
notice when origin/main is unresolvable (fresh clone, detached
HEAD, initial commit). Hits print as `commit <short-sha>: line N:
excerpt`.
Flags (all optional):
┌───────────────────┬───────────────────────────────────────────────────────────────────────────┐
│ Flag │ Effect │
├───────────────────┼───────────────────────────────────────────────────────────────────────────┤
│ --paths "<a b c>" │ override the tracked-files filter; pass an explicit space-separated list. │
│ --commits N │ force HEAD~N..HEAD instead of the default range. │
│ --no-commits │ skip the commit-body scan (file-only). │
│ --no-files │ skip the file scan (commits-only). │
│ --exclude <path> │ repeatable; repo-relative path to skip during the file scan. │
└───────────────────┴───────────────────────────────────────────────────────────────────────────┘
Exit codes: 0 clean, 1 on any hit (sets stderr with one line per
finding plus a N finding(s) summary), 2 on usage error. The CI
invocation pattern is one step before git push-eligible builds:
- name: ai-attribution gate
run: eeco gates check-attribution
Use actions/checkout@v5 with fetch-depth: 0 so the commit-body
range resolves; default 1 makes origin/main..HEAD empty and the
fallback notice triggers.
10. Safety guarantees
─────────────────────
• eeco writes only inside the repo's gitignored workspace; a path guard
enforces this and refuses .. traversal.
• It never commits or pushes your project's tracked tree. The optional
private workspace-history repo (see §11a) commits only inside the
gitignored workspace, locally, and is never pushed.
• No AI-attribution string is ever written to a tracked file.
• Everything it does is invoked by you or queued for you.
11. Removing eeco from a project
────────────────────────────────
eeco uninstall writes a single eeco-handoff.md at the repository
root summarising what eeco knew about the project, then prints the
git command to remove the workspace by hand. It never deletes anything
itself, so nothing is lost if you change your mind.
eeco uninstall # bare handoff (just paths + removal command)
eeco uninstall --scope facts # + memory facts
eeco uninstall --scope queue # + open queue items
eeco uninstall --scope everything # + facts + queue + scaffolded workflows + hooks
The handoff file is written at the repository root, alongside your
tracked tree; the file itself is not gitignored, so you choose whether
to keep, commit, or delete it. The workspace is gitignored, so a single
git rm -rf .eeco (substitute your workspace name) is the only on-disk
removal needed once the handoff has been reviewed. Turn opt-in hooks
off first (eeco hooks pre-commit off, eeco hooks session-start off)
so the reversibility ledger can do its work before the workspace goes.
If the private workspace-history repo exists (§11a), eeco uninstall
offers to de-init it — removing only its .git, never your data — after
a confirmation prompt. Pass --yes to skip the prompt, or decline to
keep the history and remove it yourself later. Your workspace data files
are never deleted by eeco uninstall.
11a. Workspace history (the private logbook)
────────────────────────────────────────────
At eeco init, eeco stands up a **private, local git repository inside
its own gitignored workspace directory** (<username>/) to version its
knowledge layer — memory facts, the queue, decisions, manifests — over
time. This repository has no remote and is never pushed, and it
never touches your project's tracked tree: it records only what eeco
already writes inside its own gitignored workspace, so it is invisible to
your project's git.
The workspace_history config key controls it:
• manual (default) — the repo exists; eeco commits only when you run
eeco history snapshot.
• auto — as manual, plus eeco commits automatically after each
mutating verb (add fact/note/task, gc, go --write, run,
new, …); still local-only, no remote.
• off — no private repo at all.
eeco init # creates the private repo (manual)
eeco init --no-track # skip it for this run
eeco history # show the recent commit log
eeco add note "decided X" # mutate the workspace
eeco history snapshot # commit the current state
eeco history snapshot -m "milestone" # …with a message
eeco history compact --dry-run # preview: how many commits would collapse
eeco history compact # squash the whole log into one (confirms first)
eeco history compact --yes # …without the prompt
Under auto the log grows a commit per mutating verb, so it can get long.
eeco history compact squashes the entire history into a single commit
whose contents are the current workspace state — the git pendant to the
docs-compaction protocol. Your workspace files are never touched (only the
commit log is collapsed), and the old commits stay reachable through the git
reflog, so the squash is recoverable; eeco runs no git gc. It is manual
only — there is no auto-trigger — and it confirms before rewriting unless you
pass --yes; --dry-run reports the count and changes nothing.
The repo is created opt-out (--no-track for one run, or
workspace_history=off durably) and removed at eeco uninstall (§11).
Inside it eeco has a free hand — commit, and later squash — because
nothing there is shared or tracked by the host repo.
12. Filing bug reports
──────────────────────
eeco report-bug captures friction with eeco itself into a structured
Markdown record and prints a pre-filled GitHub Issues URL so the
report can reach the project in one click. The command works for
everyone: anyone who installed eeco can file, with or without an
initialised workspace.
eeco report-bug --note "leak-guard tripped on a doc-only commit" \
--cmd "eeco run leak-guard"
Both flags are optional. The record contains the eeco version, a
whitelisted environment snapshot (OS, arch, Go version, SHELL,
TERM, and any EECO_* variables — never the full env), the note,
and the invoking command.
Where the record lands:
• Inside an initialised workspace. Records go to
<workspace>/bug-reports/; override the directory with the
bug_report_dir key in <workspace>/config.local (workspace-
relative — absolute paths and .. traversal are rejected at parse
time).
• Anywhere else (no eeco init yet, or not inside a git repo at
all). Records go to ~/.eeco/bug-reports/ so a fresh-install user
can file a report against eeco itself without having to set anything
up first. The repo's tracked tree is never touched.
Sharing the record:
eeco report-bug prints a pre-filled URL like
https://github.com/ajhahnde/eeco/issues/new?title=...&body=....
Opening it in a browser fills the issue form with the record body
already pasted — clicking Submit files it to the project. The local
file is your durable copy; you can refine it before clicking, or
attach it by hand if the URL ends up too long for the browser. Nothing
is sent automatically — the upstream side of the loop is always a
human click.
Pass --submit to have eeco open that URL for you (the $BROWSER
command if set, otherwise the platform default opener). It is opt-in
and changes nothing else: the form opens pre-filled, you still review
and click Submit, and the command still reports that nothing was sent
automatically — opening a pre-filled form is not the same as sending.
If no browser can be opened, the command prints a short note alongside
the URL and still exits 0; --submit is a convenience, never a failure
point.
13. Briefing an AI assistant
────────────────────────────
eeco go prints a compact, deterministic project brief written for an
AI assistant. It is the fast, cheap way to bring any assistant — not
only the strongest — up to speed on a project: one command returns a
map of the project instead of a scan across many files.
eeco go
The brief has six sections:
• Working with eeco — what eeco is, the read-only commands an
assistant can run, and the rule that decisions go to eeco's queue
rather than silent edits.
• Project — the detected profile, the parse/build gate, and the
top-level layout (taken from the tracked set, so build artifacts and
the workspace stay out).
• Where to look — every memory fact that records a file pointer,
as a topic → file map. This is the assistant's shortcut to the right
file.
• What eeco knows — the load-bearing memory facts (project,
feedback, user).
• Open decisions — the items waiting in eeco's queue.
• Recording back — how the assistant keeps the brief useful:
record durable facts and route decisions through the queue.
eeco go calls no AI provider — the brief is assembled entirely from
what eeco already tracks, so it costs no provider budget and is safe to
run any number of times. The output is deterministic: the same
workspace state produces the same brief.
The brief is Markdown by default — written for an assistant to read.
The --json flag emits the same project state as a JSON object instead,
the machine-readable form for a downstream agent or script that parses
the brief rather than reading it:
eeco go --json
The JSON object has nine top-level keys — project, profile, gate
(an argv array), top_level, initialized, workflows,
where_to_look (description/ref objects), knowledge
(name/description/type objects), and open_decisions. Every array
is always present, an empty list rather than null, so a consumer can
iterate without a nil check. These top-level keys are part of eeco's
frozen public surface. --json prints to stdout and cannot be combined
with --write.
By default the brief is printed to stdout and nothing is written. The
--write flag saves it instead to a stable file inside the workspace —
.eeco/context.md by default — so an assistant's instructions file can
point at a fixed path rather than re-running the command each session:
eeco go --write # writes .eeco/context.md
The destination is workspace-relative; override it with the
context_path key in <workspace>/config.local (absolute paths and
.. traversal are rejected). --write requires an initialised
workspace, and the file it writes is byte-identical to the stdout
brief. The file lives in the gitignored workspace, so it is never
committed; re-run eeco go --write whenever you want it refreshed.
For an assistant on a tight context budget the --brief flag reshapes
the output: the "Working with eeco" preamble and "Recording back" outro
drop out, and the per-section lists (Where to look, What eeco knows,
Open decisions) are capped at five entries each. --brief composes
with --json and --write, so every delivery axis gets the size knob:
eeco go --brief # smaller Markdown brief
eeco go --brief --json # smaller JSON brief
eeco go --brief --write # writes the smaller variant
In --brief --json the nine frozen top-level keys are preserved —
arrays may simply be shorter, never absent or null — so a consumer that
parses the JSON brief sees the same shape, never an unexpected key
disappearance.
To keep the persisted .eeco/context.md under a known size, set the
context_budget key in <workspace>/config.local to a maximum byte
count (roughly four bytes per token is a useful rule of thumb):
context_budget=4000 # cap the --write brief at 4000 bytes
When context_budget is set, eeco go --write trims the saved brief
down a deterministic ladder until it fits: it tries the full brief
first, then the smaller --brief form, then that form with the
per-section lists capped progressively shorter (5, 4, 3, 2, 1, 0). It
writes the largest tier that fits and reports which one — for example
wrote .eeco/context.md (brief, 1840/4000 bytes). A context_budget
of 0 (the default) means no cap. The budget applies only to
--write; bare eeco go and eeco go --brief printed to stdout are
unchanged. If the budget is so small that even the smallest tier
overruns it, eeco still writes that smallest brief and prints a note —
a brief slightly over budget beats no brief at all.
To surface the recent free-form notes from eeco add note (§17)
inside the brief, set the brief_include_notes key in
<workspace>/config.local:
brief_include_notes=true
When enabled, eeco go adds a Recent notes section between
What eeco knows and Open decisions, listing the five newest
notes from <workspace>/notes/ with their UTC timestamp and first
line. The section appears in Markdown output only — eeco go --json
still emits exactly the nine frozen top-level keys
(PUBLIC_API.md (PUBLIC_API.md) §"CLI commands and flags"), since
notes belong to the assistant-prose channel rather than a parsed
brief. The key accepts the standard strconv.ParseBool set —
true/false, 1/0, t/f (in either case); a typo like
yes/no is rejected at parse time so the operator notices.
The default is false, so bare eeco go omits the section; the key
composes with --brief, --write, and --copy exactly like the other
sections.
To wire the brief into a session, either point your assistant's
instructions file at .eeco/context.md, or — if you use the bundled
session-start hook (§9) — add session_start_docs=.eeco/context.md to
config.local so the hook lists it in the reading routine
automatically. Any assistant that can read a file or run a shell
command can consume the brief; eeco assumes no particular tool.
For a chat-only assistant — Gemini web, a Gem, AI Studio, ChatGPT,
or any LLM behind a chat box — the --copy flag writes the brief to
the host operating system's clipboard so you can paste it in one
shot:
eeco go --copy # full Markdown brief on the clipboard
eeco go --copy --brief # smaller brief for a tight context window
eeco go --copy --json # structured payload for a chat that parses JSON
--copy works on macOS (pbcopy), Windows (clip.exe), and Linux
(wl-copy on Wayland, xclip or xsel on X11). When none of those tools
is on PATH eeco exits 2 with an install hint — the same
"blocked: required tool missing" contract every other workflow
follows. --copy does not compose with --write (one delivery axis
per invocation); it does compose with --brief and --json.
Re-run eeco go --copy whenever the project state has moved on.
Measuring the brief — --metrics
The --metrics flag adds a one-line readout to stderr after the
brief, so you can see what the brief actually saved:
eeco go --metrics
eeco go: assembled in 412µs · brief 1840 bytes (≈460 tokens) · distilled ≈980 tokens of project knowledge into ≈460 (≈53% smaller)
It reports three things: how long the brief took to assemble (real
wall-clock), the brief's own size, and how much of the project's
knowledge layer — the on-disk memory facts, the queue, and any notes —
the brief distils. Byte counts are real measurements; token figures are
estimates via the ~4-bytes-per-token heuristic and are marked with a
leading ≈, never presented as exact counts (eeco ships no tokenizer,
by design). The compression percentage is grounded in real on-disk bytes
and is never reported below 0%.
The readout goes to stderr, so it never mixes into the brief itself:
eeco go --metrics > brief.md writes a clean brief to the file and
prints the readout to the terminal. It always describes the **canonical
Markdown brief** — even alongside --json or --copy, where it measures
the Markdown brief the assistant would read — and it never appears in
--json stdout, so the nine frozen top-level keys
(PUBLIC_API.md (PUBLIC_API.md) §"CLI commands and flags") are
untouched. With --write under a context_budget the readout still
describes that canonical brief, not the budget-trimmed file that was
persisted — the metric measures how much eeco's brief compresses the
knowledge layer, a stable property, rather than one write's fitted size.
--metrics is off by default and composes with every other flag.
Briefing assistants other than Claude Code
eeco's delivery channels are brand-free — point them at whichever
assistant you use:
• Gemini CLI (Google's terminal-native assistant) reads
GEMINI.md at session start. Set session_files=GEMINI.md in
<workspace>/config.local, then eeco hooks session-start on.
Same recipe as Claude Code with CLAUDE.md.
• OpenAI Codex reads AGENTS.md. Set session_files=AGENTS.md.
• Cursor reads .cursorrules. Set session_files=.cursorrules.
• Any other assistant whose instructions file lives at a known
path: add that path as a session_files= line and eeco will keep
a marker block in it (§9).
For an assistant with no terminal and no filesystem access (Gemini
web, Gems, AI Studio, ChatGPT, any chat box), eeco go --copy is
the one-shot bridge — render the brief into the clipboard and paste
it into the chat. Re-run when the project state moves on.
Asking a targeted question
Where eeco go is a one-shot overview, eeco ask answers a *specific*
question with a ranked set of pointers — the interactive counterpart to
the static brief:
eeco ask "where is the project brief rendered"
The answer has two sections: Memory — the memory facts whose name,
description, or body overlaps the question, shown as a topic → file map
when the fact carries a ref; and Code — the best-matching lines in
the repository's tracked files, each as a path:line reference with the
matching line. Results are ranked by how many distinct question words
they match, with stable tie-breaks, so the same question over the same
tree always gives the same answer.
eeco ask --limit 3 "how does version-sync detect drift" # cap code hits
eeco ask --json "queue" # machine-readable
--limit N caps the number of code locations (default 10); every
matching memory fact is always shown. --json emits an object with
three frozen top-level keys — question, memory, code — both arrays
always present, never null. Like eeco go, eeco ask calls no AI
provider (relevance is a word-overlap score over what eeco already
tracks), reads anywhere in the repo but writes nothing, and works in any
git repository — the memory section is simply empty when the workspace
is not initialised. Binary and very large files are skipped.
14. Applying an update
──────────────────────
eeco update checks for a newer release and prints the gap, but does
not download anything. The opt-in --apply flag turns it into a
verified self-replace: download the platform release archive, verify
its sha256 against the published SHA256SUMS, verify the cosign
signature on SHA256SUMS, verify the GitHub build-provenance
attestation on the archive, then atomically swap the running binary.
eeco update --apply
Verification gates. All three must pass before any swap. Any one
failure leaves the running binary untouched and leaves the downloaded
files in <workspace>/state/update-<tag>/ for inspection.
• cosign verify-blob against the keyless certificate identity baked
into the release workflow (refs/tags/v… on this repo).
• sha256 of the downloaded archive equal to its line in SHA256SUMS.
• gh attestation verify <archive> --repo ajhahnde/eeco.
Requirements. cosign and gh must be on PATH. The command must run
inside an initialised eeco workspace — <workspace>/state/ holds the
staging directory, the backup, and the swap ledger
(<workspace>/state/binary.json). The running binary must be on a
released tag (v…); an unpinned 0.0.0-dev build refuses with a
reinstall hint.
Package-manager handoff. If the resolved running-binary path lives
under a known package-manager root (Homebrew, Scoop, Linuxbrew),
--apply refuses and points at the package manager's upgrade verb
(brew upgrade eeco or scoop update eeco). Self-replace would fight
the package manager on its next sync; the manager owns its prefix.
Rollback. The previous binary is preserved at
<workspace>/state/update-<tag>/eeco.bak (or eeco.exe.bak on
Windows). To revert by hand:
mv <workspace>/state/update-<tag>/eeco.bak <running-path>
The exact <running-path> is recorded in
<workspace>/state/binary.json under running_path.
What stays the same. Bare eeco update (no flag) is unchanged: it
still lists the gap and exits cleanly, without touching any binary.
Constraint 6 holds — eeco only swaps when the operator passed
--apply.
15. Reading the in-binary guide
───────────────────────────────
eeco guide pages the user manual built into the binary — a verbatim
mirror of this file (docs/USAGE.md) at the tag the binary was built
from. Every install route ships the manual offline; no network access
needed, no doc directory to clone.
eeco guide
The pager selection is $PAGER first (split on whitespace; the first
token must be on PATH), then less -R if less is available, then
a plain stdout dump as the final fallback. When stdout is not a
terminal (piped or redirected) the command always dumps the manual
plainly and exits 0 — the same one-screen path the TUI uses for
eeco with no arguments.
At a terminal the manual is prettified for reading: Markdown tables
become box-drawing grids, headings are styled with a rule, and inline
code / bold / links are rendered rather than shown as raw markup.
NO_COLOR drops the colour but keeps the layout. Piped or redirected
output stays raw Markdown, byte-identical to docs/USAGE.md, so a
script that captures the guide (eeco guide > usage.md) gets the
source unchanged.
Quick recipes.
eeco guide # page interactively
eeco guide | less # explicit pager pipeline
eeco guide > usage.md # capture the embedded snapshot
PAGER='less +/^## 4' eeco guide # jump straight to the commands table
The embedded snapshot is locked to the binary version. Pair it with
eeco update --apply to keep the manual in sync with the running
release. The tracked-tree docs/USAGE.md and the in-binary guide
stay byte-identical in CI — a release commit that forgets to re-sync
the mirror fails the build.
16. Authoring tracked docs
──────────────────────────
eeco docs new <target> seeds a tracked-tree documentation file at
the repository root. Two targets ship today:
┌────────┬──────────────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Target │ File written │ Purpose │
├────────┼──────────────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ vision │ VISION.md │ seed a short manifesto: what this project is for, what it gives you, what it deliberately is not, and where the roadmap lives │
│ readme │ README.md │ seed a new-reader-friendly README: tagline, what the project does, a quick-start recipe, and a short "how it works" │
└────────┴──────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
The scaffolder is deterministic — no AI call, no provider spend — and
project-shape-aware: the rendered "See also" only links to companion
docs that already exist (README.md, docs/USAGE.md,
docs/ARCHITECTURE.md). The readme target's "See also" never
self-links, so the link set narrows to docs/USAGE.md and
docs/ARCHITECTURE.md.
eeco docs new vision # write VISION.md (refuse if it exists)
eeco docs new --overwrite vision # replace an existing VISION.md
eeco docs new readme # write README.md (refuse if it exists)
eeco docs new --overwrite readme # replace an existing README.md
Refuse-on-existing is the default. Pass --overwrite to replace a
prior scaffold or hand-edited file. **--overwrite discards the
existing body** — use it deliberately, especially on a populated
README.md. eeco writes the file but never stages or commits it
(Constraint 6); review the result and run `git add <file> && git
commit` yourself.
The seed file carries a one-line HTML comment at the top marking it
as a scaffold and naming the eeco version it was generated against.
The comment is invisible in any rendered Markdown view and can be
deleted in the same edit you fill the body in.
eeco docs refresh <target> re-renders the project-state-derived
region of a previously-scaffolded doc — the same project-shape-aware
"See also" block eeco docs new rendered — leaving operator prose
outside the marker pair untouched. The region is delimited by a pair
of HTML-comment markers:
<!-- eeco:docs:start -->
… rendered content …
<!-- eeco:docs:end -->
Markers inside fenced code blocks are ignored, mirroring `eeco docs
compact`. Unmatched, nested, or out-of-order markers exit 1 with a
parse error naming the offending line; the file is not touched. A
scaffold with no markers is auto-initialised: the freshly
rendered block is appended at EOF, marker-wrapped, with a blank-line
separator. The operator removes the prior in-place block manually.
Where --overwrite on eeco docs new discards the entire body,
refresh is the non-destructive complement.
eeco docs refresh vision # re-render VISION.md's marker block
eeco docs refresh readme # re-render README.md's marker block
eeco docs compact <path> moves resolved regions of a long doc into
a sibling archive file so the working copy stays short, then leaves a
single-line pointer stub in place at each cut. A region is anything
between a pair of HTML-comment markers:
<!-- eeco:archive:start -->
… content to archive …
<!-- eeco:archive:end -->
Markers inside fenced code blocks are ignored, so a doc explaining
the markers (this very paragraph in the original docs/USAGE.md)
does not trigger a self-archive.
eeco docs compact roadmap_v1.x.md
eeco docs compact --dry-run roadmap_v1.x.md
eeco docs compact --archive history.md roadmap_v1.x.md
The default archive destination is <basename>.archive<ext> next to
the source — e.g. roadmap_v1.x.md → roadmap_v1.x.archive.md.
Override the destination with --archive; pass a --dry-run to
preview which line ranges would move without writing. Re-running
after a successful compact is an idempotent no-op (`no archive
markers found`); the archive is appended to on subsequent runs so a
long-lived doc can be compacted in waves.
Unmatched, nested, or out-of-order markers exit 1 with a parse error
naming the offending line; the archive is not touched. Both paths
must resolve inside the repository (absolute paths and .. traversal
are rejected). eeco writes the source and the archive but never
stages or commits either (Constraint 6); review both files and `git
add` / commit when ready.
For a log that grows by prepending a new section each time — a session
journal, a changelog, a steering file — placing markers by hand every
round is fiddly. --keep-last N discovers the archivable regions by
heading instead. Pair it with --heading <prefix>, the heading-line
prefix that opens an archivable section:
eeco docs compact --keep-last 4 --heading "## Snapshot" RESUME.md
This keeps the N most-recent matching sections — newest meaning
topmost in the file — and archives everything older. The # run in
--heading fixes the section level: a section runs from its heading to
the next heading of the same or a higher level, so a live tail such as a
trailing ## Next session or ## Pointers block is never swallowed. A
deeper sub-heading inside a section does not split it, and (as in marker
mode) any heading inside a fenced code block is ignored. Adjacent
archived sections collapse into one pointer stub.
--keep-last and explicit markers are mutually exclusive: running
heading mode on a source that still carries a paired marker region exits
1 rather than mixing the two schemes. --keep-last requires --heading
and vice versa. --dry-run and --archive work the same in both modes;
re-running once only the kept window remains is an idempotent no-op.
17. Keeping notes
─────────────────
A note is a place to scribble — a half-formed idea, a reminder, a
thread to pick up later. It is deliberately neither a memory fact
(frontmatter-strict, matched to AI relevance) nor a queue item (the
append-only decision channel): routing scratch text into either of
those pollutes their contract. Notes get their own surface.
eeco add note "check round-robin fairness in queue promotion"
eeco show notes
eeco add note "<text>" saves the text verbatim to one plain Markdown
file under <workspace>/notes/, named YYYY-MM-DD-HHMMSS-<slug>.md
(the timestamp is UTC; the slug is derived from the text). The
directory is created on first use, so a fresh eeco init is not
required — being inside a git repository is enough. eeco writes only
inside the workspace (Constraint 1) and never stages or commits
(Constraint 6).
eeco show notes lists every note newest-first, one row per note:
the local date and time followed by the note's first line. An empty or
absent notes directory prints a friendly hint and exits 0.
This first slice is append + list only. To edit a note, open it in your
editor ($EDITOR <workspace>/notes/<file>); to remove one, delete the
file (rm <workspace>/notes/<file>).
added internal/guide/usage.md
@@ -0,0 +1,1519 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="../assets/eeco_logo_dark.png">
<img src="../assets/eeco_logo_light.png" alt="eeco" width="280">
</picture>
<h1>Usage</h1>
<p>
<a href="../README.md"><b>README</b></a> ·
<a href="../VISION.md"><b>Vision</b></a> ·
<a href="COCKPIT.md"><b>Cockpit</b></a> ·
<b>Usage</b> ·
<a href="ARCHITECTURE.md"><b>Architecture</b></a> ·
<a href="PUBLIC_API.md"><b>Public API</b></a> ·
<a href="../EXTENDING.md"><b>Extending</b></a> ·
<a href="../CONTRIBUTING.md"><b>Contributing</b></a> ·
<a href="UPGRADING.md"><b>Upgrading</b></a> ·
<a href="../VERSIONING.md"><b>Versioning</b></a> ·
<a href="../CHANGELOG.md"><b>Changelog</b></a> ·
<a href="../SECURITY.md"><b>Security</b></a>
</p>
</div>
---
Everything eeco can do. The runtime story, directory layout, trust
boundaries, and frozen public surface are documented in
[`ARCHITECTURE.md`](ARCHITECTURE.md).
## 1. Install
Single static binary, zero runtime dependencies. Four install routes:
pre-built binary, Homebrew, Scoop, from source.
### 1.1 Pre-built binary (recommended)
Download the archive for your platform from the
[releases page](https://github.com/ajhahnde/eeco/releases), verify it
against `SHA256SUMS`, extract the `eeco` binary, and put it on your
`PATH`.
| OS | Arch | Archive |
| --------- | ------- | ------------------------------------------ |
| `darwin` | `amd64` | `eeco_<version>_darwin_amd64.tar.gz` |
| `darwin` | `arm64` | `eeco_<version>_darwin_arm64.tar.gz` |
| `linux` | `amd64` | `eeco_<version>_linux_amd64.tar.gz` |
| `linux` | `arm64` | `eeco_<version>_linux_arm64.tar.gz` |
| `windows` | `amd64` | `eeco_<version>_windows_amd64.zip` |
| `windows` | `arm64` | `eeco_<version>_windows_arm64.zip` |
Checksum verification:
```
shasum -a 256 -c SHA256SUMS
```
Signature and provenance (recommended). `SHA256SUMS` is signed
keylessly with cosign — no key to fetch or trust on file; the signing
identity is the release workflow itself. Verify it:
```
cosign verify-blob \
--certificate SHA256SUMS.pem \
--certificate-identity-regexp \
'^https://github.com/ajhahnde/eeco/\.github/workflows/release\.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--signature SHA256SUMS.sig \
SHA256SUMS
```
Each archive also carries GitHub build provenance. Verify a downloaded
archive came from this repository's release workflow:
```
gh attestation verify eeco_<version>_<os>_<arch>.<ext> --repo ajhahnde/eeco
```
macOS Gatekeeper. The `darwin` binaries are distributed unsigned by
design; their integrity is established by the `SHA256SUMS` signature
and provenance above rather than Apple's notary service. After
extracting, clear the quarantine attribute the browser sets on the
download:
```
xattr -d com.apple.quarantine ./eeco
```
The cosign + provenance route above is the supported integrity check
on macOS; the darwin binaries are intentionally distributed without an
Apple Developer ID signature.
### 1.2 From source
Requires Go 1.24+.
Released archives are produced by GitHub Actions on every `v*` tag and
uploaded to the [releases page](https://github.com/ajhahnde/eeco/releases).
Running `make release` locally is an alternate path — useful for
offline rebuilds and for verifying that a published release is
byte-identical to a fresh build from the tag.
Reproducing the released binaries byte-for-byte requires the same Go
toolchain CI used. `go.mod` pins it; when your installed Go differs,
set `GOTOOLCHAIN=go1.24.2` so the build fetches and uses the pinned
version.
```
git clone https://github.com/ajhahnde/eeco
cd eeco
make build # ./eeco with injected version
make release VERSION=vX.Y.Z # cross-build the published matrix into dist/
make verify # go build ./... && go vet ./... && go test ./...
```
`make build` produces `./eeco` with `version`, `commit`, and `buildDate`
injected via `-ldflags` so `eeco version` reports the real build
identity. `make release` writes archives plus `SHA256SUMS` into `dist/`;
`make packaging` writes `eeco.rb` and `eeco.json` from the checksums.
### 1.3 Homebrew (macOS, Linux)
```
brew install ajhahnde/eeco/eeco
```
Installs the binary from the latest tagged release via the
`ajhahnde/eeco` tap. `brew upgrade eeco` follows new tags. The formula
is generated per release and published with the release assets as
`eeco.rb`.
### 1.4 Scoop (Windows)
```
scoop bucket add eeco https://github.com/ajhahnde/scoop-eeco
scoop install eeco
```
`scoop update eeco` follows new tags. The manifest is published with
the release assets as `eeco.json`.
## 2. First use in a project — `eeco init`
```
cd /path/to/your/repo
eeco init
```
`eeco init`:
- creates a **private, per-user workspace** at `<repo>/<owner>/.eeco/`
and adds `/<owner>/` to `.gitignore` if it is not already ignored, so
nothing eeco does can leak into your tracked history. `<owner>` is your
git `user.name`; override it with `--username NAME` or the
`EECO_USERNAME` environment variable (resolution order: flag, env, git
`user.name`, then a prompt). `--workspace NAME` renames the inner
`.eeco` engine directory;
- scaffolds the engine, memory store, builtin workflows, and state
inside that directory;
- detects the project **profile** (zig, python, node, go, generic) and
the matching parse/build gate, and classifies the project **type** to
choose the knowledge directories it scaffolds. Detection is a
deterministic marker scan; `init_detection_threshold` (a value in
`[0,1]` in `config.local`) is the confidence at or above which init
accepts the scan without prompting (`0`, the default, uses the
detector's own default). `--type CATEGORY` forces a type and skips
detection; `--ai` opts into a single gated AI pass for an ambiguous
tree.
**The one commit eeco makes.** When init adds the `.gitignore` line it
stages *only that file*, commits it as `eeco init`, and pushes — the
single sanctioned write to your git history (everything else eeco writes
stays in the gitignored workspace). `--no-commit` skips the commit and
push; `--no-push` commits locally without pushing. On a non-git or
not-yet-real repo the step is skipped with a manual hint, and any git
failure is a warning — init never fails because of it.
**Migrating an older workspace.** If a legacy `<repo>/.eeco` workspace
from before the per-user layout is found, init reports it and offers to
run `eeco migrate v1` (§4) before continuing; declining makes no changes.
This is also the "new project, professional setup" path: one command
gives a repo its private workspace plus the whole ecosystem.
## 3. The control center (TUI) — `eeco`
Run `eeco` with no arguments to open the control center. Not a terminal
(piped/CI)? It prints a one-screen status digest and exits 0 instead.
The control center opens on a home screen: a centred eeco logo at the
top, the version and one rotating usage tip below it, a hint above the
input, and a thin dim status footer (repo, profile, gate, automation,
memory and queue counts, hook state, last run) below the input. Every
command is discoverable by typing `/` (the slash-command palette) or
pressing `?` (the shortcut overlay), so the home screen stays
uncluttered. Typing or running a command collapses the home view to
input + footer so streamed output reads cleanly above; clearing the
input recovers the home view. Output stays in the normal terminal
scrollback after you quit — no full-screen takeover.
Typing `/` opens a slash-command palette: a live dropdown of every
command with its one-line purpose, between the input and the footer.
Filter it by typing (`/h` narrows to `/help` and `/hooks`), move the
highlight with Up/Down, and accept the selection with Tab or Enter; the
palette closes on a space, a newline, or when the input clears.
The input is a multi-line composer: type or paste a request that spans
several lines. Enter submits; Alt+Enter or Ctrl+J inserts a newline
(Ctrl+J is the fallback where a terminal swallows Alt+Enter). The box
grows with the draft up to eight rows and then scrolls internally,
shrinking back when you submit or clear.
Free text (no leading slash) no longer opens an in-binary chat. eeco
configures the harness that runs the AI (see `eeco cockpit`), so the
control center stays a deterministic command surface rather than a
second-rate agent: a free-text line prints a dim one-line reminder to
use a slash command (`/run`, `/memory`, …) or `?` for help, and spends
nothing. The in-binary chat turn, its read-only tool loop, and `/clear`
were retired in the move to the cockpit model.
Input accepts:
| Input | What it does |
| ------------------ | --------------------------------------------------------- |
| `/run [--ai] <wf>` | run a workflow (`--ai` opts into one gated pass) |
| `/queue` | show items awaiting a decision |
| `/memory` | list stored facts |
| `/gc` | run memory garbage collection |
| `/new <wf>` | scaffold a new workflow |
| `/hooks [<n> on\|off]` | show or toggle the opt-in reversible hooks |
| `/settings [<k> <v>]` | view or set the AI config (persists to config.local) |
| `/help` | command and key reference |
| `/quit` | leave the control center |
| plain text | a dim reminder to use a slash command (chat is retired) |
Keys: Up/Down browse command history at the top and bottom of the
composer (and move the cursor within a multi-line draft otherwise, or
move the palette highlight when it is open), Tab completes a command or
workflow name, Alt+Enter / Ctrl+J insert a newline, `?` toggles a
shortcut overlay, Esc interrupts a running task — an animated spinner in
the footer marks one in flight. `q` (on an empty line), `/quit`, and
Ctrl-C always leave the terminal in a sane state. `NO_COLOR` is honoured.
Plain text never spends silently: each conversation turn is consented and
budget-capped, and parked-and-queued when it cannot run (the conversation
does not advance on a parked turn). Commands never require AI.
## 4. Commands
```
eeco open the control center (digest if non-TTY)
eeco init [--no-track] bootstrap the ecosystem in this repo (--no-track skips the private workspace-history repo)
eeco migrate v1 [--yes] move a legacy <repo>/.eeco workspace under <username>/
eeco run <workflow> run one workflow (read-only / safe by default)
eeco run --ai <workflow> allow this run's gated, budget-capped AI pass
eeco new <workflow> scaffold a new workflow from the template
eeco gc run memory garbage collection
eeco queue print the workspace queue (resolve by editing queue.md)
eeco stats print cumulative AI usage from the call ledger (real token counts)
eeco hooks <name> on|off toggle an opt-in hook (reversible; names: pre-commit, post-merge, session-start, commit-msg, commit-guard)
eeco hooks session-start refresh re-render every session_files block from current project state
eeco hooks <name> refresh rewrite an installed hook (pre-commit, post-merge, commit-msg, commit-guard) with the current eeco binary path
eeco gates check-attribution [flags] scan tracked files + commit bodies for AI-attribution fingerprints
eeco update [--apply] check for a newer release; --apply verifies + swaps
eeco doctor run workspace and config diagnostics
eeco go [--brief] [--metrics] [--write|--json|--copy] print an AI-ready brief; --brief trims it; --metrics adds a stderr readout; --json emits JSON, --write saves, --copy clipboards
eeco ask [--limit N] [--json] "<question>" answer a question with ranked file:line pointers (no AI)
eeco docs new [--overwrite] <target> scaffold a tracked-tree doc (targets: vision, readme)
eeco docs refresh <target> re-render a scaffolded doc's marker-wrapped block (targets: vision, readme)
eeco docs compact [--keep-last N --heading <prefix>] [--archive <path>] [--dry-run] <path> archive old regions of <path> (markers, or heading-discovered with --keep-last) into a sibling
eeco history show the private workspace-history log (recent commits)
eeco history snapshot [-m <msg>] commit the current workspace state into the private history repo
eeco history compact [--dry-run] [--yes] squash the private history log into one commit (reflog-recoverable)
eeco guide page the in-binary user manual through the host pager
eeco uninstall [--yes] write a handoff summary, print the removal command, and de-init the private history repo (--yes skips the confirm)
eeco report-bug [--submit] file a structured bug report (--submit opens the pre-filled issue URL in your browser)
eeco add note "<text>" append a free-form note to the workspace
eeco add fact --description "<s>" [flags] "<body>" record a durable memory fact
eeco add task "<title>" append an item to the workspace queue
eeco adaptations <name> on|off toggle an AI-adaptation fact on or off
eeco workflows [<name> on|off] list scaffolded workflows or toggle one on/off
eeco show notes list the workspace notes, newest first
eeco show adaptations list AI-adaptation facts with their on/off state
eeco show prompt [name] list the prompt library or print one prompt body
eeco refresh-manifest [<dir>] rebuild the .ai.json manifests in the knowledge dirs
eeco version print the version
eeco help command reference
```
**Exit codes** (also the workflow contract): `0` clean ·
`1` finding/failure · `2` blocked (a required tool is missing) ·
`3` AI pass deferred (no `--ai`).
## 5. Builtin workflows
| Workflow | Inspects | Writes | AI |
| ------------------ | -------------------------------- | ------------- | ---- |
| `comment-hygiene` | source/docs for tooling fingerprints | nothing | no |
| `leak-guard` | staged + tracked tree, commit msg | nothing | no |
| `version-sync` | declared `version_locations` | nothing | no |
| `gate` | the declared `gate` command chain | nothing | no |
| `bug-sweep` | code, statically | a triage ledger | opt |
| `handover-refresh` | code/docs/git history | queue proposal | opt |
| `evolve` | run history + repeated actions | queue | opt |
| `memory-drift` | memory facts with a `ref` | queue | no |
| `doc-drift` | `CHANGELOG.md` vs git tags | queue | no |
| `manifest-refresh` | knowledge dirs (paths + kinds) | `.ai.json` | no |
- **`comment-hygiene`** fails if any shippable file carries an
AI-attribution / tooling string.
- **`leak-guard`** blocks a commit that would leak an attribution
string, a `Co-Authored-By` trailer, or a private-workspace path into
tracked files.
- **`version-sync`** reports drift between the version strings declared
in `<workspace>/config.local`'s `version_locations` list. Each entry
is a `path:regex` pair (split on the first colon); the path is
repo-relative and the regex must declare at least one capture group
(group 1 captures the version string). With no `version_locations`
entries the workflow exits 0 (the gate is opt-in per project). Use
the multiline flag `(?m)` when the regex anchors on the start of a
line further down a file — Go's `^` is start-of-string by default.
Set `version_locations=auto` instead of an explicit list to let
version-sync discover the version locations itself. In auto mode it
scans a fixed, high-precision set of common version files — `VERSION`,
`CHANGELOG.md` (the first `## [vX.Y.Z]` heading), `package.json`,
`pyproject.toml`, and `Cargo.toml` — and reports drift across
whichever of them the project actually has. A file that is absent, or
present but carrying no version-shaped string, is skipped. `auto`
cannot be mixed with explicit `path:regex` entries — it is the whole
list or none of it — and composes with `version_anchor` the same way
an explicit list does.
The `version_anchor` config key selects the source of truth declared
locations are checked against. Three modes:
- **Unset (default) — consistency-only.** The first declared location
is the anchor; the rest must match it. Exits 0 when every declared
location agrees, 1 when any drifts, and 2 when a declared path is
missing on disk. The slice-1 behaviour, preserved for backward
compatibility.
- **`version_anchor=tag` — tag-anchor.** The latest semver-shaped
(`vX.Y.Z`) tag reachable from `HEAD` is the source of truth.
Declared locations must be semver-`>=` the tag, so a release commit
can bump declared locations ahead of the not-yet-pushed tag (the
tag is pushed after the commit). Backward-drift still fails. If no
semver-shaped tag is reachable yet, falls back to the
consistency-only behaviour with a note in the summary.
- **`version_anchor=<path>:<regex>` — designated-file mode.** Same
shape as a `version_locations` entry; the captured version is the
source of truth and every declared location must strict-equal it.
A missing path exits 2 (blocked) so a typo surfaces rather than
silently falling through to consistency-only.
Worked example. Declare two entries that both must carry the latest
released tag — the `CHANGELOG.md` heading and the matching footer
compare-link, which often drift when a release bumps one but
forgets the other — and enable tag-anchor so a release commit's
bump catches a forgotten location:
```
version_locations=CHANGELOG.md:(?m)^## \[v(\d+\.\d+\.\d+)\]
version_locations=CHANGELOG.md:(?m)^\[v(\d+\.\d+\.\d+)\]: https://github
version_anchor=tag
```
Once `version_locations` is declared, wire the gate into the
pre-commit hook so drift is caught before the commit lands rather
than only under `make gates`: `eeco hooks pre-commit on` installs a
`.git/hooks/pre-commit` that runs the default workflow chain
(`leak-guard` + `version-sync`) and blocks the commit on a non-zero
exit. See §9 for the override knob and the byte-identical reversal
contract.
- **`gate`** runs the project's declared parse/build gate — the ordered
command chain in `<workspace>/config.local`'s `gate` key — step by
step, with the repository root as the working directory, stopping at
the first failure. The `gate` key is repeatable: each occurrence adds
one step (a whitespace-split command), and the first occurrence resets
the profile default so the operator-declared chain fully replaces it.
A bare single-command `gate` is a one-step chain; a project with no
gate (the generic profile, or a lone `gate=`) is a clean no-op, so the
gate is opt-in per project. Every step's command is checked on `PATH`
before the first step runs, so a chain that cannot complete exits 2
(blocked); a step that exits non-zero exits 1 and stops the chain.
Run it with `eeco run gate`, or add `gate` to `pre_commit_workflows`
(§9) to run the whole validation chain before each commit.
Worked example. A Go project that wants vet, a static linter, and the
unit tests as one gate, run in order and stopping at the first to
fail:
```
gate=go vet ./...
gate=staticcheck ./...
gate=go test ./...
```
- **`bug-sweep`** does a static read and maintains an append-only triage
ledger (open findings → resolved log); with `--ai` it performs the
reasoning pass, otherwise it reports and parks the prompt.
- **`handover-refresh`** drafts a dated handover plus a "what's now
dead" diff and queues it for your approval — it never overwrites a
note directly.
- **`evolve`** watches for repetition and *proposes* new workflows into
the queue. It never activates one on its own. The
proposal pass is two-stage: a **deterministic signal extraction**
scans the most recent 40 commit subjects for repeated
conventional-commit types (`<type>(<scope>)?!?: …`) and surfaces one
workflow candidate per type that appears at least three times (cap
five candidates per run, sorted count-desc then key-asc). Each
candidate becomes its own `evolve` queue item — the operator
resolves or dismisses one at a time. The gated AI pass becomes
**optional enrichment** layered on top: `--ai` (or `automation=auto`)
opts into it; on no consent or any provider skip eeco still exits 0
when the deterministic pass found at least one candidate, and only
preserves the exit-3 (AI deferred) contract when there is genuinely
nothing to report — no consent **and** no deterministic candidates.
It also maintains a **repetition ledger** at
`<workspace>/state/evolve-history.json`. Each surfaced candidate
writes one record (signal kind + key, count at proposal, the queue
row's kind + title, the proposal timestamp); a re-run suppresses any
candidate whose `(signal_kind, signal_key)` is already in the
ledger, so a recurring signal is proposed exactly once in its
lifetime even after the queue item is ticked. Each run also
reconciles unresolved records against the queue: a record whose
queue row is now checked flips to `resolved: true` with a
`resolved_at` timestamp. Reconciliation runs once per evolve
invocation — the queue is not watched live, so an operator who
ticks a row between runs sees the ledger update on the next
`eeco run evolve`. The re-propose-on-signal-recurrence knob is a
follow-on slice; today a resolved record still suppresses. A
future slice may add a file-touch signal alongside the commit-type
one (deferred because it needs a new `gitx` helper).
- **`memory-drift`** flags a memory fact whose `ref:` file has been
committed on a later calendar day than the fact's own `created:` date
— a sign the fact may now describe code that has moved on. `eeco gc`
already catches a `ref:` that no longer exists on disk; `memory-drift`
catches the complementary case where the file is still there but has
changed since the fact was written. One review item per stale fact is
routed to the queue — eeco flags the drift, you reconcile the fact. It
needs git on `PATH` (it exits 2, blocked, without it) and exits 0 when
every ref-carrying fact is still current. Run it with `eeco run
memory-drift`, or wire it to run automatically after a merge with the
`post-merge` hook (§9), where it is in the default workflow list.
- **`doc-drift`** flags drift between the release sections in
`CHANGELOG.md` and the project's git tags. Two cases: a `vX.Y.Z` git
tag with no `## [vX.Y.Z]` CHANGELOG section (a release that was never
documented), and a `## [vX.Y.Z]` section with no matching tag (a
release documented but never tagged). The newest section is exempt: a
release commit adds the `## [vX.Y.Z]` heading before the `vX.Y.Z` tag
is pushed, so a section strictly ahead of the latest tag is the
expected release-in-progress state, not drift. One review item per
drift is routed to the queue — eeco flags it, you reconcile the
`CHANGELOG.md` or the tag. It needs git on `PATH` (it exits 2,
blocked, without it) and exits 0 when `CHANGELOG.md` is absent, no
tags exist yet, or every tag and section agree. Run it with `eeco run
doc-drift`, or let the `post-merge` hook (§9) run it automatically — it
is in that hook's default workflow list alongside `memory-drift` and
`manifest-refresh`.
- **`manifest-refresh`** rebuilds the per-directory `.ai.json` manifests
across the knowledge dirs — the project-type directories scaffolded
under `<repo>/<owner>/` as siblings of the engine workspace. Each
manifest is a deterministic skeleton — a JSON document `{"dir",
"purpose"?, "items":[{"path","kind"}…]}` with `kind` either `"file"`
or `"dir"`, sorted by path; the optional `desc`/`find_when` fields are
reserved for a future enrichment pass and stay empty in the
deterministic walk. The `.ai.json` file is skipped in its own walk, so
a re-run over unchanged dirs reproduces byte-identical output. Writes
land in the gitignored per-user area, never the tracked tree; it needs
no AI and never blocks. Run it on demand with `eeco refresh-manifest
[<dir>]` (the same deterministic rebuild, optionally scoped to one
knowledge dir), or let the `post-merge` hook (§9) run it.
Run any of them from the TUI or `eeco run <name>`.
These ten are the complete builtin set. Additional builtin workflows
are tracked as a post-v0.1.0 item.
### Scaffolding a workflow with `eeco new`
`eeco new <name>` scaffolds a user workflow under
`<workspace>/workflows/<name>/` from a per-profile template. The
scaffold layout is picked from the project's detected profile:
- `go` profile — `run` PATH-checks `go` and runs `go vet ./...` as
the inert default.
- `python` profile — `run` PATH-checks `python3` and runs
`python3 -m compileall -q .` as the inert default.
- `generic` profile (and any profile without a dedicated template:
`zig`, `rust`, `node`) — `run` is a bare sh stub that prints a
placeholder line and exits 0.
In every case the scaffolded `run` is a starting point — replace the
default body with the real check this workflow is meant to perform.
The chosen template is internal to eeco; the verb shape `eeco new
<name>` is unchanged.
### Toggling a scaffolded workflow on or off
Once scaffolded, a workflow is **on** by default; eeco runs it
whenever it is asked to (a manual `eeco run <name>`, a hook chain
that names it, the TUI). To pause a workflow without deleting it:
```
eeco workflows <name> off # mark <name> as disabled
eeco workflows <name> on # bring <name> back online
eeco workflows # list every scaffolded workflow + state
```
`off` plants an empty sentinel marker file at
`<workspace>/workflows/<name>/disabled`; the loader sees it and
reports the workflow as blocked (exit 2) without running it. `on`
removes the marker. Repeating the same action is a clean no-op.
Builtin workflows are unaffected by this toggle — their contract is
part of the frozen public surface. To remove a scaffolded workflow
for good, delete its directory.
## 6. Memory and garbage collection
The store holds one fact per file (flat frontmatter: name, description,
type, dates, optional `ref:`/`expires:`, `pin:`), with a regenerated
`MEMORY.md` index and `[[links]]` between facts. Types: `user`,
`feedback`, `project`, `reference`, `finding`.
`eeco gc` (or the Memory screen) keeps the store honest:
- a memory whose `ref:` path is gone, that has expired, a resolved
finding, or an unused reference past the stale window is **archived**
to the recoverable attic;
- anything **load-bearing** (project/feedback/user) is **queued for
your decision**, never silently deleted;
- `pin: true` exempts a fact permanently.
This is automated and conservative — the algorithm never loses a fact
that still matters; it asks you instead.
Re-running `eeco gc` (for example through the `post-merge` hook chain)
no longer files duplicate `gc-review` items for an unresolved finding:
an open queue row for the same fact + reason is recognised and the
second filing is skipped. Resolving the row (checking its box) lets a
future trigger file again, so the queue is never wedged on a stale
recommendation.
`eeco add fact` records a fact without hand-authoring the frontmatter:
```
eeco add fact --description "<summary>" [--type <type>] [--name <slug>] [--ref <path>] [--pin] [--provenance <text>] [--agent <name>] "<body>"
```
It writes one frontmatter file under `<workspace>/memory/` and
regenerates `MEMORY.md`. `--description` is the one-line summary that
drives relevance matching and is required. `--type` is one of `user`,
`feedback`, `project`, `reference`, `finding` and defaults to `project`
— the safe default, since `project`/`user`/`feedback` facts are queued
for review rather than archived. `--name` is the lower-kebab-case
filename stem; when omitted it is derived from `--description`. `--ref`
records a repo-relative path the fact documents (garbage collection
validates it); `--pin` exempts the fact from collection.
`--provenance "<text>"` records the snippet that triggered the fact
(`source:` in frontmatter, capped at 120 characters); `--agent <name>`
records which assistant filed it (`agent:`). Both are **required for
`--type feedback` and `--type user`** — those types are durable
adaptations to the operator, and an unauthored adaptation is the silent
drift `eeco show adaptations` exists to surface. Hand-authored facts
remain permissive: an older fact file without the new fields still
loads.
eeco refuses to overwrite an existing fact — pass a different `--name`,
or edit the file directly. The command needs an initialised workspace;
it writes only inside it (Constraint 1) and never stages or commits
(Constraint 6). Editing a fact later is `$EDITOR
<workspace>/memory/<name>.md`; removing one is `rm`.
### Auditing AI adaptations
A `feedback`- or `user`-typed memory fact is a durable adaptation to
the operator — every future assistant briefed by `eeco go` honours it.
`eeco show adaptations` lists every adaptation newest-first with its
provenance and on/off state, so the operator can audit *what the AI is
doing automatically now* without grepping the memory directory:
```
eeco show adaptations
```
`eeco adaptations <name> on|off` flips a `disabled:` flag on one fact
in place and regenerates the `MEMORY.md` index so the change shows at
once. A disabled fact is **preserved on disk** but hidden from
`eeco go` briefs and `eeco ask` rankings, so re-enabling is one CLI
flip:
```
eeco adaptations terse-feedback off # mute it without deleting
eeco adaptations terse-feedback on # restore it
```
`eeco gc` skips disabled facts so a deliberately muted adaptation
never gets routed for staleness review.
A disabled fact is hidden from the AI-facing surfaces (`eeco go`,
`eeco ask`, the TUI relevance ranker) but stays **visible — marked —
on the human audit surfaces**: the regenerated `MEMORY.md` index lists
it under a `## disabled` heading, and the TUI `/memory` screen tags its
line `[off]`. This holds for a fact of any type, so a disabled
`project` or `reference` fact is never silently lost from view.
`eeco show adaptations` is the audit surface; the fact file under
`<workspace>/memory/<name>.md` is the source of truth. Constraints 1 +
6 hold (writes inside the workspace; nothing staged or committed).
## 7. The queue — the only thing that interrupts you
Workflows and GC never act unilaterally on a meaningful change. They
append an item to the queue. View it with `eeco queue` (or `/queue` in
the TUI); the status digest shows the open count. The queue file is
yours — you resolve an item by ticking its checkbox (`[ ]` → `[x]`) or
deleting the row in `<workspace>/state/queue.md`. Nothing else nags you;
nothing runs unless you invoke it.
You can also file a queue item yourself — or have an assistant file one
for you — with `eeco add task`:
```
eeco add task "wire the man page into brew/scoop"
eeco add task --kind review --detail "check the brew formula" "audit packaging"
```
The title is the checklist row; `--kind` is the short queue tag (default
`task`); `--detail` is an optional elaboration printed beneath the row.
The item is appended to `<workspace>/state/queue.md` and shows up
alongside workflow- and GC-filed items in `eeco queue` (or `/queue`) and
the status digest's queue count. `eeco add task` writes only inside the
workspace (Constraint 1) and never stages or commits (Constraint 6); it
requires an initialised workspace. Like `eeco add fact`, it closes the
read/write loop — an assistant briefed by `eeco go` can route a decision
back into eeco's one decision channel instead of letting it evaporate.
## 8. AI configuration
AI is **opt-in** (`--ai`, or an `automation` level that grants standing
consent) and **budget-capped**. eeco no longer runs an in-binary model
client — it configures the harness that runs the AI. The one inference
path it keeps for its own chores is a **CLI provider** that shells any
program of your choice (prompt on stdin, reply on stdout); with none
configured, every AI pass is parked, never failed. Configure in
`<workspace>/config.local`, or set the common keys from the control
center with `/settings`:
```
automation = propose # manual | propose | scaffold | auto
ai_budget = 1 # gated passes per invocation; 0 disables AI
# CLI provider — any program, prompt on stdin, reply on stdout:
ai_command = yourcli --print
ai_provider = cli # cli | none; empty/unknown auto-selects
```
- **Auto-select (default).** With `ai_provider` empty or unset — or an
unknown/legacy value such as `anthropic` — a configured `ai_command`
picks the CLI provider; otherwise every AI pass is **parked and
queued**, never failed — the deterministic, non-AI result still
stands. `ai_provider = none` is also accepted; nothing here is ever a
config error.
- `ai_model` and `ai_api_key_env` are inert legacy keys: read and passed
through, but ignored by the CLI provider (there is no native API path).
They stay only so an old `config.local` loads unchanged.
- Only `automation=auto` is itself standing AI consent; every other
level needs an explicit `--ai` on the run.
- The same gating — consent, budget cap, and prompt-parking — wraps
every provider call, so a run is always safe and never an uncontrolled
spend. Every attempt is recorded to the AI-call ledger
(`state/ai-calls.json`): label, provider, resolved model, prompt and
response hashes, and token usage.
`/settings` (no arguments) shows the current values; `/settings <key>
<value>` writes one to `config.local`. A change applies the next time
eeco starts; a long-lived session keeps the budget cap it began with.
### Cumulative AI usage — `eeco stats`
`eeco stats` aggregates that ledger (`state/ai-calls.json`) into a
one-glance readout of how much AI eeco has actually used on this project:
```
eeco stats
eeco stats: 12 AI calls (9 ran, 3 parked) from 2026-05-21 to 2026-05-30
tokens: 18432 input · 4096 cached · 6210 output (28738 total)
by provider: cli 9 · none 3
```
It reports the total number of gated AI calls, how many actually ran
versus were parked, the cumulative token counts, the per-provider call
breakdown, and the recorded date range. Unlike `eeco go --metrics`,
whose token figures are bytes/4 estimates marked with `≈`, these token
totals are the **real provider counts** the ledger stored, so they carry
no `≈`. The command is **read-only** and makes **no** AI call; an
uninitialised workspace or an empty ledger reports `no AI calls recorded
yet` and still exits 0. The exact wording and the displayed figures are a
human-readable readout and are **not** part of the frozen surface.
## 9. Hooks (opt-in, reversible)
Off by default. Inspect with `eeco hooks` (or `/hooks`); toggle with
`eeco hooks <name> on|off` (or `/hooks <name> on|off`). Names:
`pre-commit`, `post-merge`, `session-start`, `commit-msg`,
`commit-guard`.
- **pre-commit** — a local `.git/hooks/pre-commit` (untracked,
repo-scoped) that runs the configured builtin workflows in declared
order and stops at the first non-zero exit, blocking the commit when
any step fails. Defaults to `leak-guard` (blocks a leaking commit)
followed by `version-sync` (silent on projects that have not declared
`version_locations`, so opt-in per project). Override the list with
one or more `pre_commit_workflows` keys in `config.local`; the first
occurrence resets the binary default, subsequent occurrences append.
An empty `pre_commit_workflows=` value clears the list and `eeco
hooks pre-commit on` refuses to install. The hook is installed only
if no pre-commit hook already exists, and removed only when the
on-disk script is byte-identical to what eeco wrote; a foreign or
hand-edited hook is always left untouched. After upgrading the eeco
binary (for example a `brew upgrade` that moves the binary) or editing
`pre_commit_workflows`, run `eeco hooks pre-commit refresh` to rewrite
the installed script with the current binary path and workflow set.
- **post-merge** — a local `.git/hooks/post-merge` (untracked,
repo-scoped) that runs the configured drift-detection workflows after
a `git merge` / `git pull` — the moment another author's changes land,
the natural time to re-check whether eeco's recorded state has drifted
from the code. Defaults to `memory-drift` followed by `doc-drift` (both
silent no-ops on a project with no memory `ref:` facts and no
CHANGELOG/tags). Unlike pre-commit it does **not** block: the merge has
already completed, so a finding or a missing tool surfaces as a queue
item, never as a hook failure (each step's exit is swallowed and the
hook does not use `set -e`). The drift workflows file at most one open
queue item per finding — a repeated run does not pile up duplicates of
a finding still awaiting a decision. Override the list with one or more
`post_merge_workflows` keys in `config.local` (same reset-then-append
semantics as `pre_commit_workflows`; an empty `post_merge_workflows=`
clears the list and `eeco hooks post-merge on` refuses to install).
Install / removal / refresh behave exactly as for pre-commit: a foreign
or hand-edited hook is always left untouched.
- **session-start** — composes up to three blocks at session start
and stays silent when every block is empty:
1. A reading routine — repo-relative paths to read before
substantive work. Auto-detected from a built-in list
(`docs/PUBLIC_API.md`, `docs/ARCHITECTURE.md`, `CHANGELOG.md`,
`ARCHITECTURE.md`, `docs/USAGE.md`, `README.md`); existing
entries surface, missing ones are skipped. The
most-recently-modified match of `roadmap*.md` is appended as
the live planning surface. Override the doc list with one or
more `session_start_docs` keys in `config.local`; change the
roadmap pattern with `session_start_roadmap_glob` (empty
disables discovery).
2. A mailbox warning — surfaces when `Ideas.md` at the repo root
has content beyond its empty template (the parser skips the
file header and any HTML comment blocks). Change the filename
with `session_start_mailbox` in `config.local`; an empty value
disables the block. The hook does not create the file — it
surfaces only when the operator opted in by creating it.
3. A one-line queue reminder — `eeco: N items awaiting a decision`
when the queue is non-empty.
4. **(opt-in)** Pinned memory bodies — the full body of every
`pin: true` memory fact under `<workspace>/memory/`, separated by
Markdown dividers. Off by default, so the hook output is
byte-identical until enabled. Opt in via
`session_start_pinned_bodies=true` in
`config.local` (workspace default) or the `--with-pinned-bodies`
flag on `eeco hooks session-emit` (one invocation only). Useful
when an AI assistant treats the hook output as a system-reminder
so a pinned policy memory (for example a no-AI-attribution rule)
lands in the model's context at session start.
The session-start hook ships with two brand-free delivery channels;
use either alone or compose both:
- **JSON settings file** — an exact-match-removable entry in an AI
CLI's user-global JSON settings file (the Claude Code shape). Set
the file location with `session_settings_path` in `config.local`
or the `EECO_SESSION_SETTINGS` environment variable. The file is
backed up (inside the workspace) before the edit and re-validated
after; an invalid result is rolled back.
- **Marker block in a text/markdown file** — eeco maintains a
delimited block of the same content the JSON channel renders, in
one or more files the assistant already reads at session start
(`CLAUDE.md` for Claude Code, `GEMINI.md` for Gemini CLI,
`AGENTS.md` for OpenAI Codex, `.cursorrules` for Cursor,
`~/.config/<tool>/...`, etc.). Declare one path per `session_files=` line in
`config.local`; each value is either repo-relative (held inside
the repo) or absolute. The block is fenced by HTML comments —
`<!-- eeco:session:start -->` / `<!-- eeco:session:end -->` —
and replaces in place on re-`on` or `refresh`. The block content
is whatever `eeco hooks session-emit` prints. eeco never edits
bytes outside the marker pair, and a block whose contents have
been hand-edited since install is left untouched on `off`. Files
eeco itself created are removed when `off` would leave them
empty; files that already existed are restored byte-identical to
their pre-`on` state when the block is at end-of-file.
Neither channel is configured by default: `eeco hooks session-start
on` reports "not configured" until at least one of the two is set.
When the file channel is wired, run `eeco hooks session-start refresh`
to re-render the block — the JSON channel does not need it (it
carries a command eeco runs at session start, so it is always
fresh).
- **commit-msg** — a local `.git/hooks/commit-msg` (untracked,
repo-scoped) that rejects commit messages carrying an AI-attribution
trailer. The policy is universal and lives inside the eeco binary:
the on-disk script is a one-line wrapper that execs
`eeco hooks commit-msg-check "$1"`, so the pattern set refreshes with
every `brew upgrade eeco`. Matches the trailer-anchored forms
(`Co-Authored-By:` lines naming `claude`, `anthropic`, or
`noreply@anthropic`) plus the Claude Code robot-emoji (`U+1F916`)
"Generated-with" signature; deliberately stricter than the broad
repo-tree scan so a
docs commit that *discusses* the policy ("remove Co-Authored-By
trailer") still passes. Exit code 1 prints the matched line and names
`--no-verify` explicitly as the bypass; conscious overrides remain
available without surprise. Install / removal behave exactly as for
pre-commit and post-merge: foreign hooks are never modified. After a
`brew upgrade eeco` rewires the eeco binary path, run `eeco hooks
commit-msg refresh` to rewrite the on-disk wrapper without an
off-then-on toggle.
- **commit-guard** — the harness-layer companion to commit-msg. Where
the git `commit-msg` hook guards eeco's **own** repository, the
commit-guard installs a **Claude Code PreToolUse hook** that denies any
pending `git commit` carrying AI attribution — in **any** repository the
assistant drives, and not bypassable by `git commit --no-verify` (the
hook sits above git). It runs the **same** attribution detector
`leak-guard` uses, so the patterns stay one source of truth (the foreign
repo's own `attribution_pattern` entries are honoured when its config
loads). Default **off**; enable with `eeco hooks commit-guard on`, which
installs a `PreToolUse` group into the JSON settings file named by
`session_settings_path` (or `EECO_SESSION_SETTINGS`) — the same file the
session-start JSON channel uses, backed up and re-validated the same way;
it reports "not configured" when neither is set. The runner detects a
real `git commit` token-by-token (so `git status` and `echo "git commit"`
never fire), scans the assembled message (`-m`/`-F`/`COMMIT_EDITMSG`),
the staged diff, and the raw command, and **denies only on a positive
finding** — any parse or infrastructure uncertainty degrades **open**
(the commit proceeds), so a session is never wedged; the git `pre-commit`
/ `commit-msg` hooks and CI remain the hard gates. `off` removes only
eeco's group (foreign `PreToolUse` groups are preserved); after a
`brew upgrade eeco` run `eeco hooks commit-guard refresh` to rewrite the
binary path.
Every action is recorded in `<workspace>/state/hooks.json` so it can
be cleanly undone. The git-hook scripts and the JSON-settings writes —
the session-start and commit-guard entries plus any `session_files`
blocks — are the only touches eeco ever makes outside the gitignored
workspace.
## 9a. Gates — composed read-only policy checks
`eeco gates check-attribution` is a CI-facing companion to the
`commit-msg` hook (§9). The hook blocks at the source on a developer
machine; the gate is the CI backstop that catches a leak when a
developer bypassed the hook (`--no-verify`) or never installed it. Both
share the same trailer-anchored pattern set so prose discussing the
policy never false-fires.
Scope:
- **Tracked-file scan** — broad: delegates to the same detector
`comment-hygiene` uses (line-anchored `Co-Authored-By:`, the
`Generated with/by <ai-ish>` co-marketing line, and the robot-emoji
Generated signature). Enumerates the file list from `git ls-files`
and filters to a built-in text-extension allowlist (`.md`, `.sh`,
`.go`, `.zig`, `.S`, `.inc`, `.zon`, `.yml`, `.yaml`, `.txt`, `.ld`,
`.json`, `.toml`). Hits print as `path:line: excerpt`.
- **Commit-body scan** — strict trailer-anchored: `Co-Authored-By:`
lines naming `claude`, `anthropic`, or `noreply@anthropic`, plus the
robot-emoji "Generated" signature. Default range is
`origin/main..HEAD`; falls back to `HEAD~10..HEAD` with a stderr
notice when `origin/main` is unresolvable (fresh clone, detached
HEAD, initial commit). Hits print as `commit <short-sha>: line N:
excerpt`.
Flags (all optional):
| Flag | Effect |
| --- | --- |
| `--paths "<a b c>"` | override the tracked-files filter; pass an explicit space-separated list. |
| `--commits N` | force `HEAD~N..HEAD` instead of the default range. |
| `--no-commits` | skip the commit-body scan (file-only). |
| `--no-files` | skip the file scan (commits-only). |
| `--exclude <path>` | repeatable; repo-relative path to skip during the file scan. |
Exit codes: `0` clean, `1` on any hit (sets stderr with one line per
finding plus a `N finding(s)` summary), `2` on usage error. The CI
invocation pattern is one step before `git push`-eligible builds:
```yaml
- name: ai-attribution gate
run: eeco gates check-attribution
```
Use `actions/checkout@v5` with `fetch-depth: 0` so the commit-body
range resolves; default `1` makes `origin/main..HEAD` empty and the
fallback notice triggers.
## 10. Safety guarantees
- eeco writes only inside the repo's gitignored workspace; a path guard
enforces this and refuses `..` traversal.
- It never commits or pushes your project's tracked tree. The optional
private workspace-history repo (see §11a) commits only inside the
gitignored workspace, locally, and is never pushed.
- No AI-attribution string is ever written to a tracked file.
- Everything it does is invoked by you or queued for you.
## 11. Removing eeco from a project
`eeco uninstall` writes a single `eeco-handoff.md` at the repository
root summarising what eeco knew about the project, then prints the
git command to remove the workspace by hand. It never deletes anything
itself, so nothing is lost if you change your mind.
```
eeco uninstall # bare handoff (just paths + removal command)
eeco uninstall --scope facts # + memory facts
eeco uninstall --scope queue # + open queue items
eeco uninstall --scope everything # + facts + queue + scaffolded workflows + hooks
```
The handoff file is written at the repository root, alongside your
tracked tree; the file itself is not gitignored, so you choose whether
to keep, commit, or delete it. The workspace is gitignored, so a single
`git rm -rf .eeco` (substitute your workspace name) is the only on-disk
removal needed once the handoff has been reviewed. Turn opt-in hooks
off first (`eeco hooks pre-commit off`, `eeco hooks session-start off`)
so the reversibility ledger can do its work before the workspace goes.
If the private workspace-history repo exists (§11a), `eeco uninstall`
offers to de-init it — removing only its `.git`, never your data — after
a confirmation prompt. Pass `--yes` to skip the prompt, or decline to
keep the history and remove it yourself later. Your workspace data files
are never deleted by `eeco uninstall`.
## 11a. Workspace history (the private logbook)
At `eeco init`, eeco stands up a **private, local git repository inside
its own gitignored workspace directory** (`<username>/`) to version its
knowledge layer — memory facts, the queue, decisions, manifests — over
time. This repository has **no remote and is never pushed**, and it
**never touches your project's tracked tree**: it records only what eeco
already writes inside its own gitignored workspace, so it is invisible to
your project's git.
The `workspace_history` config key controls it:
- `manual` (default) — the repo exists; eeco commits only when you run
`eeco history snapshot`.
- `auto` — as `manual`, plus eeco commits automatically after each
mutating verb (`add fact`/`note`/`task`, `gc`, `go --write`, `run`,
`new`, …); still local-only, no remote.
- `off` — no private repo at all.
```
eeco init # creates the private repo (manual)
eeco init --no-track # skip it for this run
eeco history # show the recent commit log
eeco add note "decided X" # mutate the workspace
eeco history snapshot # commit the current state
eeco history snapshot -m "milestone" # …with a message
eeco history compact --dry-run # preview: how many commits would collapse
eeco history compact # squash the whole log into one (confirms first)
eeco history compact --yes # …without the prompt
```
Under `auto` the log grows a commit per mutating verb, so it can get long.
`eeco history compact` squashes the **entire** history into a single commit
whose contents are the current workspace state — the git pendant to the
docs-compaction protocol. Your workspace files are never touched (only the
commit log is collapsed), and the old commits stay reachable through the git
reflog, so the squash is recoverable; eeco runs no `git gc`. It is manual
only — there is no auto-trigger — and it confirms before rewriting unless you
pass `--yes`; `--dry-run` reports the count and changes nothing.
The repo is created opt-out (`--no-track` for one run, or
`workspace_history=off` durably) and removed at `eeco uninstall` (§11).
Inside it eeco has a free hand — commit, and later squash — because
nothing there is shared or tracked by the host repo.
## 12. Filing bug reports
`eeco report-bug` captures friction with eeco itself into a structured
Markdown record and prints a pre-filled GitHub Issues URL so the
report can reach the project in one click. The command works for
everyone: anyone who installed eeco can file, with or without an
initialised workspace.
```
eeco report-bug --note "leak-guard tripped on a doc-only commit" \
--cmd "eeco run leak-guard"
```
Both flags are optional. The record contains the eeco version, a
whitelisted environment snapshot (OS, arch, Go version, `SHELL`,
`TERM`, and any `EECO_*` variables — never the full env), the note,
and the invoking command.
Where the record lands:
- **Inside an initialised workspace.** Records go to
`<workspace>/bug-reports/`; override the directory with the
`bug_report_dir` key in `<workspace>/config.local` (workspace-
relative — absolute paths and `..` traversal are rejected at parse
time).
- **Anywhere else** (no `eeco init` yet, or not inside a git repo at
all). Records go to `~/.eeco/bug-reports/` so a fresh-install user
can file a report against eeco itself without having to set anything
up first. The repo's tracked tree is never touched.
Sharing the record:
`eeco report-bug` prints a pre-filled URL like
<https://github.com/ajhahnde/eeco/issues/new?title=...&body=...>.
Opening it in a browser fills the issue form with the record body
already pasted — clicking Submit files it to the project. The local
file is your durable copy; you can refine it before clicking, or
attach it by hand if the URL ends up too long for the browser. Nothing
is sent automatically — the upstream side of the loop is always a
human click.
Pass `--submit` to have eeco open that URL for you (the `$BROWSER`
command if set, otherwise the platform default opener). It is opt-in
and changes nothing else: the form opens pre-filled, you still review
and click Submit, and the command still reports that nothing was sent
automatically — opening a pre-filled form is not the same as sending.
If no browser can be opened, the command prints a short note alongside
the URL and still exits 0; `--submit` is a convenience, never a failure
point.
## 13. Briefing an AI assistant
`eeco go` prints a compact, deterministic project brief written for an
AI assistant. It is the fast, cheap way to bring any assistant — not
only the strongest — up to speed on a project: one command returns a
map of the project instead of a scan across many files.
```
eeco go
```
The brief has six sections:
- **Working with eeco** — what eeco is, the read-only commands an
assistant can run, and the rule that decisions go to eeco's queue
rather than silent edits.
- **Project** — the detected profile, the parse/build gate, and the
top-level layout (taken from the tracked set, so build artifacts and
the workspace stay out).
- **Where to look** — every memory fact that records a file pointer,
as a topic → file map. This is the assistant's shortcut to the right
file.
- **What eeco knows** — the load-bearing memory facts (project,
feedback, user).
- **Open decisions** — the items waiting in eeco's queue.
- **Recording back** — how the assistant keeps the brief useful:
record durable facts and route decisions through the queue.
`eeco go` calls no AI provider — the brief is assembled entirely from
what eeco already tracks, so it costs no provider budget and is safe to
run any number of times. The output is deterministic: the same
workspace state produces the same brief.
The brief is Markdown by default — written for an assistant to read.
The `--json` flag emits the same project state as a JSON object instead,
the machine-readable form for a downstream agent or script that parses
the brief rather than reading it:
```
eeco go --json
```
The JSON object has nine top-level keys — `project`, `profile`, `gate`
(an argv array), `top_level`, `initialized`, `workflows`,
`where_to_look` (`description`/`ref` objects), `knowledge`
(`name`/`description`/`type` objects), and `open_decisions`. Every array
is always present, an empty list rather than null, so a consumer can
iterate without a nil check. These top-level keys are part of eeco's
frozen public surface. `--json` prints to stdout and cannot be combined
with `--write`.
By default the brief is printed to stdout and nothing is written. The
`--write` flag saves it instead to a stable file inside the workspace —
`.eeco/context.md` by default — so an assistant's instructions file can
point at a fixed path rather than re-running the command each session:
```
eeco go --write # writes .eeco/context.md
```
The destination is workspace-relative; override it with the
`context_path` key in `<workspace>/config.local` (absolute paths and
`..` traversal are rejected). `--write` requires an initialised
workspace, and the file it writes is byte-identical to the stdout
brief. The file lives in the gitignored workspace, so it is never
committed; re-run `eeco go --write` whenever you want it refreshed.
For an assistant on a tight context budget the `--brief` flag reshapes
the output: the "Working with eeco" preamble and "Recording back" outro
drop out, and the per-section lists (`Where to look`, `What eeco knows`,
`Open decisions`) are capped at five entries each. `--brief` composes
with `--json` and `--write`, so every delivery axis gets the size knob:
```
eeco go --brief # smaller Markdown brief
eeco go --brief --json # smaller JSON brief
eeco go --brief --write # writes the smaller variant
```
In `--brief --json` the nine frozen top-level keys are preserved —
arrays may simply be shorter, never absent or null — so a consumer that
parses the JSON brief sees the same shape, never an unexpected key
disappearance.
To keep the persisted `.eeco/context.md` under a known size, set the
`context_budget` key in `<workspace>/config.local` to a maximum **byte**
count (roughly four bytes per token is a useful rule of thumb):
```
context_budget=4000 # cap the --write brief at 4000 bytes
```
When `context_budget` is set, `eeco go --write` trims the saved brief
down a deterministic ladder until it fits: it tries the full brief
first, then the smaller `--brief` form, then that form with the
per-section lists capped progressively shorter (5, 4, 3, 2, 1, 0). It
writes the largest tier that fits and reports which one — for example
`wrote .eeco/context.md (brief, 1840/4000 bytes)`. A `context_budget`
of `0` (the default) means no cap. The budget applies only to
`--write`; bare `eeco go` and `eeco go --brief` printed to stdout are
unchanged. If the budget is so small that even the smallest tier
overruns it, eeco still writes that smallest brief and prints a note —
a brief slightly over budget beats no brief at all.
To surface the recent free-form notes from `eeco add note` (§17)
inside the brief, set the `brief_include_notes` key in
`<workspace>/config.local`:
```
brief_include_notes=true
```
When enabled, `eeco go` adds a **Recent notes** section between
**What eeco knows** and **Open decisions**, listing the five newest
notes from `<workspace>/notes/` with their UTC timestamp and first
line. The section appears in Markdown output only — `eeco go --json`
still emits exactly the nine frozen top-level keys
([`PUBLIC_API.md`](PUBLIC_API.md) §"CLI commands and flags"), since
notes belong to the assistant-prose channel rather than a parsed
brief. The key accepts the standard `strconv.ParseBool` set —
`true`/`false`, `1`/`0`, `t`/`f` (in either case); a typo like
`yes`/`no` is rejected at parse time so the operator notices.
The default is `false`, so bare `eeco go` omits the section; the key
composes with `--brief`, `--write`, and `--copy` exactly like the other
sections.
To wire the brief into a session, either point your assistant's
instructions file at `.eeco/context.md`, or — if you use the bundled
session-start hook (§9) — add `session_start_docs=.eeco/context.md` to
`config.local` so the hook lists it in the reading routine
automatically. Any assistant that can read a file or run a shell
command can consume the brief; eeco assumes no particular tool.
For a chat-only assistant — Gemini web, a Gem, AI Studio, ChatGPT,
or any LLM behind a chat box — the `--copy` flag writes the brief to
the host operating system's clipboard so you can paste it in one
shot:
```
eeco go --copy # full Markdown brief on the clipboard
eeco go --copy --brief # smaller brief for a tight context window
eeco go --copy --json # structured payload for a chat that parses JSON
```
`--copy` works on macOS (pbcopy), Windows (clip.exe), and Linux
(wl-copy on Wayland, xclip or xsel on X11). When none of those tools
is on `PATH` eeco exits 2 with an install hint — the same
"blocked: required tool missing" contract every other workflow
follows. `--copy` does not compose with `--write` (one delivery axis
per invocation); it does compose with `--brief` and `--json`.
Re-run `eeco go --copy` whenever the project state has moved on.
### Measuring the brief — `--metrics`
The `--metrics` flag adds a one-line readout to **stderr** after the
brief, so you can see what the brief actually saved:
```
eeco go --metrics
eeco go: assembled in 412µs · brief 1840 bytes (≈460 tokens) · distilled ≈980 tokens of project knowledge into ≈460 (≈53% smaller)
```
It reports three things: how long the brief took to assemble (real
wall-clock), the brief's own size, and how much of the project's
knowledge layer — the on-disk memory facts, the queue, and any notes —
the brief distils. Byte counts are real measurements; token figures are
**estimates** via the ~4-bytes-per-token heuristic and are marked with a
leading `≈`, never presented as exact counts (eeco ships no tokenizer,
by design). The compression percentage is grounded in real on-disk bytes
and is never reported below `0%`.
The readout goes to stderr, so it never mixes into the brief itself:
`eeco go --metrics > brief.md` writes a clean brief to the file and
prints the readout to the terminal. It always describes the **canonical
Markdown brief** — even alongside `--json` or `--copy`, where it measures
the Markdown brief the assistant would read — and it never appears in
`--json` stdout, so the nine frozen top-level keys
([`PUBLIC_API.md`](PUBLIC_API.md) §"CLI commands and flags") are
untouched. With `--write` under a `context_budget` the readout still
describes that canonical brief, not the budget-trimmed file that was
persisted — the metric measures how much eeco's brief compresses the
knowledge layer, a stable property, rather than one write's fitted size.
`--metrics` is off by default and composes with every other flag.
### Briefing assistants other than Claude Code
eeco's delivery channels are brand-free — point them at whichever
assistant you use:
- **Gemini CLI** (Google's terminal-native assistant) reads
`GEMINI.md` at session start. Set `session_files=GEMINI.md` in
`<workspace>/config.local`, then `eeco hooks session-start on`.
Same recipe as Claude Code with `CLAUDE.md`.
- **OpenAI Codex** reads `AGENTS.md`. Set `session_files=AGENTS.md`.
- **Cursor** reads `.cursorrules`. Set `session_files=.cursorrules`.
- Any other assistant whose instructions file lives at a known
path: add that path as a `session_files=` line and eeco will keep
a marker block in it (§9).
For an assistant with no terminal and no filesystem access (Gemini
web, Gems, AI Studio, ChatGPT, any chat box), `eeco go --copy` is
the one-shot bridge — render the brief into the clipboard and paste
it into the chat. Re-run when the project state moves on.
### Asking a targeted question
Where `eeco go` is a one-shot overview, `eeco ask` answers a *specific*
question with a ranked set of pointers — the interactive counterpart to
the static brief:
```
eeco ask "where is the project brief rendered"
```
The answer has two sections: **Memory** — the memory facts whose name,
description, or body overlaps the question, shown as a topic → file map
when the fact carries a `ref`; and **Code** — the best-matching lines in
the repository's tracked files, each as a `path:line` reference with the
matching line. Results are ranked by how many distinct question words
they match, with stable tie-breaks, so the same question over the same
tree always gives the same answer.
```
eeco ask --limit 3 "how does version-sync detect drift" # cap code hits
eeco ask --json "queue" # machine-readable
```
`--limit N` caps the number of code locations (default 10); every
matching memory fact is always shown. `--json` emits an object with
three frozen top-level keys — `question`, `memory`, `code` — both arrays
always present, never null. Like `eeco go`, `eeco ask` calls no AI
provider (relevance is a word-overlap score over what eeco already
tracks), reads anywhere in the repo but writes nothing, and works in any
git repository — the memory section is simply empty when the workspace
is not initialised. Binary and very large files are skipped.
## 14. Applying an update
`eeco update` checks for a newer release and prints the gap, but does
not download anything. The opt-in `--apply` flag turns it into a
verified self-replace: download the platform release archive, verify
its sha256 against the published `SHA256SUMS`, verify the cosign
signature on `SHA256SUMS`, verify the GitHub build-provenance
attestation on the archive, then atomically swap the running binary.
```
eeco update --apply
```
Verification gates. All three must pass before any swap. Any one
failure leaves the running binary untouched and leaves the downloaded
files in `<workspace>/state/update-<tag>/` for inspection.
- `cosign verify-blob` against the keyless certificate identity baked
into the release workflow (`refs/tags/v…` on this repo).
- `sha256` of the downloaded archive equal to its line in `SHA256SUMS`.
- `gh attestation verify <archive> --repo ajhahnde/eeco`.
Requirements. `cosign` and `gh` must be on `PATH`. The command must run
inside an initialised eeco workspace — `<workspace>/state/` holds the
staging directory, the backup, and the swap ledger
(`<workspace>/state/binary.json`). The running binary must be on a
released tag (`v…`); an unpinned `0.0.0-dev` build refuses with a
reinstall hint.
Package-manager handoff. If the resolved running-binary path lives
under a known package-manager root (Homebrew, Scoop, Linuxbrew),
`--apply` refuses and points at the package manager's upgrade verb
(`brew upgrade eeco` or `scoop update eeco`). Self-replace would fight
the package manager on its next sync; the manager owns its prefix.
Rollback. The previous binary is preserved at
`<workspace>/state/update-<tag>/eeco.bak` (or `eeco.exe.bak` on
Windows). To revert by hand:
```
mv <workspace>/state/update-<tag>/eeco.bak <running-path>
```
The exact `<running-path>` is recorded in
`<workspace>/state/binary.json` under `running_path`.
What stays the same. Bare `eeco update` (no flag) is unchanged: it
still lists the gap and exits cleanly, without touching any binary.
Constraint 6 holds — eeco only swaps when the operator passed
`--apply`.
## 15. Reading the in-binary guide
`eeco guide` pages the user manual built into the binary — a verbatim
mirror of this file (`docs/USAGE.md`) at the tag the binary was built
from. Every install route ships the manual offline; no network access
needed, no doc directory to clone.
```
eeco guide
```
The pager selection is `$PAGER` first (split on whitespace; the first
token must be on `PATH`), then `less -R` if `less` is available, then
a plain stdout dump as the final fallback. When stdout is not a
terminal (piped or redirected) the command always dumps the manual
plainly and exits 0 — the same one-screen path the TUI uses for
`eeco` with no arguments.
At a terminal the manual is prettified for reading: Markdown tables
become box-drawing grids, headings are styled with a rule, and inline
`code` / **bold** / links are rendered rather than shown as raw markup.
`NO_COLOR` drops the colour but keeps the layout. Piped or redirected
output stays raw Markdown, byte-identical to `docs/USAGE.md`, so a
script that captures the guide (`eeco guide > usage.md`) gets the
source unchanged.
Quick recipes.
```
eeco guide # page interactively
eeco guide | less # explicit pager pipeline
eeco guide > usage.md # capture the embedded snapshot
PAGER='less +/^## 4' eeco guide # jump straight to the commands table
```
The embedded snapshot is locked to the binary version. Pair it with
`eeco update --apply` to keep the manual in sync with the running
release. The tracked-tree `docs/USAGE.md` and the in-binary guide
stay byte-identical in CI — a release commit that forgets to re-sync
the mirror fails the build.
## 16. Authoring tracked docs
`eeco docs new <target>` seeds a tracked-tree documentation file at
the repository root. Two targets ship today:
| Target | File written | Purpose |
| -------- | ------------- | --------------------------------------------- |
| `vision` | `VISION.md` | seed a short manifesto: what this project is for, what it gives you, what it deliberately is not, and where the roadmap lives |
| `readme` | `README.md` | seed a new-reader-friendly README: tagline, what the project does, a quick-start recipe, and a short "how it works" |
The scaffolder is deterministic — no AI call, no provider spend — and
project-shape-aware: the rendered "See also" only links to companion
docs that already exist (`README.md`, `docs/USAGE.md`,
`docs/ARCHITECTURE.md`). The `readme` target's "See also" never
self-links, so the link set narrows to `docs/USAGE.md` and
`docs/ARCHITECTURE.md`.
```
eeco docs new vision # write VISION.md (refuse if it exists)
eeco docs new --overwrite vision # replace an existing VISION.md
eeco docs new readme # write README.md (refuse if it exists)
eeco docs new --overwrite readme # replace an existing README.md
```
Refuse-on-existing is the default. Pass `--overwrite` to replace a
prior scaffold or hand-edited file. **`--overwrite` discards the
existing body** — use it deliberately, especially on a populated
`README.md`. eeco writes the file but never stages or commits it
(Constraint 6); review the result and run `git add <file> && git
commit` yourself.
The seed file carries a one-line HTML comment at the top marking it
as a scaffold and naming the eeco version it was generated against.
The comment is invisible in any rendered Markdown view and can be
deleted in the same edit you fill the body in.
`eeco docs refresh <target>` re-renders the project-state-derived
region of a previously-scaffolded doc — the same project-shape-aware
"See also" block `eeco docs new` rendered — leaving operator prose
outside the marker pair untouched. The region is delimited by a pair
of HTML-comment markers:
```
<!-- eeco:docs:start -->
… rendered content …
<!-- eeco:docs:end -->
```
Markers inside fenced code blocks are ignored, mirroring `eeco docs
compact`. Unmatched, nested, or out-of-order markers exit 1 with a
parse error naming the offending line; the file is not touched. A
scaffold with no markers is auto-initialised: the freshly
rendered block is appended at EOF, marker-wrapped, with a blank-line
separator. The operator removes the prior in-place block manually.
Where `--overwrite` on `eeco docs new` discards the entire body,
`refresh` is the non-destructive complement.
```
eeco docs refresh vision # re-render VISION.md's marker block
eeco docs refresh readme # re-render README.md's marker block
```
`eeco docs compact <path>` moves resolved regions of a long doc into
a sibling archive file so the working copy stays short, then leaves a
single-line pointer stub in place at each cut. A region is anything
between a pair of HTML-comment markers:
```
<!-- eeco:archive:start -->
… content to archive …
<!-- eeco:archive:end -->
```
Markers inside fenced code blocks are ignored, so a doc explaining
the markers (this very paragraph in the original `docs/USAGE.md`)
does not trigger a self-archive.
```
eeco docs compact roadmap_v1.x.md
eeco docs compact --dry-run roadmap_v1.x.md
eeco docs compact --archive history.md roadmap_v1.x.md
```
The default archive destination is `<basename>.archive<ext>` next to
the source — e.g. `roadmap_v1.x.md` → `roadmap_v1.x.archive.md`.
Override the destination with `--archive`; pass a `--dry-run` to
preview which line ranges would move without writing. Re-running
after a successful compact is an idempotent no-op (`no archive
markers found`); the archive is appended to on subsequent runs so a
long-lived doc can be compacted in waves.
Unmatched, nested, or out-of-order markers exit 1 with a parse error
naming the offending line; the archive is not touched. Both paths
must resolve inside the repository (absolute paths and `..` traversal
are rejected). eeco writes the source and the archive but never
stages or commits either (Constraint 6); review both files and `git
add` / commit when ready.
For a log that grows by prepending a new section each time — a session
journal, a changelog, a steering file — placing markers by hand every
round is fiddly. `--keep-last N` discovers the archivable regions by
heading instead. Pair it with `--heading <prefix>`, the heading-line
prefix that opens an archivable section:
```
eeco docs compact --keep-last 4 --heading "## Snapshot" RESUME.md
```
This keeps the **N most-recent** matching sections — newest meaning
topmost in the file — and archives everything older. The `#` run in
`--heading` fixes the section level: a section runs from its heading to
the next heading of the same or a higher level, so a live tail such as a
trailing `## Next session` or `## Pointers` block is never swallowed. A
deeper sub-heading inside a section does not split it, and (as in marker
mode) any heading inside a fenced code block is ignored. Adjacent
archived sections collapse into one pointer stub.
`--keep-last` and explicit markers are mutually exclusive: running
heading mode on a source that still carries a paired marker region exits
1 rather than mixing the two schemes. `--keep-last` requires `--heading`
and vice versa. `--dry-run` and `--archive` work the same in both modes;
re-running once only the kept window remains is an idempotent no-op.
## 17. Keeping notes
A note is a place to scribble — a half-formed idea, a reminder, a
thread to pick up later. It is deliberately neither a memory fact
(frontmatter-strict, matched to AI relevance) nor a queue item (the
append-only decision channel): routing scratch text into either of
those pollutes their contract. Notes get their own surface.
```
eeco add note "check round-robin fairness in queue promotion"
eeco show notes
```
`eeco add note "<text>"` saves the text verbatim to one plain Markdown
file under `<workspace>/notes/`, named `YYYY-MM-DD-HHMMSS-<slug>.md`
(the timestamp is UTC; the slug is derived from the text). The
directory is created on first use, so a fresh `eeco init` is not
required — being inside a git repository is enough. eeco writes only
inside the workspace (Constraint 1) and never stages or commits
(Constraint 6).
`eeco show notes` lists every note newest-first, one row per note:
the local date and time followed by the note's first line. An empty or
absent notes directory prints a friendly hint and exits 0.
This first slice is append + list only. To edit a note, open it in your
editor (`$EDITOR <workspace>/notes/<file>`); to remove one, delete the
file (`rm <workspace>/notes/<file>`).
---
[← Prev: Cockpit](COCKPIT.md) · [Next: Architecture →](ARCHITECTURE.md)
added internal/hooks/cockpitmachinery.go
@@ -0,0 +1,501 @@
package hooks
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/ajhahnde/eeco/internal/config"
)
// The cockpit machinery is the auto-firing deterministic layer the cockpit
// program (C4) emits as harness config. It lands in the per-project Claude
// settings file <UserDir>/.claude/settings.json — the FlashOS pattern, where
// the harness is launched from <username>/ — which is distinct from the
// machine-wide SessionSettingsPath the session-start and commit-guard channels
// edit. Token-identified groups never collide, so the two settings files (and
// the two PreToolUse guards) coexist.
//
// It manages four hook events as ONE reversible unit (one CockpitMachinery
// ledger record, one backup pointer):
// - PreToolUse(Bash): the git-write guard (deny an unauthorized commit/tag) — C4a.
// - SessionStart: orient + drift inject + sentinel clear (reuses session-emit).
// - Stop: a throttled handover nudge.
// - PostToolUse(Edit|Write|…): contract-watch (flag a cockpit-input edit).
//
// Reuses the same atomic-write / backup / validate / restore machinery as the
// commit-guard channel (hooks.go). Explicit opt-in, reversible: `eeco cockpit
// machinery on` installs every group, `off` removes only eeco's groups, and
// foreign groups + unknown keys are preserved. Each group carries a
// path-independent namespace token so removal is exact and survives a moved
// eeco binary.
const (
// cockpitMachineryToken is the PreToolUse git-write-guard marker (C4a).
// Distinct from commitGuardToken so the two PreToolUse guards are
// independently installable / removable.
cockpitMachineryToken = "hooks git-write-guard-check"
// cockpitSessionToken marks the machinery's SessionStart orient group. It
// reuses the session-emit runner; the per-project settings file keeps it
// distinct from the machine-wide session-start channel (a different file).
cockpitSessionToken = "hooks session-emit"
// stopNudgeToken marks the Stop handover-nudge group.
stopNudgeToken = "hooks stop-nudge-check"
// contractWatchToken marks the PostToolUse contract-watch group.
contractWatchToken = "hooks contract-watch-check"
// contractWatchMatcher is the tool matcher for the contract-watch group:
// the file-writing tools whose edits can touch a cockpit input.
contractWatchMatcher = "Edit|Write|MultiEdit|NotebookEdit"
)
// errCockpitMachineryUserDir is returned when the per-user dir is unknown, so
// there is no <UserDir>/.claude/settings.json to write. A clean, expected
// condition (not a failure): nothing is touched.
var errCockpitMachineryUserDir = fmt.Errorf(
"cockpit machinery not configured: no per-user directory resolved (run inside an initialized eeco workspace)")
// cockpitSettingsPath is the per-project Claude settings file the machinery
// edits: <UserDir>/.claude/settings.json.
func cockpitSettingsPath(cfg *config.Config) string {
return filepath.Join(cfg.UserDir, ".claude", "settings.json")
}
func gitWriteGuardCommand() string {
return fmt.Sprintf("%q %s", selfPath(), cockpitMachineryToken)
}
func cockpitSessionCommand() string {
return fmt.Sprintf("%q %s --if-initialized", selfPath(), cockpitSessionToken)
}
func stopNudgeCommand() string {
return fmt.Sprintf("%q %s", selfPath(), stopNudgeToken)
}
func contractWatchCommand() string {
return fmt.Sprintf("%q %s", selfPath(), contractWatchToken)
}
// machineryHook describes one auto-firing hook the cockpit machinery installs
// into <UserDir>/.claude/settings.json. Event is the Claude settings hook key;
// Token is the path-independent namespace marker carried in the command (so
// removal is exact and survives a moved binary); Matcher is the tool matcher
// for tool events ("" for SessionStart/Stop, which are not tool-scoped);
// Command builds the full command string from the current binary path; Desc is
// the human status label.
type machineryHook struct {
Event string
Token string
Matcher string
Command func() string
Desc string
}
// machineryHookSet returns the full set the machinery manages as one unit,
// recorded under the single CockpitMachinery ledger record. Order is the
// install + status report order. (A fresh slice each call: callers never mutate
// it, but the function shape mirrors the Default*Workflows pattern.)
func machineryHookSet() []machineryHook {
return []machineryHook{
{Event: "PreToolUse", Token: cockpitMachineryToken, Matcher: "Bash", Command: gitWriteGuardCommand,
Desc: "git-write guard (deny unauthorized commit/tag)"},
{Event: "SessionStart", Token: cockpitSessionToken, Matcher: "", Command: cockpitSessionCommand,
Desc: "orient + drift inject + sentinel clear"},
{Event: "Stop", Token: stopNudgeToken, Matcher: "", Command: stopNudgeCommand,
Desc: "handover nudge"},
{Event: "PostToolUse", Token: contractWatchToken, Matcher: contractWatchMatcher, Command: contractWatchCommand,
Desc: "contract-watch (flag cockpit-input edits)"},
}
}
// machineryGroup builds the settings group for one machinery hook. Tool events
// carry a matcher; SessionStart/Stop do not.
func machineryGroup(h machineryHook) map[string]any {
group := map[string]any{
"hooks": []any{
map[string]any{"type": "command", "command": h.Command()},
},
}
if h.Matcher != "" {
group["matcher"] = h.Matcher
}
return group
}
// eventGroups returns the group list under root.hooks[event], or nil.
func eventGroups(root map[string]any, event string) []any {
h, ok := root["hooks"].(map[string]any)
if !ok {
return nil
}
groups, _ := h[event].([]any)
return groups
}
// groupCarriesToken reports whether a settings group has a hook command
// containing token.
func groupCarriesToken(group any, token string) bool {
gm, ok := group.(map[string]any)
if !ok {
return false
}
hs, ok := gm["hooks"].([]any)
if !ok {
return false
}
for _, h := range hs {
hm, ok := h.(map[string]any)
if !ok {
continue
}
if cmd, ok := hm["command"].(string); ok && strings.Contains(cmd, token) {
return true
}
}
return false
}
// hookPresent reports whether root carries the group for one machinery hook.
func hookPresent(root map[string]any, h machineryHook) bool {
for _, g := range eventGroups(root, h.Event) {
if groupCarriesToken(g, h.Token) {
return true
}
}
return false
}
// machineryInstalled reports whether root contains ANY machinery group across
// the four events. One present group reads as on, so a partially-installed
// state still reports installed and Enable tops up the rest.
func machineryInstalled(root map[string]any) bool {
for _, h := range machineryHookSet() {
if hookPresent(root, h) {
return true
}
}
return false
}
// machineryFullyInstalled reports whether every machinery group is present, so
// Enable can no-op cleanly when nothing needs topping up.
func machineryFullyInstalled(root map[string]any) bool {
for _, h := range machineryHookSet() {
if !hookPresent(root, h) {
return false
}
}
return true
}
// addMachineryGroups appends every machinery group not already present, keyed
// by event. Returns true if it added at least one.
func addMachineryGroups(root map[string]any) bool {
h, ok := root["hooks"].(map[string]any)
if !ok {
h = map[string]any{}
root["hooks"] = h
}
added := false
for _, mh := range machineryHookSet() {
if hookPresent(root, mh) {
continue
}
groups, _ := h[mh.Event].([]any)
h[mh.Event] = append(groups, machineryGroup(mh))
added = true
}
return added
}
// removeMachineryGroups strips every machinery group across the four events,
// dropping an event key (and the hooks object) left empty, while preserving
// foreign groups and unknown keys.
func removeMachineryGroups(root map[string]any) {
h, ok := root["hooks"].(map[string]any)
if !ok {
return
}
for _, mh := range machineryHookSet() {
groups, ok := h[mh.Event].([]any)
if !ok {
continue
}
kept := make([]any, 0, len(groups))
for _, g := range groups {
if groupCarriesToken(g, mh.Token) {
continue
}
kept = append(kept, g)
}
if len(kept) == 0 {
delete(h, mh.Event)
} else {
h[mh.Event] = kept
}
}
if len(h) == 0 {
delete(root, "hooks")
}
}
// rewriteMachineryCommands rewrites any machinery command whose value differs
// from the current builder output (a moved binary path). Returns true if any
// command was changed.
func rewriteMachineryCommands(root map[string]any) bool {
changed := false
for _, mh := range machineryHookSet() {
want := mh.Command()
for _, g := range eventGroups(root, mh.Event) {
gm, ok := g.(map[string]any)
if !ok {
continue
}
hs, ok := gm["hooks"].([]any)
if !ok {
continue
}
for _, hk := range hs {
hm, ok := hk.(map[string]any)
if !ok {
continue
}
cmd, ok := hm["command"].(string)
if !ok || !strings.Contains(cmd, mh.Token) || cmd == want {
continue
}
hm["command"] = want
changed = true
}
}
}
return changed
}
// EnableCockpitMachinery installs every machinery hook group into
// <UserDir>/.claude/settings.json, creating the .claude dir if needed. It is a
// no-op when all groups are already present, tops up any missing group
// otherwise, refuses (touching nothing) when the settings file is present but
// not valid JSON, and restores the original on a post-edit validation failure.
func EnableCockpitMachinery(cfg *config.Config) (string, error) {
if cfg.UserDir == "" {
return "", errCockpitMachineryUserDir
}
path := cockpitSettingsPath(cfg)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return "", fmt.Errorf("create .claude dir: %w", err)
}
orig, existed, perm, rerr := readSettings(path)
if rerr != nil {
return "", rerr
}
root := map[string]any{}
if existed {
if jerr := json.Unmarshal(orig, &root); jerr != nil {
return "", fmt.Errorf("settings file %s is not valid JSON — left untouched", path)
}
}
if machineryFullyInstalled(root) {
return "cockpit machinery already enabled (" + path + ")", nil
}
backup, berr := backupOriginal(cfg, orig, existed)
if berr != nil {
return "", berr
}
addMachineryGroups(root)
if werr := writeJSONAtomic(path, root, perm); werr != nil {
return "", werr
}
if verr := validateJSON(path); verr != nil {
_ = restoreOriginal(path, orig, existed)
return "", fmt.Errorf("settings file failed validation after edit, restored: %w", verr)
}
l, lerr := loadLedger(cfg)
if lerr != nil {
return "", lerr
}
rec := record{
Installed: true,
Path: path,
Backup: backup,
At: time.Now().UTC().Format(time.RFC3339),
}
// Preserve the first-enable Backup across a top-up: a missing Backup on an
// existing record means enable created the file (Disable's created-by-us
// path relies on that signal), so a later top-up must not overwrite it.
if l.CockpitMachinery.Installed {
rec.Backup = l.CockpitMachinery.Backup
}
l.CockpitMachinery = rec
if err := saveLedger(cfg, l); err != nil {
return "", err
}
msg := "cockpit machinery enabled (" + path
if rec.Backup != "" {
msg += ", backup " + rec.Backup
}
return msg + ")", nil
}
// DisableCockpitMachinery removes eeco's machinery groups across all four
// events, preserving foreign groups and unknown keys. It is a no-op when not
// installed, and restores the original on a post-edit validation failure.
func DisableCockpitMachinery(cfg *config.Config) (string, error) {
if cfg.UserDir == "" {
return "", errCockpitMachineryUserDir
}
path := cockpitSettingsPath(cfg)
l, lerr := loadLedger(cfg)
if lerr != nil {
return "", lerr
}
orig, existed, perm, rerr := readSettings(path)
if rerr != nil {
return "", rerr
}
notEnabled := func() (string, error) {
l.CockpitMachinery = record{}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
return "cockpit machinery not enabled", nil
}
if !existed {
return notEnabled()
}
root := map[string]any{}
if jerr := json.Unmarshal(orig, &root); jerr != nil {
return "", fmt.Errorf("settings file %s is not valid JSON — left untouched", path)
}
if !machineryInstalled(root) {
return notEnabled()
}
backup, berr := backupOriginal(cfg, orig, existed)
if berr != nil {
return "", berr
}
// A missing Backup on the install record means enable found no pre-existing
// settings file — eeco created it. The cockpit settings file is eeco-owned
// and per-project (unlike the shared machine-wide commit-guard channel), so
// when our groups were its only content, restore the original absent state
// byte-for-byte rather than leaving a {} shell.
removeMachineryGroups(root)
createdByUs := l.CockpitMachinery.Installed && l.CockpitMachinery.Backup == ""
if createdByUs && len(root) == 0 {
if rerr := os.Remove(path); rerr != nil && !os.IsNotExist(rerr) {
return "", fmt.Errorf("remove settings: %w", rerr)
}
} else {
if werr := writeJSONAtomic(path, root, perm); werr != nil {
return "", werr
}
if verr := validateJSON(path); verr != nil {
_ = restoreOriginal(path, orig, existed)
return "", fmt.Errorf("settings file failed validation after edit, restored: %w", verr)
}
}
l.CockpitMachinery = record{}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
msg := "cockpit machinery disabled (" + path
if backup != "" {
msg += ", backup " + backup
}
return msg + ")", nil
}
// RefreshCockpitMachinery rewrites every machinery command whose embedded
// binary path no longer matches selfPath() — the self-heal for a `brew upgrade
// eeco` that moved the cellar directory. No-op when not installed or already
// current.
func RefreshCockpitMachinery(cfg *config.Config) (string, error) {
if cfg.UserDir == "" {
return "", errCockpitMachineryUserDir
}
path := cockpitSettingsPath(cfg)
orig, existed, perm, rerr := readSettings(path)
if rerr != nil {
return "", rerr
}
if !existed {
return "cockpit machinery not enabled", nil
}
root := map[string]any{}
if jerr := json.Unmarshal(orig, &root); jerr != nil {
return "", fmt.Errorf("settings file %s is not valid JSON — left untouched", path)
}
if !machineryInstalled(root) {
return "cockpit machinery not enabled", nil
}
if !rewriteMachineryCommands(root) {
return "cockpit machinery already current", nil
}
if _, berr := backupOriginal(cfg, orig, existed); berr != nil {
return "", berr
}
if werr := writeJSONAtomic(path, root, perm); werr != nil {
return "", werr
}
if verr := validateJSON(path); verr != nil {
_ = restoreOriginal(path, orig, existed)
return "", fmt.Errorf("settings file failed validation after edit, restored: %w", verr)
}
l, lerr := loadLedger(cfg)
if lerr != nil {
return "", lerr
}
l.CockpitMachinery.Installed = true
l.CockpitMachinery.Path = path
l.CockpitMachinery.At = time.Now().UTC().Format(time.RFC3339)
if err := saveLedger(cfg, l); err != nil {
return "", err
}
return "cockpit machinery refreshed (" + path + ")", nil
}
// CockpitMachineryStatus reports the machinery state, one line per managed hook
// event, reflecting on-disk reality so a hand-removed group reads as off. It
// changes nothing. Fidelity is honest: these runtime hooks fire only on Claude
// (the one target with real hook channels); advisory targets carry the policy
// as prose only — see cockpit.MachineryFidelity / the cmd layer's per-target
// fidelity print.
func CockpitMachineryStatus(cfg *config.Config) []string {
if cfg.UserDir == "" {
return []string{"cockpit-machinery: not configured (no per-user directory)"}
}
path := cockpitSettingsPath(cfg)
present := map[string]bool{}
on := false
orig, existed, _, err := readSettings(path)
if err == nil && existed {
root := map[string]any{}
if json.Unmarshal(orig, &root) == nil {
for _, h := range machineryHookSet() {
if hookPresent(root, h) {
present[h.Event] = true
on = true
}
}
}
}
state := "off"
if on {
state = "on (" + path + ")"
}
lines := []string{
"cockpit-machinery: " + state + " (claude — enforced; other targets advisory prose only)",
}
for _, h := range machineryHookSet() {
mark := "off"
if present[h.Event] {
mark = "on"
}
lines = append(lines, fmt.Sprintf(" %s: %s — %s", h.Event, mark, h.Desc))
}
return lines
}
added internal/hooks/cockpitmachinery_test.go
@@ -0,0 +1,215 @@
package hooks
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/ajhahnde/eeco/internal/config"
)
// newMachineryCfg builds a config with a per-user dir (so the machinery has
// a <UserDir>/.claude/settings.json to write) and a workspace for the
// ledger. The .claude dir is intentionally NOT pre-created, exercising the
// MkdirAll-on-enable path.
func newMachineryCfg(t *testing.T) *config.Config {
t.Helper()
root := t.TempDir()
userDir := filepath.Join(root, "tester")
ws := filepath.Join(userDir, ".eeco")
if err := os.MkdirAll(filepath.Join(ws, "state"), 0o755); err != nil {
t.Fatal(err)
}
return &config.Config{
RepoRoot: root,
UserDir: userDir,
WorkspaceName: ".eeco",
Workspace: ws,
}
}
func TestCockpitMachinery_EnableInstallsGuardGroup(t *testing.T) {
cfg := newMachineryCfg(t)
if _, err := EnableCockpitMachinery(cfg); err != nil {
t.Fatalf("EnableCockpitMachinery: %v", err)
}
path := cockpitSettingsPath(cfg)
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("settings.json not written: %v", err)
}
root := map[string]any{}
if err := json.Unmarshal(b, &root); err != nil {
t.Fatalf("settings.json not valid JSON: %v", err)
}
if !machineryFullyInstalled(root) {
t.Errorf("not all machinery groups present after enable:\n%s", b)
}
// All four event groups land (PreToolUse / SessionStart / Stop / PostToolUse).
for _, ev := range []string{"PreToolUse", "SessionStart", "Stop", "PostToolUse"} {
if len(eventGroups(root, ev)) == 0 {
t.Errorf("event %s group missing after enable", ev)
}
}
// Ledger records the install.
l, _ := loadLedger(cfg)
if !l.CockpitMachinery.Installed {
t.Error("ledger CockpitMachinery.Installed = false after enable")
}
}
func TestCockpitMachinery_EnableIdempotent(t *testing.T) {
cfg := newMachineryCfg(t)
if _, err := EnableCockpitMachinery(cfg); err != nil {
t.Fatal(err)
}
path := cockpitSettingsPath(cfg)
first, _ := os.ReadFile(path)
msg, err := EnableCockpitMachinery(cfg)
if err != nil {
t.Fatal(err)
}
second, _ := os.ReadFile(path)
if string(first) != string(second) {
t.Error("settings.json changed on a no-op re-enable")
}
if msg == "" {
t.Error("expected an already-enabled message")
}
}
func TestCockpitMachinery_OffRestoresAndPreservesForeign(t *testing.T) {
cfg := newMachineryCfg(t)
path := cockpitSettingsPath(cfg)
// A pre-existing settings file with a foreign PreToolUse group + an
// unknown key; `off` must restore it byte-for-byte after on/off.
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
original := `{
"model": "opus",
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "some-other-tool guard"
}
]
}
]
}
}
`
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
if _, err := EnableCockpitMachinery(cfg); err != nil {
t.Fatalf("enable: %v", err)
}
if _, err := DisableCockpitMachinery(cfg); err != nil {
t.Fatalf("disable: %v", err)
}
b, _ := os.ReadFile(path)
root := map[string]any{}
if err := json.Unmarshal(b, &root); err != nil {
t.Fatalf("settings.json not valid JSON after off: %v", err)
}
if machineryInstalled(root) {
t.Error("machinery group survived disable")
}
// The foreign group + unknown key must remain.
groups := eventGroups(root, "PreToolUse")
if len(groups) != 1 {
t.Fatalf("foreign PreToolUse group count = %d, want 1", len(groups))
}
if root["model"] != "opus" {
t.Errorf("unknown key not preserved: model=%v", root["model"])
}
}
func TestCockpitMachinery_OffRemovesFileItCreated(t *testing.T) {
cfg := newMachineryCfg(t)
path := cockpitSettingsPath(cfg)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("settings file should be absent before enable, stat err=%v", err)
}
if _, err := EnableCockpitMachinery(cfg); err != nil {
t.Fatalf("enable: %v", err)
}
if _, err := DisableCockpitMachinery(cfg); err != nil {
t.Fatalf("disable: %v", err)
}
// eeco created the file and our group was its only content → absent
// again (byte-for-byte restore), not a leftover {} shell.
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("settings file eeco created should be removed on off, stat err=%v", err)
}
}
func TestCockpitMachinery_DisableNotEnabled(t *testing.T) {
cfg := newMachineryCfg(t)
msg, err := DisableCockpitMachinery(cfg)
if err != nil {
t.Fatal(err)
}
if msg != "cockpit machinery not enabled" {
t.Errorf("disable-not-enabled msg = %q", msg)
}
}
func TestCockpitMachinery_StatusReflectsDisk(t *testing.T) {
cfg := newMachineryCfg(t)
lines := CockpitMachineryStatus(cfg)
if len(lines) == 0 || !strings.Contains(lines[0], "off") {
t.Errorf("status before enable = %v, want off", lines)
}
if _, err := EnableCockpitMachinery(cfg); err != nil {
t.Fatal(err)
}
lines = CockpitMachineryStatus(cfg)
if !strings.Contains(lines[0], "on") {
t.Errorf("status after enable = %v, want on", lines)
}
// One header line + one line per managed event, every event reading "on".
if len(lines) != 1+len(machineryHookSet()) {
t.Fatalf("status line count = %d, want %d", len(lines), 1+len(machineryHookSet()))
}
for _, ev := range []string{"PreToolUse", "SessionStart", "Stop", "PostToolUse"} {
found := false
for _, ln := range lines[1:] {
if strings.Contains(ln, ev) && strings.Contains(ln, "on") {
found = true
}
}
if !found {
t.Errorf("status missing an on-line for event %s:\n%v", ev, lines)
}
}
}
func TestCockpitMachinery_Refresh(t *testing.T) {
cfg := newMachineryCfg(t)
// Not enabled → a clean no-op message, nothing touched.
if msg, err := RefreshCockpitMachinery(cfg); err != nil || !strings.Contains(msg, "not enabled") {
t.Errorf("refresh before enable = (%q, %v), want a not-enabled no-op", msg, err)
}
if _, err := EnableCockpitMachinery(cfg); err != nil {
t.Fatal(err)
}
// Freshly enabled → commands already embed selfPath(), so refresh is a
// no-op "already current".
msg, err := RefreshCockpitMachinery(cfg)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(msg, "already current") {
t.Errorf("refresh of a freshly-enabled machinery = %q, want already-current", msg)
}
}
added internal/hooks/commitguard.go
@@ -0,0 +1,343 @@
package hooks
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/ajhahnde/eeco/internal/config"
)
// The commit-guard is eeco's harness-layer enforcement: a Claude Code
// PreToolUse hook that runs eeco's attribution detector against a pending
// `git commit` and denies it before it executes — in any repo, even one
// eeco does not manage, and not bypassable by `git commit --no-verify`
// (the hook sits above git). It installs a PreToolUse group into the same
// JSON settings file the session-start channel edits (the AI CLI's
// settings.json), reusing the same atomic-write / backup / validate /
// restore machinery as the session-start JSON path. The installed command
// invokes the hidden `eeco hooks commit-guard-check` runner.
//
// Default OFF, opt-in, reversible: the operator enables it deliberately
// (e.g. in a foreign repo driven through the harness), `off` removes only
// eeco's group, and foreign PreToolUse groups and unknown keys are
// preserved exactly.
// commitGuardCommand is the command string written into the PreToolUse
// group. It carries the namespace token so removal is exact and
// path-independent, the analog of sessionCommand().
func commitGuardCommand() string {
return fmt.Sprintf("%q %s", selfPath(), commitGuardToken)
}
// preToolGroup is the PreToolUse group eeco appends. Unlike a SessionStart
// group it carries a "matcher" (Bash), since the guard only inspects Bash
// tool calls; the runner then self-filters to a real `git commit`.
func preToolGroup() map[string]any {
return map[string]any{
"matcher": "Bash",
"hooks": []any{
map[string]any{
"type": "command",
"command": commitGuardCommand(),
},
},
}
}
// preToolGroups returns the PreToolUse group list, or nil.
func preToolGroups(root map[string]any) []any {
h, ok := root["hooks"].(map[string]any)
if !ok {
return nil
}
groups, _ := h["PreToolUse"].([]any)
return groups
}
// groupHasCommitGuardToken reports whether a PreToolUse group carries a
// hook command containing eeco's commit-guard namespace token.
func groupHasCommitGuardToken(group any) bool {
gm, ok := group.(map[string]any)
if !ok {
return false
}
hs, ok := gm["hooks"].([]any)
if !ok {
return false
}
for _, h := range hs {
hm, ok := h.(map[string]any)
if !ok {
continue
}
if cmd, ok := hm["command"].(string); ok && strings.Contains(cmd, commitGuardToken) {
return true
}
}
return false
}
// commitGuardInstalled reports whether root already contains eeco's
// commit-guard PreToolUse group (identified by the namespace token).
func commitGuardInstalled(root map[string]any) bool {
for _, g := range preToolGroups(root) {
if groupHasCommitGuardToken(g) {
return true
}
}
return false
}
func addPreToolGroup(root map[string]any) {
h, ok := root["hooks"].(map[string]any)
if !ok {
h = map[string]any{}
root["hooks"] = h
}
groups, _ := h["PreToolUse"].([]any)
h["PreToolUse"] = append(groups, preToolGroup())
}
func removePreToolGroups(root map[string]any) {
h, ok := root["hooks"].(map[string]any)
if !ok {
return
}
groups, ok := h["PreToolUse"].([]any)
if !ok {
return
}
kept := make([]any, 0, len(groups))
for _, g := range groups {
if groupHasCommitGuardToken(g) {
continue
}
kept = append(kept, g)
}
if len(kept) == 0 {
// Leave no empty PreToolUse array behind: drop the key, and the
// hooks object too if eeco's edit left it empty.
delete(h, "PreToolUse")
if len(h) == 0 {
delete(root, "hooks")
}
return
}
h["PreToolUse"] = kept
}
// rewriteCommitGuardCommand walks every PreToolUse group and replaces any
// command containing eeco's token whose value differs from want. Returns
// true if any command was changed.
func rewriteCommitGuardCommand(root map[string]any, want string) bool {
changed := false
for _, g := range preToolGroups(root) {
gm, ok := g.(map[string]any)
if !ok {
continue
}
hs, ok := gm["hooks"].([]any)
if !ok {
continue
}
for _, h := range hs {
hm, ok := h.(map[string]any)
if !ok {
continue
}
cmd, ok := hm["command"].(string)
if !ok || !strings.Contains(cmd, commitGuardToken) || cmd == want {
continue
}
hm["command"] = want
changed = true
}
}
return changed
}
// EnableCommitGuard installs the commit-guard PreToolUse group into the
// JSON settings file. It is a no-op when already installed, refuses (and
// touches nothing) when the settings file is present but not valid JSON,
// and restores the original on a post-edit validation failure. Returns
// ErrCommitGuardNotConfigured when no settings file is configured.
func EnableCommitGuard(cfg *config.Config) (string, error) {
if cfg.SessionSettingsPath == "" {
return "", ErrCommitGuardNotConfigured
}
path := cfg.SessionSettingsPath
orig, existed, perm, rerr := readSettings(path)
if rerr != nil {
return "", rerr
}
root := map[string]any{}
if existed {
if jerr := json.Unmarshal(orig, &root); jerr != nil {
return "", fmt.Errorf("settings file %s is not valid JSON — left untouched", path)
}
}
if commitGuardInstalled(root) {
return "commit-guard already enabled (" + path + ")", nil
}
backup, berr := backupOriginal(cfg, orig, existed)
if berr != nil {
return "", berr
}
addPreToolGroup(root)
if werr := writeJSONAtomic(path, root, perm); werr != nil {
return "", werr
}
if verr := validateJSON(path); verr != nil {
_ = restoreOriginal(path, orig, existed)
return "", fmt.Errorf("settings file failed validation after edit, restored: %w", verr)
}
l, lerr := loadLedger(cfg)
if lerr != nil {
return "", lerr
}
l.CommitGuard = record{
Installed: true,
Path: path,
Backup: backup,
At: time.Now().UTC().Format(time.RFC3339),
}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
msg := "commit-guard enabled (" + path
if backup != "" {
msg += ", backup " + backup
}
return msg + ")", nil
}
// DisableCommitGuard removes eeco's commit-guard PreToolUse group,
// preserving foreign PreToolUse groups and unknown keys. It is a no-op
// when not installed, and restores the original on a post-edit validation
// failure.
func DisableCommitGuard(cfg *config.Config) (string, error) {
if cfg.SessionSettingsPath == "" {
return "", ErrCommitGuardNotConfigured
}
path := cfg.SessionSettingsPath
l, lerr := loadLedger(cfg)
if lerr != nil {
return "", lerr
}
orig, existed, perm, rerr := readSettings(path)
if rerr != nil {
return "", rerr
}
notEnabled := func() (string, error) {
l.CommitGuard = record{}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
return "commit-guard not enabled", nil
}
if !existed {
return notEnabled()
}
root := map[string]any{}
if jerr := json.Unmarshal(orig, &root); jerr != nil {
return "", fmt.Errorf("settings file %s is not valid JSON — left untouched", path)
}
if !commitGuardInstalled(root) {
return notEnabled()
}
backup, berr := backupOriginal(cfg, orig, existed)
if berr != nil {
return "", berr
}
removePreToolGroups(root)
if werr := writeJSONAtomic(path, root, perm); werr != nil {
return "", werr
}
if verr := validateJSON(path); verr != nil {
_ = restoreOriginal(path, orig, existed)
return "", fmt.Errorf("settings file failed validation after edit, restored: %w", verr)
}
l.CommitGuard = record{}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
msg := "commit-guard disabled (" + path
if backup != "" {
msg += ", backup " + backup
}
return msg + ")", nil
}
// RefreshCommitGuard rewrites the eeco commit-guard command in the
// settings file when its embedded binary path no longer matches what
// selfPath() resolves today — the self-heal for a `brew upgrade eeco`
// that moved the cellar directory. No-op when the guard is not installed
// or the command is already current.
func RefreshCommitGuard(cfg *config.Config) (string, error) {
if cfg.SessionSettingsPath == "" {
return "", ErrCommitGuardNotConfigured
}
path := cfg.SessionSettingsPath
orig, existed, perm, rerr := readSettings(path)
if rerr != nil {
return "", rerr
}
if !existed {
return "commit-guard not enabled", nil
}
root := map[string]any{}
if jerr := json.Unmarshal(orig, &root); jerr != nil {
return "", fmt.Errorf("settings file %s is not valid JSON — left untouched", path)
}
if !commitGuardInstalled(root) {
return "commit-guard not enabled", nil
}
if !rewriteCommitGuardCommand(root, commitGuardCommand()) {
return "commit-guard already current", nil
}
if _, berr := backupOriginal(cfg, orig, existed); berr != nil {
return "", berr
}
if werr := writeJSONAtomic(path, root, perm); werr != nil {
return "", werr
}
if verr := validateJSON(path); verr != nil {
_ = restoreOriginal(path, orig, existed)
return "", fmt.Errorf("settings file failed validation after edit, restored: %w", verr)
}
l, lerr := loadLedger(cfg)
if lerr != nil {
return "", lerr
}
l.CommitGuard.Installed = true
l.CommitGuard.Path = path
l.CommitGuard.At = time.Now().UTC().Format(time.RFC3339)
if err := saveLedger(cfg, l); err != nil {
return "", err
}
return "commit-guard refreshed (" + path + ")", nil
}
// commitGuardStatus reports on/off for the commit-guard hook, reflecting
// on-disk reality so a hand-removed group reads as off. It changes
// nothing. "not configured" when no settings file is set.
func commitGuardStatus(cfg *config.Config) string {
if cfg.SessionSettingsPath == "" {
return "not configured"
}
orig, existed, _, err := readSettings(cfg.SessionSettingsPath)
if err != nil || !existed {
return "off"
}
root := map[string]any{}
if json.Unmarshal(orig, &root) != nil {
return "unknown (settings file is not valid JSON)"
}
if commitGuardInstalled(root) {
return "on"
}
return "off"
}
added internal/hooks/commitguard_test.go
@@ -0,0 +1,183 @@
package hooks
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCommitGuard_NotConfigured(t *testing.T) {
cfg := newCfg(t, "")
if _, err := EnableCommitGuard(cfg); err != ErrCommitGuardNotConfigured {
t.Errorf("enable err = %v, want ErrCommitGuardNotConfigured", err)
}
if _, err := DisableCommitGuard(cfg); err != ErrCommitGuardNotConfigured {
t.Errorf("disable err = %v, want ErrCommitGuardNotConfigured", err)
}
if _, err := RefreshCommitGuard(cfg); err != ErrCommitGuardNotConfigured {
t.Errorf("refresh err = %v, want ErrCommitGuardNotConfigured", err)
}
}
func TestCommitGuard_EnableWritesPreToolUseGroup(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
if _, err := EnableCommitGuard(cfg); err != nil {
t.Fatalf("EnableCommitGuard: %v", err)
}
b, err := os.ReadFile(sp)
if err != nil {
t.Fatal(err)
}
var root map[string]any
if err := json.Unmarshal(b, &root); err != nil {
t.Fatalf("settings not valid JSON: %v", err)
}
if !commitGuardInstalled(root) {
t.Errorf("commit-guard group not present:\n%s", b)
}
// The group carries the Bash matcher and the hidden runner command.
groups := preToolGroups(root)
if len(groups) != 1 {
t.Fatalf("want 1 PreToolUse group, got %d", len(groups))
}
gm := groups[0].(map[string]any)
if gm["matcher"] != "Bash" {
t.Errorf("matcher = %v, want Bash", gm["matcher"])
}
cmd := gm["hooks"].([]any)[0].(map[string]any)["command"].(string)
if !strings.Contains(cmd, commitGuardToken) {
t.Errorf("command missing token: %q", cmd)
}
// Idempotent.
msg, err := EnableCommitGuard(cfg)
if err != nil || !strings.Contains(msg, "already enabled") {
t.Errorf("re-enable: msg=%q err=%v", msg, err)
}
}
func TestCommitGuard_DisablePreservesForeignGroupsAndKeys(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
original := `{
"model": "x",
"hooks": {
"PreToolUse": [
{ "matcher": "Bash", "hooks": [ { "type": "command", "command": "other-tool guard" } ] }
],
"SessionStart": [
{ "hooks": [ { "type": "command", "command": "keep-session" } ] }
]
}
}`
if err := os.WriteFile(sp, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
if _, err := EnableCommitGuard(cfg); err != nil {
t.Fatalf("EnableCommitGuard: %v", err)
}
// Two PreToolUse groups now (foreign + eeco).
var root map[string]any
b, _ := os.ReadFile(sp)
if err := json.Unmarshal(b, &root); err != nil {
t.Fatal(err)
}
if len(preToolGroups(root)) != 2 {
t.Fatalf("want 2 PreToolUse groups after enable, got %d", len(preToolGroups(root)))
}
if _, err := DisableCommitGuard(cfg); err != nil {
t.Fatalf("DisableCommitGuard: %v", err)
}
b, _ = os.ReadFile(sp)
if err := json.Unmarshal(b, &root); err != nil {
t.Fatal(err)
}
if commitGuardInstalled(root) {
t.Error("eeco group still present after disable")
}
if root["model"] != "x" {
t.Errorf("foreign top-level key lost: %v", root["model"])
}
groups := preToolGroups(root)
if len(groups) != 1 {
t.Fatalf("foreign PreToolUse group not preserved, groups=%d", len(groups))
}
gm := groups[0].(map[string]any)
cmd := gm["hooks"].([]any)[0].(map[string]any)["command"].(string)
if cmd != "other-tool guard" {
t.Errorf("wrong PreToolUse group survived: %q", cmd)
}
// The SessionStart channel is untouched.
if len(sessionGroups(root)) != 1 {
t.Errorf("SessionStart group disturbed by commit-guard disable")
}
}
func TestCommitGuard_EnableRefusesMalformedJSON(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
bad := "{ not valid json"
if err := os.WriteFile(sp, []byte(bad), 0o644); err != nil {
t.Fatal(err)
}
if _, err := EnableCommitGuard(cfg); err == nil {
t.Fatal("expected refusal on malformed settings")
}
if b, _ := os.ReadFile(sp); string(b) != bad {
t.Errorf("malformed settings file was modified:\n%s", b)
}
}
func TestCommitGuard_DisableNoOpWhenAbsent(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
original := `{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"keep-me"}]}]}}`
if err := os.WriteFile(sp, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
msg, err := DisableCommitGuard(cfg)
if err != nil {
t.Fatalf("DisableCommitGuard: %v", err)
}
if !strings.Contains(msg, "not enabled") {
t.Errorf("msg = %q, want 'not enabled'", msg)
}
if b, _ := os.ReadFile(sp); string(b) != original {
t.Errorf("settings modified despite no eeco group:\n%s", b)
}
}
func TestCommitGuard_RefreshRewritesStalePath(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
// Install a group carrying the token but a stale binary path.
stale := `{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"\"/old/path/eeco\" ` + commitGuardToken + `"}]}]}}`
if err := os.WriteFile(sp, []byte(stale), 0o644); err != nil {
t.Fatal(err)
}
msg, err := RefreshCommitGuard(cfg)
if err != nil {
t.Fatalf("RefreshCommitGuard: %v", err)
}
if !strings.Contains(msg, "refreshed") {
t.Errorf("msg = %q, want 'refreshed'", msg)
}
b, _ := os.ReadFile(sp)
if strings.Contains(string(b), "/old/path/eeco") {
t.Errorf("stale path not rewritten:\n%s", b)
}
// Second refresh is a no-op.
msg, err = RefreshCommitGuard(cfg)
if err != nil || !strings.Contains(msg, "already current") {
t.Errorf("second refresh: msg=%q err=%v, want 'already current'", msg, err)
}
}
added internal/hooks/commitmsg.go
@@ -0,0 +1,225 @@
package hooks
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/ajhahnde/eeco/internal/config"
)
// commitMsgMarker is the unique identifier embedded in eeco's commit-msg
// hook. It is the exact-match fallback when the ledger hash is
// unavailable; an unrelated hook never carries it.
const commitMsgMarker = "eeco-managed-commit-msg-v1"
// Pattern fragments are assembled at runtime so this source file stays
// self-clean for eeco's own comment-hygiene scan — Constraint 3, the
// same discipline `internal/workflow/attribution.go` uses. The trailer
// rule is line-anchored so a prose mention of the trailer's name (for
// example "remove Co-Authored-By trailer" in a docs commit subject) is
// not a false positive; only an actual trailer line is.
var (
cmCoAuthored = "[Cc]o-" + "[Aa]uthored-" + "[Bb]y"
cmGenVerb = "[Gg]enerated"
cmRobotEmoji = "\\x{1F916}" // U+1F916, not written as a literal glyph here.
)
// commitMsgPatterns block AI-attribution trailers. The first three
// anchor on the Co-Authored-By trailer line and require a claude /
// anthropic / noreply@anthropic mention on the same line; the fourth
// catches the Claude Code robot-emoji "Generated with" signature.
var commitMsgPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?im)^` + cmCoAuthored + `:.*claude`),
regexp.MustCompile(`(?im)^` + cmCoAuthored + `:.*anthropic`),
regexp.MustCompile(`(?im)^` + cmCoAuthored + `:.*noreply@anthropic`),
regexp.MustCompile(cmRobotEmoji + `[^\n]{0,20}` + cmGenVerb),
}
// commitMsgScript renders the hook body. Git invokes commit-msg with
// the path to the staged commit-message file as $1; the hook execs back
// into the eeco binary to run the policy check, keeping the script body
// trivially short and the pattern set inside the binary so a brew
// upgrade refreshes the policy without rewriting the on-disk script.
func commitMsgScript() string {
var b strings.Builder
b.WriteString("#!/bin/sh\n")
b.WriteString("# eeco managed commit-msg hook. Reversible:\n")
b.WriteString("# eeco hooks commit-msg off\n")
b.WriteString("# Refresh after `brew upgrade eeco` (rewrites EECO path):\n")
b.WriteString("# eeco hooks commit-msg refresh\n")
b.WriteString("# Do not edit the next line; removal is exact-match.\n")
b.WriteString("# " + commitMsgMarker + "\n")
fmt.Fprintf(&b, "EECO=%q\n", selfPath())
b.WriteString("exec \"$EECO\" hooks commit-msg-check \"$1\"\n")
return b.String()
}
// EnableCommitMsg installs the commit-msg hook. Unlike pre-commit and
// post-merge, this hook needs no workflow-list configuration: the policy
// is universal (no AI-attribution trailers) and lives inside the eeco
// binary. Refuses, without modifying anything, when a non-eeco
// commit-msg hook already exists. Re-enabling an already-eeco hook is a
// no-op; use `refresh` to pick up a moved binary path.
func EnableCommitMsg(cfg *config.Config) (string, error) {
hooksDir, err := gitHooksDir(cfg)
if err != nil {
return "", err
}
path := filepath.Join(hooksDir, "commit-msg")
script := commitMsgScript()
if existing, rerr := os.ReadFile(path); rerr == nil {
if isEecoManaged(existing, "", commitMsgMarker) {
return "commit-msg already enabled", nil
}
return "", errors.New("a non-eeco commit-msg hook already exists — left untouched")
} else if !errors.Is(rerr, os.ErrNotExist) {
return "", fmt.Errorf("inspect commit-msg: %w", rerr)
}
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
return "", fmt.Errorf("create hooks dir: %w", err)
}
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
return "", fmt.Errorf("write commit-msg: %w", err)
}
l, err := loadLedger(cfg)
if err != nil {
return "", err
}
l.CommitMsg = record{
Installed: true,
Path: path,
SHA256: sha256hex([]byte(script)),
At: time.Now().UTC().Format(time.RFC3339),
}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
return "commit-msg enabled (" + path + ")", nil
}
// DisableCommitMsg removes the commit-msg hook only when the on-disk
// script is byte-identical to what eeco wrote (the recorded hash, with
// a marker-line fallback). A foreign or hand-edited hook is left in
// place and reported.
func DisableCommitMsg(cfg *config.Config) (string, error) {
hooksDir, err := gitHooksDir(cfg)
if err != nil {
return "", err
}
path := filepath.Join(hooksDir, "commit-msg")
l, lerr := loadLedger(cfg)
if lerr != nil {
return "", lerr
}
b, rerr := os.ReadFile(path)
if errors.Is(rerr, os.ErrNotExist) {
l.CommitMsg = record{}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
return "commit-msg not enabled", nil
}
if rerr != nil {
return "", fmt.Errorf("inspect commit-msg: %w", rerr)
}
if !isEecoManaged(b, l.CommitMsg.SHA256, commitMsgMarker) {
return "", errors.New("commit-msg hook is present but not eeco's — left untouched")
}
if err := os.Remove(path); err != nil {
return "", fmt.Errorf("remove commit-msg: %w", err)
}
l.CommitMsg = record{}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
return "commit-msg disabled", nil
}
// RefreshCommitMsg rewrites the on-disk script when its embedded eeco
// binary path no longer matches what selfPath() resolves today — the
// self-heal for a `brew upgrade eeco` that moved the cellar directory
// out from under a previously-installed hook (the stableBrewBin
// path is reused). No-op when no eeco-managed commit-msg hook exists or
// when the on-disk script already matches the desired bytes.
func RefreshCommitMsg(cfg *config.Config) (string, error) {
hooksDir, err := gitHooksDir(cfg)
if err != nil {
return "", err
}
path := filepath.Join(hooksDir, "commit-msg")
l, lerr := loadLedger(cfg)
if lerr != nil {
return "", lerr
}
b, rerr := os.ReadFile(path)
if errors.Is(rerr, os.ErrNotExist) {
return "commit-msg not enabled", nil
}
if rerr != nil {
return "", fmt.Errorf("inspect commit-msg: %w", rerr)
}
if !isEecoManaged(b, l.CommitMsg.SHA256, commitMsgMarker) {
return "", errors.New("commit-msg hook is present but not eeco's — left untouched")
}
desired := commitMsgScript()
if string(b) == desired {
return "commit-msg already current", nil
}
if err := os.WriteFile(path, []byte(desired), 0o755); err != nil {
return "", fmt.Errorf("write commit-msg: %w", err)
}
l.CommitMsg = record{
Installed: true,
Path: path,
SHA256: sha256hex([]byte(desired)),
At: time.Now().UTC().Format(time.RFC3339),
}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
return "commit-msg refreshed (" + path + ")", nil
}
// CheckCommitMsg reads the commit-message file at path and returns an
// error when its contents carry an AI-attribution trailer matching any
// commitMsgPatterns regex. The error names the matched line and the
// explicit --no-verify bypass so a conscious operator can still ship
// the message; the hook stdin contract for commit-msg is exit 0
// (accept) vs non-zero (reject).
func CheckCommitMsg(path string) error {
b, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read commit message: %w", err)
}
for _, p := range commitMsgPatterns {
loc := p.FindIndex(b)
if loc == nil {
continue
}
line := bytes.Count(b[:loc[0]], []byte("\n")) + 1
snippet := strings.TrimRight(string(b[loc[0]:loc[1]]), "\r\n")
return fmt.Errorf(
"commit-msg: AI-attribution forbidden (line %d: %s)\n"+
"remove the trailer; pass --no-verify to bypass (not recommended)",
line, snippet)
}
return nil
}
// commitMsgStatus reports on/off for the commit-msg hook, reflecting
// on-disk reality so a hand-removed hook reads as off and a foreign
// hook of the same name reads as off-with-note.
func commitMsgStatus(cfg *config.Config, l ledger) string {
return managedHookStatus(cfg, "commit-msg", l.CommitMsg.SHA256, commitMsgMarker)
}
added internal/hooks/commitmsg_test.go
@@ -0,0 +1,269 @@
package hooks
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestCommitMsg_EnableWritesExecutableMarkedScript(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX exec bit is not represented on Windows filesystems")
}
cfg := newCfg(t, "")
if _, err := EnableCommitMsg(cfg); err != nil {
t.Fatalf("EnableCommitMsg: %v", err)
}
p := filepath.Join(cfg.RepoRoot, ".git", "hooks", "commit-msg")
info, err := os.Stat(p)
if err != nil {
t.Fatalf("stat commit-msg: %v", err)
}
if info.Mode().Perm()&0o100 == 0 {
t.Errorf("commit-msg not executable: %v", info.Mode())
}
b, _ := os.ReadFile(p)
got := string(b)
if !strings.Contains(got, commitMsgMarker) {
t.Errorf("script missing marker line:\n%s", got)
}
if !strings.Contains(got, "hooks commit-msg-check") {
t.Errorf("script does not exec the check verb:\n%s", got)
}
}
func TestCommitMsg_EnableIsIdempotent(t *testing.T) {
cfg := newCfg(t, "")
if _, err := EnableCommitMsg(cfg); err != nil {
t.Fatal(err)
}
msg, err := EnableCommitMsg(cfg)
if err != nil {
t.Fatalf("second EnableCommitMsg errored: %v", err)
}
if !strings.Contains(msg, "already enabled") {
t.Errorf("msg = %q, want already-enabled", msg)
}
}
func TestCommitMsg_EnableRefusesForeignHook(t *testing.T) {
cfg := newCfg(t, "")
hooksDir := filepath.Join(cfg.RepoRoot, ".git", "hooks")
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
t.Fatal(err)
}
foreign := "#!/bin/sh\necho someone elses commit-msg hook\n"
fp := filepath.Join(hooksDir, "commit-msg")
if err := os.WriteFile(fp, []byte(foreign), 0o755); err != nil {
t.Fatal(err)
}
if _, err := EnableCommitMsg(cfg); err == nil {
t.Fatal("expected EnableCommitMsg to refuse a foreign hook")
}
b, _ := os.ReadFile(fp)
if string(b) != foreign {
t.Errorf("foreign hook was modified:\n%s", b)
}
}
func TestCommitMsg_DisableRemovesOnlyEecoHook(t *testing.T) {
cfg := newCfg(t, "")
if _, err := EnableCommitMsg(cfg); err != nil {
t.Fatal(err)
}
if _, err := DisableCommitMsg(cfg); err != nil {
t.Fatalf("DisableCommitMsg: %v", err)
}
p := filepath.Join(cfg.RepoRoot, ".git", "hooks", "commit-msg")
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Errorf("commit-msg still present after disable (err=%v)", err)
}
if msg, err := DisableCommitMsg(cfg); err != nil || !strings.Contains(msg, "not enabled") {
t.Errorf("re-disable: msg=%q err=%v", msg, err)
}
}
func TestCommitMsg_DisableLeavesForeignHook(t *testing.T) {
cfg := newCfg(t, "")
hooksDir := filepath.Join(cfg.RepoRoot, ".git", "hooks")
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
t.Fatal(err)
}
foreign := "#!/bin/sh\necho keep me\n"
fp := filepath.Join(hooksDir, "commit-msg")
if err := os.WriteFile(fp, []byte(foreign), 0o755); err != nil {
t.Fatal(err)
}
if _, err := DisableCommitMsg(cfg); err == nil {
t.Fatal("expected DisableCommitMsg to refuse a foreign hook")
}
if b, _ := os.ReadFile(fp); string(b) != foreign {
t.Errorf("foreign hook was touched:\n%s", b)
}
}
func TestCommitMsg_DisableViaMarkerWhenLedgerLost(t *testing.T) {
cfg := newCfg(t, "")
if _, err := EnableCommitMsg(cfg); err != nil {
t.Fatal(err)
}
if err := os.Remove(ledgerPath(cfg)); err != nil {
t.Fatal(err)
}
if _, err := DisableCommitMsg(cfg); err != nil {
t.Fatalf("DisableCommitMsg with lost ledger: %v", err)
}
p := filepath.Join(cfg.RepoRoot, ".git", "hooks", "commit-msg")
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Error("hook not removed via marker fallback")
}
}
func TestCommitMsg_RefreshIsNoOpWhenCurrent(t *testing.T) {
cfg := newCfg(t, "")
if _, err := EnableCommitMsg(cfg); err != nil {
t.Fatal(err)
}
msg, err := RefreshCommitMsg(cfg)
if err != nil {
t.Fatalf("RefreshCommitMsg: %v", err)
}
if !strings.Contains(msg, "already current") {
t.Errorf("refresh msg = %q, want already-current", msg)
}
}
func TestCommitMsg_RefreshRewritesStaleScript(t *testing.T) {
cfg := newCfg(t, "")
hooksDir := filepath.Join(cfg.RepoRoot, ".git", "hooks")
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
t.Fatal(err)
}
// Hand-build a stale script that carries the marker but a stale EECO
// path — simulates the post-`brew upgrade eeco` state the self-heal fixes.
stale := "#!/bin/sh\n" +
"# " + commitMsgMarker + "\n" +
"EECO=\"/old/path/eeco\"\n" +
"exec \"$EECO\" hooks commit-msg-check \"$1\"\n"
fp := filepath.Join(hooksDir, "commit-msg")
if err := os.WriteFile(fp, []byte(stale), 0o755); err != nil {
t.Fatal(err)
}
// Refresh must accept this as eeco-managed (marker present) and
// rewrite the file with the current commitMsgScript().
msg, err := RefreshCommitMsg(cfg)
if err != nil {
t.Fatalf("RefreshCommitMsg: %v", err)
}
if !strings.Contains(msg, "refreshed") {
t.Errorf("refresh msg = %q, want refreshed", msg)
}
b, _ := os.ReadFile(fp)
if strings.Contains(string(b), "/old/path/eeco") {
t.Errorf("stale path survived refresh:\n%s", b)
}
}
func TestCheckCommitMsg_AcceptsCleanMessage(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "msg")
body := "feat: add the thing\n\nResolves #123\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
if err := CheckCommitMsg(path); err != nil {
t.Errorf("CheckCommitMsg: %v (want clean)", err)
}
}
func TestCheckCommitMsg_RejectsClaudeTrailer(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "msg")
body := "feat: thing\n\nCo-Authored-By: Claude Opus 4.7 <[email protected]>\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
err := CheckCommitMsg(path)
if err == nil {
t.Fatal("expected rejection for Claude trailer")
}
if !strings.Contains(err.Error(), "AI-attribution") {
t.Errorf("error missing AI-attribution context: %v", err)
}
}
func TestCheckCommitMsg_RejectsAnthropicTrailer(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "msg")
body := "feat: x\n\nCo-Authored-By: Bot <[email protected]>\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
if err := CheckCommitMsg(path); err == nil {
t.Fatal("expected rejection for anthropic-domain trailer")
}
}
func TestCheckCommitMsg_RejectsNoreplyAnthropic(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "msg")
body := "fix: y\n\nCo-Authored-By: Whoever <[email protected]>\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
if err := CheckCommitMsg(path); err == nil {
t.Fatal("expected rejection for noreply@anthropic trailer")
}
}
func TestCheckCommitMsg_RejectsGeneratedWithEmoji(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "msg")
// Assemble the emoji-attribution body from runtime fragments so this
// source file stays self-clean for eeco's own comment-hygiene scan
// (same discipline as internal/workflow/attribution.go).
robot := string([]rune{0x1F916})
body := "feat: z\n\n" + robot + " " + "Generated" + " with [Claude Code](https://claude.com/claude-code)\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
if err := CheckCommitMsg(path); err == nil {
t.Fatal("expected rejection for Generated-with emoji signature")
}
}
func TestCheckCommitMsg_AllowsPolicyDiscussionInSubject(t *testing.T) {
// A docs commit that mentions the forbidden strings in its subject
// or body — but not as an actual Co-Authored-By trailer — must pass.
// This is the false-positive-resistance the trailer-anchored pattern
// buys us over the broad file-scan pattern.
dir := t.TempDir()
path := filepath.Join(dir, "msg")
body := "docs: remove the Co-Authored-By trailer from CONTRIBUTING\n\n" +
"The Claude and anthropic strings used to leak via this template.\n" +
"Updated CI to reject noreply@anthropic now.\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
if err := CheckCommitMsg(path); err != nil {
t.Errorf("CheckCommitMsg rejected a policy-discussion commit: %v", err)
}
}
func TestCheckCommitMsg_ErrorMentionsNoVerifyBypass(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "msg")
body := "feat: x\n\nCo-Authored-By: Claude <[email protected]>\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
err := CheckCommitMsg(path)
if err == nil {
t.Fatal("expected rejection")
}
if !strings.Contains(err.Error(), "--no-verify") {
t.Errorf("error must name --no-verify bypass; got: %v", err)
}
}
added internal/hooks/contractwatch.go
@@ -0,0 +1,128 @@
package hooks
import (
"os"
"path/filepath"
"time"
"github.com/ajhahnde/eeco/internal/cockpit"
"github.com/ajhahnde/eeco/internal/config"
)
// The contract-watch / doc-drift-nudge pair is the cockpit machinery's
// event-driven drift signal: a PostToolUse edit to a cockpit input drops a
// flag (ContractWatch), and the next SessionStart consumes it into a one-time
// doc-drift nudge (DocDriftNudge) — so the drift check fires on real change,
// not a blind timer (a weekly backstop still catches silent drift). Both ends
// live here so the flag names stay in one place.
const (
// contractChangedFlag marks that a watched cockpit input changed since the
// last session; DocDriftNudge consumes it.
contractChangedFlag = "contract-changed"
// cockpitDirtyFlag is the companion marker for the cockpit specifically,
// dropped alongside contractChangedFlag.
cockpitDirtyFlag = "cockpit-dirty"
// docDriftStampName throttles the doc-drift nudge's time backstop.
docDriftStampName = "doc-drift.last"
)
// docDriftBackstop is the time backstop for the doc-drift nudge when no
// contract-changed flag is present: docs drift on code change, but a weekly
// backstop still catches drift no edit announced.
const docDriftBackstop = 7 * 24 * time.Hour
// ClearGitWriteSentinels removes both one-shot git-write authorization
// sentinels under <workspace>/state, so no new session inherits a standing
// authorization left over from a prior one (security-critical — it pairs with
// the C4a git-write guard). Called from runSessionEmit before the pure Emit.
// Missing sentinels are not an error.
func ClearGitWriteSentinels(cfg *config.Config) {
stateDir := filepath.Join(cfg.Workspace, "state")
for _, kind := range []string{"commit", "tag"} {
_ = os.Remove(filepath.Join(stateDir, "git-"+kind+"-authorized"))
}
}
// DocDriftNudge decides whether session start should nudge a doc/cockpit-drift
// check. It fires when a cockpit input changed since the last session (the
// contract-watch PostToolUse hook dropped the contract-changed flag) OR the
// weekly backstop elapsed. On a fire it writes the throttle stamp and clears
// the flags (so the nudge is one-shot per change), returning the nudge line.
// It performs WRITES, so runSessionEmit calls it around the pure Emit, never
// from inside Emit. It never errors.
func DocDriftNudge(cfg *config.Config, now time.Time) (line string, fire bool) {
stateDir := filepath.Join(cfg.Workspace, "state")
flag := filepath.Join(stateDir, contractChangedFlag)
stamp := filepath.Join(stateDir, docDriftStampName)
flagged := fileExists(flag)
if !flagged && !throttleElapsed(stamp, now, docDriftBackstop) {
return "", false
}
// A backstop-only trigger (no explicit contract change) stays silent unless
// the cockpit is actually generated here — otherwise there is nothing to
// drift-check, and session start must not nag an unrelated repo. (The flag
// path needs no such gate: the contract-changed flag can only be dropped by
// the machinery's PostToolUse hook, which implies the cockpit is in use.)
if !flagged && !cockpit.IsGenerated(cfg) {
return "", false
}
trigger := "the weekly backstop is due"
if flagged {
trigger = "a cockpit input (cockpit.json / config.local) changed since the last session"
}
writeStamp(stamp, now)
_ = os.Remove(flag)
_ = os.Remove(filepath.Join(stateDir, cockpitDirtyFlag))
return "[eeco maintenance] run a doc/cockpit drift check (`eeco cockpit verify`): " + trigger +
". Report the verdict (one line if clean), then carry on.", true
}
// ContractWatch is the PostToolUse side-effect: when the edited file is a
// cockpit input (the selection store <workspace>/cockpit.json or
// <workspace>/config.local), it drops the contract-changed + cockpit-dirty
// flags under <workspace>/state so the next SessionStart orient nudges a drift
// check. filePath is the absolute path the tool wrote (from the PostToolUse
// event); a blank or non-matching path is a no-op. It never blocks and never
// errors — a flag write that fails is simply skipped. Returns whether a flag
// was dropped (for tests / the runner's accounting).
func ContractWatch(cfg *config.Config, filePath string) bool {
if filePath == "" || !isWatchedInput(cfg, filePath) {
return false
}
stateDir := filepath.Join(cfg.Workspace, "state")
if err := os.MkdirAll(stateDir, 0o755); err != nil {
return false
}
wrote := false
for _, name := range []string{contractChangedFlag, cockpitDirtyFlag} {
if err := os.WriteFile(filepath.Join(stateDir, name), nil, 0o644); err == nil {
wrote = true
}
}
return wrote
}
// isWatchedInput reports whether path (the tool's edited file) is a cockpit
// input whose change should trigger a drift re-check: the selection store or
// config.local. Matching is on the cleaned absolute path so a relative or loose
// form still resolves.
func isWatchedInput(cfg *config.Config, path string) bool {
abs := path
if !filepath.IsAbs(abs) {
if a, err := filepath.Abs(abs); err == nil {
abs = a
}
}
abs = filepath.Clean(abs)
watched := []string{
cockpit.SelectionPath(cfg),
filepath.Join(cfg.Workspace, "config.local"),
}
for _, w := range watched {
if filepath.Clean(w) == abs {
return true
}
}
return false
}
added internal/hooks/contractwatch_test.go
@@ -0,0 +1,126 @@
package hooks
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/ajhahnde/eeco/internal/cockpit"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/playbooks"
)
// watchCfg builds a config with a workspace for the flags/stamps (no git).
func watchCfg(t *testing.T) *config.Config {
t.Helper()
root := t.TempDir()
ws := filepath.Join(root, "tester", ".eeco")
if err := os.MkdirAll(filepath.Join(ws, "state"), 0o755); err != nil {
t.Fatal(err)
}
return &config.Config{
RepoRoot: root,
UserDir: filepath.Join(root, "tester"),
WorkspaceName: ".eeco",
Workspace: ws,
}
}
func TestContractWatch_FlagsWatchedInput(t *testing.T) {
cfg := watchCfg(t)
if !ContractWatch(cfg, cockpit.SelectionPath(cfg)) {
t.Fatal("editing the selection store should drop a flag")
}
for _, name := range []string{contractChangedFlag, cockpitDirtyFlag} {
if _, err := os.Stat(filepath.Join(cfg.Workspace, "state", name)); err != nil {
t.Errorf("flag %s not written: %v", name, err)
}
}
if !ContractWatch(cfg, filepath.Join(cfg.Workspace, "config.local")) {
t.Error("editing config.local should drop a flag")
}
}
func TestContractWatch_IgnoresUnrelated(t *testing.T) {
cfg := watchCfg(t)
if ContractWatch(cfg, filepath.Join(cfg.RepoRoot, "README.md")) {
t.Error("an unrelated edit must not drop a flag")
}
if ContractWatch(cfg, "") {
t.Error("a blank path must be a no-op")
}
if _, err := os.Stat(filepath.Join(cfg.Workspace, "state", contractChangedFlag)); !os.IsNotExist(err) {
t.Errorf("no flag should exist after unrelated edits, stat err=%v", err)
}
}
func TestDocDriftNudge_FlagFiresAndClears(t *testing.T) {
cfg := watchCfg(t)
flag := filepath.Join(cfg.Workspace, "state", contractChangedFlag)
if err := os.WriteFile(flag, nil, 0o644); err != nil {
t.Fatal(err)
}
line, fire := DocDriftNudge(cfg, time.Now())
if !fire {
t.Fatal("a contract-changed flag should fire the nudge")
}
if !strings.Contains(line, "changed") {
t.Errorf("flag-driven nudge text off: %q", line)
}
if _, err := os.Stat(flag); !os.IsNotExist(err) {
t.Error("the nudge should clear the contract-changed flag (one-shot)")
}
// Right after firing (flag cleared, stamp fresh) it is silent.
if _, fire := DocDriftNudge(cfg, time.Now()); fire {
t.Error("the nudge should be silent right after firing")
}
}
func TestDocDriftNudge_BackstopSilentWhenCockpitUnused(t *testing.T) {
cfg := watchCfg(t)
// No flag, no stamp (backstop elapsed), but the cockpit was never generated
// here → must stay silent (the empty-ledger gate).
if _, fire := DocDriftNudge(cfg, time.Now()); fire {
t.Error("the backstop must not fire where the cockpit was never generated")
}
}
func TestDocDriftNudge_BackstopFiresWhenGenerated(t *testing.T) {
cfg := watchCfg(t)
if err := cockpit.SaveSelection(cfg, cockpit.Selection{Targets: []string{"claude"}, Playbooks: []string{"handover"}}); err != nil {
t.Fatal(err)
}
pb, err := playbooks.Get("handover")
if err != nil {
t.Fatal(err)
}
if _, err := cockpit.Generate(cfg, pb, "claude"); err != nil {
t.Fatal(err)
}
// No flag, no stamp (backstop elapsed) and the cockpit IS generated → fire.
line, fire := DocDriftNudge(cfg, time.Now())
if !fire {
t.Fatal("the backstop should fire once the cockpit is generated")
}
if !strings.Contains(line, "backstop") {
t.Errorf("backstop nudge text off: %q", line)
}
}
func TestClearGitWriteSentinels(t *testing.T) {
cfg := watchCfg(t)
stateDir := filepath.Join(cfg.Workspace, "state")
for _, k := range []string{"commit", "tag"} {
if err := os.WriteFile(filepath.Join(stateDir, "git-"+k+"-authorized"), nil, 0o600); err != nil {
t.Fatal(err)
}
}
ClearGitWriteSentinels(cfg)
for _, k := range []string{"commit", "tag"} {
if _, err := os.Stat(filepath.Join(stateDir, "git-"+k+"-authorized")); !os.IsNotExist(err) {
t.Errorf("sentinel git-%s-authorized not cleared, stat err=%v", k, err)
}
}
}
added internal/hooks/degrade_boundary_test.go
@@ -0,0 +1,41 @@
package hooks
import (
"bytes"
"path/filepath"
"strings"
"testing"
)
// TestBoundary_SessionEmitDegradesOpenOnMalformedMemory pins the background
// arm of the H1.6 degrade matrix: a session-start hook must never disrupt a
// developer's session, so when the pinned-memories store is corrupt the block
// is omitted and Emit still returns a clean partial (fail-OPEN, exit 0).
// Paired with internal/brief TestBoundary_BriefFailsClosedOnMalformedMemory,
// which pins the foreground `eeco go` fail-CLOSED posture. The asymmetry is
// intentional (Option A, H1.6) — no production change.
func TestBoundary_SessionEmitDegradesOpenOnMalformedMemory(t *testing.T) {
cfg := newEmitCfg(t)
// MANDATORY: the pinned-bodies swallow path is gated behind this flag.
// Without it the block is skipped before LoadAll and the test passes
// trivially.
cfg.SessionStartPinnedBodies = true
// A good pinned fact: would normally produce the pinned-memories block.
writePinnedFact(t, cfg, "policy-x", "important", "body", true)
// A malformed sibling: memory.LoadAll now aborts the whole load.
writeFile(t, filepath.Join(cfg.Workspace, "memory", "broken.md"), "---\nname: broken\n")
// A non-memory reading-routine source so Emit still has clean output.
writeFile(t, filepath.Join(cfg.RepoRoot, "README.md"), "# Hi\n")
var buf bytes.Buffer
Emit(cfg, &buf) // returning at all proves no panic
got := buf.String()
if strings.Contains(got, "pinned memories") {
t.Errorf("malformed store must omit the pinned-memories block (fail-open), got:\n%s", got)
}
if !strings.Contains(got, "README.md") {
t.Errorf("degrade-open must still emit the clean reading-routine partial, got:\n%s", got)
}
}
added internal/hooks/hooks.go
@@ -0,0 +1,1217 @@
// Package hooks wires and unwires eeco's two opt-in, reversible
// integration points — the only touches outside the gitignored
// workspace (Constraint 2).
//
// - a local .git/hooks/pre-commit that runs leak-guard, installed
// only when no pre-commit hook exists and removed only when the
// on-disk script is byte-identical to what eeco wrote;
// - one namespaced entry in the AI CLI's user-global JSON settings
// file that emits a one-line queue reminder at session start.
//
// Every action is recorded in a ledger inside the workspace
// (<workspace>/state/hooks.json) so it is cleanly undoable, and the
// settings-file edit is backed up (into the workspace) and re-validated
// after the write, restoring the backup if the result is not valid
// JSON. Nothing here ever commits, pushes, or writes the tracked tree.
package hooks
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/ajhahnde/eeco/internal/config"
)
// Hook names accepted by Toggle and reported by Status.
const (
PreCommit = "pre-commit"
PostMerge = "post-merge"
SessionStart = "session-start"
CommitMsg = "commit-msg"
CommitGuard = "commit-guard"
)
// Names lists the toggleable hook names in report order.
var Names = []string{PreCommit, PostMerge, SessionStart, CommitMsg, CommitGuard}
// ledgerName is the reversibility record inside <workspace>/state.
const ledgerName = "hooks.json"
// backupSubdir is where the original settings file is copied before an
// edit, inside <workspace>/state (never beside the user's own file).
const backupSubdir = "backups"
// preCommitMarker is a unique line embedded in eeco's pre-commit
// script. It is the exact-match fallback identifier when the ledger
// hash is unavailable; an unrelated hook never carries it.
const preCommitMarker = "eeco-managed-pre-commit-v1"
// postMergeMarker is the exact-match fallback identifier in eeco's
// post-merge script, the analog of preCommitMarker.
const postMergeMarker = "eeco-managed-post-merge-v1"
// sessionToken is the path-independent namespace marker carried in the
// session-start hook command. Removal matches on this token, so a moved
// eeco binary is still cleanly removable.
const sessionToken = "hooks session-emit"
// commitGuardToken is the path-independent namespace marker carried in
// the commit-guard PreToolUse hook command — the analog of sessionToken
// for the harness PreToolUse channel, so a moved eeco binary stays
// cleanly removable.
const commitGuardToken = "hooks commit-guard-check"
// ErrSessionNotConfigured is returned by the session-start operations
// when neither delivery channel is configured. It is a clean, expected
// condition (not a failure): nothing is touched. No brand path is baked
// in, per Constraint 4 — the operator points eeco at the file or files.
var ErrSessionNotConfigured = errors.New(
"session-start not configured: set session_settings_path (or " +
"EECO_SESSION_SETTINGS) for an AI CLI that reads a JSON settings " +
"file, and/or set session_files in config.local for an assistant " +
"that reads a plain text/markdown file")
// ErrCommitGuardNotConfigured is returned by the commit-guard operations
// when no settings file is configured. Like ErrSessionNotConfigured it is
// a clean, expected condition (not a failure): nothing is touched. The
// commit-guard installs a PreToolUse group into the same Claude settings
// file the session-start JSON channel uses.
var ErrCommitGuardNotConfigured = errors.New(
"commit-guard not configured: set session_settings_path (or " +
"EECO_SESSION_SETTINGS) to the AI CLI's JSON settings file so eeco " +
"can install the PreToolUse hook")
// record is one hook's reversibility state. Files is set only on the
// session-start record and only when the file-delivery channel
// (`session_files`) wired one or more targets — additive, so older
// ledgers without the field still load.
type record struct {
Installed bool `json:"installed"`
Path string `json:"path,omitempty"`
SHA256 string `json:"sha256,omitempty"`
Backup string `json:"backup,omitempty"`
At string `json:"at,omitempty"`
Files []fileRecord `json:"files,omitempty"`
}
// ledger is the persisted state of the hooks. PostMerge, CommitMsg, and
// CockpitMachinery are additive; an older ledger.json without the key still
// loads (zero record = off).
type ledger struct {
PreCommit record `json:"pre_commit"`
PostMerge record `json:"post_merge"`
SessionStart record `json:"session_start"`
CommitMsg record `json:"commit_msg"`
CommitGuard record `json:"commit_guard"`
CockpitMachinery record `json:"cockpit_machinery"`
}
func ledgerPath(cfg *config.Config) string {
return filepath.Join(cfg.Workspace, "state", ledgerName)
}
func loadLedger(cfg *config.Config) (ledger, error) {
var l ledger
b, err := os.ReadFile(ledgerPath(cfg))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return l, nil
}
return l, fmt.Errorf("read hook ledger: %w", err)
}
if len(b) == 0 {
return l, nil
}
if err := json.Unmarshal(b, &l); err != nil {
// A corrupt ledger must not wedge the tool: start from empty
// state. On-disk verification still protects against deletion.
return ledger{}, nil
}
return l, nil
}
func saveLedger(cfg *config.Config, l ledger) error {
dir := filepath.Join(cfg.Workspace, "state")
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("hook ledger dir: %w", err)
}
b, err := json.MarshalIndent(l, "", " ")
if err != nil {
return err
}
return os.WriteFile(ledgerPath(cfg), append(b, '\n'), 0o644)
}
// selfPath resolves the absolute path of the running eeco binary, used
// in the installed hook so the integration does not depend on PATH.
// A resolution failure degrades to the bare name. On a brew-installed
// eeco the resolved path lands inside the versioned Cellar directory;
// stableBrewBin unwinds that to the version-agnostic bin shim so the
// installed hook survives every `brew upgrade eeco`.
func selfPath() string {
p, err := os.Executable()
if err != nil || p == "" {
return "eeco"
}
if r, rerr := filepath.EvalSymlinks(p); rerr == nil {
p = r
}
if stable := stableBrewBin(p); stable != "" {
return stable
}
return p
}
// stableBrewBin returns the brew bin shim for a path inside
// "<prefix>/Cellar/eeco/<version>/bin/eeco", or "" if p is not a brew
// cellar path or the bin shim is not present. The bin shim is the
// version-agnostic entry brew creates in <prefix>/bin/, so an installed
// hook keeps working after `brew upgrade eeco` reaps the old cellar
// directory.
func stableBrewBin(p string) string {
prefix, _, ok := strings.Cut(p, "/Cellar/eeco/")
if !ok {
return ""
}
stable := filepath.Join(prefix, "bin", "eeco")
if _, err := os.Stat(stable); err != nil {
return ""
}
return stable
}
// --- pre-commit -----------------------------------------------------
// gitHooksDir returns <repo>/.git/hooks, or an error when .git is not a
// directory (for example a worktree, whose .git is a file). The
// pre-commit hook is deliberately repo-scoped and untracked.
func gitHooksDir(cfg *config.Config) (string, error) {
gitDir := filepath.Join(cfg.RepoRoot, ".git")
info, err := os.Stat(gitDir)
if err != nil {
return "", fmt.Errorf("locate .git: %w", err)
}
if !info.IsDir() {
return "", errors.New(".git is not a directory (worktree?) — pre-commit wiring unsupported here")
}
return filepath.Join(gitDir, "hooks"), nil
}
// preCommitScript renders the hook body. workflows is the ordered list
// of builtin workflow names to invoke; the runner stops at the first
// non-zero exit via `set -e`. The eeco binary path is captured once in
// a shell variable so a relocated binary that resolves identically at
// install time stays referenced consistently across steps.
func preCommitScript(workflows []string) string {
var b strings.Builder
b.WriteString("#!/bin/sh\n")
b.WriteString("# eeco managed pre-commit hook. Reversible:\n")
b.WriteString("# eeco hooks pre-commit off\n")
b.WriteString("# Do not edit the next line; removal is exact-match.\n")
b.WriteString("# " + preCommitMarker + "\n")
b.WriteString("set -e\n")
fmt.Fprintf(&b, "EECO=%q\n", selfPath())
for _, w := range workflows {
fmt.Fprintf(&b, "\"$EECO\" run %s\n", w)
}
return b.String()
}
func sha256hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
// EnablePreCommit installs the pre-commit hook. It refuses, without
// modifying anything, when a non-eeco pre-commit hook already exists or
// when cfg.PreCommitWorkflows is empty (the operator opted out via an
// explicit empty `pre_commit_workflows` in config.local). Re-enabling
// an already-eeco hook is a no-op even when the desired workflow set
// has changed: run `eeco hooks pre-commit off` first to refresh.
func EnablePreCommit(cfg *config.Config) (string, error) {
if len(cfg.PreCommitWorkflows) == 0 {
return "", errors.New("pre_commit_workflows is empty in config.local — nothing to wire")
}
hooksDir, err := gitHooksDir(cfg)
if err != nil {
return "", err
}
path := filepath.Join(hooksDir, "pre-commit")
script := preCommitScript(cfg.PreCommitWorkflows)
if existing, rerr := os.ReadFile(path); rerr == nil {
if isEecoPreCommit(existing, "") {
return "pre-commit already enabled", nil
}
return "", errors.New("a non-eeco pre-commit hook already exists — left untouched")
} else if !errors.Is(rerr, os.ErrNotExist) {
return "", fmt.Errorf("inspect pre-commit: %w", rerr)
}
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
return "", fmt.Errorf("create hooks dir: %w", err)
}
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
return "", fmt.Errorf("write pre-commit: %w", err)
}
l, err := loadLedger(cfg)
if err != nil {
return "", err
}
l.PreCommit = record{
Installed: true,
Path: path,
SHA256: sha256hex([]byte(script)),
At: time.Now().UTC().Format(time.RFC3339),
}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
return "pre-commit enabled (" + path + ")", nil
}
// DisablePreCommit removes the pre-commit hook only when the on-disk
// script is byte-identical to what eeco wrote (the recorded hash, with
// a marker-line fallback). A foreign or hand-edited hook is left in
// place and reported.
func DisablePreCommit(cfg *config.Config) (string, error) {
hooksDir, err := gitHooksDir(cfg)
if err != nil {
return "", err
}
path := filepath.Join(hooksDir, "pre-commit")
l, lerr := loadLedger(cfg)
if lerr != nil {
return "", lerr
}
b, rerr := os.ReadFile(path)
if errors.Is(rerr, os.ErrNotExist) {
l.PreCommit = record{}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
return "pre-commit not enabled", nil
}
if rerr != nil {
return "", fmt.Errorf("inspect pre-commit: %w", rerr)
}
if !isEecoPreCommit(b, l.PreCommit.SHA256) {
return "", errors.New("pre-commit hook is present but not eeco's — left untouched")
}
if err := os.Remove(path); err != nil {
return "", fmt.Errorf("remove pre-commit: %w", err)
}
l.PreCommit = record{}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
return "pre-commit disabled", nil
}
// RefreshPreCommit rewrites the on-disk pre-commit script when its
// embedded eeco binary path (or workflow set) no longer matches what the
// running binary would write today — the self-heal for a `brew upgrade
// eeco` that moved the cellar directory out from under a previously
// installed hook, or an `eeco migrate v1` workspace move (the stableBrewBin
// path is reused). No-op when no eeco-managed pre-commit hook exists or
// when the on-disk script already matches the desired bytes.
func RefreshPreCommit(cfg *config.Config) (string, error) {
hooksDir, err := gitHooksDir(cfg)
if err != nil {
return "", err
}
path := filepath.Join(hooksDir, "pre-commit")
l, lerr := loadLedger(cfg)
if lerr != nil {
return "", lerr
}
b, rerr := os.ReadFile(path)
if errors.Is(rerr, os.ErrNotExist) {
return "pre-commit not enabled", nil
}
if rerr != nil {
return "", fmt.Errorf("inspect pre-commit: %w", rerr)
}
if !isEecoManaged(b, l.PreCommit.SHA256, preCommitMarker) {
return "", errors.New("pre-commit hook is present but not eeco's — left untouched")
}
desired := preCommitScript(cfg.PreCommitWorkflows)
if string(b) == desired {
return "pre-commit already current", nil
}
if err := os.WriteFile(path, []byte(desired), 0o755); err != nil {
return "", fmt.Errorf("write pre-commit: %w", err)
}
l.PreCommit = record{
Installed: true,
Path: path,
SHA256: sha256hex([]byte(desired)),
At: time.Now().UTC().Format(time.RFC3339),
}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
return "pre-commit refreshed (" + path + ")", nil
}
// isEecoPreCommit reports whether content is eeco's pre-commit script.
func isEecoPreCommit(content []byte, recordedSHA string) bool {
return isEecoManaged(content, recordedSHA, preCommitMarker)
}
// isEecoManaged reports whether content is an eeco-managed hook script:
// byte-identical to the recorded hash when one is known, otherwise
// carrying the unique marker line. A foreign hook carries neither.
func isEecoManaged(content []byte, recordedSHA, marker string) bool {
if recordedSHA != "" && sha256hex(content) == recordedSHA {
return true
}
if recordedSHA != "" {
return false
}
return strings.Contains(string(content), marker)
}
// --- post-merge -----------------------------------------------------
// postMergeScript renders the post-merge hook body. Unlike the
// pre-commit script it does NOT use `set -e` and swallows each step's
// exit (`|| true`): the merge has already completed, so a drift finding
// (exit 1) or a missing-tool block (exit 2) must surface as queue items
// and workflow output, never as a hook failure that alarms the user
// after a successful `git pull`. The eeco binary path is captured once.
func postMergeScript(workflows []string) string {
var b strings.Builder
b.WriteString("#!/bin/sh\n")
b.WriteString("# eeco managed post-merge hook. Reversible:\n")
b.WriteString("# eeco hooks post-merge off\n")
b.WriteString("# Do not edit the next line; removal is exact-match.\n")
b.WriteString("# " + postMergeMarker + "\n")
fmt.Fprintf(&b, "EECO=%q\n", selfPath())
for _, w := range workflows {
fmt.Fprintf(&b, "\"$EECO\" run %s || true\n", w)
}
return b.String()
}
// EnablePostMerge installs the post-merge hook. It refuses, without
// modifying anything, when a non-eeco post-merge hook already exists or
// when cfg.PostMergeWorkflows is empty (the operator opted out via an
// explicit empty `post_merge_workflows`). Re-enabling an already-eeco
// hook is a no-op even when the desired workflow set has changed: run
// `eeco hooks post-merge off` first to refresh.
func EnablePostMerge(cfg *config.Config) (string, error) {
if len(cfg.PostMergeWorkflows) == 0 {
return "", errors.New("post_merge_workflows is empty in config.local — nothing to wire")
}
hooksDir, err := gitHooksDir(cfg)
if err != nil {
return "", err
}
path := filepath.Join(hooksDir, "post-merge")
script := postMergeScript(cfg.PostMergeWorkflows)
if existing, rerr := os.ReadFile(path); rerr == nil {
if isEecoManaged(existing, "", postMergeMarker) {
return "post-merge already enabled", nil
}
return "", errors.New("a non-eeco post-merge hook already exists — left untouched")
} else if !errors.Is(rerr, os.ErrNotExist) {
return "", fmt.Errorf("inspect post-merge: %w", rerr)
}
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
return "", fmt.Errorf("create hooks dir: %w", err)
}
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
return "", fmt.Errorf("write post-merge: %w", err)
}
l, err := loadLedger(cfg)
if err != nil {
return "", err
}
l.PostMerge = record{
Installed: true,
Path: path,
SHA256: sha256hex([]byte(script)),
At: time.Now().UTC().Format(time.RFC3339),
}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
return "post-merge enabled (" + path + ")", nil
}
// DisablePostMerge removes the post-merge hook only when the on-disk
// script is byte-identical to what eeco wrote (the recorded hash, with a
// marker-line fallback). A foreign or hand-edited hook is left in place
// and reported.
func DisablePostMerge(cfg *config.Config) (string, error) {
hooksDir, err := gitHooksDir(cfg)
if err != nil {
return "", err
}
path := filepath.Join(hooksDir, "post-merge")
l, lerr := loadLedger(cfg)
if lerr != nil {
return "", lerr
}
b, rerr := os.ReadFile(path)
if errors.Is(rerr, os.ErrNotExist) {
l.PostMerge = record{}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
return "post-merge not enabled", nil
}
if rerr != nil {
return "", fmt.Errorf("inspect post-merge: %w", rerr)
}
if !isEecoManaged(b, l.PostMerge.SHA256, postMergeMarker) {
return "", errors.New("post-merge hook is present but not eeco's — left untouched")
}
if err := os.Remove(path); err != nil {
return "", fmt.Errorf("remove post-merge: %w", err)
}
l.PostMerge = record{}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
return "post-merge disabled", nil
}
// RefreshPostMerge rewrites the on-disk post-merge script when its
// embedded eeco binary path (or workflow set) no longer matches what the
// running binary would write today — the self-heal for a `brew upgrade
// eeco` that moved the cellar directory out from under a previously
// installed hook, or an `eeco migrate v1` workspace move (the stableBrewBin
// path is reused). No-op when no eeco-managed post-merge hook exists or
// when the on-disk script already matches the desired bytes.
func RefreshPostMerge(cfg *config.Config) (string, error) {
hooksDir, err := gitHooksDir(cfg)
if err != nil {
return "", err
}
path := filepath.Join(hooksDir, "post-merge")
l, lerr := loadLedger(cfg)
if lerr != nil {
return "", lerr
}
b, rerr := os.ReadFile(path)
if errors.Is(rerr, os.ErrNotExist) {
return "post-merge not enabled", nil
}
if rerr != nil {
return "", fmt.Errorf("inspect post-merge: %w", rerr)
}
if !isEecoManaged(b, l.PostMerge.SHA256, postMergeMarker) {
return "", errors.New("post-merge hook is present but not eeco's — left untouched")
}
desired := postMergeScript(cfg.PostMergeWorkflows)
if string(b) == desired {
return "post-merge already current", nil
}
if err := os.WriteFile(path, []byte(desired), 0o755); err != nil {
return "", fmt.Errorf("write post-merge: %w", err)
}
l.PostMerge = record{
Installed: true,
Path: path,
SHA256: sha256hex([]byte(desired)),
At: time.Now().UTC().Format(time.RFC3339),
}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
return "post-merge refreshed (" + path + ")", nil
}
// --- session-start --------------------------------------------------
// sessionCommand is the command string written into the settings file.
// It carries the namespace token so removal is exact and
// path-independent, and --if-initialized so the bundled hook stays
// silent in any repo that is not an initialized eeco workspace.
func sessionCommand() string {
return fmt.Sprintf("%q %s --if-initialized", selfPath(), sessionToken)
}
// sessionGroup is the SessionStart group eeco appends. It is built as a
// generic map so the surrounding settings document round-trips with
// unknown fields preserved.
func sessionGroup() map[string]any {
return map[string]any{
"hooks": []any{
map[string]any{
"type": "command",
"command": sessionCommand(),
},
},
}
}
// EnableSessionStart wires the session-start hook across both delivery
// channels: a JSON-settings file (Claude-shaped, keyed by
// SessionSettingsPath) and one or more text/markdown files
// (marker-block delivery, keyed by SessionFiles). Either channel alone
// is enough; both compose. When neither is configured the function
// returns ErrSessionNotConfigured and touches nothing.
func EnableSessionStart(cfg *config.Config) (string, error) {
if cfg.SessionSettingsPath == "" && len(cfg.SessionFiles) == 0 {
return "", ErrSessionNotConfigured
}
l, lerr := loadLedger(cfg)
if lerr != nil {
return "", lerr
}
var (
jsonMsg string
backup string
fileRecords []fileRecord
)
if cfg.SessionSettingsPath != "" {
m, b, err := enableSessionJSON(cfg)
if err != nil {
return "", err
}
jsonMsg = m
backup = b
}
var fileNotes []string
if len(cfg.SessionFiles) > 0 {
records, errs := enableSessionFiles(cfg)
if len(errs) > 0 {
var msgs []string
for _, e := range errs {
msgs = append(msgs, e.Error())
}
return "", fmt.Errorf("session_files: %s", strings.Join(msgs, "; "))
}
fileRecords = records
for _, r := range records {
fileNotes = append(fileNotes, r.Path)
}
}
l.SessionStart = record{
Installed: true,
Path: cfg.SessionSettingsPath,
Backup: backup,
At: time.Now().UTC().Format(time.RFC3339),
Files: fileRecords,
}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
var parts []string
if jsonMsg != "" {
parts = append(parts, jsonMsg)
}
if len(fileNotes) > 0 {
parts = append(parts, "files "+strings.Join(fileNotes, ", "))
}
if len(parts) == 0 {
return "session-start enabled", nil
}
return "session-start enabled (" + strings.Join(parts, "; ") + ")", nil
}
// enableSessionJSON applies the JSON-settings-file half of the
// session-start hook. Returns a per-channel message and the backup
// path (empty when the settings file did not exist before).
func enableSessionJSON(cfg *config.Config) (msg, backup string, err error) {
path := cfg.SessionSettingsPath
orig, existed, perm, rerr := readSettings(path)
if rerr != nil {
return "", "", rerr
}
root := map[string]any{}
if existed {
if jerr := json.Unmarshal(orig, &root); jerr != nil {
return "", "", fmt.Errorf("settings file %s is not valid JSON — left untouched", path)
}
}
if sessionInstalled(root) {
return path + " already enabled", "", nil
}
backup, berr := backupOriginal(cfg, orig, existed)
if berr != nil {
return "", "", berr
}
addSessionGroup(root)
if werr := writeJSONAtomic(path, root, perm); werr != nil {
return "", "", werr
}
if verr := validateJSON(path); verr != nil {
_ = restoreOriginal(path, orig, existed)
return "", "", fmt.Errorf("settings file failed validation after edit, restored: %w", verr)
}
msg = path
if backup != "" {
msg += ", backup " + backup
}
return msg, backup, nil
}
// DisableSessionStart undoes both delivery channels: removes the eeco
// SessionStart group from the JSON-settings file when configured, and
// removes the marker block from every file recorded in the ledger (or
// in cfg.SessionFiles, when no ledger files survived). Foreign edits
// inside a marker block leave that file untouched with a per-file note.
func DisableSessionStart(cfg *config.Config) (string, error) {
if cfg.SessionSettingsPath == "" && len(cfg.SessionFiles) == 0 {
return "", ErrSessionNotConfigured
}
l, lerr := loadLedger(cfg)
if lerr != nil {
return "", lerr
}
var parts []string
if cfg.SessionSettingsPath != "" {
m, b, err := disableSessionJSON(cfg)
if err != nil {
return "", err
}
if m != "" {
if b != "" {
m += " (backup " + b + ")"
}
parts = append(parts, m)
}
}
// Take the recorded files, fall back to the current configured list
// when the ledger has no entries (older eeco wired the channel; the
// file paths may still be there to clean up).
fileRecs := l.SessionStart.Files
if len(fileRecs) == 0 && len(cfg.SessionFiles) > 0 {
for _, entry := range cfg.SessionFiles {
fileRecs = append(fileRecs, fileRecord{Path: resolveSessionFile(cfg, entry)})
}
}
if len(fileRecs) > 0 {
notes, errs := disableSessionFiles(fileRecs)
if len(errs) > 0 {
var msgs []string
for _, e := range errs {
msgs = append(msgs, e.Error())
}
return "", fmt.Errorf("session_files: %s", strings.Join(msgs, "; "))
}
if len(notes) > 0 {
parts = append(parts, "files left untouched: "+strings.Join(notes, "; "))
} else {
var ps []string
for _, r := range fileRecs {
ps = append(ps, r.Path)
}
parts = append(parts, "files "+strings.Join(ps, ", "))
}
}
l.SessionStart = record{}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
if len(parts) == 0 {
return "session-start not enabled", nil
}
return "session-start disabled (" + strings.Join(parts, "; ") + ")", nil
}
// disableSessionJSON removes the JSON-settings half. Returns "" for msg
// when the settings file did not have an eeco entry (the caller treats
// this as a no-op for the JSON channel).
func disableSessionJSON(cfg *config.Config) (msg, backup string, err error) {
path := cfg.SessionSettingsPath
orig, existed, perm, rerr := readSettings(path)
if rerr != nil {
return "", "", rerr
}
if !existed {
return "", "", nil
}
root := map[string]any{}
if jerr := json.Unmarshal(orig, &root); jerr != nil {
return "", "", fmt.Errorf("settings file %s is not valid JSON — left untouched", path)
}
if !sessionInstalled(root) {
return "", "", nil
}
backup, berr := backupOriginal(cfg, orig, existed)
if berr != nil {
return "", "", berr
}
removeSessionGroups(root)
if werr := writeJSONAtomic(path, root, perm); werr != nil {
return "", "", werr
}
if verr := validateJSON(path); verr != nil {
_ = restoreOriginal(path, orig, existed)
return "", "", fmt.Errorf("settings file failed validation after edit, restored: %w", verr)
}
return path, backup, nil
}
// RefreshSessionStart re-derives both delivery channels from current
// project state. For the file-delivery channel it re-renders the marker
// block in every configured session_files entry (picking up a new queue
// item, an emptied mailbox, a fresh `roadmap*.md` match). For the
// JSON-settings channel it rewrites the eeco SessionStart command when
// the embedded binary path no longer matches what selfPath() produces
// — the self-heal for a brew upgrade that moved the cellar directory
// out from under a previously-installed hook. Refresh is safe to run
// repeatedly; the file outputs are byte-deterministic for a given
// project state, and the JSON rewrite is a no-op when the command is
// already current (idempotent).
func RefreshSessionStart(cfg *config.Config) (string, error) {
if cfg.SessionSettingsPath == "" && len(cfg.SessionFiles) == 0 {
return "", ErrSessionNotConfigured
}
var parts []string
jsonRefreshed := false
if cfg.SessionSettingsPath != "" {
jsonPath, jerr := refreshSessionJSON(cfg)
if jerr != nil {
return "", jerr
}
if jsonPath != "" {
parts = append(parts, jsonPath)
jsonRefreshed = true
}
}
var fileRecords []fileRecord
if len(cfg.SessionFiles) > 0 {
records, errs := refreshSessionFiles(cfg)
if len(errs) > 0 {
var msgs []string
for _, e := range errs {
msgs = append(msgs, e.Error())
}
return "", fmt.Errorf("session_files: %s", strings.Join(msgs, "; "))
}
fileRecords = records
}
if jsonRefreshed || len(fileRecords) > 0 {
l, lerr := loadLedger(cfg)
if lerr != nil {
return "", lerr
}
if len(fileRecords) > 0 {
// Preserve Created across refreshes: a file that existed
// before the first enable must not become "created" by a
// later refresh.
preserved := map[string]bool{}
for _, prev := range l.SessionStart.Files {
preserved[prev.Path] = prev.Created
}
for i := range fileRecords {
if c, ok := preserved[fileRecords[i].Path]; ok {
fileRecords[i].Created = c
}
}
l.SessionStart.Files = fileRecords
}
l.SessionStart.At = time.Now().UTC().Format(time.RFC3339)
if !l.SessionStart.Installed {
l.SessionStart.Installed = true
}
if err := saveLedger(cfg, l); err != nil {
return "", err
}
}
for _, r := range fileRecords {
parts = append(parts, r.Path)
}
if len(parts) == 0 {
return "nothing to refresh (the JSON channel is already current)", nil
}
return "session-start refreshed (" + strings.Join(parts, ", ") + ")", nil
}
// refreshSessionJSON rewrites the eeco SessionStart command in the
// settings file when it carries the namespace token but its current
// command string differs from sessionCommand(). Returns the settings
// path on a successful rewrite, "" when there is nothing to do (no
// settings file, no eeco group present, or the command is already
// current). Atomic via writeJSONAtomic and revalidated like the enable
// path; an existing backup of the pre-refresh bytes is captured under
// <workspace>/state/backups/.
func refreshSessionJSON(cfg *config.Config) (string, error) {
path := cfg.SessionSettingsPath
if path == "" {
return "", nil
}
orig, existed, perm, rerr := readSettings(path)
if rerr != nil {
return "", rerr
}
if !existed {
return "", nil
}
root := map[string]any{}
if jerr := json.Unmarshal(orig, &root); jerr != nil {
return "", fmt.Errorf("settings file %s is not valid JSON — left untouched", path)
}
if !sessionInstalled(root) {
return "", nil
}
want := sessionCommand()
if !rewriteSessionCommand(root, want) {
return "", nil
}
if _, berr := backupOriginal(cfg, orig, existed); berr != nil {
return "", berr
}
if werr := writeJSONAtomic(path, root, perm); werr != nil {
return "", werr
}
if verr := validateJSON(path); verr != nil {
_ = restoreOriginal(path, orig, existed)
return "", fmt.Errorf("settings file failed validation after edit, restored: %w", verr)
}
return path, nil
}
// rewriteSessionCommand walks every SessionStart group in root and
// replaces any command containing eeco's namespace token whose current
// value differs from want. Returns true if any command was changed.
func rewriteSessionCommand(root map[string]any, want string) bool {
changed := false
for _, g := range sessionGroups(root) {
gm, ok := g.(map[string]any)
if !ok {
continue
}
hs, ok := gm["hooks"].([]any)
if !ok {
continue
}
for _, h := range hs {
hm, ok := h.(map[string]any)
if !ok {
continue
}
cmd, ok := hm["command"].(string)
if !ok {
continue
}
if !strings.Contains(cmd, sessionToken) {
continue
}
if cmd == want {
continue
}
hm["command"] = want
changed = true
}
}
return changed
}
// sessionInstalled reports whether root already contains an eeco
// SessionStart group (identified by the namespace token).
func sessionInstalled(root map[string]any) bool {
for _, g := range sessionGroups(root) {
if groupHasToken(g) {
return true
}
}
return false
}
// sessionGroups returns the SessionStart group list, or nil.
func sessionGroups(root map[string]any) []any {
hooks, ok := root["hooks"].(map[string]any)
if !ok {
return nil
}
groups, _ := hooks["SessionStart"].([]any)
return groups
}
// groupHasToken reports whether a SessionStart group carries a hook
// command containing eeco's namespace token.
func groupHasToken(group any) bool {
gm, ok := group.(map[string]any)
if !ok {
return false
}
hs, ok := gm["hooks"].([]any)
if !ok {
return false
}
for _, h := range hs {
hm, ok := h.(map[string]any)
if !ok {
continue
}
if cmd, ok := hm["command"].(string); ok && strings.Contains(cmd, sessionToken) {
return true
}
}
return false
}
func addSessionGroup(root map[string]any) {
hooks, ok := root["hooks"].(map[string]any)
if !ok {
hooks = map[string]any{}
root["hooks"] = hooks
}
groups, _ := hooks["SessionStart"].([]any)
hooks["SessionStart"] = append(groups, sessionGroup())
}
func removeSessionGroups(root map[string]any) {
hooks, ok := root["hooks"].(map[string]any)
if !ok {
return
}
groups, ok := hooks["SessionStart"].([]any)
if !ok {
return
}
kept := make([]any, 0, len(groups))
for _, g := range groups {
if groupHasToken(g) {
continue
}
kept = append(kept, g)
}
if len(kept) == 0 {
// Leave no empty SessionStart array behind: drop the key, and
// the hooks object too if eeco's edit left it empty.
delete(hooks, "SessionStart")
if len(hooks) == 0 {
delete(root, "hooks")
}
return
}
hooks["SessionStart"] = kept
}
// readSettings reads the settings file. A missing file is not an error:
// existed is false and perm defaults to 0o644.
func readSettings(path string) (data []byte, existed bool, perm os.FileMode, err error) {
info, serr := os.Stat(path)
if errors.Is(serr, os.ErrNotExist) {
return nil, false, 0o644, nil
}
if serr != nil {
return nil, false, 0, fmt.Errorf("stat settings: %w", serr)
}
b, rerr := os.ReadFile(path)
if rerr != nil {
return nil, false, 0, fmt.Errorf("read settings: %w", rerr)
}
return b, true, info.Mode().Perm(), nil
}
// backupOriginal copies the pre-edit bytes into the workspace (never
// beside the user's file). When the file did not exist there is nothing
// to back up and it returns "".
func backupOriginal(cfg *config.Config, orig []byte, existed bool) (string, error) {
if !existed {
return "", nil
}
dir := filepath.Join(cfg.Workspace, "state", backupSubdir)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", fmt.Errorf("backup dir: %w", err)
}
name := "session-settings-" + time.Now().UTC().Format("20060102T150405.000000000Z") + ".json"
bp := filepath.Join(dir, name)
if err := os.WriteFile(bp, orig, 0o644); err != nil {
return "", fmt.Errorf("write backup: %w", err)
}
return bp, nil
}
// writeJSONAtomic marshals root and replaces path via a same-directory
// temp file and rename, so a crash mid-write cannot leave a truncated
// settings file.
func writeJSONAtomic(path string, root map[string]any, perm os.FileMode) error {
b, err := json.MarshalIndent(root, "", " ")
if err != nil {
return fmt.Errorf("encode settings: %w", err)
}
b = append(b, '\n')
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".eeco-settings-*")
if err != nil {
return fmt.Errorf("temp settings: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, werr := tmp.Write(b); werr != nil {
tmp.Close()
return fmt.Errorf("write temp settings: %w", werr)
}
if cerr := tmp.Close(); cerr != nil {
return fmt.Errorf("close temp settings: %w", cerr)
}
if perm == 0 {
perm = 0o644
}
if cherr := os.Chmod(tmpName, perm); cherr != nil {
return fmt.Errorf("chmod temp settings: %w", cherr)
}
if rerr := os.Rename(tmpName, path); rerr != nil {
return fmt.Errorf("replace settings: %w", rerr)
}
return nil
}
// validateJSON re-reads path and confirms it parses as JSON.
func validateJSON(path string) error {
b, err := os.ReadFile(path)
if err != nil {
return err
}
var v any
return json.Unmarshal(b, &v)
}
// restoreOriginal puts the pre-edit state back: the original bytes, or
// removal when the file did not exist before the edit.
func restoreOriginal(path string, orig []byte, existed bool) error {
if !existed {
return os.Remove(path)
}
return os.WriteFile(path, orig, 0o644)
}
// --- status ---------------------------------------------------------
// Status returns one human-readable line per hook, reflecting both the
// ledger and on-disk reality (so a hand-removed hook reads as off). It
// changes nothing.
func Status(cfg *config.Config) []string {
l, _ := loadLedger(cfg)
return []string{
PreCommit + ": " + preCommitStatus(cfg, l),
PostMerge + ": " + postMergeStatus(cfg, l),
SessionStart + ": " + sessionStatus(cfg),
CommitMsg + ": " + commitMsgStatus(cfg, l),
CommitGuard + ": " + commitGuardStatus(cfg),
}
}
func preCommitStatus(cfg *config.Config, l ledger) string {
return managedHookStatus(cfg, "pre-commit", l.PreCommit.SHA256, preCommitMarker)
}
func postMergeStatus(cfg *config.Config, l ledger) string {
return managedHookStatus(cfg, "post-merge", l.PostMerge.SHA256, postMergeMarker)
}
// managedHookStatus reports on/off for a repo-scoped managed git hook,
// reflecting on-disk reality so a hand-removed hook reads as off and a
// foreign hook of the same name reads as off-with-note.
func managedHookStatus(cfg *config.Config, hookName, recordedSHA, marker string) string {
hooksDir, err := gitHooksDir(cfg)
if err != nil {
return "unavailable (" + err.Error() + ")"
}
b, rerr := os.ReadFile(filepath.Join(hooksDir, hookName))
if errors.Is(rerr, os.ErrNotExist) {
return "off"
}
if rerr != nil {
return "unknown (" + rerr.Error() + ")"
}
if isEecoManaged(b, recordedSHA, marker) {
return "on"
}
return "off (a non-eeco " + hookName + " hook is present)"
}
func sessionStatus(cfg *config.Config) string {
if cfg.SessionSettingsPath == "" && len(cfg.SessionFiles) == 0 {
return "not configured"
}
jsonOn := false
if cfg.SessionSettingsPath != "" {
orig, existed, _, err := readSettings(cfg.SessionSettingsPath)
switch {
case err != nil || !existed:
jsonOn = false
default:
root := map[string]any{}
if json.Unmarshal(orig, &root) != nil {
return "unknown (settings file is not valid JSON)"
}
jsonOn = sessionInstalled(root)
}
}
filesOn := false
for _, entry := range cfg.SessionFiles {
path := resolveSessionFile(cfg, entry)
b, rerr := os.ReadFile(path)
if rerr != nil {
continue
}
if _, _, found, ferr := findSessionBlock(b); ferr == nil && found {
filesOn = true
break
}
}
if jsonOn || filesOn {
return "on"
}
return "off"
}
// ShortState is the compact "<name>:on/off" pair for the status digest.
func ShortState(cfg *config.Config) string {
l, _ := loadLedger(cfg)
pc := "off"
if strings.HasPrefix(preCommitStatus(cfg, l), "on") {
pc = "on"
}
pm := "off"
if strings.HasPrefix(postMergeStatus(cfg, l), "on") {
pm = "on"
}
ss := sessionStatus(cfg)
if ss != "on" {
ss = "off"
}
cm := "off"
if strings.HasPrefix(commitMsgStatus(cfg, l), "on") {
cm = "on"
}
cg := "off"
if commitGuardStatus(cfg) == "on" {
cg = "on"
}
return "pre-commit:" + pc + " post-merge:" + pm + " session:" + ss + " commit-msg:" + cm + " commit-guard:" + cg
}
added internal/hooks/hooks_test.go
@@ -0,0 +1,1012 @@
package hooks
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/ajhahnde/eeco/internal/config"
)
// newCfg builds a config rooted at a fresh temp repo (with a .git
// directory so pre-commit wiring is supported) and a workspace beside
// it. settings, when non-empty, becomes SessionSettingsPath.
func newCfg(t *testing.T, settings string) *config.Config {
t.Helper()
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
t.Fatal(err)
}
ws := filepath.Join(root, ".eeco")
if err := os.MkdirAll(filepath.Join(ws, "state"), 0o755); err != nil {
t.Fatal(err)
}
return &config.Config{
RepoRoot: root,
WorkspaceName: ".eeco",
Workspace: ws,
SessionSettingsPath: settings,
PreCommitWorkflows: config.DefaultPreCommitWorkflows(),
PostMergeWorkflows: config.DefaultPostMergeWorkflows(),
}
}
func postMergePath(cfg *config.Config) string {
return filepath.Join(cfg.RepoRoot, ".git", "hooks", "post-merge")
}
func preCommitPath(cfg *config.Config) string {
return filepath.Join(cfg.RepoRoot, ".git", "hooks", "pre-commit")
}
func TestPreCommit_EnableWritesExecutableMarkedScript(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX exec bit is not represented on Windows filesystems")
}
cfg := newCfg(t, "")
if _, err := EnablePreCommit(cfg); err != nil {
t.Fatalf("EnablePreCommit: %v", err)
}
p := preCommitPath(cfg)
info, err := os.Stat(p)
if err != nil {
t.Fatalf("stat pre-commit: %v", err)
}
if info.Mode().Perm()&0o100 == 0 {
t.Errorf("pre-commit not executable: %v", info.Mode())
}
b, _ := os.ReadFile(p)
if !strings.Contains(string(b), preCommitMarker) {
t.Errorf("script missing marker line:\n%s", b)
}
if !strings.Contains(string(b), "run leak-guard") {
t.Errorf("script does not invoke leak-guard:\n%s", b)
}
if !strings.Contains(string(b), "run version-sync") {
t.Errorf("script does not invoke version-sync (default list):\n%s", b)
}
if !strings.Contains(string(b), "set -e") {
t.Errorf("script missing `set -e` for fail-fast chain:\n%s", b)
}
}
func TestPreCommit_EnableHonoursCustomWorkflows(t *testing.T) {
cfg := newCfg(t, "")
cfg.PreCommitWorkflows = []string{"comment-hygiene", "leak-guard"}
if _, err := EnablePreCommit(cfg); err != nil {
t.Fatalf("EnablePreCommit: %v", err)
}
b, _ := os.ReadFile(preCommitPath(cfg))
got := string(b)
if !strings.Contains(got, "run comment-hygiene") {
t.Errorf("script does not invoke comment-hygiene:\n%s", got)
}
if !strings.Contains(got, "run leak-guard") {
t.Errorf("script does not invoke leak-guard:\n%s", got)
}
if strings.Contains(got, "run version-sync") {
t.Errorf("custom list must not include the default version-sync step:\n%s", got)
}
chSeen := strings.Index(got, "run comment-hygiene")
lgSeen := strings.Index(got, "run leak-guard")
if chSeen < 0 || lgSeen < 0 || chSeen > lgSeen {
t.Errorf("workflows out of declared order:\n%s", got)
}
}
func TestPreCommit_EnableRefusesEmptyWorkflowList(t *testing.T) {
cfg := newCfg(t, "")
cfg.PreCommitWorkflows = nil
if _, err := EnablePreCommit(cfg); err == nil {
t.Fatal("expected EnablePreCommit to refuse an empty workflow list")
}
if _, err := os.Stat(preCommitPath(cfg)); !os.IsNotExist(err) {
t.Errorf("hook should not exist after refused install (err=%v)", err)
}
}
func TestPreCommit_EnableIsIdempotent(t *testing.T) {
cfg := newCfg(t, "")
if _, err := EnablePreCommit(cfg); err != nil {
t.Fatal(err)
}
msg, err := EnablePreCommit(cfg)
if err != nil {
t.Fatalf("second EnablePreCommit errored: %v", err)
}
if !strings.Contains(msg, "already enabled") {
t.Errorf("msg = %q, want already-enabled", msg)
}
}
func TestPreCommit_EnableRefusesForeignHook(t *testing.T) {
cfg := newCfg(t, "")
hooksDir := filepath.Join(cfg.RepoRoot, ".git", "hooks")
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
t.Fatal(err)
}
foreign := "#!/bin/sh\necho someone elses hook\n"
if err := os.WriteFile(filepath.Join(hooksDir, "pre-commit"), []byte(foreign), 0o755); err != nil {
t.Fatal(err)
}
if _, err := EnablePreCommit(cfg); err == nil {
t.Fatal("expected EnablePreCommit to refuse a foreign hook")
}
b, _ := os.ReadFile(preCommitPath(cfg))
if string(b) != foreign {
t.Errorf("foreign hook was modified:\n%s", b)
}
}
func TestPreCommit_DisableRemovesOnlyEecoHook(t *testing.T) {
cfg := newCfg(t, "")
if _, err := EnablePreCommit(cfg); err != nil {
t.Fatal(err)
}
if _, err := DisablePreCommit(cfg); err != nil {
t.Fatalf("DisablePreCommit: %v", err)
}
if _, err := os.Stat(preCommitPath(cfg)); !os.IsNotExist(err) {
t.Errorf("pre-commit still present after disable (err=%v)", err)
}
// Disabling again is a clean no-op.
if msg, err := DisablePreCommit(cfg); err != nil || !strings.Contains(msg, "not enabled") {
t.Errorf("re-disable: msg=%q err=%v", msg, err)
}
}
func TestPreCommit_DisableLeavesForeignHook(t *testing.T) {
cfg := newCfg(t, "")
hooksDir := filepath.Join(cfg.RepoRoot, ".git", "hooks")
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
t.Fatal(err)
}
foreign := "#!/bin/sh\nmake lint\n"
fp := filepath.Join(hooksDir, "pre-commit")
if err := os.WriteFile(fp, []byte(foreign), 0o755); err != nil {
t.Fatal(err)
}
if _, err := DisablePreCommit(cfg); err == nil {
t.Fatal("expected DisablePreCommit to refuse a foreign hook")
}
if b, _ := os.ReadFile(fp); string(b) != foreign {
t.Errorf("foreign hook was touched:\n%s", b)
}
}
func TestPreCommit_DisableViaMarkerWhenLedgerLost(t *testing.T) {
cfg := newCfg(t, "")
if _, err := EnablePreCommit(cfg); err != nil {
t.Fatal(err)
}
// Simulate a lost ledger: removing it must not strand the hook,
// because the marker line still identifies it as eeco's.
if err := os.Remove(ledgerPath(cfg)); err != nil {
t.Fatal(err)
}
if _, err := DisablePreCommit(cfg); err != nil {
t.Fatalf("DisablePreCommit with lost ledger: %v", err)
}
if _, err := os.Stat(preCommitPath(cfg)); !os.IsNotExist(err) {
t.Error("hook not removed via marker fallback")
}
}
func TestPostMerge_EnableWritesNonBlockingMarkedScript(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX exec bit is not represented on Windows filesystems")
}
cfg := newCfg(t, "")
if _, err := EnablePostMerge(cfg); err != nil {
t.Fatalf("EnablePostMerge: %v", err)
}
p := postMergePath(cfg)
info, err := os.Stat(p)
if err != nil {
t.Fatalf("stat post-merge: %v", err)
}
if info.Mode().Perm()&0o100 == 0 {
t.Errorf("post-merge not executable: %v", info.Mode())
}
got := string(mustRead(t, p))
if !strings.Contains(got, postMergeMarker) {
t.Errorf("script missing marker line:\n%s", got)
}
if !strings.Contains(got, "run memory-drift || true") {
t.Errorf("script does not invoke memory-drift with swallowed exit:\n%s", got)
}
if !strings.Contains(got, "run doc-drift || true") {
t.Errorf("script does not invoke doc-drift with swallowed exit (default list):\n%s", got)
}
// A post-merge runs after the merge has completed, so a drift finding
// must not abort the hook: no `set -e`.
if strings.Contains(got, "set -e") {
t.Errorf("post-merge must not use `set -e`:\n%s", got)
}
}
func TestPostMerge_EnableRefusesEmptyWorkflowList(t *testing.T) {
cfg := newCfg(t, "")
cfg.PostMergeWorkflows = nil
if _, err := EnablePostMerge(cfg); err == nil {
t.Fatal("expected EnablePostMerge to refuse an empty workflow list")
}
if _, err := os.Stat(postMergePath(cfg)); !os.IsNotExist(err) {
t.Errorf("hook should not exist after refused install (err=%v)", err)
}
}
func TestPostMerge_EnableRefusesForeignHook(t *testing.T) {
cfg := newCfg(t, "")
hooksDir := filepath.Join(cfg.RepoRoot, ".git", "hooks")
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
t.Fatal(err)
}
foreign := "#!/bin/sh\necho someone elses post-merge\n"
if err := os.WriteFile(filepath.Join(hooksDir, "post-merge"), []byte(foreign), 0o755); err != nil {
t.Fatal(err)
}
if _, err := EnablePostMerge(cfg); err == nil {
t.Fatal("expected EnablePostMerge to refuse a foreign hook")
}
if b := mustRead(t, postMergePath(cfg)); string(b) != foreign {
t.Errorf("foreign hook was modified:\n%s", b)
}
}
func TestPostMerge_DisableRemovesOnlyEecoHook(t *testing.T) {
cfg := newCfg(t, "")
if _, err := EnablePostMerge(cfg); err != nil {
t.Fatal(err)
}
if _, err := DisablePostMerge(cfg); err != nil {
t.Fatalf("DisablePostMerge: %v", err)
}
if _, err := os.Stat(postMergePath(cfg)); !os.IsNotExist(err) {
t.Errorf("post-merge still present after disable (err=%v)", err)
}
if msg, err := DisablePostMerge(cfg); err != nil || !strings.Contains(msg, "not enabled") {
t.Errorf("re-disable: msg=%q err=%v", msg, err)
}
}
func TestPostMerge_DisableViaMarkerWhenLedgerLost(t *testing.T) {
cfg := newCfg(t, "")
if _, err := EnablePostMerge(cfg); err != nil {
t.Fatal(err)
}
if err := os.Remove(ledgerPath(cfg)); err != nil {
t.Fatal(err)
}
if _, err := DisablePostMerge(cfg); err != nil {
t.Fatalf("DisablePostMerge with lost ledger: %v", err)
}
if _, err := os.Stat(postMergePath(cfg)); !os.IsNotExist(err) {
t.Error("hook not removed via marker fallback")
}
}
func TestPreCommit_RefreshIsNoOpWhenCurrent(t *testing.T) {
cfg := newCfg(t, "")
if _, err := EnablePreCommit(cfg); err != nil {
t.Fatal(err)
}
msg, err := RefreshPreCommit(cfg)
if err != nil {
t.Fatalf("RefreshPreCommit: %v", err)
}
if !strings.Contains(msg, "already current") {
t.Errorf("refresh msg = %q, want already-current", msg)
}
}
func TestPreCommit_RefreshNotEnabledIsNoOp(t *testing.T) {
cfg := newCfg(t, "")
msg, err := RefreshPreCommit(cfg)
if err != nil {
t.Fatalf("RefreshPreCommit: %v", err)
}
if !strings.Contains(msg, "not enabled") {
t.Errorf("refresh msg = %q, want not-enabled", msg)
}
if _, err := os.Stat(preCommitPath(cfg)); !os.IsNotExist(err) {
t.Errorf("refresh created a hook where none existed (err=%v)", err)
}
}
func TestPreCommit_RefreshRewritesStaleBinaryPath(t *testing.T) {
cfg := newCfg(t, "")
hooksDir := filepath.Join(cfg.RepoRoot, ".git", "hooks")
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
t.Fatal(err)
}
// A marker-carrying script with a stale absolute binary path — the
// post-`brew upgrade eeco` / post-workspace-move state the self-heal fixes.
stale := "#!/bin/sh\n" +
"# " + preCommitMarker + "\n" +
"set -e\n" +
"EECO=\"/opt/homebrew/Cellar/eeco/2.0.0/bin/eeco\"\n" +
"\"$EECO\" run leak-guard\n"
fp := preCommitPath(cfg)
if err := os.WriteFile(fp, []byte(stale), 0o755); err != nil {
t.Fatal(err)
}
msg, err := RefreshPreCommit(cfg)
if err != nil {
t.Fatalf("RefreshPreCommit: %v", err)
}
if !strings.Contains(msg, "refreshed") {
t.Errorf("refresh msg = %q, want refreshed", msg)
}
b := mustRead(t, fp)
if strings.Contains(string(b), "Cellar/eeco/2.0.0") {
t.Errorf("stale binary path survived refresh:\n%s", b)
}
if string(b) != preCommitScript(cfg.PreCommitWorkflows) {
t.Errorf("refreshed script is not the current desired script:\n%s", b)
}
}
func TestPreCommit_RefreshRefusesForeignHook(t *testing.T) {
cfg := newCfg(t, "")
hooksDir := filepath.Join(cfg.RepoRoot, ".git", "hooks")
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
t.Fatal(err)
}
foreign := "#!/bin/sh\necho someone elses hook\n"
fp := preCommitPath(cfg)
if err := os.WriteFile(fp, []byte(foreign), 0o755); err != nil {
t.Fatal(err)
}
if _, err := RefreshPreCommit(cfg); err == nil {
t.Fatal("expected RefreshPreCommit to refuse a foreign hook")
}
if b := mustRead(t, fp); string(b) != foreign {
t.Errorf("foreign hook was modified by refresh:\n%s", b)
}
}
func TestPostMerge_RefreshIsNoOpWhenCurrent(t *testing.T) {
cfg := newCfg(t, "")
if _, err := EnablePostMerge(cfg); err != nil {
t.Fatal(err)
}
msg, err := RefreshPostMerge(cfg)
if err != nil {
t.Fatalf("RefreshPostMerge: %v", err)
}
if !strings.Contains(msg, "already current") {
t.Errorf("refresh msg = %q, want already-current", msg)
}
}
func TestPostMerge_RefreshRewritesStaleBinaryPath(t *testing.T) {
cfg := newCfg(t, "")
hooksDir := filepath.Join(cfg.RepoRoot, ".git", "hooks")
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
t.Fatal(err)
}
stale := "#!/bin/sh\n" +
"# " + postMergeMarker + "\n" +
"EECO=\"/opt/homebrew/Cellar/eeco/2.0.0/bin/eeco\"\n" +
"\"$EECO\" run memory-drift || true\n"
fp := postMergePath(cfg)
if err := os.WriteFile(fp, []byte(stale), 0o755); err != nil {
t.Fatal(err)
}
msg, err := RefreshPostMerge(cfg)
if err != nil {
t.Fatalf("RefreshPostMerge: %v", err)
}
if !strings.Contains(msg, "refreshed") {
t.Errorf("refresh msg = %q, want refreshed", msg)
}
b := mustRead(t, fp)
if strings.Contains(string(b), "Cellar/eeco/2.0.0") {
t.Errorf("stale binary path survived refresh:\n%s", b)
}
if string(b) != postMergeScript(cfg.PostMergeWorkflows) {
t.Errorf("refreshed script is not the current desired script:\n%s", b)
}
}
func mustRead(t *testing.T, path string) []byte {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return b
}
func TestSessionStart_NotConfigured(t *testing.T) {
cfg := newCfg(t, "")
if _, err := EnableSessionStart(cfg); err != ErrSessionNotConfigured {
t.Errorf("err = %v, want ErrSessionNotConfigured", err)
}
if _, err := DisableSessionStart(cfg); err != ErrSessionNotConfigured {
t.Errorf("disable err = %v, want ErrSessionNotConfigured", err)
}
}
func TestSessionStart_EnableCreatesValidGroup(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
if _, err := EnableSessionStart(cfg); err != nil {
t.Fatalf("EnableSessionStart: %v", err)
}
b, err := os.ReadFile(sp)
if err != nil {
t.Fatal(err)
}
var root map[string]any
if err := json.Unmarshal(b, &root); err != nil {
t.Fatalf("settings not valid JSON: %v", err)
}
if !sessionInstalled(root) {
t.Errorf("session group not present:\n%s", b)
}
// Idempotent.
msg, err := EnableSessionStart(cfg)
if err != nil || !strings.Contains(msg, "already enabled") {
t.Errorf("re-enable: msg=%q err=%v", msg, err)
}
}
func TestSessionStart_BacksUpAndPreservesForeignKeys(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
original := `{
"model": "x",
"hooks": {
"SessionStart": [
{ "hooks": [ { "type": "command", "command": "other-tool run" } ] }
]
}
}`
if err := os.WriteFile(sp, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
msg, err := EnableSessionStart(cfg)
if err != nil {
t.Fatalf("EnableSessionStart: %v", err)
}
if !strings.Contains(msg, "backup ") {
t.Errorf("expected a backup path in msg, got %q", msg)
}
// A backup of the exact original bytes lives inside the workspace.
backups, _ := os.ReadDir(filepath.Join(cfg.Workspace, "state", backupSubdir))
if len(backups) != 1 {
t.Fatalf("want 1 backup, got %d", len(backups))
}
bb, _ := os.ReadFile(filepath.Join(cfg.Workspace, "state", backupSubdir, backups[0].Name()))
if string(bb) != original {
t.Errorf("backup is not the exact original:\n%s", bb)
}
var root map[string]any
b, _ := os.ReadFile(sp)
if err := json.Unmarshal(b, &root); err != nil {
t.Fatal(err)
}
if root["model"] != "x" {
t.Errorf("foreign top-level key lost: %v", root["model"])
}
groups := sessionGroups(root)
if len(groups) != 2 {
t.Fatalf("want 2 SessionStart groups (foreign + eeco), got %d", len(groups))
}
}
func TestSessionStart_RefusesMalformedJSON(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
bad := "{ not valid json"
if err := os.WriteFile(sp, []byte(bad), 0o644); err != nil {
t.Fatal(err)
}
if _, err := EnableSessionStart(cfg); err == nil {
t.Fatal("expected refusal on malformed settings")
}
if b, _ := os.ReadFile(sp); string(b) != bad {
t.Errorf("malformed settings file was modified:\n%s", b)
}
}
func TestSessionStart_DisableRemovesOnlyEecoGroup(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
original := `{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"keep-me"}]}]}}`
if err := os.WriteFile(sp, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
if _, err := EnableSessionStart(cfg); err != nil {
t.Fatal(err)
}
if _, err := DisableSessionStart(cfg); err != nil {
t.Fatalf("DisableSessionStart: %v", err)
}
var root map[string]any
b, _ := os.ReadFile(sp)
if err := json.Unmarshal(b, &root); err != nil {
t.Fatal(err)
}
if sessionInstalled(root) {
t.Error("eeco group still present after disable")
}
groups := sessionGroups(root)
if len(groups) != 1 {
t.Fatalf("foreign group not preserved, groups=%d", len(groups))
}
gm := groups[0].(map[string]any)
hs := gm["hooks"].([]any)
h0 := hs[0].(map[string]any)
if h0["command"] != "keep-me" {
t.Errorf("wrong group survived: %v", h0["command"])
}
}
func TestStatusReflectsOnDisk(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
got := strings.Join(Status(cfg), "\n")
for _, want := range []string{"pre-commit: off", "post-merge: off", "session-start: off", "commit-msg: off", "commit-guard: off"} {
if !strings.Contains(got, want) {
t.Errorf("fresh status = %q, want %q", got, want)
}
}
if _, err := EnablePreCommit(cfg); err != nil {
t.Fatal(err)
}
if _, err := EnablePostMerge(cfg); err != nil {
t.Fatal(err)
}
if _, err := EnableSessionStart(cfg); err != nil {
t.Fatal(err)
}
if _, err := EnableCommitMsg(cfg); err != nil {
t.Fatal(err)
}
if _, err := EnableCommitGuard(cfg); err != nil {
t.Fatal(err)
}
got = strings.Join(Status(cfg), "\n")
for _, want := range []string{"pre-commit: on", "post-merge: on", "session-start: on", "commit-msg: on", "commit-guard: on"} {
if !strings.Contains(got, want) {
t.Errorf("enabled status = %q, want %q", got, want)
}
}
if ss := ShortState(cfg); ss != "pre-commit:on post-merge:on session:on commit-msg:on commit-guard:on" {
t.Errorf("ShortState = %q", ss)
}
}
func TestSessionStart_NotConfiguredStatus(t *testing.T) {
cfg := newCfg(t, "")
if s := sessionStatus(cfg); s != "not configured" {
t.Errorf("sessionStatus = %q, want 'not configured'", s)
}
}
func TestSessionStart_FileOnlyEnablesAndDisables(t *testing.T) {
cfg := newCfg(t, "")
cfg.SessionFiles = []string{"CLAUDE.md"}
msg, err := EnableSessionStart(cfg)
if err != nil {
t.Fatalf("EnableSessionStart: %v", err)
}
if !strings.Contains(msg, "files") {
t.Errorf("msg = %q, want a files mention", msg)
}
if s := sessionStatus(cfg); s != "on" {
t.Errorf("status after enable = %q, want on", s)
}
if _, derr := DisableSessionStart(cfg); derr != nil {
t.Fatalf("DisableSessionStart: %v", derr)
}
if s := sessionStatus(cfg); s != "off" {
t.Errorf("status after disable = %q, want off", s)
}
if _, err := os.Stat(filepath.Join(cfg.RepoRoot, "CLAUDE.md")); !os.IsNotExist(err) {
t.Errorf("CLAUDE.md still present after disable (err=%v)", err)
}
}
func TestSessionStart_BothChannelsCompose(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
cfg.SessionFiles = []string{"AGENTS.md"}
msg, err := EnableSessionStart(cfg)
if err != nil {
t.Fatalf("EnableSessionStart: %v", err)
}
if !strings.Contains(msg, sp) || !strings.Contains(msg, "files") {
t.Errorf("msg = %q, want both JSON path and files mention", msg)
}
if s := sessionStatus(cfg); s != "on" {
t.Errorf("status = %q, want on", s)
}
// The JSON file got the eeco group.
jb, _ := os.ReadFile(sp)
var root map[string]any
if err := json.Unmarshal(jb, &root); err != nil {
t.Fatalf("settings not valid JSON: %v", err)
}
if !sessionInstalled(root) {
t.Errorf("session group missing in JSON channel")
}
// The file got the marker block.
fb, _ := os.ReadFile(filepath.Join(cfg.RepoRoot, "AGENTS.md"))
if !strings.Contains(string(fb), sessionStartMarker) {
t.Errorf("AGENTS.md missing marker block:\n%s", fb)
}
if _, derr := DisableSessionStart(cfg); derr != nil {
t.Fatalf("DisableSessionStart: %v", derr)
}
if s := sessionStatus(cfg); s != "off" {
t.Errorf("status after disable = %q, want off", s)
}
}
func TestSessionStart_RefreshUpdatesBlock(t *testing.T) {
cfg := newCfg(t, "")
cfg.SessionFiles = []string{"CLAUDE.md"}
if _, err := EnableSessionStart(cfg); err != nil {
t.Fatal(err)
}
path := filepath.Join(cfg.RepoRoot, "CLAUDE.md")
// Adding a README.md should change the auto-detected reading routine.
if err := os.WriteFile(filepath.Join(cfg.RepoRoot, "README.md"), []byte("# x\n"), 0o644); err != nil {
t.Fatal(err)
}
msg, err := RefreshSessionStart(cfg)
if err != nil {
t.Fatalf("RefreshSessionStart: %v", err)
}
if !strings.Contains(msg, "refreshed") {
t.Errorf("msg = %q, want 'refreshed' mention", msg)
}
b, _ := os.ReadFile(path)
if !strings.Contains(string(b), "README.md") {
t.Errorf("refresh did not pick up README.md:\n%s", b)
}
}
func TestSessionStart_RefreshNoFilesIsNoOp(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
if _, err := EnableSessionStart(cfg); err != nil {
t.Fatal(err)
}
msg, err := RefreshSessionStart(cfg)
if err != nil {
t.Fatalf("RefreshSessionStart: %v", err)
}
if !strings.Contains(msg, "nothing to refresh") {
t.Errorf("msg = %q, want 'nothing to refresh' for JSON-only", msg)
}
}
func TestSessionStart_RefreshUnconfiguredErrors(t *testing.T) {
cfg := newCfg(t, "")
if _, err := RefreshSessionStart(cfg); err != ErrSessionNotConfigured {
t.Errorf("err = %v, want ErrSessionNotConfigured", err)
}
}
// fakeBrewCellar lays down a tmpdir-rooted brew layout
// (<prefix>/Cellar/eeco/<version>/bin/eeco) plus a stable bin shim
// (<prefix>/bin/eeco). Returns the prefix, the versioned cellar binary
// path, and the stable shim path.
func fakeBrewCellar(t *testing.T, version string) (prefix, cellarBin, shim string) {
t.Helper()
prefix = t.TempDir()
binDir := filepath.Join(prefix, "bin")
cellarDir := filepath.Join(prefix, "Cellar", "eeco", version, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(cellarDir, 0o755); err != nil {
t.Fatal(err)
}
cellarBin = filepath.Join(cellarDir, "eeco")
if err := os.WriteFile(cellarBin, []byte("real-bin"), 0o755); err != nil {
t.Fatal(err)
}
shim = filepath.Join(binDir, "eeco")
if err := os.WriteFile(shim, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
return prefix, cellarBin, shim
}
func TestStableBrewBin_CellarPathReturnsShim(t *testing.T) {
// Homebrew is macOS/Linux only; the cellar-path heuristic is keyed
// on the unix `/Cellar/eeco/` substring so a Windows tempdir
// (backslash-separated) cannot exercise this path.
if runtime.GOOS == "windows" {
t.Skip("stableBrewBin matches a unix /Cellar/eeco/ substring; brew is not a Windows install path")
}
_, cellarBin, shim := fakeBrewCellar(t, "2.0.0")
got := stableBrewBin(cellarBin)
if got != shim {
t.Errorf("stableBrewBin(%q) = %q, want %q", cellarBin, got, shim)
}
}
func TestStableBrewBin_MissingShimReturnsEmpty(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("stableBrewBin matches a unix /Cellar/eeco/ substring; brew is not a Windows install path")
}
_, cellarBin, shim := fakeBrewCellar(t, "2.0.0")
if err := os.Remove(shim); err != nil {
t.Fatal(err)
}
if got := stableBrewBin(cellarBin); got != "" {
t.Errorf("stableBrewBin without shim = %q, want \"\"", got)
}
}
func TestStableBrewBin_NonCellarPathReturnsEmpty(t *testing.T) {
cases := []string{
"/usr/local/bin/eeco",
"/opt/homebrew/bin/eeco",
"/Users/anyone/go/bin/eeco",
"eeco",
"",
}
for _, p := range cases {
if got := stableBrewBin(p); got != "" {
t.Errorf("stableBrewBin(%q) = %q, want \"\"", p, got)
}
}
}
// staleSessionSettings writes a settings.json carrying an eeco
// SessionStart group whose command embeds a fake versioned cellar path
// that does not match the current sessionCommand() value.
func staleSessionSettings(t *testing.T, path, stale string) {
t.Helper()
body := map[string]any{
"hooks": map[string]any{
"SessionStart": []any{
map[string]any{
"hooks": []any{
map[string]any{
"type": "command",
"command": stale + " " + sessionToken,
},
},
},
},
},
}
b, err := json.MarshalIndent(body, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, append(b, '\n'), 0o644); err != nil {
t.Fatal(err)
}
}
// firstSessionCommand parses path and returns the command string of
// the first SessionStart group whose command carries the eeco
// namespace token. Empty string when nothing matches.
func firstSessionCommand(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read settings: %v", err)
}
var root map[string]any
if err := json.Unmarshal(b, &root); err != nil {
t.Fatalf("parse settings: %v", err)
}
for _, g := range sessionGroups(root) {
gm, ok := g.(map[string]any)
if !ok {
continue
}
hs, ok := gm["hooks"].([]any)
if !ok {
continue
}
for _, h := range hs {
hm, ok := h.(map[string]any)
if !ok {
continue
}
cmd, ok := hm["command"].(string)
if !ok {
continue
}
if strings.Contains(cmd, sessionToken) {
return cmd
}
}
}
return ""
}
func TestSessionStart_RefreshRewritesStaleJSONCommand(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
stale := `"/opt/homebrew/Cellar/eeco/2.0.0/bin/eeco"`
staleSessionSettings(t, sp, stale)
msg, err := RefreshSessionStart(cfg)
if err != nil {
t.Fatalf("RefreshSessionStart: %v", err)
}
if !strings.Contains(msg, "refreshed") {
t.Errorf("msg = %q, want 'refreshed' on stale rewrite", msg)
}
got := firstSessionCommand(t, sp)
want := sessionCommand()
if got != want {
t.Errorf("command after refresh = %q, want %q", got, want)
}
staleCmd := stale + " " + sessionToken
if got == staleCmd {
t.Errorf("stale command still present after refresh: %q", got)
}
}
func TestSessionStart_RefreshCurrentJSONIsNoOp(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
if _, err := EnableSessionStart(cfg); err != nil {
t.Fatal(err)
}
before, err := os.ReadFile(sp)
if err != nil {
t.Fatal(err)
}
beforeInfo, err := os.Stat(sp)
if err != nil {
t.Fatal(err)
}
msg, err := RefreshSessionStart(cfg)
if err != nil {
t.Fatalf("RefreshSessionStart: %v", err)
}
if !strings.Contains(msg, "nothing to refresh") {
t.Errorf("msg = %q, want 'nothing to refresh' when JSON is current", msg)
}
after, _ := os.ReadFile(sp)
if string(before) != string(after) {
t.Errorf("settings file bytes changed on no-op refresh:\nbefore:\n%s\nafter:\n%s", before, after)
}
afterInfo, _ := os.Stat(sp)
if !beforeInfo.ModTime().Equal(afterInfo.ModTime()) {
t.Errorf("settings file mtime changed on no-op refresh: %v -> %v", beforeInfo.ModTime(), afterInfo.ModTime())
}
}
func TestSessionStart_RefreshIgnoresForeignSessionEntries(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
body := `{
"hooks": {
"SessionStart": [
{ "hooks": [ { "type": "command", "command": "other-tool run" } ] }
]
}
}`
if err := os.WriteFile(sp, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
msg, err := RefreshSessionStart(cfg)
if err != nil {
t.Fatalf("RefreshSessionStart: %v", err)
}
if !strings.Contains(msg, "nothing to refresh") {
t.Errorf("msg = %q, want 'nothing to refresh' when no eeco group present", msg)
}
after, _ := os.ReadFile(sp)
if string(after) != body {
t.Errorf("foreign settings file modified by refresh:\n%s", after)
}
}
func TestSessionStart_RefreshMissingSettingsFileIsNoOp(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
msg, err := RefreshSessionStart(cfg)
if err != nil {
t.Fatalf("RefreshSessionStart: %v", err)
}
if !strings.Contains(msg, "nothing to refresh") {
t.Errorf("msg = %q, want 'nothing to refresh' when settings file absent", msg)
}
if _, err := os.Stat(sp); !os.IsNotExist(err) {
t.Errorf("settings file created by refresh; want absent (err=%v)", err)
}
}
func TestSessionStart_RefreshMalformedJSONErrors(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
bad := "{ not valid json"
if err := os.WriteFile(sp, []byte(bad), 0o644); err != nil {
t.Fatal(err)
}
if _, err := RefreshSessionStart(cfg); err == nil {
t.Fatal("expected refusal on malformed settings")
}
if b, _ := os.ReadFile(sp); string(b) != bad {
t.Errorf("malformed settings file was modified:\n%s", b)
}
}
func TestSessionStart_RefreshHandlesBothChannels(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
cfg.SessionFiles = []string{"CLAUDE.md"}
stale := `"/opt/homebrew/Cellar/eeco/2.0.0/bin/eeco"`
staleSessionSettings(t, sp, stale)
// Pre-write a marker block so refresh has something to update.
if _, err := EnableSessionStart(cfg); err == nil {
// Already-installed JSON entry blocks Enable from writing a new
// JSON group; the file channel still wires. Either path is fine
// for this fixture — we only need both channels present.
_ = err
}
msg, err := RefreshSessionStart(cfg)
if err != nil {
t.Fatalf("RefreshSessionStart: %v", err)
}
if !strings.Contains(msg, "refreshed") {
t.Errorf("msg = %q, want 'refreshed'", msg)
}
if !strings.Contains(msg, sp) {
t.Errorf("msg = %q, want JSON path mention", msg)
}
got := firstSessionCommand(t, sp)
if got != sessionCommand() {
t.Errorf("JSON command after refresh = %q, want %q", got, sessionCommand())
}
fb, _ := os.ReadFile(filepath.Join(cfg.RepoRoot, "CLAUDE.md"))
if !strings.Contains(string(fb), sessionStartMarker) {
t.Errorf("file channel not refreshed:\n%s", fb)
}
}
// TestSessionStart_InstalledCommandIsInitGated guards the install side of
// the briefer-gating fix: the command wired into the settings file must
// carry --if-initialized so the bundled hook stays silent outside an eeco
// workspace, in every repo the user opens.
func TestSessionStart_InstalledCommandIsInitGated(t *testing.T) {
dir := t.TempDir()
sp := filepath.Join(dir, "settings.json")
cfg := newCfg(t, sp)
if _, err := EnableSessionStart(cfg); err != nil {
t.Fatalf("EnableSessionStart: %v", err)
}
got := firstSessionCommand(t, sp)
if !strings.Contains(got, "--if-initialized") {
t.Errorf("installed session command = %q, want it to contain --if-initialized", got)
}
}
added internal/hooks/orient_test.go
@@ -0,0 +1,93 @@
package hooks
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/ajhahnde/eeco/internal/cockpit"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/playbooks"
)
// orientCfg builds a config with a workspace + private user dir for the
// SessionStart orient blocks (no git: LatestSemverTag degrades to "").
func orientCfg(t *testing.T) *config.Config {
t.Helper()
root := t.TempDir()
ws := filepath.Join(root, "tester", ".eeco")
if err := os.MkdirAll(filepath.Join(ws, "state"), 0o755); err != nil {
t.Fatal(err)
}
return &config.Config{
RepoRoot: root,
UserDir: filepath.Join(root, "tester"),
WorkspaceName: ".eeco",
Workspace: ws,
}
}
func TestLiveStateBlock_ShowsNewestNote(t *testing.T) {
cfg := orientCfg(t)
notesDir := filepath.Join(cfg.Workspace, "notes")
if err := os.MkdirAll(notesDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(notesDir, "2026-06-05-150000-resume.md"), []byte("resume here next time"), 0o644); err != nil {
t.Fatal(err)
}
got := liveStateBlock(cfg)
if !strings.Contains(got, "newest handover") || !strings.Contains(got, "resume here next time") {
t.Errorf("liveStateBlock missing the handover note: %q", got)
}
}
func TestLiveStateBlock_EmptyWhenNothing(t *testing.T) {
if got := liveStateBlock(orientCfg(t)); got != "" {
t.Errorf("liveStateBlock should be empty with no tag/note, got %q", got)
}
}
func TestNewestHandover_GlobWins(t *testing.T) {
cfg := orientCfg(t)
if err := os.WriteFile(filepath.Join(cfg.RepoRoot, "HANDOVER.md"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
cfg.HandoverGlob = "HANDOVER*.md"
if got := newestHandover(cfg); got != "HANDOVER.md" {
t.Errorf("newestHandover with glob = %q, want HANDOVER.md", got)
}
}
func TestDriftBlock_SilentThenFlagsDrift(t *testing.T) {
cfg := orientCfg(t)
// No cockpit generated → silent (empty-ledger gate).
if got := driftBlock(cfg); got != "" {
t.Errorf("driftBlock should be silent on an unused cockpit, got %q", got)
}
// Narrow the selection to handover so generating it alone leaves no
// "missing" findings for the other playbooks (the default selection is all).
if err := cockpit.SaveSelection(cfg, cockpit.Selection{Targets: []string{"claude"}, Playbooks: []string{"handover"}}); err != nil {
t.Fatal(err)
}
pb, err := playbooks.Get("handover")
if err != nil {
t.Fatal(err)
}
if _, err := cockpit.Generate(cfg, pb, "claude"); err != nil {
t.Fatal(err)
}
// Clean → still silent.
if got := driftBlock(cfg); got != "" {
t.Errorf("driftBlock should be silent when clean, got %q", got)
}
// Hand-edit → drift surfaces.
dst := filepath.Join(cfg.UserDir, ".claude", "skills", "handover", "SKILL.md")
if err := os.WriteFile(dst, []byte("edited\n"), 0o644); err != nil {
t.Fatal(err)
}
if got := driftBlock(cfg); !strings.Contains(got, "cockpit drift") {
t.Errorf("driftBlock should report drift after a hand-edit, got %q", got)
}
}
added internal/hooks/reversible_boundary_test.go
@@ -0,0 +1,165 @@
package hooks
import (
"bytes"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/ajhahnde/eeco/internal/config"
)
// Trust-boundary suite H1.6, invariant (b): every hook type stays reversible.
// enable→disable restores the user's pre-install on-disk reality, and the
// ledger round-trips. The single named guard is driven off intent (Names),
// not off whatever each hook's author happened to test, so a future sixth
// hook cannot silently slip the reversibility net.
// hookCase pins one hook type's enable/disable round-trip: the cfg fixture
// that satisfies its not-configured guard, the on-disk artifact whose
// pre-install reality must be restored, and the namespace marker that must be
// present after enable and gone after disable.
type hookCase struct {
name string
cfg func(*testing.T) *config.Config
enable func(*config.Config) (string, error)
disable func(*config.Config) (string, error)
artifact func(*config.Config) string
marker string
}
func gitHookPath(c *config.Config, name string) string {
return filepath.Join(c.RepoRoot, ".git", "hooks", name)
}
func boundaryHookCases() []hookCase {
return []hookCase{
{
name: PreCommit,
cfg: func(t *testing.T) *config.Config { return newCfg(t, "") },
enable: EnablePreCommit,
disable: DisablePreCommit,
artifact: func(c *config.Config) string { return gitHookPath(c, "pre-commit") },
marker: preCommitMarker,
},
{
name: PostMerge,
cfg: func(t *testing.T) *config.Config { return newCfg(t, "") },
enable: EnablePostMerge,
disable: DisablePostMerge,
artifact: func(c *config.Config) string { return gitHookPath(c, "post-merge") },
marker: postMergeMarker,
},
{
name: CommitMsg,
cfg: func(t *testing.T) *config.Config { return newCfg(t, "") },
enable: EnableCommitMsg,
disable: DisableCommitMsg,
artifact: func(c *config.Config) string { return gitHookPath(c, "commit-msg") },
marker: commitMsgMarker,
},
{
name: SessionStart,
cfg: func(t *testing.T) *config.Config { return sessionCfg(t, "CLAUDE.md") },
enable: EnableSessionStart,
disable: DisableSessionStart,
artifact: func(c *config.Config) string { return filepath.Join(c.RepoRoot, "CLAUDE.md") },
marker: sessionStartMarker,
},
{
name: CommitGuard,
cfg: func(t *testing.T) *config.Config {
return newCfg(t, filepath.Join(t.TempDir(), "settings.json"))
},
enable: EnableCommitGuard,
disable: DisableCommitGuard,
artifact: func(c *config.Config) string { return c.SessionSettingsPath },
marker: commitGuardToken,
},
}
}
func TestBoundary_AllHooksReversible(t *testing.T) {
cases := boundaryHookCases()
if len(cases) != len(Names) {
t.Fatalf("reversibility cases = %d, hooks.Names = %d — a new hook in Names lacks a reversibility case", len(cases), len(Names))
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := tc.cfg(t)
art := tc.artifact(cfg)
// Pre-install reality: every artifact in this matrix is absent.
if _, err := os.Stat(art); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("precondition: artifact %s should be absent, stat err=%v", art, err)
}
if _, err := tc.enable(cfg); err != nil {
t.Fatalf("enable: %v", err)
}
b, err := os.ReadFile(art)
if err != nil {
t.Fatalf("artifact missing after enable: %v", err)
}
if !strings.Contains(string(b), tc.marker) {
t.Fatalf("enabled artifact lacks the eeco marker %q:\n%s", tc.marker, b)
}
if _, err := tc.disable(cfg); err != nil {
t.Fatalf("disable: %v", err)
}
// Restored pre-install reality: the artifact is absent again, OR a
// now-empty managed file that no longer carries the eeco marker
// (the JSON channel leaves a stripped {} behind by design).
rb, rerr := os.ReadFile(art)
if errors.Is(rerr, os.ErrNotExist) {
return
}
if rerr != nil {
t.Fatalf("re-read artifact after disable: %v", rerr)
}
if strings.Contains(string(rb), tc.marker) {
t.Errorf("disable did not restore pre-install state — marker %q still present:\n%s", tc.marker, rb)
}
})
}
}
// TestBoundary_HookLedgerRoundTrips pins the reversibility record itself: a
// fully-populated ledger (all 5 records, session-start carrying a fileRecord)
// survives save→load→save byte-identically and parses back equal.
func TestBoundary_HookLedgerRoundTrips(t *testing.T) {
cfg := newCfg(t, "")
at := "2026-05-31T00:00:00Z"
want := ledger{
PreCommit: record{Installed: true, Path: "/h/pre-commit", SHA256: "aa", At: at},
PostMerge: record{Installed: true, Path: "/h/post-merge", SHA256: "bb", At: at},
SessionStart: record{Installed: true, Path: "/s/settings.json", Backup: "/b/sess.json", At: at, Files: []fileRecord{{Path: "/r/CLAUDE.md", SHA256: "cc", Created: true}}},
CommitMsg: record{Installed: true, Path: "/h/commit-msg", SHA256: "dd", At: at},
CommitGuard: record{Installed: true, Path: "/s/settings.json", Backup: "/b/guard.json", At: at},
}
if err := saveLedger(cfg, want); err != nil {
t.Fatalf("saveLedger: %v", err)
}
got, err := loadLedger(cfg)
if err != nil {
t.Fatalf("loadLedger: %v", err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ledger round-trip mismatch:\n got %+v\nwant %+v", got, want)
}
first := mustRead(t, ledgerPath(cfg))
if err := saveLedger(cfg, got); err != nil {
t.Fatalf("re-save: %v", err)
}
second := mustRead(t, ledgerPath(cfg))
if !bytes.Equal(first, second) {
t.Errorf("ledger bytes not stable across save→load→save:\nfirst:\n%s\nsecond:\n%s", first, second)
}
}
added internal/hooks/sessiondelivery.go
@@ -0,0 +1,377 @@
package hooks
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/ajhahnde/eeco/internal/config"
)
// Marker spellings for the file-based session-start delivery channel.
// Mirrors the `<!-- eeco:archive:... -->` spellings from
// `eeco docs compact`. HTML-comment so a Markdown reader (and
// most assistants reading a CLAUDE.md / AGENTS.md / .cursorrules) hides
// them from the rendered prose while still treating the body between
// them as content.
const (
sessionStartMarker = "<!-- eeco:session:start -->"
sessionEndMarker = "<!-- eeco:session:end -->"
)
// sessionBlockHeader is a single HTML-comment line placed inside the
// block (right after the start marker) so an operator reading the file
// sees what the block is and how to remove it. Kept terse.
const sessionBlockHeader = "<!-- Managed by eeco. Removed by `eeco hooks session-start off`; refreshed by `eeco hooks session-start refresh`. -->"
// fileRecord is one managed file's reversibility state for the
// session-start file delivery channel.
type fileRecord struct {
Path string `json:"path"`
SHA256 string `json:"sha256"`
Created bool `json:"created,omitempty"`
}
// resolveSessionFile expands a session_files entry against the repo
// root. Repo-relative entries are joined with cfg.RepoRoot; absolute
// entries are accepted verbatim. The config parser already rejected
// `..` traversal and whitespace for repo-relative entries.
func resolveSessionFile(cfg *config.Config, entry string) string {
if filepath.IsAbs(entry) {
return entry
}
return filepath.Join(cfg.RepoRoot, entry)
}
// renderSessionBlock builds the marker-delimited block from emitted
// content. The exact bytes are deterministic for a given input so a
// repeated enable/refresh with the same project state is a byte-for-byte
// no-op (idempotency).
func renderSessionBlock(emitted string, newline string) string {
var b strings.Builder
b.WriteString(sessionStartMarker)
b.WriteString(newline)
b.WriteString(sessionBlockHeader)
b.WriteString(newline)
if emitted != "" {
// Emit may end with a trailing newline; strip then re-add a
// single newline so the block ends consistently.
body := strings.TrimRight(emitted, "\r\n")
b.WriteString(body)
b.WriteString(newline)
}
b.WriteString(sessionEndMarker)
b.WriteString(newline)
return b.String()
}
// emitSessionContent renders what the file-delivery block should
// contain: the same text the JSON-channel command (`eeco hooks
// session-emit`) prints. When every Emit block is empty (no docs, no
// mailbox content, no queue items) the returned string is empty and the
// caller still writes the block — a minimal block with no content body
// is a valid signal to the operator that delivery is wired.
func emitSessionContent(cfg *config.Config) string {
var buf bytes.Buffer
Emit(cfg, &buf)
return buf.String()
}
// findSessionBlock walks src once and returns the byte offsets of the
// start-marker line and the end-marker line for the single eeco
// session-start block. Markers inside fenced code blocks are ignored.
// found=false when no markers (or only one) are present at top level.
// An unmatched/nested marker pair is reported as an error so the
// caller can refuse to write rather than guess.
func findSessionBlock(src []byte) (startByte, endByte int, found bool, err error) {
lines := splitLinesKeepEOL(src)
inFence := false
startIdx, endIdx := -1, -1
for i, line := range lines {
trimmed := strings.TrimRight(line, "\r\n")
stripped := strings.TrimLeft(trimmed, " \t")
if strings.HasPrefix(stripped, "```") || strings.HasPrefix(stripped, "~~~") {
inFence = !inFence
continue
}
if inFence {
continue
}
marker := strings.TrimSpace(trimmed)
switch marker {
case sessionStartMarker:
if startIdx != -1 {
return 0, 0, false, fmt.Errorf("session-start file: nested start marker at line %d (previous still open at line %d)", i+1, startIdx+1)
}
startIdx = i
case sessionEndMarker:
if startIdx == -1 {
return 0, 0, false, fmt.Errorf("session-start file: end marker at line %d with no matching start", i+1)
}
endIdx = i
// Compute byte offsets by summing line lengths.
startByte = 0
for j := 0; j < startIdx; j++ {
startByte += len(lines[j])
}
endByte = startByte
for j := startIdx; j <= endIdx; j++ {
endByte += len(lines[j])
}
return startByte, endByte, true, nil
}
}
if startIdx != -1 {
return 0, 0, false, fmt.Errorf("session-start file: start marker at line %d with no matching end", startIdx+1)
}
return 0, 0, false, nil
}
// writeFileAtomic mirrors writeJSONAtomic's discipline — same-directory
// temp + rename — but takes raw bytes so it serves the file-delivery
// channel that does not produce JSON.
func writeFileAtomic(path string, content []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("ensure dir %s: %w", dir, err)
}
tmp, err := os.CreateTemp(dir, ".eeco-session-*")
if err != nil {
return fmt.Errorf("temp file: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, werr := tmp.Write(content); werr != nil {
tmp.Close()
return fmt.Errorf("write temp file: %w", werr)
}
if cerr := tmp.Close(); cerr != nil {
return fmt.Errorf("close temp file: %w", cerr)
}
if perm == 0 {
perm = 0o644
}
if cherr := os.Chmod(tmpName, perm); cherr != nil {
return fmt.Errorf("chmod temp file: %w", cherr)
}
if rerr := os.Rename(tmpName, path); rerr != nil {
return fmt.Errorf("replace file: %w", rerr)
}
return nil
}
// dominantNewlineRaw picks the newline style used most often in src,
// with a "\n" fallback for empty or newline-less input.
func dominantNewlineRaw(src []byte) string {
crlf := bytes.Count(src, []byte("\r\n"))
lf := bytes.Count(src, []byte("\n")) - crlf
if crlf > lf {
return "\r\n"
}
return "\n"
}
// applySessionBlock returns the bytes of path after enabling/refreshing
// the marker block, plus per-file metadata: the file existed before this
// write, and the sha256 of the new block content alone. When the file
// did not exist, a new file is created containing only the block.
type applyResult struct {
NewBytes []byte
Block string
Perm os.FileMode
Existed bool
Newline string
}
func applySessionBlock(path string, emitted string) (applyResult, error) {
var res applyResult
info, statErr := os.Stat(path)
if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
return res, fmt.Errorf("stat %s: %w", path, statErr)
}
if statErr == nil && info.IsDir() {
return res, fmt.Errorf("%s is a directory", path)
}
var existing []byte
if statErr == nil {
res.Existed = true
res.Perm = info.Mode().Perm()
b, rerr := os.ReadFile(path)
if rerr != nil {
return res, fmt.Errorf("read %s: %w", path, rerr)
}
existing = b
} else {
res.Perm = 0o644
}
res.Newline = dominantNewlineRaw(existing)
res.Block = renderSessionBlock(emitted, res.Newline)
if !res.Existed {
res.NewBytes = []byte(res.Block)
return res, nil
}
startByte, endByte, found, ferr := findSessionBlock(existing)
if ferr != nil {
return res, ferr
}
if found {
var buf bytes.Buffer
buf.Write(existing[:startByte])
buf.WriteString(res.Block)
buf.Write(existing[endByte:])
res.NewBytes = buf.Bytes()
return res, nil
}
// No block present yet. Append at EOF, guaranteeing a blank line
// between any prior content and the new block.
var buf bytes.Buffer
buf.Write(existing)
if len(existing) > 0 {
if !bytes.HasSuffix(existing, []byte("\n")) {
buf.WriteString(res.Newline)
}
buf.WriteString(res.Newline)
}
buf.WriteString(res.Block)
res.NewBytes = buf.Bytes()
return res, nil
}
// enableSessionFiles materialises the marker block in every configured
// session_files entry. Each entry's outcome is independent: a per-file
// failure is returned in errs but does not abort the others. A path that
// existed before this write is preserved (block replaced in place when
// present, appended at EOF otherwise); a fresh file is created with
// only the block content.
func enableSessionFiles(cfg *config.Config) (records []fileRecord, errs []error) {
emitted := emitSessionContent(cfg)
for _, entry := range cfg.SessionFiles {
path := resolveSessionFile(cfg, entry)
res, err := applySessionBlock(path, emitted)
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", entry, err))
continue
}
if err := writeFileAtomic(path, res.NewBytes, res.Perm); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", entry, err))
continue
}
records = append(records, fileRecord{
Path: path,
SHA256: sha256hex([]byte(res.Block)),
Created: !res.Existed,
})
}
return records, errs
}
// refreshSessionFiles re-renders the marker block in every entry. The
// outcome shape mirrors enableSessionFiles; a path that has lost its
// block (operator deleted the markers, or the file is gone) is treated
// the same as on enable — block re-inserted, or file re-created when
// missing.
func refreshSessionFiles(cfg *config.Config) (records []fileRecord, errs []error) {
return enableSessionFiles(cfg)
}
// disableSessionFiles removes the marker block from every recorded path
// that still carries an eeco-written block. A file whose block has been
// hand-edited (sha mismatch) is left untouched and reported in notes;
// a file whose only content was the block is deleted, restoring the
// pre-enable state.
func disableSessionFiles(records []fileRecord) (notes []string, errs []error) {
for _, rec := range records {
existing, rerr := os.ReadFile(rec.Path)
if errors.Is(rerr, os.ErrNotExist) {
continue
}
if rerr != nil {
errs = append(errs, fmt.Errorf("%s: %w", rec.Path, rerr))
continue
}
startByte, endByte, found, ferr := findSessionBlock(existing)
if ferr != nil {
notes = append(notes, fmt.Sprintf("%s: %v — left untouched", rec.Path, ferr))
continue
}
if !found {
continue
}
blockBytes := existing[startByte:endByte]
if rec.SHA256 != "" && sha256hex(blockBytes) != rec.SHA256 {
notes = append(notes, fmt.Sprintf("%s: session block edited since install — left untouched", rec.Path))
continue
}
head := append([]byte{}, existing[:startByte]...)
tail := existing[endByte:]
// When the block was at EOF, the bytes we kept ended with the
// blank-line separator we inserted on enable. Normalise to a
// single trailing newline (or nothing, when head is empty); the
// in-middle case keeps head+tail byte-identical to the
// pre-enable bytes.
var remaining []byte
if len(tail) == 0 {
head = bytes.TrimRight(head, "\r\n")
if len(head) > 0 {
head = append(head, '\n')
}
remaining = head
} else {
remaining = append(head, tail...)
}
if rec.Created && isOnlyWhitespace(remaining) {
if err := os.Remove(rec.Path); err != nil && !errors.Is(err, os.ErrNotExist) {
errs = append(errs, fmt.Errorf("%s: %w", rec.Path, err))
}
continue
}
perm := os.FileMode(0o644)
if info, serr := os.Stat(rec.Path); serr == nil {
perm = info.Mode().Perm()
}
if err := writeFileAtomic(rec.Path, remaining, perm); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", rec.Path, err))
}
}
return notes, errs
}
// isOnlyWhitespace reports whether b consists solely of whitespace
// characters.
func isOnlyWhitespace(b []byte) bool {
for _, c := range b {
switch c {
case ' ', '\t', '\r', '\n':
continue
default:
return false
}
}
return true
}
// splitLinesKeepEOL returns the lines of src with the trailing newline
// (LF or CRLF) preserved on each. An unterminated final line is
// returned as-is. Mirrors internal/docs/compact.go's helper.
func splitLinesKeepEOL(src []byte) []string {
var lines []string
for len(src) > 0 {
i := bytes.IndexByte(src, '\n')
if i < 0 {
lines = append(lines, string(src))
break
}
lines = append(lines, string(src[:i+1]))
src = src[i+1:]
}
return lines
}
added internal/hooks/sessiondelivery_test.go
@@ -0,0 +1,283 @@
package hooks
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/ajhahnde/eeco/internal/config"
)
// sessionCfg builds a session-files-only cfg for delivery tests.
func sessionCfg(t *testing.T, files ...string) *config.Config {
t.Helper()
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil {
t.Fatal(err)
}
ws := filepath.Join(root, ".eeco")
if err := os.MkdirAll(filepath.Join(ws, "state"), 0o755); err != nil {
t.Fatal(err)
}
return &config.Config{
RepoRoot: root,
WorkspaceName: ".eeco",
Workspace: ws,
SessionFiles: files,
PreCommitWorkflows: config.DefaultPreCommitWorkflows(),
PostMergeWorkflows: config.DefaultPostMergeWorkflows(),
}
}
func TestEnableSessionFiles_CreatesMissingFile(t *testing.T) {
cfg := sessionCfg(t, "CLAUDE.md")
records, errs := enableSessionFiles(cfg)
if len(errs) != 0 {
t.Fatalf("errs: %v", errs)
}
if len(records) != 1 {
t.Fatalf("records = %d, want 1", len(records))
}
if !records[0].Created {
t.Errorf("Created = false, want true (file did not exist)")
}
b, err := os.ReadFile(filepath.Join(cfg.RepoRoot, "CLAUDE.md"))
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(string(b), sessionStartMarker) {
t.Errorf("file does not start with start marker:\n%s", b)
}
if !strings.Contains(string(b), sessionEndMarker) {
t.Errorf("file missing end marker:\n%s", b)
}
if !strings.Contains(string(b), sessionBlockHeader) {
t.Errorf("file missing managed header:\n%s", b)
}
}
func TestEnableSessionFiles_AppendsToExisting(t *testing.T) {
cfg := sessionCfg(t, "CLAUDE.md")
path := filepath.Join(cfg.RepoRoot, "CLAUDE.md")
original := "# Project notes\n\nSome content here.\n"
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
records, errs := enableSessionFiles(cfg)
if len(errs) != 0 {
t.Fatalf("errs: %v", errs)
}
if records[0].Created {
t.Errorf("Created = true, want false")
}
b, _ := os.ReadFile(path)
got := string(b)
if !strings.HasPrefix(got, original) {
t.Errorf("original content not preserved at file start:\n%s", got)
}
idx := strings.Index(got, sessionStartMarker)
if idx <= 0 {
t.Errorf("start marker missing or at file start:\n%s", got)
}
}
func TestEnableSessionFiles_ReplacesExistingBlock(t *testing.T) {
cfg := sessionCfg(t, "CLAUDE.md")
path := filepath.Join(cfg.RepoRoot, "CLAUDE.md")
original := "# Top\n\n" + sessionStartMarker + "\nOLD\n" + sessionEndMarker + "\n\n# Bottom\n"
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
if _, errs := enableSessionFiles(cfg); len(errs) != 0 {
t.Fatalf("errs: %v", errs)
}
b, _ := os.ReadFile(path)
got := string(b)
if strings.Contains(got, "OLD") {
t.Errorf("old block content not replaced:\n%s", got)
}
if !strings.Contains(got, "# Top") || !strings.Contains(got, "# Bottom") {
t.Errorf("surrounding content lost:\n%s", got)
}
if strings.Count(got, sessionStartMarker) != 1 || strings.Count(got, sessionEndMarker) != 1 {
t.Errorf("marker count wrong: starts=%d ends=%d\n%s",
strings.Count(got, sessionStartMarker),
strings.Count(got, sessionEndMarker), got)
}
}
func TestEnableSessionFiles_IgnoresFencedMarkers(t *testing.T) {
cfg := sessionCfg(t, "DOC.md")
path := filepath.Join(cfg.RepoRoot, "DOC.md")
// Documentation that mentions the marker syntax inside a fenced
// code block must not be interpreted as a real eeco block.
original := "# Doc\n\n```\n" + sessionStartMarker + "\n```\n"
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
records, errs := enableSessionFiles(cfg)
if len(errs) != 0 {
t.Fatalf("errs: %v", errs)
}
if records[0].Created {
t.Errorf("Created = true, want false")
}
b, _ := os.ReadFile(path)
got := string(b)
if !strings.Contains(got, "```\n"+sessionStartMarker+"\n```") {
t.Errorf("fenced marker mention was modified:\n%s", got)
}
if strings.Count(got, sessionEndMarker) != 1 {
t.Errorf("end marker should appear exactly once at EOF, got:\n%s", got)
}
}
func TestEnableSessionFiles_Idempotent(t *testing.T) {
cfg := sessionCfg(t, "CLAUDE.md")
path := filepath.Join(cfg.RepoRoot, "CLAUDE.md")
if _, errs := enableSessionFiles(cfg); len(errs) != 0 {
t.Fatal(errs)
}
b1, _ := os.ReadFile(path)
if _, errs := enableSessionFiles(cfg); len(errs) != 0 {
t.Fatal(errs)
}
b2, _ := os.ReadFile(path)
if !bytes.Equal(b1, b2) {
t.Errorf("second enable produced different bytes:\nfirst=%q\nsecond=%q", b1, b2)
}
}
func TestEnableSessionFiles_AcceptsAbsolutePath(t *testing.T) {
abs := filepath.Join(t.TempDir(), "rules.md")
cfg := sessionCfg(t, abs)
records, errs := enableSessionFiles(cfg)
if len(errs) != 0 {
t.Fatalf("errs: %v", errs)
}
if records[0].Path != abs {
t.Errorf("path = %q, want %q", records[0].Path, abs)
}
if _, err := os.Stat(abs); err != nil {
t.Errorf("absolute target not written: %v", err)
}
}
func TestEnableSessionFiles_RejectsNestedMarkers(t *testing.T) {
cfg := sessionCfg(t, "CLAUDE.md")
path := filepath.Join(cfg.RepoRoot, "CLAUDE.md")
bad := sessionStartMarker + "\n" + sessionStartMarker + "\n" + sessionEndMarker + "\n"
if err := os.WriteFile(path, []byte(bad), 0o644); err != nil {
t.Fatal(err)
}
_, errs := enableSessionFiles(cfg)
if len(errs) != 1 {
t.Fatalf("errs = %v, want exactly one", errs)
}
b, _ := os.ReadFile(path)
if string(b) != bad {
t.Errorf("file was modified despite malformed markers:\n%s", b)
}
}
func TestDisableSessionFiles_RemovesEecoCreatedFile(t *testing.T) {
cfg := sessionCfg(t, "CLAUDE.md")
path := filepath.Join(cfg.RepoRoot, "CLAUDE.md")
records, errs := enableSessionFiles(cfg)
if len(errs) != 0 {
t.Fatal(errs)
}
notes, derrs := disableSessionFiles(records)
if len(derrs) != 0 || len(notes) != 0 {
t.Fatalf("disable errs=%v notes=%v", derrs, notes)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("file still exists after disable: %v", err)
}
}
func TestDisableSessionFiles_RestoresPreEnableContent(t *testing.T) {
cfg := sessionCfg(t, "CLAUDE.md")
path := filepath.Join(cfg.RepoRoot, "CLAUDE.md")
original := "# Project\n\nKept content.\n"
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatal(err)
}
records, _ := enableSessionFiles(cfg)
notes, derrs := disableSessionFiles(records)
if len(derrs) != 0 || len(notes) != 0 {
t.Fatalf("disable errs=%v notes=%v", derrs, notes)
}
b, _ := os.ReadFile(path)
if string(b) != original {
t.Errorf("post-disable content differs:\nwant=%q\ngot =%q", original, b)
}
}
func TestDisableSessionFiles_LeavesForeignEditedBlockUntouched(t *testing.T) {
cfg := sessionCfg(t, "CLAUDE.md")
path := filepath.Join(cfg.RepoRoot, "CLAUDE.md")
records, _ := enableSessionFiles(cfg)
// Operator hand-edits the block content between markers.
b, _ := os.ReadFile(path)
tampered := bytes.Replace(b, []byte(sessionBlockHeader), []byte("<!-- hand-edited -->"), 1)
if err := os.WriteFile(path, tampered, 0o644); err != nil {
t.Fatal(err)
}
notes, derrs := disableSessionFiles(records)
if len(derrs) != 0 {
t.Fatalf("derrs: %v", derrs)
}
if len(notes) != 1 || !strings.Contains(notes[0], "edited since install") {
t.Errorf("expected one foreign-edit note, got: %v", notes)
}
// The file is left exactly as the operator edited it.
post, _ := os.ReadFile(path)
if !bytes.Equal(post, tampered) {
t.Errorf("file modified despite foreign edit detection")
}
}
func TestRefreshSessionFiles_UpdatesBlock(t *testing.T) {
cfg := sessionCfg(t, "CLAUDE.md")
path := filepath.Join(cfg.RepoRoot, "CLAUDE.md")
if _, errs := enableSessionFiles(cfg); len(errs) != 0 {
t.Fatal(errs)
}
// Simulate state drift: write a docs file the auto-detect picks up.
if err := os.WriteFile(filepath.Join(cfg.RepoRoot, "README.md"), []byte("# x\n"), 0o644); err != nil {
t.Fatal(err)
}
records, errs := refreshSessionFiles(cfg)
if len(errs) != 0 {
t.Fatal(errs)
}
b, _ := os.ReadFile(path)
if !strings.Contains(string(b), "README.md") {
t.Errorf("refresh did not pick up the new README.md mention:\n%s", b)
}
if records[0].SHA256 == "" {
t.Errorf("refresh did not record a sha")
}
}
func TestFindSessionBlock_NoMarkers(t *testing.T) {
src := []byte("# plain doc\n\nno markers here\n")
_, _, found, err := findSessionBlock(src)
if err != nil || found {
t.Errorf("found=%v err=%v, want found=false err=nil", found, err)
}
}
func TestRenderSessionBlock_DeterministicNewlines(t *testing.T) {
lf := renderSessionBlock("hello\n", "\n")
crlf := renderSessionBlock("hello\n", "\r\n")
if strings.Contains(lf, "\r") {
t.Errorf("LF render contained CR:\n%q", lf)
}
if !strings.Contains(crlf, "\r\n") {
t.Errorf("CRLF render missing CRLF:\n%q", crlf)
}
}
added internal/hooks/sessionemit.go
@@ -0,0 +1,370 @@
package hooks
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/ajhahnde/eeco/internal/cockpit"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/gitx"
"github.com/ajhahnde/eeco/internal/memory"
"github.com/ajhahnde/eeco/internal/notes"
"github.com/ajhahnde/eeco/internal/playbooks"
"github.com/ajhahnde/eeco/internal/queue"
)
// autoDetectDocs is the ordered list of repo-relative paths the bundled
// session-start hook checks when SessionStartDocs is empty. Only the
// entries that exist on disk are surfaced; the rest are silently
// skipped. The order is the read order — frozen contract docs first,
// then architecture, then changelog, then the more general entry
// points.
var autoDetectDocs = []string{
"docs/PUBLIC_API.md",
"docs/ARCHITECTURE.md",
"CHANGELOG.md",
"ARCHITECTURE.md",
"docs/USAGE.md",
"README.md",
}
// Emit writes any non-empty session-start blocks to w, separated by
// blank lines, in the order: reading routine, mailbox, queue reminder,
// pinned memory bodies, live state, cockpit drift. The pinned block fires
// only when cfg.SessionStartPinnedBodies is true and at least one `pin: true`
// memory fact exists. The live-state block (version + newest handover note)
// and the cockpit-drift block (live cockpit.Sync findings) are the cockpit
// machinery's SessionStart orientation; both are silent on a repo that carries
// no tags / notes / generated cockpit, so non-cockpit output is unchanged.
//
// Emit is strictly READ-ONLY (best-effort: missing or unreadable state is
// treated as absent, and it never returns an error) so it is safe to wire
// directly into a session-start hook. Any session-start WRITE — clearing the
// one-shot git-write sentinels, advancing a throttle stamp — lives in the cmd
// runner (runSessionEmit), never here.
func Emit(cfg *config.Config, w io.Writer) {
if cfg == nil {
return
}
var blocks []string
if r := readingRoutine(cfg); r != "" {
blocks = append(blocks, r)
}
if m := mailboxBlock(cfg); m != "" {
blocks = append(blocks, m)
}
if q := queueLine(cfg); q != "" {
blocks = append(blocks, q)
}
if cfg.SessionStartPinnedBodies {
if p := pinnedMemoriesBlock(cfg); p != "" {
blocks = append(blocks, p)
}
}
if ls := liveStateBlock(cfg); ls != "" {
blocks = append(blocks, ls)
}
if d := driftBlock(cfg); d != "" {
blocks = append(blocks, d)
}
if len(blocks) == 0 {
return
}
fmt.Fprintln(w, strings.Join(blocks, "\n\n"))
}
// liveStateBlock composes the "live state" orientation block: the newest
// semver-shaped git tag (the current version) and the newest handover / resume
// note (the session's resume point). The handover note is the most-recently
// -modified handover_glob match when that config key is set, else the newest
// note under <workspace>/notes/. Returns "" when neither is available.
// Best-effort and read-only: any error degrades to a missing field or "".
func liveStateBlock(cfg *config.Config) string {
version, _ := gitx.LatestSemverTag(cfg.RepoRoot)
handover := newestHandover(cfg)
if version == "" && handover == "" {
return ""
}
var b strings.Builder
b.WriteString("[eeco live state]")
if version != "" {
b.WriteString("\n version: ")
b.WriteString(version)
}
if handover != "" {
b.WriteString("\n newest handover: ")
b.WriteString(handover)
}
return b.String()
}
// driftBlock composes the live cockpit-drift orientation block: it runs the
// read-only cockpit.Sync over every registered playbook and prints each
// finding's Detail, one per line. Returns "" when the cockpit was never
// generated here (Sync's empty-ledger gate makes this the common, silent
// case), when there is no drift, or on any error — best-effort, never disrupts
// session start. This is the SessionStart "drift inject" the C3 slice deferred
// (C3 surfaced drift only via the one-line queue reminder).
func driftBlock(cfg *config.Config) string {
report, err := cockpit.Sync(cfg, playbooks.All())
if err != nil || report.Clean {
return ""
}
var b strings.Builder
b.WriteString("[eeco cockpit drift] regenerate or reconcile:")
for _, f := range report.Findings {
b.WriteString("\n - ")
b.WriteString(f.Detail)
}
return b.String()
}
// newestHandover returns a short label for the newest handover note: the
// repo-relative path of the most-recently-modified handover_glob match when
// that key is set, otherwise the one-line summary (falling back to the
// filename) of the newest note under <workspace>/notes/. Returns "" when
// neither yields anything.
func newestHandover(cfg *config.Config) string {
if rel, _, ok := newestGlobMatch(cfg.RepoRoot, cfg.HandoverGlob); ok {
return rel
}
ns, err := notes.List(filepath.Join(cfg.Workspace, "notes"))
if err != nil || len(ns) == 0 {
return ""
}
if s := strings.TrimSpace(ns[0].Summary); s != "" {
return s
}
return filepath.Base(ns[0].Path)
}
// newestHandoverMtime returns the modification time of the newest handover
// note — the handover_glob match when configured, else the newest workspace
// note. ok is false when no handover note exists. Used by the Stop nudge to
// compare against the last commit time.
func newestHandoverMtime(cfg *config.Config) (time.Time, bool) {
if _, mod, ok := newestGlobMatch(cfg.RepoRoot, cfg.HandoverGlob); ok {
return mod, true
}
ns, err := notes.List(filepath.Join(cfg.Workspace, "notes"))
if err != nil || len(ns) == 0 {
return time.Time{}, false
}
return ns[0].When, true
}
// pinnedMemoriesBlock composes the fourth session-start block: every
// `pin: true` memory fact's name, description, and full body separated
// by markdown dividers. Returns "" when the workspace has no memory
// store, the store has no pinned facts, or pinned-body emission is
// otherwise unavailable — Emit's best-effort posture applies; the
// session-start hook never disrupts startup over a missing block.
func pinnedMemoriesBlock(cfg *config.Config) string {
store, err := memory.Open(cfg)
if err != nil {
return ""
}
facts, err := store.LoadAll()
if err != nil {
return ""
}
var pinned []*memory.Fact
for _, f := range facts {
if f.Pin && !f.Disabled {
pinned = append(pinned, f)
}
}
if len(pinned) == 0 {
return ""
}
var b strings.Builder
b.WriteString("[eeco pinned memories — read these before substantive work]")
for i, f := range pinned {
if i > 0 {
b.WriteString("\n\n---")
}
b.WriteString("\n\n## ")
b.WriteString(f.Name)
if f.Description != "" {
b.WriteString("\n")
b.WriteString(f.Description)
}
body := strings.TrimSpace(f.Body)
if body != "" {
b.WriteString("\n\n")
b.WriteString(body)
}
}
return b.String()
}
// readingRoutine composes the "before substantive work, read these"
// block. When SessionStartDocs is set in config it is used verbatim
// (filtered to existing files only); otherwise autoDetectDocs is
// scanned and existing entries are included. The live planning surface
// (most-recently-modified match of SessionStartRoadmapGlob) is appended
// last when discovery is enabled. Returns "" when no docs surface.
func readingRoutine(cfg *config.Config) string {
var docs []string
if len(cfg.SessionStartDocs) > 0 {
for _, rel := range cfg.SessionStartDocs {
if fileExists(filepath.Join(cfg.RepoRoot, rel)) {
docs = append(docs, rel)
}
}
} else {
for _, rel := range autoDetectDocs {
if fileExists(filepath.Join(cfg.RepoRoot, rel)) {
docs = append(docs, rel)
}
}
}
roadmap := liveRoadmap(cfg)
if len(docs) == 0 && roadmap == "" {
return ""
}
var b strings.Builder
b.WriteString("[eeco session start] Before substantive work, read these for current state and contracts:")
for _, rel := range docs {
b.WriteString("\n - ")
b.WriteString(rel)
}
if roadmap != "" {
b.WriteString("\n - ")
b.WriteString(roadmap)
b.WriteString(" (live planning surface)")
}
return b.String()
}
// liveRoadmap returns the repo-relative path of the most
// recently-modified match for the configured roadmap glob, or "" when
// discovery is disabled or no match exists.
func liveRoadmap(cfg *config.Config) string {
rel, _, _ := newestGlobMatch(cfg.RepoRoot, cfg.SessionStartRoadmapGlob)
return rel
}
// newestGlobMatch returns the most-recently-modified file matching pattern
// (joined under repoRoot): its repo-relative, slash-separated path, its
// modification time, and ok. ok is false when the pattern is empty, invalid,
// or matches no regular file. Errors from filepath.Glob (bad pattern) are
// treated as no-match: the hook stays silent rather than fail. Directories are
// skipped.
func newestGlobMatch(repoRoot, pattern string) (rel string, mod time.Time, ok bool) {
if pattern == "" {
return "", time.Time{}, false
}
matches, err := filepath.Glob(filepath.Join(repoRoot, pattern))
if err != nil || len(matches) == 0 {
return "", time.Time{}, false
}
var bestPath string
var bestMod time.Time
for _, m := range matches {
info, serr := os.Stat(m)
if serr != nil || info.IsDir() {
continue
}
if bestPath == "" || info.ModTime().After(bestMod) {
bestPath, bestMod = m, info.ModTime()
}
}
if bestPath == "" {
return "", time.Time{}, false
}
r, rerr := filepath.Rel(repoRoot, bestPath)
if rerr != nil {
return "", time.Time{}, false
}
return filepath.ToSlash(r), bestMod, true
}
// mailboxBlock returns the "unprocessed ideas" instruction when the
// configured mailbox file is present and has content beyond its header
// + commented template. Returns "" when the mailbox is disabled
// (SessionStartMailbox empty), the file is missing, or the file
// contains only the empty template.
func mailboxBlock(cfg *config.Config) string {
if cfg.SessionStartMailbox == "" {
return ""
}
path := filepath.Join(cfg.RepoRoot, cfg.SessionStartMailbox)
b, err := os.ReadFile(path)
if err != nil {
return ""
}
if !mailboxHasContent(b) {
return ""
}
name := cfg.SessionStartMailbox
return fmt.Sprintf(
"[Ideas mailbox] %s has unprocessed ideas. Read it and file each idea where it belongs — "+
"feature/fix/cross-cut into the roadmap planning doc, durable preference or fact into an "+
"auto-memory, anything unclear raise with the operator. Report what went where, then reset "+
"%s to its empty template. Never remove an idea until it is durably filed — if %s is "+
"gitignored, a cleaned-but-unfiled idea is lost.",
name, name, name)
}
// mailboxHasContent ports the awk logic from the legacy bash hook:
// skip the first line (the header), elide HTML comment blocks
// (including multi-line ones), and report whether any non-blank line
// remains. The opening `<!--` line and every line through the matching
// `-->` are all treated as comment.
func mailboxHasContent(b []byte) bool {
lines := strings.Split(string(b), "\n")
inComment := false
for i, line := range lines {
if i == 0 {
continue
}
if inComment {
if strings.Contains(line, "-->") {
inComment = false
}
continue
}
if strings.Contains(line, "<!--") {
// A comment opens on this line. If it also closes on the
// same line, the whole line is comment-only; either way the
// awk port skips the line. Multi-line comments stay in
// inComment until a line carries `-->`.
if !strings.Contains(line, "-->") {
inComment = true
}
continue
}
if strings.TrimSpace(line) != "" {
return true
}
}
return false
}
// queueLine preserves the legacy one-line queue reminder produced by
// the hidden `session-emit` subcommand. Returns "" when the queue is
// empty or unreadable so the block is omitted from the composed output.
func queueLine(cfg *config.Config) string {
n, err := queue.Count(filepath.Join(cfg.Workspace, "state"))
if err != nil || n <= 0 {
return ""
}
noun := "items"
if n == 1 {
noun = "item"
}
return fmt.Sprintf("eeco: %d %s awaiting a decision — run `eeco` to review", n, noun)
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
added internal/hooks/sessionemit_test.go
@@ -0,0 +1,315 @@
package hooks
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/ajhahnde/eeco/internal/config"
)
// newEmitCfg builds a config rooted at a fresh repo root (no .git
// needed — Emit never shells out) with an .eeco workspace beside it.
// Tests populate the repo with whichever fixture files they need.
func newEmitCfg(t *testing.T) *config.Config {
t.Helper()
root := t.TempDir()
ws := filepath.Join(root, ".eeco")
if err := os.MkdirAll(filepath.Join(ws, "state"), 0o755); err != nil {
t.Fatal(err)
}
return &config.Config{
RepoRoot: root,
WorkspaceName: ".eeco",
Workspace: ws,
SessionStartMailbox: config.DefaultSessionStartMailbox,
SessionStartRoadmapGlob: config.DefaultSessionStartRoadmapGlob,
}
}
func writeFile(t *testing.T, path, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestEmit_EmptyProjectIsSilent(t *testing.T) {
cfg := newEmitCfg(t)
var buf bytes.Buffer
Emit(cfg, &buf)
if buf.Len() != 0 {
t.Errorf("expected silent output, got %q", buf.String())
}
}
func TestEmit_AutoDetectsReadmeOnly(t *testing.T) {
cfg := newEmitCfg(t)
writeFile(t, filepath.Join(cfg.RepoRoot, "README.md"), "# Hi\n")
var buf bytes.Buffer
Emit(cfg, &buf)
out := buf.String()
if !strings.Contains(out, "[eeco session start]") {
t.Errorf("missing reading-routine header: %q", out)
}
if !strings.Contains(out, "- README.md") {
t.Errorf("README not surfaced: %q", out)
}
if strings.Contains(out, "PUBLIC_API.md") || strings.Contains(out, "ARCHITECTURE.md") {
t.Errorf("unrelated docs surfaced: %q", out)
}
}
func TestEmit_AutoDetectIncludesAllPresentDocsAndLiveRoadmap(t *testing.T) {
cfg := newEmitCfg(t)
writeFile(t, filepath.Join(cfg.RepoRoot, "docs/PUBLIC_API.md"), "x")
writeFile(t, filepath.Join(cfg.RepoRoot, "CHANGELOG.md"), "x")
older := filepath.Join(cfg.RepoRoot, "roadmap_to_v1.0.0.md")
newer := filepath.Join(cfg.RepoRoot, "roadmap_v1.x.md")
writeFile(t, older, "x")
writeFile(t, newer, "x")
// Force the newer file's mtime to be strictly later than the older
// file's, independent of how fast the writes ran.
past := time.Now().Add(-time.Hour)
if err := os.Chtimes(older, past, past); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
Emit(cfg, &buf)
out := buf.String()
for _, want := range []string{"docs/PUBLIC_API.md", "CHANGELOG.md", "roadmap_v1.x.md"} {
if !strings.Contains(out, want) {
t.Errorf("missing %q in output: %q", want, out)
}
}
if strings.Contains(out, "roadmap_to_v1.0.0.md") {
t.Errorf("older roadmap surfaced (should pick newest only): %q", out)
}
if !strings.Contains(out, "(live planning surface)") {
t.Errorf("missing roadmap suffix: %q", out)
}
}
func TestEmit_MailboxTemplateIsSkipped(t *testing.T) {
cfg := newEmitCfg(t)
template := "# Ideas\n<!--\nDrop loose ideas below this comment block.\n-->\n\n"
writeFile(t, filepath.Join(cfg.RepoRoot, "Ideas.md"), template)
var buf bytes.Buffer
Emit(cfg, &buf)
if strings.Contains(buf.String(), "[Ideas mailbox]") {
t.Errorf("empty template should not trigger mailbox block: %q", buf.String())
}
}
func TestEmit_MailboxWithContentSurfaces(t *testing.T) {
cfg := newEmitCfg(t)
body := "# Ideas\n<!--\ntemplate hint\n-->\n\nRefactor the loader.\n"
writeFile(t, filepath.Join(cfg.RepoRoot, "Ideas.md"), body)
var buf bytes.Buffer
Emit(cfg, &buf)
if !strings.Contains(buf.String(), "[Ideas mailbox]") {
t.Errorf("missing mailbox block: %q", buf.String())
}
if !strings.Contains(buf.String(), "Ideas.md has unprocessed ideas") {
t.Errorf("mailbox block does not name the file: %q", buf.String())
}
}
func TestEmit_ConfigDocsOverrideAutoDetect(t *testing.T) {
cfg := newEmitCfg(t)
cfg.SessionStartDocs = []string{"docs/CUSTOM.md"}
cfg.SessionStartRoadmapGlob = "" // disable roadmap discovery for a clean assertion
writeFile(t, filepath.Join(cfg.RepoRoot, "docs/CUSTOM.md"), "x")
// Auto-detect entries also exist; the override should still win.
writeFile(t, filepath.Join(cfg.RepoRoot, "README.md"), "x")
writeFile(t, filepath.Join(cfg.RepoRoot, "CHANGELOG.md"), "x")
var buf bytes.Buffer
Emit(cfg, &buf)
out := buf.String()
if !strings.Contains(out, "docs/CUSTOM.md") {
t.Errorf("override doc not surfaced: %q", out)
}
if strings.Contains(out, "README.md") || strings.Contains(out, "CHANGELOG.md") {
t.Errorf("auto-detect leaked when override was set: %q", out)
}
}
func TestEmit_ConfigDocsFilterMissingFiles(t *testing.T) {
cfg := newEmitCfg(t)
cfg.SessionStartDocs = []string{"docs/EXISTS.md", "docs/MISSING.md"}
cfg.SessionStartRoadmapGlob = ""
writeFile(t, filepath.Join(cfg.RepoRoot, "docs/EXISTS.md"), "x")
var buf bytes.Buffer
Emit(cfg, &buf)
out := buf.String()
if !strings.Contains(out, "docs/EXISTS.md") {
t.Errorf("existing override doc not surfaced: %q", out)
}
if strings.Contains(out, "docs/MISSING.md") {
t.Errorf("missing override doc surfaced: %q", out)
}
}
func TestEmit_CustomMailboxFilename(t *testing.T) {
cfg := newEmitCfg(t)
cfg.SessionStartMailbox = "INBOX.md"
body := "# Inbox\n\nAn idea.\n"
writeFile(t, filepath.Join(cfg.RepoRoot, "INBOX.md"), body)
// Ideas.md (the default) should be ignored when the override is set.
writeFile(t, filepath.Join(cfg.RepoRoot, "Ideas.md"), "# Ideas\n\nLeak me.\n")
var buf bytes.Buffer
Emit(cfg, &buf)
out := buf.String()
if !strings.Contains(out, "INBOX.md has unprocessed ideas") {
t.Errorf("custom mailbox name not surfaced: %q", out)
}
if strings.Contains(out, "Ideas.md has unprocessed ideas") {
t.Errorf("default mailbox leaked when override was set: %q", out)
}
}
func TestEmit_EmptyMailboxOverrideDisables(t *testing.T) {
cfg := newEmitCfg(t)
cfg.SessionStartMailbox = ""
writeFile(t, filepath.Join(cfg.RepoRoot, "Ideas.md"), "# Ideas\n\nReal content.\n")
var buf bytes.Buffer
Emit(cfg, &buf)
if strings.Contains(buf.String(), "[Ideas mailbox]") {
t.Errorf("mailbox emitted with empty override: %q", buf.String())
}
}
func TestEmit_QueueAndRoutineSeparatedByBlankLine(t *testing.T) {
cfg := newEmitCfg(t)
writeFile(t, filepath.Join(cfg.RepoRoot, "README.md"), "x")
queuePath := filepath.Join(cfg.Workspace, "state", "queue.md")
writeFile(t, queuePath,
"- [ ] **decision** — a _(proj, 2026-05-21)_\n detail line\n"+
"- [ ] **decision** — b _(proj, 2026-05-21)_\n detail line\n")
var buf bytes.Buffer
Emit(cfg, &buf)
out := buf.String()
if !strings.Contains(out, "[eeco session start]") {
t.Errorf("missing routine block: %q", out)
}
if !strings.Contains(out, "2 items awaiting a decision") {
t.Errorf("missing queue reminder: %q", out)
}
if !strings.Contains(out, "\n\neeco: 2 items") {
t.Errorf("blocks not separated by blank line: %q", out)
}
}
func TestEmit_SingleItemQueueUsesSingularNoun(t *testing.T) {
cfg := newEmitCfg(t)
queuePath := filepath.Join(cfg.Workspace, "state", "queue.md")
writeFile(t, queuePath, "- [ ] **decision** — a _(proj, 2026-05-21)_\n detail\n")
var buf bytes.Buffer
Emit(cfg, &buf)
if !strings.Contains(buf.String(), "1 item awaiting") {
t.Errorf("singular noun not used: %q", buf.String())
}
}
func TestEmit_NilConfigIsSafe(t *testing.T) {
var buf bytes.Buffer
Emit(nil, &buf)
if buf.Len() != 0 {
t.Errorf("nil config should yield no output, got %q", buf.String())
}
}
// --- pinned memory bodies ------------------------------------------
// writePinnedFact installs a memory fact file in cfg.Workspace/memory
// with the given name, description, pin flag, and body. The frontmatter
// shape matches what eeco's memory parser writes; created/last_used are
// fixed dates so the test is deterministic.
func writePinnedFact(t *testing.T, cfg *config.Config, name, description, body string, pin bool) {
t.Helper()
dir := filepath.Join(cfg.Workspace, "memory")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
pinVal := "false"
if pin {
pinVal = "true"
}
content := "---\n" +
"name: " + name + "\n" +
"description: " + description + "\n" +
"type: feedback\n" +
"created: 2026-05-24\n" +
"last_used: 2026-05-24\n" +
"pin: " + pinVal + "\n" +
"---\n" +
body
path := filepath.Join(dir, name+".md")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestEmit_PinnedBodiesDefaultOffStaysSilent(t *testing.T) {
cfg := newEmitCfg(t)
writePinnedFact(t, cfg, "policy-x", "an important policy", "body of policy X", true)
var buf bytes.Buffer
Emit(cfg, &buf)
if strings.Contains(buf.String(), "pinned memories") {
t.Errorf("default-off must omit the pinned-memories block, got: %q", buf.String())
}
}
func TestEmit_PinnedBodiesOnEmitsBlock(t *testing.T) {
cfg := newEmitCfg(t)
cfg.SessionStartPinnedBodies = true
writePinnedFact(t, cfg, "policy-x", "an important policy", "policy X body line 1\npolicy X body line 2", true)
var buf bytes.Buffer
Emit(cfg, &buf)
out := buf.String()
if !strings.Contains(out, "[eeco pinned memories") {
t.Errorf("missing pinned-memories block header: %q", out)
}
if !strings.Contains(out, "## policy-x") {
t.Errorf("missing fact name heading: %q", out)
}
if !strings.Contains(out, "an important policy") {
t.Errorf("missing fact description: %q", out)
}
if !strings.Contains(out, "policy X body line 1") {
t.Errorf("missing fact body: %q", out)
}
}
func TestEmit_PinnedBodiesOnNoPinnedFactsStaysSilent(t *testing.T) {
cfg := newEmitCfg(t)
cfg.SessionStartPinnedBodies = true
writePinnedFact(t, cfg, "unpinned", "ordinary fact", "body", false)
var buf bytes.Buffer
Emit(cfg, &buf)
if strings.Contains(buf.String(), "pinned memories") {
t.Errorf("with no pinned facts the block must be omitted, got: %q", buf.String())
}
}
func TestEmit_PinnedBodiesMultipleAreSeparatedByDivider(t *testing.T) {
cfg := newEmitCfg(t)
cfg.SessionStartPinnedBodies = true
writePinnedFact(t, cfg, "policy-a", "first", "alpha body", true)
writePinnedFact(t, cfg, "policy-b", "second", "beta body", true)
var buf bytes.Buffer
Emit(cfg, &buf)
out := buf.String()
if !strings.Contains(out, "## policy-a") || !strings.Contains(out, "## policy-b") {
t.Errorf("missing one of the fact headings: %q", out)
}
if !strings.Contains(out, "\n---\n") {
t.Errorf("multiple facts must be separated by a markdown divider: %q", out)
}
}
added internal/hooks/stopnudge.go
@@ -0,0 +1,87 @@
package hooks
import (
"path/filepath"
"strings"
"time"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/gitx"
)
// stopNudgeThrottle is the minimum gap between handover nudges — long enough
// that the Stop hook nudges at most once per working session.
const stopNudgeThrottle = 6 * time.Hour
// stopNudgeStampName is the throttle stamp under <workspace>/state.
const stopNudgeStampName = "handover-nudge.last"
// StopNudge decides whether the Stop hook should surface a one-time handover
// reminder. It fires when the working tree carries undocumented work — a dirty
// tree, OR a commit newer than the newest handover note — and the throttle has
// elapsed. On a fire it writes the throttle stamp first (so a continuation turn
// cannot re-trigger) and returns the advisory reason with fire=true; otherwise
// it returns fire=false and writes nothing. It never returns an error: any
// uncertainty (no git, unreadable stamp) degrades to "no nudge" so a session is
// never wedged. The caller must honor stop_hook_active before calling.
func StopNudge(cfg *config.Config, now time.Time) (reason string, fire bool) {
stamp := filepath.Join(cfg.Workspace, "state", stopNudgeStampName)
if !throttleElapsed(stamp, now, stopNudgeThrottle) {
return "", false
}
reasons := undocumentedWork(cfg)
if len(reasons) == 0 {
return "", false
}
// Stamp first so a continuation turn can't re-trigger, then advise.
writeStamp(stamp, now)
return "Session housekeeping (handover-nudge): undocumented work in the tree (" +
joinReasons(reasons) + "). Do NOT auto-run a handover. Tell the user once: " +
`"Heads-up — there is undocumented work; want me to capture a handover before we stop?" ` +
"then stop normally. (Fires at most once per 6h.)", true
}
// undocumentedWork returns human reasons the tree looks undocumented: a dirty
// working tree, and/or commits newer than the newest handover note. An empty
// result means nothing to nudge about. Every check degrades to "no signal" on
// error, so a repo without git or without notes simply yields fewer reasons.
func undocumentedWork(cfg *config.Config) []string {
var reasons []string
if dirty, err := gitx.IsDirty(cfg.RepoRoot); err == nil && dirty {
reasons = append(reasons, "a dirty working tree")
}
if commitNewerThanHandover(cfg) {
reasons = append(reasons, "commits newer than the last handover note")
}
return reasons
}
// commitNewerThanHandover reports whether HEAD's commit time is later than the
// newest handover note's mtime. It returns false (no signal) when there is no
// commit yet or git is unavailable; when a commit exists but there is no
// handover note at all, it reports true (the commit is by definition
// undocumented).
func commitNewerThanHandover(cfg *config.Config) bool {
commitTime, ok, err := gitx.LastCommitTime(cfg.RepoRoot)
if err != nil || !ok {
return false
}
noteTime, ok := newestHandoverMtime(cfg)
if !ok {
return true
}
return commitTime.After(noteTime)
}
// joinReasons renders a reason list as "a, b and c" (Oxford-free) for the
// nudge text.
func joinReasons(reasons []string) string {
switch len(reasons) {
case 0:
return ""
case 1:
return reasons[0]
default:
return strings.Join(reasons[:len(reasons)-1], ", ") + " and " + reasons[len(reasons)-1]
}
}
added internal/hooks/stopnudge_test.go
@@ -0,0 +1,122 @@
package hooks
import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/ajhahnde/eeco/internal/config"
)
// gitRepoCfg builds a config rooted at a real git repo with one commit, plus a
// workspace for the throttle stamp. Skips when git is unavailable.
func gitRepoCfg(t *testing.T) *config.Config {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
root := t.TempDir()
for _, args := range [][]string{
{"init", "-q"}, {"config", "user.email", "t@x"}, {"config", "user.name", "t"},
} {
c := exec.Command("git", args...)
c.Dir = root
if out, err := c.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
if err := os.WriteFile(filepath.Join(root, "f.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
for _, args := range [][]string{{"add", "-A"}, {"commit", "-qm", "seed"}} {
c := exec.Command("git", args...)
c.Dir = root
if out, err := c.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
ws := filepath.Join(root, "tester", ".eeco")
if err := os.MkdirAll(filepath.Join(ws, "state"), 0o755); err != nil {
t.Fatal(err)
}
return &config.Config{
RepoRoot: root,
UserDir: filepath.Join(root, "tester"),
WorkspaceName: ".eeco",
Workspace: ws,
}
}
func TestStopNudge_FiresOnDirtyThenThrottled(t *testing.T) {
cfg := gitRepoCfg(t)
if err := os.WriteFile(filepath.Join(cfg.RepoRoot, "wip.txt"), []byte("z"), 0o644); err != nil {
t.Fatal(err)
}
now := time.Now()
reason, fire := StopNudge(cfg, now)
if !fire {
t.Fatal("expected a nudge on a dirty tree with no handover note")
}
if !strings.Contains(reason, "handover") || !strings.Contains(reason, "dirty working tree") {
t.Errorf("nudge reason off: %q", reason)
}
// Stamp written → a second call within the throttle window is silent.
if _, fire := StopNudge(cfg, now.Add(time.Minute)); fire {
t.Error("second nudge within the 6h throttle should be silent")
}
// Past the throttle, it can fire again.
if _, fire := StopNudge(cfg, now.Add(7*time.Hour)); !fire {
t.Error("nudge should fire again past the 6h throttle")
}
}
func TestStopNudge_RecentStampSilences(t *testing.T) {
cfg := gitRepoCfg(t)
if err := os.WriteFile(filepath.Join(cfg.RepoRoot, "wip.txt"), []byte("z"), 0o644); err != nil {
t.Fatal(err)
}
now := time.Now()
stamp := filepath.Join(cfg.Workspace, "state", stopNudgeStampName)
if err := os.WriteFile(stamp, []byte(strconv.FormatInt(now.Unix(), 10)), 0o644); err != nil {
t.Fatal(err)
}
if _, fire := StopNudge(cfg, now.Add(time.Hour)); fire {
t.Error("a fresh throttle stamp must silence the nudge")
}
}
func TestThrottleElapsed(t *testing.T) {
stamp := filepath.Join(t.TempDir(), "x.last")
now := time.Now()
if !throttleElapsed(stamp, now, time.Hour) {
t.Error("a missing stamp should count as elapsed")
}
writeStamp(stamp, now)
if throttleElapsed(stamp, now.Add(time.Minute), time.Hour) {
t.Error("a fresh stamp should not be elapsed")
}
if !throttleElapsed(stamp, now.Add(2*time.Hour), time.Hour) {
t.Error("a stamp older than min should be elapsed")
}
}
func TestJoinReasons(t *testing.T) {
cases := []struct {
in []string
want string
}{
{nil, ""},
{[]string{"a"}, "a"},
{[]string{"a", "b"}, "a and b"},
{[]string{"a", "b", "c"}, "a, b and c"},
}
for _, c := range cases {
if got := joinReasons(c.in); got != c.want {
t.Errorf("joinReasons(%v) = %q, want %q", c.in, got, c.want)
}
}
}
added internal/hooks/throttle.go
@@ -0,0 +1,33 @@
package hooks
import (
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// throttleElapsed reports whether at least min has passed since the unix
// timestamp recorded in the stamp file. A missing or unparseable stamp counts
// as elapsed, so a first run always fires — mirroring the FlashOS `due()`
// helper the cockpit machinery ports.
func throttleElapsed(stamp string, now time.Time, min time.Duration) bool {
b, err := os.ReadFile(stamp)
if err != nil {
return true
}
last, perr := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
if perr != nil {
return true
}
return now.Sub(time.Unix(last, 0)) >= min
}
// writeStamp records now as a unix timestamp in the stamp file, creating the
// parent state dir if needed. Best-effort: an error is ignored — the throttle
// simply does not advance, which is safe (the next run re-evaluates).
func writeStamp(stamp string, now time.Time) {
_ = os.MkdirAll(filepath.Dir(stamp), 0o755)
_ = os.WriteFile(stamp, []byte(strconv.FormatInt(now.Unix(), 10)+"\n"), 0o644)
}
added internal/manifest/manifest.go
@@ -0,0 +1,150 @@
package manifest
// Package manifest writes per-directory .ai.json manifests: a compact,
// deterministic enumeration of a knowledge directory's immediate entries for
// human audit and AI orientation. The deterministic walk fills paths and kinds
// only; descriptions are left empty and an opt-in AI pass (the Slice-D
// `eeco refresh-manifest` verb, using the ManifestSummary prompt) fills them.
import (
"encoding/json"
"errors"
"io/fs"
"os"
"path/filepath"
"sort"
)
// FileName is the per-directory manifest file name.
const FileName = ".ai.json"
// vcsDir is the version-control directory the manifest walk never descends
// into or enumerates: the private workspace-history repo (and any nested VCS
// dir) is engine plumbing, not knowledge. Pairs with the engine-workspace
// exclusion so a refresh after `init` never writes into ajhahnde/.git.
const vcsDir = ".git"
// Item is one entry in a directory manifest. Desc and FindWhen are populated by
// the opt-in AI enrichment pass, never by the deterministic walk.
type Item struct {
Path string `json:"path"`
Kind string `json:"kind"` // "file" or "dir"
Desc string `json:"desc,omitempty"`
FindWhen string `json:"find_when,omitempty"`
}
// Manifest is the .ai.json document for one directory.
type Manifest struct {
Dir string `json:"dir"`
Purpose string `json:"purpose,omitempty"`
Items []Item `json:"items"`
}
// Build walks the immediate children of <root>/<dir> and returns a
// deterministic skeleton manifest (paths + kinds; descriptions empty), sorted
// by path. The manifest file itself is never listed, so re-running over a
// directory that already holds an .ai.json is idempotent.
func Build(root, dir string) (Manifest, error) {
target := filepath.Join(root, dir)
entries, err := os.ReadDir(target)
if err != nil {
return Manifest{}, err
}
items := make([]Item, 0, len(entries))
for _, e := range entries {
name := e.Name()
if name == FileName || name == vcsDir {
continue
}
if e.IsDir() {
items = append(items, Item{Path: name + "/", Kind: "dir"})
continue
}
items = append(items, Item{Path: name, Kind: "file"})
}
sort.Slice(items, func(i, j int) bool { return items[i].Path < items[j].Path })
return Manifest{Dir: dir, Items: items}, nil
}
// KnowledgeDirs walks the whole tree under userDir and returns every directory
// in it — top-level knowledge dirs and their nested subdirectories alike — as
// paths relative to userDir, sorted. userDir itself is never returned, and the
// engine workspace (engineName, e.g. ".eeco") is excluded along with its entire
// subtree. Separators in the returned paths are OS-native. A userDir that does
// not exist yet yields an empty list and no error, so a refresh on an un-inited
// repo is a clean no-op.
func KnowledgeDirs(userDir, engineName string) ([]string, error) {
var out []string
err := filepath.WalkDir(userDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
return nil
}
if path == userDir {
return nil
}
if d.Name() == engineName || d.Name() == vcsDir {
return fs.SkipDir
}
rel, err := filepath.Rel(userDir, path)
if err != nil {
return err
}
out = append(out, rel)
return nil
})
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
sort.Strings(out)
return out, nil
}
// Subtree walks <userDir>/<dir> and returns dir plus all of its nested
// subdirectories as paths relative to userDir, sorted — so each is directly
// usable with Build(userDir, x) and Write(userDir, x, m). No engine exclusion
// applies, as the engine workspace is never passed as dir.
func Subtree(userDir, dir string) ([]string, error) {
root := filepath.Join(userDir, dir)
var out []string
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
return nil
}
if d.Name() == vcsDir {
return fs.SkipDir
}
rel, err := filepath.Rel(userDir, path)
if err != nil {
return err
}
out = append(out, rel)
return nil
})
if err != nil {
return nil, err
}
sort.Strings(out)
return out, nil
}
// Write marshals m to <root>/<dir>/.ai.json with stable indentation and a
// trailing newline.
func Write(root, dir string, m Manifest) error {
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
return os.WriteFile(filepath.Join(root, dir, FileName), data, 0o644)
}
added internal/manifest/manifest_test.go
@@ -0,0 +1,256 @@
package manifest
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestBuild_SkeletonSortedAndKinded(t *testing.T) {
root := t.TempDir()
dir := "frontend"
base := filepath.Join(root, dir)
if err := os.MkdirAll(filepath.Join(base, "routes"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(base, "App.tsx"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(base, "index.ts"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
m, err := Build(root, dir)
if err != nil {
t.Fatal(err)
}
if m.Dir != "frontend" {
t.Fatalf("Dir = %q, want frontend", m.Dir)
}
want := []Item{
{Path: "App.tsx", Kind: "file"},
{Path: "index.ts", Kind: "file"},
{Path: "routes/", Kind: "dir"},
}
if len(m.Items) != len(want) {
t.Fatalf("items = %+v, want %+v", m.Items, want)
}
for i := range want {
if m.Items[i] != want[i] {
t.Fatalf("item %d = %+v, want %+v", i, m.Items[i], want[i])
}
}
}
func TestWriteAndIdempotentRebuild(t *testing.T) {
root := t.TempDir()
dir := "lib"
base := filepath.Join(root, dir)
if err := os.MkdirAll(base, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(base, "a.go"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
m, err := Build(root, dir)
if err != nil {
t.Fatal(err)
}
if err := Write(root, dir, m); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(base, FileName)); err != nil {
t.Fatalf("manifest not written: %v", err)
}
// The written .ai.json must not appear in a rebuild.
m2, err := Build(root, dir)
if err != nil {
t.Fatal(err)
}
if len(m2.Items) != 1 || m2.Items[0].Path != "a.go" {
t.Fatalf("rebuild not idempotent: %+v", m2.Items)
}
}
func TestKnowledgeDirs_RecursiveExcludingEngine(t *testing.T) {
userDir := t.TempDir()
// Two top-level knowledge dirs with nested subdirs, plus the engine
// workspace which must be excluded along with everything beneath it.
for _, p := range []string{
filepath.Join("management", "knowledge"),
filepath.Join("management", "roadmap"),
filepath.Join("backend"),
filepath.Join(".eeco", "state"),
} {
if err := os.MkdirAll(filepath.Join(userDir, p), 0o755); err != nil {
t.Fatal(err)
}
}
got, err := KnowledgeDirs(userDir, ".eeco")
if err != nil {
t.Fatal(err)
}
want := []string{
"backend",
"management",
filepath.Join("management", "knowledge"),
filepath.Join("management", "roadmap"),
}
if len(got) != len(want) {
t.Fatalf("dirs = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("dir %d = %q, want %q (full: %v)", i, got[i], want[i], got)
}
}
}
func TestKnowledgeDirs_MissingUserDirIsNoop(t *testing.T) {
got, err := KnowledgeDirs(filepath.Join(t.TempDir(), "absent"), ".eeco")
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
if got != nil {
t.Fatalf("dirs = %v, want nil", got)
}
}
func TestSubtree_DirPlusNested(t *testing.T) {
userDir := t.TempDir()
for _, p := range []string{
filepath.Join("management", "knowledge"),
filepath.Join("management", "roadmap"),
filepath.Join("backend"),
} {
if err := os.MkdirAll(filepath.Join(userDir, p), 0o755); err != nil {
t.Fatal(err)
}
}
got, err := Subtree(userDir, "management")
if err != nil {
t.Fatal(err)
}
want := []string{
"management",
filepath.Join("management", "knowledge"),
filepath.Join("management", "roadmap"),
}
if len(got) != len(want) {
t.Fatalf("subtree = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("subtree %d = %q, want %q (full: %v)", i, got[i], want[i], got)
}
}
}
func TestBuild_EmptyDirHasEmptyItems(t *testing.T) {
root := t.TempDir()
dir := "empty"
if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil {
t.Fatal(err)
}
m, err := Build(root, dir)
if err != nil {
t.Fatal(err)
}
if m.Items == nil {
t.Fatal("Items should be a non-nil empty slice")
}
if len(m.Items) != 0 {
t.Fatalf("want empty, got %+v", m.Items)
}
if err := Write(root, dir, m); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(root, dir, FileName))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), `"items": []`) {
t.Fatalf("empty items should marshal as []:\n%s", b)
}
}
func TestKnowledgeDirs_ExcludesGitRepo(t *testing.T) {
userDir := t.TempDir()
// A real knowledge dir alongside the private workspace-history repo. The
// .git tree and everything beneath it must be excluded — a refresh after
// `init` must never enumerate (and so never write into) ajhahnde/.git.
for _, p := range []string{
"backend",
filepath.Join(".git", "objects", "ab"),
filepath.Join(".git", "refs", "heads"),
} {
if err := os.MkdirAll(filepath.Join(userDir, p), 0o755); err != nil {
t.Fatal(err)
}
}
got, err := KnowledgeDirs(userDir, ".eeco")
if err != nil {
t.Fatal(err)
}
for _, d := range got {
if d == vcsDir || strings.HasPrefix(d, vcsDir+string(filepath.Separator)) {
t.Fatalf("KnowledgeDirs leaked a .git path: %q (full: %v)", d, got)
}
}
if len(got) != 1 || got[0] != "backend" {
t.Fatalf("dirs = %v, want [backend]", got)
}
}
func TestSubtree_ExcludesNestedGit(t *testing.T) {
userDir := t.TempDir()
for _, p := range []string{
filepath.Join("backend", "api"),
filepath.Join("backend", ".git", "objects"),
} {
if err := os.MkdirAll(filepath.Join(userDir, p), 0o755); err != nil {
t.Fatal(err)
}
}
got, err := Subtree(userDir, "backend")
if err != nil {
t.Fatal(err)
}
want := []string{"backend", filepath.Join("backend", "api")}
if len(got) != len(want) {
t.Fatalf("subtree = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("subtree %d = %q, want %q (full: %v)", i, got[i], want[i], got)
}
}
}
func TestBuild_SkipsGitDir(t *testing.T) {
root := t.TempDir()
dir := "backend"
base := filepath.Join(root, dir)
if err := os.MkdirAll(filepath.Join(base, ".git"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(base, "a.go"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
m, err := Build(root, dir)
if err != nil {
t.Fatal(err)
}
if len(m.Items) != 1 || m.Items[0].Path != "a.go" {
t.Fatalf("Build should skip .git: %+v", m.Items)
}
}
added internal/memory/fact.go
@@ -0,0 +1,138 @@
// Package memory is the working-memory store and its garbage collector.
//
// Memory facts live one-per-file under <workspace>/memory/ with a small
// `---`-delimited frontmatter block of flat `key: value` pairs followed
// by a free-text body. The package owns:
//
// - parsing and serialising fact files;
// - loading the whole store (skipping the attic and the index);
// - regenerating the MEMORY.md index;
// - selecting facts that overlap with a task by word match and
// bumping their last_used timestamp;
// - garbage collection: archiving stale technical facts to the attic,
// and queueing review items for load-bearing user/project/feedback
// facts (which are never silently dropped).
//
// The package writes only inside cfg.Workspace and never touches files
// outside cfg.RepoRoot.
package memory
import (
"errors"
"fmt"
"regexp"
"slices"
"strings"
"time"
)
// FactType is the classification of a memory fact. The five types drive
// garbage-collection behaviour: reference and finding facts may be
// archived to the attic; user, feedback, and project facts are queued
// for review instead of being silently dropped.
type FactType string
const (
TypeUser FactType = "user"
TypeFeedback FactType = "feedback"
TypeProject FactType = "project"
TypeReference FactType = "reference"
TypeFinding FactType = "finding"
)
// DateLayout is the canonical YYYY-MM-DD date format used everywhere in
// the memory store.
const DateLayout = "2006-01-02"
// Fact is a single working-memory entry. One Fact corresponds to one
// file on disk.
type Fact struct {
Name string
Description string
Type FactType
Created time.Time
LastUsed time.Time
Ref string
Expires *time.Time
Status string
Pin bool
Source string
Agent string
Disabled bool
Body string
// Path is the absolute path to the source file on disk, set by the
// store when the fact is loaded. It is empty for in-memory facts
// constructed by callers and is not part of the file format.
Path string
}
// MaxSourceLen caps the provenance snippet recorded on a fact. The
// limit keeps the value to one short line of frontmatter; longer
// context belongs in the body.
const MaxSourceLen = 120
var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// Validate checks that a fact has the required fields and that
// field-specific constraints hold. It does not touch disk.
func (f *Fact) Validate() error {
if f == nil {
return errors.New("fact is nil")
}
if f.Name == "" {
return errors.New("name is required")
}
if !nameRE.MatchString(f.Name) {
return fmt.Errorf("name %q: must be lower-kebab-case (a-z, 0-9, '-')", f.Name)
}
if f.Description == "" {
return errors.New("description is required")
}
if !ValidType(f.Type) {
return fmt.Errorf("type %q: must be one of user, feedback, project, reference, finding", f.Type)
}
if f.Created.IsZero() {
return errors.New("created is required")
}
if f.LastUsed.IsZero() {
return errors.New("last_used is required")
}
if f.Ref != "" {
if err := validateRef(f.Ref); err != nil {
return fmt.Errorf("ref %q: %w", f.Ref, err)
}
}
if f.Type == TypeFinding && f.Status != "" && f.Status != "open" && f.Status != "resolved" {
return fmt.Errorf("status %q: finding status must be open or resolved", f.Status)
}
if len(f.Source) > MaxSourceLen {
return fmt.Errorf("source: must be %d chars or fewer (got %d)", MaxSourceLen, len(f.Source))
}
return nil
}
// ValidType reports whether t is a known FactType.
func ValidType(t FactType) bool {
switch t {
case TypeUser, TypeFeedback, TypeProject, TypeReference, TypeFinding:
return true
}
return false
}
// validateRef rejects refs that would escape the repository root or
// shadow an absolute path. GC stats the path; this guard prevents both
// `..` traversal and absolute-path probing.
func validateRef(ref string) error {
if ref == "" {
return nil
}
if strings.HasPrefix(ref, "/") || strings.HasPrefix(ref, `\`) {
return errors.New("must be repo-relative (no leading slash)")
}
if slices.Contains(strings.Split(ref, "/"), "..") {
return errors.New("must not contain '..'")
}
return nil
}
added internal/memory/fact_test.go
@@ -0,0 +1,68 @@
package memory
import (
"strings"
"testing"
"time"
)
// Validate residual branches: the nil-receiver guard and the
// required-field checks that the happy-path tests never trip.
func TestValidate_NilFact(t *testing.T) {
var f *Fact
err := f.Validate()
if err == nil {
t.Fatal("expected nil fact to error")
}
if !strings.Contains(err.Error(), "fact is nil") {
t.Errorf("err = %v, want substring fact is nil", err)
}
}
func TestValidate_CreatedZero(t *testing.T) {
f := &Fact{Name: "a", Description: "b", Type: TypeUser}
err := f.Validate()
if err == nil {
t.Fatal("expected zero created to error")
}
if !strings.Contains(err.Error(), "created is required") {
t.Errorf("err = %v, want substring created is required", err)
}
}
func TestValidate_LastUsedZero(t *testing.T) {
f := &Fact{
Name: "a",
Description: "b",
Type: TypeUser,
Created: time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC),
}
err := f.Validate()
if err == nil {
t.Fatal("expected zero last_used to error")
}
if !strings.Contains(err.Error(), "last_used is required") {
t.Errorf("err = %v, want substring last_used is required", err)
}
}
func TestValidate_FindingBadStatus(t *testing.T) {
f := makeFact("f", "d", TypeFinding, withStatus("bogus"))
err := f.Validate()
if err == nil {
t.Fatal("expected bad finding status to error")
}
if !strings.Contains(err.Error(), "finding status must be open or resolved") {
t.Errorf("err = %v, want substring about finding status", err)
}
}
func TestValidateRef_EmptyDirect(t *testing.T) {
// validateRef("") is unreachable through Validate (which guards
// f.Ref != ""); call it directly to pin that the empty-ref guard
// returns nil rather than erroring.
if err := validateRef(""); err != nil {
t.Errorf("validateRef(\"\") = %v, want nil", err)
}
}
added internal/memory/frontmatter.go
@@ -0,0 +1,221 @@
package memory
import (
"bytes"
"errors"
"fmt"
"sort"
"strings"
"time"
)
const (
fmDelim = "---"
)
// ParseFact reads a fact file's raw bytes and returns the parsed Fact.
// The file must begin with a `---` line, contain a single-line
// `key: value` frontmatter block (blank lines and `#` comments allowed),
// be terminated by another `---` line, and may be followed by an
// arbitrary body. ParseFact validates required fields before returning.
func ParseFact(content []byte) (*Fact, error) {
lines := splitLines(string(content))
if len(lines) == 0 || strings.TrimSpace(lines[0]) != fmDelim {
return nil, errors.New("frontmatter: missing opening '---'")
}
endIdx := -1
for i := 1; i < len(lines); i++ {
if strings.TrimSpace(lines[i]) == fmDelim {
endIdx = i
break
}
}
if endIdx < 0 {
return nil, errors.New("frontmatter: missing closing '---'")
}
f := &Fact{}
for i := 1; i < endIdx; i++ {
line := lines[i]
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
key, value, ok := strings.Cut(trimmed, ":")
if !ok {
return nil, fmt.Errorf("frontmatter line %d: missing ':'", i+1)
}
key = strings.TrimSpace(key)
val := unquoteFM(strings.TrimSpace(value))
if err := setField(f, key, val); err != nil {
return nil, fmt.Errorf("frontmatter line %d: %w", i+1, err)
}
}
bodyLines := lines[endIdx+1:]
body := strings.Join(bodyLines, "\n")
body = strings.TrimLeft(body, "\n")
body = strings.TrimRight(body, " \t\r\n") + "\n"
if strings.TrimSpace(body) == "" {
body = ""
}
f.Body = body
if err := f.Validate(); err != nil {
return nil, fmt.Errorf("frontmatter: %w", err)
}
return f, nil
}
func setField(f *Fact, key, val string) error {
switch key {
case "name":
f.Name = val
case "description":
f.Description = val
case "type":
f.Type = FactType(val)
case "created":
t, err := time.Parse(DateLayout, val)
if err != nil {
return fmt.Errorf("created: %w", err)
}
f.Created = t
case "last_used":
t, err := time.Parse(DateLayout, val)
if err != nil {
return fmt.Errorf("last_used: %w", err)
}
f.LastUsed = t
case "ref":
f.Ref = val
case "expires":
if val == "" {
f.Expires = nil
return nil
}
t, err := time.Parse(DateLayout, val)
if err != nil {
return fmt.Errorf("expires: %w", err)
}
f.Expires = &t
case "status":
f.Status = val
case "pin":
switch val {
case "true":
f.Pin = true
case "false", "":
f.Pin = false
default:
return fmt.Errorf("pin: must be true or false (got %q)", val)
}
case "source":
f.Source = val
case "agent":
f.Agent = val
case "disabled":
switch val {
case "true":
f.Disabled = true
case "false", "":
f.Disabled = false
default:
return fmt.Errorf("disabled: must be true or false (got %q)", val)
}
default:
// Unknown keys tolerated for forward-compatibility.
}
return nil
}
// Serialize renders a Fact to disk-ready bytes. Field order is stable:
// name, description, type, created, last_used, then optional fields
// (ref, expires, status) only when set, then pin, then optional
// provenance fields (source, agent) and disabled when true. The body is
// appended after the closing `---` separator with a single blank line.
// The output always ends with a newline.
func Serialize(f *Fact) ([]byte, error) {
if err := f.Validate(); err != nil {
return nil, err
}
var buf bytes.Buffer
buf.WriteString(fmDelim)
buf.WriteByte('\n')
writeKV(&buf, "name", f.Name)
writeKV(&buf, "description", f.Description)
writeKV(&buf, "type", string(f.Type))
writeKV(&buf, "created", f.Created.UTC().Format(DateLayout))
writeKV(&buf, "last_used", f.LastUsed.UTC().Format(DateLayout))
if f.Ref != "" {
writeKV(&buf, "ref", f.Ref)
}
if f.Expires != nil {
writeKV(&buf, "expires", f.Expires.UTC().Format(DateLayout))
}
if f.Status != "" {
writeKV(&buf, "status", f.Status)
}
if f.Pin {
writeKV(&buf, "pin", "true")
} else {
writeKV(&buf, "pin", "false")
}
if f.Source != "" {
writeKV(&buf, "source", f.Source)
}
if f.Agent != "" {
writeKV(&buf, "agent", f.Agent)
}
if f.Disabled {
writeKV(&buf, "disabled", "true")
}
buf.WriteString(fmDelim)
buf.WriteByte('\n')
if f.Body != "" {
buf.WriteByte('\n')
body := strings.TrimRight(f.Body, "\n")
buf.WriteString(body)
buf.WriteByte('\n')
}
return buf.Bytes(), nil
}
func writeKV(buf *bytes.Buffer, k, v string) {
buf.WriteString(k)
buf.WriteString(": ")
buf.WriteString(v)
buf.WriteByte('\n')
}
// splitLines splits on `\n` and strips trailing `\r` so files written
// with CRLF round-trip correctly.
func splitLines(s string) []string {
lines := strings.Split(s, "\n")
for i, line := range lines {
lines[i] = strings.TrimRight(line, "\r")
}
return lines
}
// unquoteFM strips matching surrounding single or double quotes. The
// memory store does not interpret escape sequences inside quoted
// strings; values that need quoting should be one-liners.
func unquoteFM(s string) string {
if len(s) >= 2 {
first, last := s[0], s[len(s)-1]
if (first == '"' || first == '\'') && first == last {
return s[1 : len(s)-1]
}
}
return s
}
// sortedFacts returns a copy of facts ordered by name. Used by the
// index and by tests that need a deterministic iteration order.
func sortedFacts(facts []*Fact) []*Fact {
out := make([]*Fact, len(facts))
copy(out, facts)
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
added internal/memory/frontmatter_test.go
@@ -0,0 +1,363 @@
package memory
import (
"strings"
"testing"
"time"
)
func TestParseFact_HappyPath(t *testing.T) {
src := strings.Join([]string{
"---",
"name: build-gate",
"description: gate is `go vet ./...`",
"type: reference",
"created: 2026-05-19",
"last_used: 2026-05-19",
"pin: false",
"---",
"",
"body line one",
"body line two",
"",
}, "\n")
f, err := ParseFact([]byte(src))
if err != nil {
t.Fatalf("ParseFact: %v", err)
}
if f.Name != "build-gate" {
t.Errorf("name = %q", f.Name)
}
if f.Type != TypeReference {
t.Errorf("type = %q", f.Type)
}
if f.Pin {
t.Error("pin should be false")
}
if f.Body != "body line one\nbody line two\n" {
t.Errorf("body = %q", f.Body)
}
}
func TestParseFact_OptionalFields(t *testing.T) {
src := strings.Join([]string{
"---",
"name: bug-x",
"description: fix bug X",
"type: finding",
"created: 2026-05-19",
"last_used: 2026-05-19",
"ref: internal/config/config.go",
"expires: 2026-12-31",
"status: open",
"pin: true",
"---",
"detail",
"",
}, "\n")
f, err := ParseFact([]byte(src))
if err != nil {
t.Fatalf("ParseFact: %v", err)
}
if f.Ref != "internal/config/config.go" {
t.Errorf("ref = %q", f.Ref)
}
if f.Expires == nil || !f.Expires.Equal(time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)) {
t.Errorf("expires = %v", f.Expires)
}
if f.Status != "open" {
t.Errorf("status = %q", f.Status)
}
if !f.Pin {
t.Error("pin should be true")
}
}
func TestParseFact_QuotedAndComments(t *testing.T) {
src := strings.Join([]string{
"---",
`name: "quoted-name"`,
`description: 'single quoted'`,
"# comment line",
"",
"type: user",
"created: 2026-05-19",
"last_used: 2026-05-19",
"pin: false",
"unknown_key: tolerated",
"---",
}, "\n")
f, err := ParseFact([]byte(src))
if err != nil {
t.Fatalf("ParseFact: %v", err)
}
if f.Name != "quoted-name" {
t.Errorf("name = %q", f.Name)
}
if f.Description != "single quoted" {
t.Errorf("description = %q", f.Description)
}
}
func TestParseFact_ErrorCases(t *testing.T) {
cases := map[string]string{
"missing opening delim": "name: x\n",
"missing closing delim": "---\nname: x\n",
"missing colon": "---\nthis has no colon\n---\n",
"bad date": "---\nname: a\ndescription: b\ntype: user\ncreated: not-a-date\nlast_used: 2026-05-19\npin: false\n---\n",
"bad type": "---\nname: a\ndescription: b\ntype: not-a-type\ncreated: 2026-05-19\nlast_used: 2026-05-19\npin: false\n---\n",
"bad pin": "---\nname: a\ndescription: b\ntype: user\ncreated: 2026-05-19\nlast_used: 2026-05-19\npin: maybe\n---\n",
"bad ref-traversal": "---\nname: a\ndescription: b\ntype: reference\ncreated: 2026-05-19\nlast_used: 2026-05-19\nref: ../escape\npin: false\n---\n",
"bad ref-absolute": "---\nname: a\ndescription: b\ntype: reference\ncreated: 2026-05-19\nlast_used: 2026-05-19\nref: /etc/passwd\npin: false\n---\n",
"missing name": "---\ndescription: b\ntype: user\ncreated: 2026-05-19\nlast_used: 2026-05-19\npin: false\n---\n",
"bad name": "---\nname: NotKebab\ndescription: b\ntype: user\ncreated: 2026-05-19\nlast_used: 2026-05-19\npin: false\n---\n",
"missing description": "---\nname: a\ntype: user\ncreated: 2026-05-19\nlast_used: 2026-05-19\npin: false\n---\n",
}
for label, src := range cases {
t.Run(label, func(t *testing.T) {
if _, err := ParseFact([]byte(src)); err == nil {
t.Fatalf("ParseFact(%q) succeeded; expected error", label)
}
})
}
}
func TestSerialize_RoundTrip(t *testing.T) {
exp := time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)
in := &Fact{
Name: "round-trip",
Description: "round trip",
Type: TypeFinding,
Created: time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC),
LastUsed: time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC),
Ref: "internal/config/config.go",
Expires: &exp,
Status: "open",
Pin: true,
Body: "body text\nwith two lines",
}
raw, err := Serialize(in)
if err != nil {
t.Fatal(err)
}
got, err := ParseFact(raw)
if err != nil {
t.Fatalf("re-parse:\n%s\nerr: %v", raw, err)
}
if got.Name != in.Name || got.Description != in.Description || got.Type != in.Type ||
!got.Created.Equal(in.Created) || !got.LastUsed.Equal(in.LastUsed) ||
got.Ref != in.Ref || got.Status != in.Status || got.Pin != in.Pin {
t.Errorf("round-trip diverged.\nwant: %+v\ngot: %+v", *in, *got)
}
if got.Expires == nil || !got.Expires.Equal(*in.Expires) {
t.Errorf("expires diverged: %v", got.Expires)
}
if strings.TrimRight(got.Body, "\n") != strings.TrimRight(in.Body, "\n") {
t.Errorf("body diverged:\nwant: %q\ngot: %q", in.Body, got.Body)
}
}
func TestSerialize_OmitsEmptyOptionals(t *testing.T) {
in := &Fact{
Name: "minimal",
Description: "minimal fact",
Type: TypeUser,
Created: time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC),
LastUsed: time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC),
}
raw, err := Serialize(in)
if err != nil {
t.Fatal(err)
}
s := string(raw)
for _, banned := range []string{"ref:", "expires:", "status:", "source:", "agent:", "disabled:"} {
if strings.Contains(s, banned) {
t.Errorf("output should omit %q for empty values:\n%s", banned, s)
}
}
if !strings.Contains(s, "pin: false") {
t.Errorf("pin: false should always be emitted:\n%s", s)
}
}
func TestParseFact_AdaptationFields(t *testing.T) {
src := strings.Join([]string{
"---",
"name: terse-feedback",
"description: user prefers terse responses",
"type: feedback",
"created: 2026-05-22",
"last_used: 2026-05-22",
"pin: false",
"source: stop summarizing what you just did",
"agent: claude-opus-4-7",
"disabled: true",
"---",
"reasoning",
"",
}, "\n")
f, err := ParseFact([]byte(src))
if err != nil {
t.Fatalf("ParseFact: %v", err)
}
if f.Source != "stop summarizing what you just did" {
t.Errorf("source = %q", f.Source)
}
if f.Agent != "claude-opus-4-7" {
t.Errorf("agent = %q", f.Agent)
}
if !f.Disabled {
t.Error("disabled should be true")
}
}
func TestParseFact_DisabledBadValue(t *testing.T) {
src := strings.Join([]string{
"---",
"name: bad",
"description: bad",
"type: user",
"created: 2026-05-22",
"last_used: 2026-05-22",
"pin: false",
"disabled: maybe",
"---",
}, "\n")
if _, err := ParseFact([]byte(src)); err == nil {
t.Fatal("expected error on disabled: maybe")
}
}
func TestParseFact_OldFactStillLoads(t *testing.T) {
// A fact written before the source/agent/disabled fields existed
// must still parse cleanly: the new fields are optional on the wire.
src := strings.Join([]string{
"---",
"name: legacy",
"description: legacy fact",
"type: feedback",
"created: 2026-01-01",
"last_used: 2026-01-01",
"pin: false",
"---",
}, "\n")
f, err := ParseFact([]byte(src))
if err != nil {
t.Fatalf("ParseFact: %v", err)
}
if f.Source != "" || f.Agent != "" || f.Disabled {
t.Errorf("new fields should be zero for legacy fact: %+v", f)
}
}
func TestSerialize_RoundTripAdaptationFields(t *testing.T) {
in := &Fact{
Name: "adapt",
Description: "adapt to operator",
Type: TypeUser,
Created: time.Date(2026, 5, 22, 0, 0, 0, 0, time.UTC),
LastUsed: time.Date(2026, 5, 22, 0, 0, 0, 0, time.UTC),
Source: "stop using emojis",
Agent: "claude-opus-4-7",
Disabled: true,
Body: "body",
}
raw, err := Serialize(in)
if err != nil {
t.Fatal(err)
}
got, err := ParseFact(raw)
if err != nil {
t.Fatalf("re-parse: %v", err)
}
if got.Source != in.Source || got.Agent != in.Agent || got.Disabled != in.Disabled {
t.Errorf("adaptation fields diverged: %+v", got)
}
}
func TestValidate_RejectsOversizeSource(t *testing.T) {
long := strings.Repeat("x", MaxSourceLen+1)
in := &Fact{
Name: "oversize",
Description: "oversize source",
Type: TypeFeedback,
Created: time.Date(2026, 5, 22, 0, 0, 0, 0, time.UTC),
LastUsed: time.Date(2026, 5, 22, 0, 0, 0, 0, time.UTC),
Source: long,
}
if err := in.Validate(); err == nil {
t.Fatal("Validate: expected error on oversize source")
}
}
func TestSerialize_RejectsInvalid(t *testing.T) {
in := &Fact{Name: "x"} // missing required fields
if _, err := Serialize(in); err == nil {
t.Fatal("Serialize: expected validation error")
}
}
// --- setField residual branches ---
func TestSetField_ExpiresEmpty_ClearsField(t *testing.T) {
src := strings.Join([]string{
"---",
"name: exp-empty",
"description: empty expires clears the field",
"type: user",
"created: 2026-05-19",
"last_used: 2026-05-19",
"expires:",
"pin: false",
"---",
}, "\n")
f, err := ParseFact([]byte(src))
if err != nil {
t.Fatalf("ParseFact: %v", err)
}
if f.Expires != nil {
t.Errorf("expires = %v, want nil", f.Expires)
}
}
func TestSetField_ExpiresBadDate(t *testing.T) {
src := strings.Join([]string{
"---",
"name: exp-bad",
"description: bad expires date",
"type: user",
"created: 2026-05-19",
"last_used: 2026-05-19",
"expires: not-a-date",
"pin: false",
"---",
}, "\n")
_, err := ParseFact([]byte(src))
if err == nil {
t.Fatal("expected bad expires date to error")
}
if !strings.Contains(err.Error(), "expires:") {
t.Errorf("err = %v, want substring expires:", err)
}
}
func TestSetField_DisabledFalseExplicit(t *testing.T) {
src := strings.Join([]string{
"---",
"name: dis-false",
"description: explicit disabled false",
"type: user",
"created: 2026-05-19",
"last_used: 2026-05-19",
"pin: false",
"disabled: false",
"---",
}, "\n")
f, err := ParseFact([]byte(src))
if err != nil {
t.Fatalf("ParseFact: %v", err)
}
if f.Disabled {
t.Error("disabled should be false")
}
}
added internal/memory/gc.go
@@ -0,0 +1,181 @@
package memory
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/ajhahnde/eeco/internal/queue"
)
// GCAction summarises what GC did with a single fact. Action is one of
// "archived", "queued", or "kept". Reason is the human-readable trigger
// description for archive/queue, or empty for kept facts.
type GCAction struct {
Name string
Type FactType
Action string
Reason string
}
// GCResult is the aggregate result of a GC pass.
type GCResult struct {
Actions []GCAction
Archived int
Queued int
Kept int
}
const (
gcLogFilename = "gc.log"
)
// GC walks every fact in the store, applies the PLAN.md GC table, and
// performs the prescribed action on each. Pinned facts are always
// kept. Reference and finding facts are archived to the attic.
// Project, feedback, and user facts are queued for review (never
// silently dropped). The MEMORY.md index is regenerated from whatever
// remains. Errors short-circuit; a failure mid-pass leaves the store
// in a consistent on-disk state up to that point.
func (s *Store) GC() (GCResult, error) {
var res GCResult
facts, err := s.LoadAll()
if err != nil {
return res, fmt.Errorf("gc: %w", err)
}
now := s.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
project := filepath.Base(s.RepoRoot)
if project == "." || project == "/" {
project = "repo"
}
for _, f := range facts {
if f.Pin {
res.Kept++
res.Actions = append(res.Actions, GCAction{Name: f.Name, Type: f.Type, Action: "kept", Reason: "pinned"})
continue
}
if f.Disabled {
res.Kept++
res.Actions = append(res.Actions, GCAction{Name: f.Name, Type: f.Type, Action: "kept", Reason: "disabled"})
continue
}
reason := s.triggerFor(f, today)
if reason == "" {
res.Kept++
res.Actions = append(res.Actions, GCAction{Name: f.Name, Type: f.Type, Action: "kept"})
continue
}
switch f.Type {
case TypeReference, TypeFinding:
if err := s.archive(f, reason, now); err != nil {
return res, fmt.Errorf("gc archive %s: %w", f.Name, err)
}
res.Archived++
res.Actions = append(res.Actions, GCAction{Name: f.Name, Type: f.Type, Action: "archived", Reason: reason})
case TypeProject, TypeFeedback, TypeUser:
if err := s.queueReview(f, reason, project, today, now); err != nil {
return res, fmt.Errorf("gc queue %s: %w", f.Name, err)
}
res.Queued++
res.Actions = append(res.Actions, GCAction{Name: f.Name, Type: f.Type, Action: "queued", Reason: reason})
default:
// Should be unreachable; ValidType is enforced at parse.
res.Kept++
res.Actions = append(res.Actions, GCAction{Name: f.Name, Type: f.Type, Action: "kept", Reason: "unknown type"})
}
}
remaining, err := s.LoadAll()
if err != nil {
return res, fmt.Errorf("gc: reload after pass: %w", err)
}
if err := s.WriteIndex(remaining); err != nil {
return res, fmt.Errorf("gc: write index: %w", err)
}
return res, nil
}
// triggerFor evaluates the GC table rows in spec order and returns the
// first matching reason, or "" if the fact is to be kept. Today is the
// UTC date to compare against.
func (s *Store) triggerFor(f *Fact, today time.Time) string {
if f.Ref != "" {
full := filepath.Join(s.RepoRoot, f.Ref)
if _, err := os.Stat(full); err != nil {
if errors.Is(err, os.ErrNotExist) {
return "ref missing: " + f.Ref
}
// Other stat errors (perm, etc.) — surface as a trigger so
// the user is alerted rather than silently dropped.
return "ref unreadable: " + f.Ref
}
}
if f.Expires != nil && f.Expires.Before(today) {
return "expired " + f.Expires.UTC().Format(DateLayout)
}
if f.Type == TypeFinding && f.Status == "resolved" {
return "finding resolved"
}
if f.Type == TypeReference {
age := today.Sub(f.LastUsed.UTC())
threshold := time.Duration(s.StaleDays) * 24 * time.Hour
if age > threshold {
return fmt.Sprintf("stale: last_used %s (> %d days)", f.LastUsed.UTC().Format(DateLayout), s.StaleDays)
}
}
return ""
}
// archive moves the fact file from MemoryDir to AtticDir and appends a
// log entry. A name collision in the attic is renamed by suffix to
// avoid clobbering an earlier archive.
func (s *Store) archive(f *Fact, reason string, now time.Time) error {
if err := os.MkdirAll(s.AtticDir, 0o755); err != nil {
return err
}
dst := filepath.Join(s.AtticDir, f.Name+".md")
if _, err := os.Stat(dst); err == nil {
dst = filepath.Join(s.AtticDir, fmt.Sprintf("%s.%d.md", f.Name, now.Unix()))
}
if err := os.Rename(f.Path, dst); err != nil {
return err
}
return s.logGC(now, "archived", f.Name, reason)
}
// queueReview appends a review item to the queue. The fact file is
// left in place: load-bearing user/project/feedback facts are never
// silently moved. today is the calendar date stamped on the queue row;
// now is the wall-clock timestamp recorded in gc.log.
func (s *Store) queueReview(f *Fact, reason, project string, today, now time.Time) error {
item := queue.Item{
Kind: "gc-review",
Title: fmt.Sprintf("memory '%s' looks stale: %s", f.Name, reason),
Project: project,
Detail: fmt.Sprintf("type=%s description=%q", f.Type, f.Description),
Date: today,
}
if _, err := queue.AppendUnique(s.StateDir, item); err != nil {
return err
}
return s.logGC(now, "queued", f.Name, reason)
}
func (s *Store) logGC(now time.Time, action, name, reason string) error {
if err := os.MkdirAll(s.StateDir, 0o755); err != nil {
return err
}
logPath := filepath.Join(s.StateDir, gcLogFilename)
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer f.Close()
_, err = fmt.Fprintf(f, "%s %s %s reason=%q\n", now.UTC().Format(time.RFC3339), action, name, reason)
return err
}
added internal/memory/gc_test.go
@@ -0,0 +1,561 @@
package memory
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// gcStore returns a store seeded with the given facts and a fixed
// clock at 2026-05-19.
func gcStore(t *testing.T, facts ...*Fact) *Store {
t.Helper()
s := newStore(t)
for _, f := range facts {
if err := s.Save(f); err != nil {
t.Fatalf("seed %s: %v", f.Name, err)
}
}
return s
}
func withExpires(d time.Time) func(*Fact) { return func(f *Fact) { f.Expires = &d } }
func withRef(r string) func(*Fact) { return func(f *Fact) { f.Ref = r } }
func withStatus(s string) func(*Fact) { return func(f *Fact) { f.Status = s } }
func withPin(p bool) func(*Fact) { return func(f *Fact) { f.Pin = p } }
func withDisabled(d bool) func(*Fact) { return func(f *Fact) { f.Disabled = d } }
func withLastUsed(d time.Time) func(*Fact) {
return func(f *Fact) { f.LastUsed = d }
}
func assertAction(t *testing.T, res GCResult, name, action string) {
t.Helper()
for _, a := range res.Actions {
if a.Name == name {
if a.Action != action {
t.Errorf("%s action = %s, want %s (reason=%q)", name, a.Action, action, a.Reason)
}
return
}
}
t.Errorf("no action recorded for %s", name)
}
// --- pin skip ---
func TestGC_DisabledKeptDespiteTriggers(t *testing.T) {
// A disabled fact with every trigger pulled must still be kept: the
// operator deliberately turned it off and may turn it back on.
past := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
s := gcStore(t,
makeFact("disabled-feedback", "x", TypeFeedback, withRef("does-not-exist.go"), withExpires(past), withDisabled(true)),
makeFact("disabled-user", "x", TypeUser, withRef("does-not-exist.go"), withExpires(past), withDisabled(true)),
)
res, err := s.GC()
if err != nil {
t.Fatal(err)
}
if res.Archived != 0 || res.Queued != 0 {
t.Errorf("disabled should suppress all actions: %+v", res)
}
if res.Kept != 2 {
t.Errorf("Kept = %d, want 2", res.Kept)
}
assertAction(t, res, "disabled-feedback", "kept")
assertAction(t, res, "disabled-user", "kept")
}
func TestGC_PinAlwaysKept(t *testing.T) {
// A fact with every trigger pulled, but pinned, must be kept.
past := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
s := gcStore(t,
makeFact("pinned-ref", "x", TypeReference, withRef("does-not-exist.go"), withExpires(past), withPin(true)),
makeFact("pinned-user", "x", TypeUser, withRef("does-not-exist.go"), withExpires(past), withPin(true)),
)
res, err := s.GC()
if err != nil {
t.Fatal(err)
}
if res.Archived != 0 || res.Queued != 0 {
t.Errorf("pin should suppress all actions: %+v", res)
}
if res.Kept != 2 {
t.Errorf("Kept = %d, want 2", res.Kept)
}
}
// --- ref missing × type bucket ---
func TestGC_RefMissing_TypeBuckets(t *testing.T) {
missing := "internal/does-not-exist.go"
s := gcStore(t,
makeFact("ref-ref", "x", TypeReference, withRef(missing)),
makeFact("ref-finding", "x", TypeFinding, withRef(missing), withStatus("open")),
makeFact("ref-project", "x", TypeProject, withRef(missing)),
makeFact("ref-feedback", "x", TypeFeedback, withRef(missing)),
makeFact("ref-user", "x", TypeUser, withRef(missing)),
)
res, err := s.GC()
if err != nil {
t.Fatal(err)
}
assertAction(t, res, "ref-ref", "archived")
assertAction(t, res, "ref-finding", "archived")
assertAction(t, res, "ref-project", "queued")
assertAction(t, res, "ref-feedback", "queued")
assertAction(t, res, "ref-user", "queued")
if res.Archived != 2 || res.Queued != 3 || res.Kept != 0 {
t.Errorf("counts: archived=%d queued=%d kept=%d", res.Archived, res.Queued, res.Kept)
}
}
func TestGC_RefPresent_NoTrigger(t *testing.T) {
s := newStore(t)
// Create the file the ref points to so the trigger does NOT fire.
if err := os.WriteFile(filepath.Join(s.RepoRoot, "real.go"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := s.Save(makeFact("ref-ok", "x", TypeReference, withRef("real.go"))); err != nil {
t.Fatal(err)
}
res, err := s.GC()
if err != nil {
t.Fatal(err)
}
if res.Kept != 1 || res.Archived != 0 {
t.Errorf("expected ref present to keep: %+v", res)
}
}
// --- expires past × type bucket ---
func TestGC_ExpiresPast_TypeBuckets(t *testing.T) {
past := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
s := gcStore(t,
makeFact("exp-ref", "x", TypeReference, withExpires(past)),
makeFact("exp-finding", "x", TypeFinding, withExpires(past), withStatus("open")),
makeFact("exp-project", "x", TypeProject, withExpires(past)),
makeFact("exp-feedback", "x", TypeFeedback, withExpires(past)),
makeFact("exp-user", "x", TypeUser, withExpires(past)),
)
res, err := s.GC()
if err != nil {
t.Fatal(err)
}
assertAction(t, res, "exp-ref", "archived")
assertAction(t, res, "exp-finding", "archived")
assertAction(t, res, "exp-project", "queued")
assertAction(t, res, "exp-feedback", "queued")
assertAction(t, res, "exp-user", "queued")
}
func TestGC_ExpiresFuture_NoTrigger(t *testing.T) {
future := time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC)
s := gcStore(t, makeFact("exp-future", "x", TypeUser, withExpires(future)))
res, err := s.GC()
if err != nil {
t.Fatal(err)
}
if res.Kept != 1 {
t.Errorf("future expiry should keep: %+v", res)
}
}
// --- finding+resolved ---
func TestGC_FindingResolvedArchived(t *testing.T) {
s := gcStore(t,
makeFact("done", "x", TypeFinding, withStatus("resolved")),
makeFact("open", "x", TypeFinding, withStatus("open")),
)
res, err := s.GC()
if err != nil {
t.Fatal(err)
}
assertAction(t, res, "done", "archived")
assertAction(t, res, "open", "kept")
}
// --- reference + last_used > N days ---
func TestGC_ReferenceStaleByLastUsed(t *testing.T) {
old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) // ~139 days before fixed clock
recent := time.Date(2026, 5, 18, 0, 0, 0, 0, time.UTC)
s := gcStore(t,
makeFact("stale-ref", "x", TypeReference, withLastUsed(old)),
makeFact("fresh-ref", "x", TypeReference, withLastUsed(recent)),
)
res, err := s.GC()
if err != nil {
t.Fatal(err)
}
assertAction(t, res, "stale-ref", "archived")
assertAction(t, res, "fresh-ref", "kept")
}
func TestGC_ReferenceStale_HonoursStaleDays(t *testing.T) {
s := newStore(t)
s.StaleDays = 1 // very aggressive
twoDaysAgo := time.Date(2026, 5, 17, 0, 0, 0, 0, time.UTC)
if err := s.Save(makeFact("borderline", "x", TypeReference, withLastUsed(twoDaysAgo))); err != nil {
t.Fatal(err)
}
res, err := s.GC()
if err != nil {
t.Fatal(err)
}
assertAction(t, res, "borderline", "archived")
}
func TestGC_StaleDoesNotApplyToOtherTypes(t *testing.T) {
old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
s := gcStore(t,
makeFact("old-user", "x", TypeUser, withLastUsed(old)),
makeFact("old-feedback", "x", TypeFeedback, withLastUsed(old)),
makeFact("old-project", "x", TypeProject, withLastUsed(old)),
makeFact("old-finding", "x", TypeFinding, withLastUsed(old), withStatus("open")),
)
res, err := s.GC()
if err != nil {
t.Fatal(err)
}
if res.Kept != 4 {
t.Errorf("non-reference types should not stale: %+v", res)
}
}
// --- "none" → keep ---
func TestGC_NoTriggerKept(t *testing.T) {
s := gcStore(t,
makeFact("clean", "x", TypeUser),
makeFact("clean2", "x", TypeProject),
makeFact("clean3", "x", TypeFeedback),
)
res, err := s.GC()
if err != nil {
t.Fatal(err)
}
if res.Kept != 3 || res.Archived != 0 || res.Queued != 0 {
t.Errorf("clean facts should be kept: %+v", res)
}
}
// --- side effects ---
func TestGC_ArchiveMovesFileToAttic(t *testing.T) {
s := gcStore(t, makeFact("doomed", "x", TypeFinding, withStatus("resolved")))
srcPath := filepath.Join(s.MemoryDir, "doomed.md")
if _, err := os.Stat(srcPath); err != nil {
t.Fatalf("source not present pre-GC: %v", err)
}
if _, err := s.GC(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(srcPath); !os.IsNotExist(err) {
t.Errorf("source not removed: %v", err)
}
if _, err := os.Stat(filepath.Join(s.AtticDir, "doomed.md")); err != nil {
t.Errorf("attic copy missing: %v", err)
}
}
func TestGC_LogAppended(t *testing.T) {
s := gcStore(t,
makeFact("a", "x", TypeFinding, withStatus("resolved")),
makeFact("b", "x", TypeUser, withRef("missing.go")),
)
if _, err := s.GC(); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(s.StateDir, "gc.log"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), "archived a") || !strings.Contains(string(b), "queued b") {
t.Errorf("log missing entries:\n%s", string(b))
}
}
func TestGC_QueueEntryWritten(t *testing.T) {
s := gcStore(t, makeFact("user-stale", "user notes", TypeUser, withRef("missing.go")))
if _, err := s.GC(); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(s.StateDir, "queue.md"))
if err != nil {
t.Fatal(err)
}
got := string(b)
if !strings.Contains(got, "- [ ] **gc-review**") {
t.Errorf("queue entry missing kind:\n%s", got)
}
if !strings.Contains(got, "user-stale") {
t.Errorf("queue entry missing fact name:\n%s", got)
}
}
func TestGC_QueueDedupOnRerun(t *testing.T) {
// A second GC pass over the same unresolved finding must not pile up
// a duplicate gc-review row. queueReview routes through
// queue.AppendUnique (kind+title key), so two consecutive runs file
// one open row, not two. This unblocks running gc from the
// post-merge hook chain without spamming the queue.
s := gcStore(t, makeFact("user-stale", "user notes", TypeUser, withRef("missing.go")))
if _, err := s.GC(); err != nil {
t.Fatalf("first GC: %v", err)
}
if _, err := s.GC(); err != nil {
t.Fatalf("second GC: %v", err)
}
b, err := os.ReadFile(filepath.Join(s.StateDir, "queue.md"))
if err != nil {
t.Fatal(err)
}
got := string(b)
n := strings.Count(got, "- [ ] **gc-review**")
if n != 1 {
t.Errorf("open gc-review rows = %d, want 1\n%s", n, got)
}
}
func TestGC_RegeneratesIndex(t *testing.T) {
s := gcStore(t,
makeFact("survivor", "stays put", TypeUser),
makeFact("doomed", "to attic", TypeFinding, withStatus("resolved")),
)
if _, err := s.GC(); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(s.MemoryDir, IndexFilename))
if err != nil {
t.Fatal(err)
}
got := string(b)
if !strings.Contains(got, "**survivor**") {
t.Errorf("index missing survivor:\n%s", got)
}
if strings.Contains(got, "**doomed**") {
t.Errorf("index should not list archived fact:\n%s", got)
}
}
func TestGC_EmptyStore(t *testing.T) {
s := newStore(t)
res, err := s.GC()
if err != nil {
t.Fatal(err)
}
if res.Archived != 0 || res.Queued != 0 || res.Kept != 0 {
t.Errorf("empty store should be no-op: %+v", res)
}
if _, err := os.Stat(filepath.Join(s.MemoryDir, IndexFilename)); err != nil {
t.Errorf("empty index not written: %v", err)
}
}
func TestGC_RefPrecedesExpires(t *testing.T) {
// Both ref-missing and expires-past trigger on the same fact; the
// reported reason should be the first row (ref-missing) per spec
// order.
past := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
s := gcStore(t, makeFact("dual", "x", TypeFinding,
withRef("missing.go"), withExpires(past), withStatus("open")))
res, err := s.GC()
if err != nil {
t.Fatal(err)
}
for _, a := range res.Actions {
if a.Name == "dual" {
if !strings.HasPrefix(a.Reason, "ref missing") {
t.Errorf("expected ref-missing to win, got reason %q", a.Reason)
}
return
}
}
t.Error("no action for dual")
}
// --- I/O-fault error paths (target a: malformed/I/O → clean error) ---
func TestGC_LoadAllFail_Malformed(t *testing.T) {
// A malformed fact file makes the entry LoadAll fail; GC must surface a
// clean wrapped error, never panic (pins malformed→error at the GC entry).
s := newStore(t)
if err := os.WriteFile(filepath.Join(s.MemoryDir, "broken.md"), []byte("not a fact"), 0o644); err != nil {
t.Fatal(err)
}
_, err := s.GC()
if err == nil {
t.Fatal("expected malformed fact to fail GC")
}
if !strings.Contains(err.Error(), "gc:") {
t.Errorf("err = %v, want wrap gc:", err)
}
}
func TestGC_ArchiveFail_AtticIsFile(t *testing.T) {
s := gcStore(t, makeFact("done", "x", TypeFinding, withStatus("resolved")))
// Attic path is a regular file → archive's MkdirAll(AtticDir) returns
// ENOTDIR, surfaced as the gc archive wrap.
if err := os.WriteFile(s.AtticDir, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
_, err := s.GC()
if err == nil {
t.Fatal("expected archive to fail when attic is a file")
}
if !strings.Contains(err.Error(), "gc archive done") {
t.Errorf("err = %v, want wrap gc archive done", err)
}
}
func TestGC_QueueFail_StateDirIsFile(t *testing.T) {
s := gcStore(t, makeFact("u", "x", TypeUser, withRef("missing.go")))
// StateDir is a regular file → queue.AppendUnique's MkdirAll(stateDir)
// returns ENOTDIR, surfaced as the gc queue wrap.
if err := os.RemoveAll(s.StateDir); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(s.StateDir, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
_, err := s.GC()
if err == nil {
t.Fatal("expected queueReview to fail when state dir is a file")
}
if !strings.Contains(err.Error(), "gc queue u") {
t.Errorf("err = %v, want wrap gc queue u", err)
}
}
func TestGC_WriteIndexFail_IndexIsDir(t *testing.T) {
s := gcStore(t, makeFact("kept-user", "x", TypeUser))
// MEMORY.md is a directory. LoadAll skips it (e.IsDir() runs before the
// IndexFilename check), so both load passes succeed; WriteIndex's
// os.WriteFile then fails with EISDIR.
if err := os.Mkdir(filepath.Join(s.MemoryDir, IndexFilename), 0o755); err != nil {
t.Fatal(err)
}
_, err := s.GC()
if err == nil {
t.Fatal("expected WriteIndex to fail when MEMORY.md is a dir")
}
if !strings.Contains(err.Error(), "gc: write index") {
t.Errorf("err = %v, want wrap gc: write index", err)
}
}
func TestGC_ArchiveCollisionSuffix(t *testing.T) {
s := gcStore(t, makeFact("dup", "x", TypeFinding, withStatus("resolved")))
if err := os.MkdirAll(s.AtticDir, 0o755); err != nil {
t.Fatal(err)
}
// A pre-existing attic file with the same name forces the suffix branch;
// the clock is fixed, so now.Unix() is deterministic.
existing := filepath.Join(s.AtticDir, "dup.md")
if err := os.WriteFile(existing, []byte("prior archive"), 0o644); err != nil {
t.Fatal(err)
}
res, err := s.GC()
if err != nil {
t.Fatal(err)
}
if res.Archived != 1 {
t.Errorf("Archived = %d, want 1", res.Archived)
}
suffixed := filepath.Join(s.AtticDir, fmt.Sprintf("dup.%d.md", s.Now().UTC().Unix()))
if _, err := os.Stat(suffixed); err != nil {
t.Errorf("suffixed archive missing: %v", err)
}
if _, err := os.Stat(existing); err != nil {
t.Errorf("pre-existing attic file clobbered: %v", err)
}
}
func TestLogGC_OpenFileFail_LogIsDir(t *testing.T) {
s := newStore(t)
if err := os.MkdirAll(s.StateDir, 0o755); err != nil {
t.Fatal(err)
}
// gc.log is a directory → OpenFile(O_APPEND|O_CREATE|O_WRONLY) fails with
// EISDIR. No O_EXCL, so this is not the ErrExist path. logGC returns the
// raw os error with no wrap of its own, so assert err != nil only.
if err := os.Mkdir(filepath.Join(s.StateDir, "gc.log"), 0o755); err != nil {
t.Fatal(err)
}
if err := s.logGC(s.Now(), "archived", "x", "r"); err == nil {
t.Fatal("expected logGC to fail when gc.log is a dir")
}
}
// --- tombstone idempotency (target b) ---
func TestGC_TombstoneRestoreIdempotent(t *testing.T) {
s := gcStore(t, makeFact("ref1", "x", TypeReference, withRef("gone.go")))
atticPath := filepath.Join(s.AtticDir, "ref1.md")
memPath := filepath.Join(s.MemoryDir, "ref1.md")
res1, err := s.GC()
if err != nil {
t.Fatalf("first GC: %v", err)
}
if res1.Archived != 1 {
t.Fatalf("first pass Archived = %d, want 1", res1.Archived)
}
if _, err := os.Stat(atticPath); err != nil {
t.Errorf("attic copy missing after first pass: %v", err)
}
if _, err := os.Stat(memPath); !os.IsNotExist(err) {
t.Errorf("source not removed after first pass: %v", err)
}
// Restore the fact to the live dir and re-run: GC must re-archive it
// cleanly and idempotently, with no panic.
data, err := os.ReadFile(atticPath)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(memPath, data, 0o644); err != nil {
t.Fatal(err)
}
if err := os.Remove(atticPath); err != nil {
t.Fatal(err)
}
res2, err := s.GC()
if err != nil {
t.Fatalf("second GC: %v", err)
}
if res2.Archived != 1 {
t.Errorf("second pass Archived = %d, want 1", res2.Archived)
}
if _, err := os.Stat(atticPath); err != nil {
t.Errorf("attic copy missing after restore + re-GC: %v", err)
}
}
func TestGC_RepeatedStability(t *testing.T) {
s := gcStore(t,
makeFact("clean-a", "x", TypeUser),
makeFact("clean-b", "x", TypeProject),
)
var firstKept int
for i := range 3 {
res, err := s.GC()
if err != nil {
t.Fatalf("GC pass %d: %v", i, err)
}
if i == 0 {
firstKept = res.Kept
} else if res.Kept != firstKept {
t.Errorf("pass %d Kept = %d, want stable %d", i, res.Kept, firstKept)
}
}
if firstKept != 2 {
t.Errorf("Kept = %d, want 2", firstKept)
}
}
added internal/memory/index.go
@@ -0,0 +1,72 @@
package memory
import (
"bytes"
"fmt"
"os"
)
// WriteIndex regenerates <MemoryDir>/MEMORY.md from facts. The file is
// overwritten on each call; users are not expected to edit it by hand.
// The index lists facts sorted by name, with pinned facts grouped under
// a "## pinned" heading and disabled facts under a "## disabled"
// heading at the bottom for at-a-glance separation.
func (s *Store) WriteIndex(facts []*Fact) error {
var buf bytes.Buffer
buf.WriteString("# memory index\n\n")
buf.WriteString("Regenerated by eeco; do not edit by hand.\n")
buf.WriteString("Run `eeco gc` to refresh and prune.\n\n")
if len(facts) == 0 {
buf.WriteString("_(empty — no facts recorded yet)_\n")
return os.WriteFile(s.indexPath(), buf.Bytes(), 0o644)
}
// A disabled fact is bucketed regardless of its pin state: every
// muted adaptation belongs in one place so an audit of "what the AI
// has turned off" is a single glance at the "## disabled" section.
var active, pinned, disabled []*Fact
for _, f := range sortedFacts(facts) {
switch {
case f.Disabled:
disabled = append(disabled, f)
case f.Pin:
pinned = append(pinned, f)
default:
active = append(active, f)
}
}
if len(active) == 0 {
buf.WriteString("_(no active facts)_\n")
} else {
for _, f := range active {
writeIndexLine(&buf, f)
}
}
if len(pinned) > 0 {
buf.WriteString("\n## pinned\n\n")
for _, f := range pinned {
writeIndexLine(&buf, f)
}
}
if len(disabled) > 0 {
buf.WriteString("\n## disabled\n\n")
for _, f := range disabled {
writeIndexLine(&buf, f)
}
}
return os.WriteFile(s.indexPath(), buf.Bytes(), 0o644)
}
func (s *Store) indexPath() string {
return s.MemoryDir + "/" + IndexFilename
}
func writeIndexLine(buf *bytes.Buffer, f *Fact) {
fmt.Fprintf(buf, "- **%s** (%s, last_used %s) — %s\n",
f.Name, f.Type, f.LastUsed.UTC().Format(DateLayout), f.Description)
}
added internal/memory/index_test.go
@@ -0,0 +1,124 @@
package memory
import (
"os"
"strings"
"testing"
)
func TestWriteIndex_Empty(t *testing.T) {
s := newStore(t)
if err := s.WriteIndex(nil); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(s.indexPath())
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), "no facts recorded") {
t.Errorf("empty index missing placeholder:\n%s", string(b))
}
}
func TestWriteIndex_SeparatesPinned(t *testing.T) {
s := newStore(t)
a := makeFact("alpha", "active fact", TypeUser)
p := makeFact("pinned-one", "pinned fact", TypeProject, func(f *Fact) { f.Pin = true })
if err := s.WriteIndex([]*Fact{p, a}); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(s.indexPath())
if err != nil {
t.Fatal(err)
}
s2 := string(b)
iAlpha := strings.Index(s2, "**alpha**")
iPinHdr := strings.Index(s2, "## pinned")
iPinned := strings.Index(s2, "**pinned-one**")
if iAlpha < 0 || iPinHdr < 0 || iPinned < 0 {
t.Fatalf("missing expected entries:\n%s", s2)
}
if iAlpha >= iPinHdr || iPinHdr >= iPinned {
t.Errorf("ordering wrong (alpha < pinned-header < pinned-one):\n%s", s2)
}
}
func TestWriteIndex_SeparatesDisabled(t *testing.T) {
s := newStore(t)
a := makeFact("alpha", "active fact", TypeUser)
d := makeFact("muted-one", "disabled fact", TypeFeedback, func(f *Fact) { f.Disabled = true })
if err := s.WriteIndex([]*Fact{d, a}); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(s.indexPath())
if err != nil {
t.Fatal(err)
}
s2 := string(b)
iAlpha := strings.Index(s2, "**alpha**")
iDisHdr := strings.Index(s2, "## disabled")
iMuted := strings.Index(s2, "**muted-one**")
if iAlpha < 0 || iDisHdr < 0 || iMuted < 0 {
t.Fatalf("missing expected entries:\n%s", s2)
}
if iAlpha >= iDisHdr || iDisHdr >= iMuted {
t.Errorf("ordering wrong (alpha < disabled-header < muted-one):\n%s", s2)
}
}
func TestWriteIndex_DisabledOverridesPin(t *testing.T) {
s := newStore(t)
pd := makeFact("pinned-muted", "pinned and disabled", TypeProject, func(f *Fact) {
f.Pin = true
f.Disabled = true
})
if err := s.WriteIndex([]*Fact{pd}); err != nil {
t.Fatal(err)
}
b, _ := os.ReadFile(s.indexPath())
s2 := string(b)
if strings.Contains(s2, "## pinned") {
t.Errorf("pinned+disabled fact must not create a ## pinned section:\n%s", s2)
}
iDisHdr := strings.Index(s2, "## disabled")
iFact := strings.Index(s2, "**pinned-muted**")
if iDisHdr < 0 || iFact < 0 || iDisHdr >= iFact {
t.Errorf("pinned+disabled fact must list under ## disabled:\n%s", s2)
}
}
func TestWriteIndex_AllDisabled(t *testing.T) {
s := newStore(t)
d := makeFact("muted-one", "disabled fact", TypeFeedback, func(f *Fact) { f.Disabled = true })
if err := s.WriteIndex([]*Fact{d}); err != nil {
t.Fatal(err)
}
b, _ := os.ReadFile(s.indexPath())
s2 := string(b)
if !strings.Contains(s2, "_(no active facts)_") {
t.Errorf("all-disabled store must print the no-active-facts placeholder:\n%s", s2)
}
if !strings.Contains(s2, "## disabled") {
t.Errorf("all-disabled store must still render the ## disabled section:\n%s", s2)
}
}
func TestWriteIndex_AlphabeticalActive(t *testing.T) {
s := newStore(t)
facts := []*Fact{
makeFact("zeta", "z", TypeUser),
makeFact("alpha", "a", TypeUser),
makeFact("middle", "m", TypeUser),
}
if err := s.WriteIndex(facts); err != nil {
t.Fatal(err)
}
b, _ := os.ReadFile(s.indexPath())
s2 := string(b)
ia := strings.Index(s2, "**alpha**")
im := strings.Index(s2, "**middle**")
iz := strings.Index(s2, "**zeta**")
if ia >= im || im >= iz {
t.Errorf("expected alpha < middle < zeta in:\n%s", s2)
}
}
added internal/memory/select.go
@@ -0,0 +1,72 @@
package memory
import (
"regexp"
"sort"
"strings"
)
// tokenSplit matches the inverse of \w (letters/digits/underscore).
// Tokenisation is intentionally simple: lowercase, split on non-word
// characters, dedupe. No stopword list — the store is small enough that
// recall matters more than precision at this stage.
var tokenSplit = regexp.MustCompile(`[^\p{L}\p{N}_]+`)
// Select returns the facts whose name or description shares a word with
// task, sorted by name. Each selected fact's last_used is bumped to the
// store clock and re-saved. Pinned facts participate in selection so
// that explicit knowledge still surfaces.
func (s *Store) Select(task string) ([]*Fact, error) {
facts, err := s.LoadAll()
if err != nil {
return nil, err
}
terms := tokenize(task)
if len(terms) == 0 {
return nil, nil
}
var out []*Fact
for _, f := range facts {
if f.Disabled {
continue
}
if overlap(terms, tokenize(f.Name+" "+f.Description)) {
out = append(out, f)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
now := s.Now().UTC()
today := now.Truncate(24 * 60 * 60 * 1e9) // truncate to day; equivalent to date-only
for _, f := range out {
f.LastUsed = today
if err := s.Save(f); err != nil {
return out, err
}
}
return out, nil
}
func tokenize(s string) map[string]struct{} {
out := map[string]struct{}{}
for _, t := range tokenSplit.Split(strings.ToLower(s), -1) {
if t == "" {
continue
}
out[t] = struct{}{}
}
return out
}
func overlap(a, b map[string]struct{}) bool {
short, long := a, b
if len(b) < len(a) {
short, long = b, a
}
for k := range short {
if _, ok := long[k]; ok {
return true
}
}
return false
}
added internal/memory/select_test.go
@@ -0,0 +1,112 @@
package memory
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestSelect_WordOverlap(t *testing.T) {
s := newStore(t)
a := makeFact("pipeline-ref", "pipeline bugs tracked in linear", TypeReference)
b := makeFact("auth-feedback", "always lowercase usernames", TypeFeedback)
if err := s.Save(a); err != nil {
t.Fatal(err)
}
if err := s.Save(b); err != nil {
t.Fatal(err)
}
got, err := s.Select("ingest pipeline broke")
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Name != "pipeline-ref" {
t.Fatalf("Select got %v, want [pipeline-ref]", got)
}
}
func TestSelect_CaseInsensitive(t *testing.T) {
s := newStore(t)
if err := s.Save(makeFact("foo", "Bar Baz Qux", TypeUser)); err != nil {
t.Fatal(err)
}
got, err := s.Select("BAZ")
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Errorf("expected one match, got %d", len(got))
}
}
func TestSelect_BumpsLastUsed(t *testing.T) {
s := newStore(t)
f := makeFact("hit", "matches query", TypeUser, func(f *Fact) {
f.LastUsed = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
})
if err := s.Save(f); err != nil {
t.Fatal(err)
}
if _, err := s.Select("matches"); err != nil {
t.Fatal(err)
}
facts, _ := s.LoadAll()
if len(facts) != 1 {
t.Fatal("expected one fact")
}
wantDay := s.Now().UTC().Format(DateLayout)
if got := facts[0].LastUsed.UTC().Format(DateLayout); got != wantDay {
t.Errorf("last_used after Select = %s, want %s", got, wantDay)
}
}
func TestSelect_EmptyQueryReturnsNothing(t *testing.T) {
s := newStore(t)
if err := s.Save(makeFact("x", "any", TypeUser)); err != nil {
t.Fatal(err)
}
got, err := s.Select(" ")
if err != nil {
t.Fatal(err)
}
if got != nil {
t.Errorf("expected nil, got %v", got)
}
}
func TestSelect_LoadAllFail(t *testing.T) {
// A malformed fact file makes LoadAll fail; Select must surface the
// wrapped LoadAll error, never panic (pins malformed→error at the
// Select entry).
s := newStore(t)
if err := os.WriteFile(filepath.Join(s.MemoryDir, "bad.md"), []byte("nope"), 0o644); err != nil {
t.Fatal(err)
}
_, err := s.Select("anything")
if err == nil {
t.Fatal("expected malformed fact to fail Select")
}
if !strings.Contains(err.Error(), "memory.LoadAll:") {
t.Errorf("err = %v, want wrap memory.LoadAll:", err)
}
}
func TestSelect_MissDoesNotBump(t *testing.T) {
s := newStore(t)
oldDay := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
f := makeFact("miss", "no overlap here", TypeUser, func(f *Fact) {
f.LastUsed = oldDay
})
if err := s.Save(f); err != nil {
t.Fatal(err)
}
if _, err := s.Select("totally unrelated"); err != nil {
t.Fatal(err)
}
facts, _ := s.LoadAll()
if !facts[0].LastUsed.Equal(oldDay) {
t.Errorf("non-selected fact bumped: got %v", facts[0].LastUsed)
}
}
added internal/memory/store.go
@@ -0,0 +1,158 @@
package memory
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/ajhahnde/eeco/internal/config"
)
// IndexFilename is the name of the regenerated index living alongside
// the fact files under <workspace>/memory/.
const IndexFilename = "MEMORY.md"
// AtticDir is the name of the archive subdirectory under
// <workspace>/memory/.
const AtticDir = "attic"
// Store owns the on-disk memory store. It is constructed by Open and is
// safe to reuse across operations within a single process.
type Store struct {
// RepoRoot is the repository root; used to resolve fact `ref` paths.
RepoRoot string
// MemoryDir is the directory holding fact files (<workspace>/memory).
MemoryDir string
// AtticDir is the archive directory (<workspace>/memory/attic).
AtticDir string
// StateDir is <workspace>/state, used for queue.md and gc.log.
StateDir string
// StaleDays is the threshold for reference-fact ageing.
StaleDays int
// Now is the clock source; defaulted to time.Now and injectable for
// tests.
Now func() time.Time
}
// Open returns a Store rooted at cfg.Workspace. The memory directory is
// created if missing so that callers may operate on a freshly
// initialised repository without a separate ensure step.
func Open(cfg *config.Config) (*Store, error) {
if cfg == nil {
return nil, errors.New("memory.Open: nil config")
}
memDir := filepath.Join(cfg.Workspace, "memory")
if err := os.MkdirAll(memDir, 0o755); err != nil {
return nil, fmt.Errorf("memory: create memory dir: %w", err)
}
stateDir := filepath.Join(cfg.Workspace, "state")
return &Store{
RepoRoot: cfg.RepoRoot,
MemoryDir: memDir,
AtticDir: filepath.Join(memDir, AtticDir),
StateDir: stateDir,
StaleDays: cfg.StaleDays,
Now: time.Now,
}, nil
}
// pathFor returns the on-disk path for a fact with the given name.
func (s *Store) pathFor(name string) string {
return filepath.Join(s.MemoryDir, name+".md")
}
// Save serialises f and writes it atomically to disk. The filename
// derives from f.Name. Save validates f and refuses to overwrite a
// file that exists with a different name (which would indicate a
// rename via Save rather than an explicit move).
func (s *Store) Save(f *Fact) error {
if err := f.Validate(); err != nil {
return fmt.Errorf("memory.Save: %w", err)
}
data, err := Serialize(f)
if err != nil {
return fmt.Errorf("memory.Save: %w", err)
}
path := s.pathFor(f.Name)
tmp, err := os.CreateTemp(s.MemoryDir, "."+f.Name+".*.tmp")
if err != nil {
return fmt.Errorf("memory.Save: %w", err)
}
tmpPath := tmp.Name()
cleanup := func() { _ = os.Remove(tmpPath) }
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
cleanup()
return fmt.Errorf("memory.Save: %w", err)
}
if err := tmp.Close(); err != nil {
cleanup()
return fmt.Errorf("memory.Save: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
cleanup()
return fmt.Errorf("memory.Save: %w", err)
}
f.Path = path
return nil
}
// LoadAll reads every fact file in MemoryDir (skipping AtticDir, the
// index, any dot-prefixed entry, and any non-`.md` entry) and returns
// the parsed facts. Dot-prefixed names can never be valid facts
// because Fact.Name is restricted to `^[a-z0-9][a-z0-9-]*$`, so a
// dot-prefixed file under MemoryDir was placed by hand and is ignored
// rather than parsed. A parse error on any other single file aborts
// the load: the store is a small curated set and silent skips would
// hide bugs.
func (s *Store) LoadAll() ([]*Fact, error) {
entries, err := os.ReadDir(s.MemoryDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("memory.LoadAll: %w", err)
}
var out []*Fact
seen := map[string]string{}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if name == IndexFilename {
continue
}
if strings.HasPrefix(name, ".") {
continue
}
if !strings.HasSuffix(name, ".md") {
continue
}
path := filepath.Join(s.MemoryDir, name)
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("memory.LoadAll: read %s: %w", name, err)
}
f, err := ParseFact(data)
if err != nil {
return nil, fmt.Errorf("memory.LoadAll: parse %s: %w", name, err)
}
expectedName := strings.TrimSuffix(name, ".md")
if f.Name != expectedName {
return nil, fmt.Errorf("memory.LoadAll: %s: frontmatter name %q does not match filename", name, f.Name)
}
if dup, ok := seen[f.Name]; ok {
return nil, fmt.Errorf("memory.LoadAll: duplicate fact %q (%s and %s)", f.Name, dup, name)
}
seen[f.Name] = name
f.Path = path
out = append(out, f)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
added internal/memory/store_test.go
@@ -0,0 +1,251 @@
package memory
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/ajhahnde/eeco/internal/config"
)
// newStore returns an opened Store rooted at a fresh temp workspace,
// with a fake repo root (no .git required). The Now clock is fixed.
func newStore(t *testing.T) *Store {
t.Helper()
root := t.TempDir()
ws := filepath.Join(root, ".eeco")
if err := os.MkdirAll(filepath.Join(ws, "state"), 0o755); err != nil {
t.Fatal(err)
}
cfg := &config.Config{
RepoRoot: root,
WorkspaceName: ".eeco",
Workspace: ws,
Profile: config.ProfileGeneric,
StaleDays: config.DefaultStaleDays,
}
s, err := Open(cfg)
if err != nil {
t.Fatal(err)
}
s.Now = func() time.Time { return time.Date(2026, 5, 19, 12, 0, 0, 0, time.UTC) }
return s
}
func makeFact(name, desc string, typ FactType, opts ...func(*Fact)) *Fact {
f := &Fact{
Name: name,
Description: desc,
Type: typ,
Created: time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC),
LastUsed: time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC),
}
for _, o := range opts {
o(f)
}
return f
}
func TestStoreSaveLoad_RoundTrip(t *testing.T) {
s := newStore(t)
f := makeFact("alpha", "alpha fact", TypeProject)
if err := s.Save(f); err != nil {
t.Fatal(err)
}
facts, err := s.LoadAll()
if err != nil {
t.Fatal(err)
}
if len(facts) != 1 {
t.Fatalf("LoadAll = %d, want 1", len(facts))
}
if facts[0].Name != "alpha" {
t.Errorf("name = %q", facts[0].Name)
}
}
func TestStoreLoadAll_SkipsAtticAndIndex(t *testing.T) {
s := newStore(t)
f := makeFact("keep", "keep me", TypeProject)
if err := s.Save(f); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(s.AtticDir, 0o755); err != nil {
t.Fatal(err)
}
// Drop an "archived" file in attic; LoadAll must ignore it.
if err := os.WriteFile(filepath.Join(s.AtticDir, "garbage.md"), []byte("---\nname: garbage\n---\n"), 0o644); err != nil {
t.Fatal(err)
}
// Drop a MEMORY.md sibling; LoadAll must ignore it.
if err := os.WriteFile(filepath.Join(s.MemoryDir, IndexFilename), []byte("# index"), 0o644); err != nil {
t.Fatal(err)
}
// Drop a non-md sibling; LoadAll must ignore it.
if err := os.WriteFile(filepath.Join(s.MemoryDir, "notes.txt"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
facts, err := s.LoadAll()
if err != nil {
t.Fatal(err)
}
if len(facts) != 1 || facts[0].Name != "keep" {
t.Errorf("unexpected facts: %+v", facts)
}
}
func TestStoreLoadAll_SkipsDotPrefixed(t *testing.T) {
s := newStore(t)
f := makeFact("keep", "keep me", TypeProject)
if err := s.Save(f); err != nil {
t.Fatal(err)
}
// Dot-prefixed file with body that would otherwise fail ParseFact;
// proves the skip happens before parsing.
if err := os.WriteFile(filepath.Join(s.MemoryDir, ".gc-policy.md"), []byte("# policy\n"), 0o644); err != nil {
t.Fatal(err)
}
// Dot-prefixed file with valid-looking frontmatter; proves the
// skip is filename-based, not parse-outcome-based.
hidden := makeFact("hidden", "hidden fact", TypeUser)
data, err := Serialize(hidden)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(s.MemoryDir, ".hidden.md"), data, 0o644); err != nil {
t.Fatal(err)
}
facts, err := s.LoadAll()
if err != nil {
t.Fatalf("LoadAll errored: %v", err)
}
if len(facts) != 1 || facts[0].Name != "keep" {
t.Errorf("unexpected facts: %+v", facts)
}
}
func TestStoreLoadAll_FilenameMustMatchName(t *testing.T) {
s := newStore(t)
f := makeFact("good-name", "x", TypeUser)
data, err := Serialize(f)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(s.MemoryDir, "wrong-name.md"), data, 0o644); err != nil {
t.Fatal(err)
}
if _, err := s.LoadAll(); err == nil {
t.Fatal("expected mismatched filename to error")
}
}
func TestStoreLoadAll_RejectsParseError(t *testing.T) {
s := newStore(t)
if err := os.WriteFile(filepath.Join(s.MemoryDir, "broken.md"), []byte("not a fact"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := s.LoadAll(); err == nil {
t.Fatal("expected parse error to bubble up")
}
}
func TestStoreLoadAll_EmptyDirOK(t *testing.T) {
s := newStore(t)
facts, err := s.LoadAll()
if err != nil {
t.Fatal(err)
}
if len(facts) != 0 {
t.Errorf("expected empty, got %d facts", len(facts))
}
}
// --- Save atomic-write faults (target c: fail loudly and cleanly) ---
func TestSave_ValidateFail(t *testing.T) {
s := newStore(t)
if err := s.Save(&Fact{}); err == nil {
t.Fatal("expected Save of an invalid fact to error")
} else if !strings.Contains(err.Error(), "memory.Save:") {
t.Errorf("err = %v, want wrap memory.Save:", err)
}
}
func TestSave_CreateTempFail_ParentNotDir(t *testing.T) {
s := newStore(t)
// Point MemoryDir at a regular file: os.CreateTemp tries to create a
// temp file *inside* it and fails with ENOTDIR. Validate and Serialize
// pass first, so this exercises the CreateTemp error branch.
filePath := filepath.Join(s.RepoRoot, "not-a-dir")
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
s.MemoryDir = filePath
err := s.Save(makeFact("foo", "x", TypeUser))
if err == nil {
t.Fatal("expected CreateTemp to fail when MemoryDir is a file")
}
if !strings.Contains(err.Error(), "memory.Save:") {
t.Errorf("err = %v, want wrap memory.Save:", err)
}
}
func TestSave_RenameFail_TargetIsDir(t *testing.T) {
s := newStore(t)
// Target foo.md is a directory: CreateTemp/Write/Close succeed, but the
// final Rename onto a directory fails. The cleanup() closure must then
// remove the temp file so no .foo.*.tmp leaks.
if err := os.Mkdir(s.pathFor("foo"), 0o755); err != nil {
t.Fatal(err)
}
err := s.Save(makeFact("foo", "x", TypeUser))
if err == nil {
t.Fatal("expected Rename onto a directory to fail")
}
if !strings.Contains(err.Error(), "memory.Save:") {
t.Errorf("err = %v, want wrap memory.Save:", err)
}
leftover, _ := filepath.Glob(filepath.Join(s.MemoryDir, ".foo.*.tmp"))
if len(leftover) != 0 {
t.Errorf("temp file not cleaned up: %v", leftover)
}
}
func TestOpen_NilConfig(t *testing.T) {
_, err := Open(nil)
if err == nil {
t.Fatal("expected nil config to error")
}
if !strings.Contains(err.Error(), "memory.Open: nil config") {
t.Errorf("err = %v, want memory.Open: nil config", err)
}
}
func TestOpen_MkdirAllFail_WorkspaceIsFile(t *testing.T) {
root := t.TempDir()
filePath := filepath.Join(root, "file")
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
// Workspace sits *under* a regular file, so Join(Workspace, "memory")
// can't be created: MkdirAll returns ENOTDIR. (newStore is not reused
// here — it must succeed; this case needs Open itself to fail.)
cfg := &config.Config{
RepoRoot: root,
WorkspaceName: ".eeco",
Workspace: filepath.Join(filePath, "sub"),
Profile: config.ProfileGeneric,
StaleDays: config.DefaultStaleDays,
}
_, err := Open(cfg)
if err == nil {
t.Fatal("expected MkdirAll to fail when workspace is under a file")
}
if !strings.Contains(err.Error(), "memory: create memory dir:") {
t.Errorf("err = %v, want memory: create memory dir:", err)
}
}
added internal/notes/notes.go
@@ -0,0 +1,156 @@
// Package notes is eeco's free-form workspace scratch surface.
//
// A note is neither a memory fact (frontmatter-strict,
// AI-relevance-matched) nor a queue item (an append-only decision
// channel) — it is a place to scribble. Notes live as one plain
// Markdown file each under <workspace>/notes/, named with a UTC
// timestamp and a slug derived from the text. The surface is
// append + list only: editing is `$EDITOR <file>`, deletion is `rm`.
package notes
import (
"errors"
"os"
"path/filepath"
"sort"
"strings"
"time"
"unicode"
)
// stampLayout is the UTC timestamp prefix on a note filename. The
// resolution is one second; a slug keeps two notes in the same second
// from colliding in practice.
const stampLayout = "2006-01-02-150405"
// Note is one listed note: the file it lives in, the time it was
// written (parsed from the filename, falling back to mtime), and a
// one-line summary (the first non-blank line of the body).
type Note struct {
Path string
When time.Time
Summary string
}
// Add writes text to a new note file under notesDir, creating the
// directory if missing, and returns the written path. The filename is
// "<stamp>-<slug>.md"; the body is text verbatim. Empty or
// whitespace-only text is rejected.
func Add(notesDir, text string, now time.Time) (string, error) {
if notesDir == "" {
return "", errors.New("notes.Add: notesDir is empty")
}
if strings.TrimSpace(text) == "" {
return "", errors.New("notes.Add: text is empty")
}
if now.IsZero() {
now = time.Now()
}
if err := os.MkdirAll(notesDir, 0o755); err != nil {
return "", err
}
name := now.UTC().Format(stampLayout) + "-" + slug(text) + ".md"
path := filepath.Join(notesDir, name)
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
return "", err
}
return path, nil
}
// List returns the notes under notesDir, newest first. A missing
// directory yields an empty slice and a nil error, mirroring
// queue.Count's missing-file handling.
func List(notesDir string) ([]Note, error) {
entries, err := os.ReadDir(notesDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
var out []Note
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".md") {
continue
}
path := filepath.Join(notesDir, e.Name())
out = append(out, Note{
Path: path,
When: noteTime(e, path),
Summary: summary(path),
})
}
sort.Slice(out, func(i, j int) bool {
return out[i].When.After(out[j].When)
})
return out, nil
}
// noteTime parses the timestamp prefix from a note filename, falling
// back to the file's mtime when the name does not carry a parseable
// stamp.
func noteTime(e os.DirEntry, path string) time.Time {
name := strings.TrimSuffix(e.Name(), ".md")
if len(name) >= len(stampLayout) {
if t, err := time.ParseInLocation(stampLayout, name[:len(stampLayout)], time.UTC); err == nil {
return t
}
}
if info, err := e.Info(); err == nil {
return info.ModTime()
}
if info, err := os.Stat(path); err == nil {
return info.ModTime()
}
return time.Time{}
}
// summary returns the first non-blank line of the note body, trimmed.
// A note that is unreadable or all-blank yields an empty summary.
func summary(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
for _, line := range strings.Split(string(b), "\n") {
if s := strings.TrimSpace(line); s != "" {
return s
}
}
return ""
}
// slug reduces a note's text to a short filename-safe stem. Runs of
// non-`[a-z0-9]` collapse to `-`; the result is capped at slugMaxRunes
// runes and trimmed of leading and trailing `-`. An empty or
// all-punctuation note yields the fallback "note".
func slug(text string) string {
const slugMaxRunes = 30
var b strings.Builder
dash := false
count := 0
for _, r := range text {
if count >= slugMaxRunes {
break
}
lr := unicode.ToLower(r)
if (lr >= 'a' && lr <= 'z') || (lr >= '0' && lr <= '9') {
b.WriteRune(lr)
dash = false
count++
continue
}
if !dash && b.Len() > 0 {
b.WriteRune('-')
dash = true
count++
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
return "note"
}
return out
}
added internal/notes/notes_test.go
@@ -0,0 +1,134 @@
package notes
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestAdd_WritesFilenameAndVerbatimBody(t *testing.T) {
dir := t.TempDir()
now := time.Date(2026, 5, 22, 14, 3, 0, 0, time.UTC)
body := "check round-robin fairness in queue promotion"
path, err := Add(dir, body, now)
if err != nil {
t.Fatal(err)
}
wantName := "2026-05-22-140300-check-round-robin-fairness-in.md"
if got := filepath.Base(path); got != wantName {
t.Errorf("filename = %q, want %q", got, wantName)
}
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(b) != body {
t.Errorf("body = %q, want verbatim %q", string(b), body)
}
}
func TestAdd_CreatesDirIfMissing(t *testing.T) {
dir := filepath.Join(t.TempDir(), "notes")
if _, err := Add(dir, "scribble", time.Now()); err != nil {
t.Fatal(err)
}
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
t.Errorf("notes dir not created: %v", err)
}
}
func TestAdd_RejectsEmptyText(t *testing.T) {
dir := t.TempDir()
for _, in := range []string{"", " ", "\t\n"} {
if _, err := Add(dir, in, time.Now()); err == nil {
t.Errorf("Add(%q) = nil error, want rejection", in)
}
}
}
func TestList_NewestFirst(t *testing.T) {
dir := t.TempDir()
older := time.Date(2026, 5, 21, 9, 11, 0, 0, time.UTC)
newer := time.Date(2026, 5, 22, 14, 3, 0, 0, time.UTC)
if _, err := Add(dir, "older note", older); err != nil {
t.Fatal(err)
}
if _, err := Add(dir, "newer note", newer); err != nil {
t.Fatal(err)
}
got, err := List(dir)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("len = %d, want 2", len(got))
}
if got[0].Summary != "newer note" || got[1].Summary != "older note" {
t.Errorf("order = [%q, %q], want newest first", got[0].Summary, got[1].Summary)
}
if !got[0].When.Equal(newer) {
t.Errorf("When = %v, want %v parsed from filename", got[0].When, newer)
}
}
func TestList_MissingDirEmpty(t *testing.T) {
got, err := List(filepath.Join(t.TempDir(), "absent"))
if err != nil {
t.Fatalf("err = %v, want nil for missing dir", err)
}
if len(got) != 0 {
t.Errorf("len = %d, want 0", len(got))
}
}
func TestList_SummaryIsFirstNonBlankLine(t *testing.T) {
dir := t.TempDir()
if _, err := Add(dir, "\n\n first real line\nsecond line", time.Now()); err != nil {
t.Fatal(err)
}
got, err := List(dir)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Summary != "first real line" {
t.Errorf("summary = %q, want %q", got[0].Summary, "first real line")
}
}
func TestList_IgnoresNonMarkdown(t *testing.T) {
dir := t.TempDir()
if _, err := Add(dir, "real note", time.Now()); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "stray.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
t.Fatal(err)
}
got, err := List(dir)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Errorf("len = %d, want 1 (only the .md note)", len(got))
}
}
func TestSlug(t *testing.T) {
cases := []struct{ in, want string }{
{"check round-robin fairness in queue promotion", "check-round-robin-fairness-in"},
{"Hello, World!", "hello-world"},
{"!!!", "note"},
{"", "note"},
{" ", "note"},
{"a", "a"},
}
for _, c := range cases {
if got := slug(c.in); got != c.want {
t.Errorf("slug(%q) = %q, want %q", c.in, got, c.want)
}
}
}
added internal/playbooks/data/commit.json
@@ -0,0 +1,61 @@
{
"name": "commit",
"description": "Inspect the staged changes and propose a Conventional-Commits message (subject and body) for the operator to use. Reads and proposes only — never stages, never commits, never tags, never touches git history.",
"intent": {
"guarantee": "This skill only inspects the working tree and prints a proposed commit message — the operator runs the commit",
"forbidden": [
"git add",
"git commit",
"git push",
"git tag",
"git reset",
"git rebase",
"stage anything",
"amend a commit"
]
},
"capabilities": [
{ "kind": "tool", "name": "Read" },
{ "kind": "tool", "name": "Grep" },
{ "kind": "tool", "name": "Glob" },
{ "kind": "tool", "name": "AskUserQuestion" },
{ "kind": "bash", "verb": "git status", "scope": "*" },
{ "kind": "bash", "verb": "git diff", "scope": "*" },
{ "kind": "bash", "verb": "git log", "scope": "*" },
{ "kind": "bash", "verb": "git describe", "scope": "*" }
],
"steps": [
{
"index": 0,
"title": "Inspect the staged set",
"body": "Read the staged changes with the read-only commands below. Note which files changed, the nature of each change, and whether anything is staged at all. If nothing is staged, say so and stop — there is nothing to commit a message for. Never run a write subcommand; this step only reads.",
"runs": [
"git status --short",
"git diff --staged",
"git log --oneline -10"
]
},
{
"index": 1,
"title": "Classify the change",
"body": "Pick the Conventional-Commits type from the staged diff: feat (new behaviour), fix (bug fix), docs, refactor, test, chore, build, ci, perf, style. Derive an optional scope from the dominant area of the change (a package or subsystem name). When the change spans several types, pick the one that describes the primary intent and mention the rest in the body."
},
{
"index": 2,
"title": "Hygiene scan (advise only)",
"body": "Scan the staged diff for things that should not ship in a commit: secrets or credentials, leftover debug prints, commented-out code, and AI-attribution trailers or fingerprints. Report anything found as advice for the operator to address before committing. Do not edit any file and do not block — this step only warns."
},
{
"index": 3,
"title": "Draft the message",
"body": "Write a Conventional-Commits subject of the form `type(scope): summary`, in the imperative mood, with the summary line at most 50 characters. Add a body only when the why is not obvious from the subject: wrap it at ~72 columns and explain the motivation, not the mechanics. Keep technical terms and file paths verbatim. Never add an AI-attribution trailer."
},
{
"index": 4,
"title": "Print, never commit",
"body": "Print the proposed message in a fenced block the operator can copy, plus a one-line reminder that nothing was staged or committed. If the staged set looks like it should be split into more than one logical commit, say so and propose the split. Never run git add, git commit, git tag, or any other write subcommand."
}
],
"output_format": "Print the proposed Conventional-Commits message in a fenced block, followed by a one-line note that nothing was staged or committed (the operator runs the commit).",
"maps_to_workflow": "commit-msg"
}
added internal/playbooks/data/doc-drift.json
@@ -0,0 +1,62 @@
{
"name": "doc-drift",
"description": "Compare a documented version record (a changelog) against the source-of-truth git tags and report any drift. Reads and reports only — never edits the document, never creates, moves, or deletes a tag.",
"intent": {
"guarantee": "This skill only reads the changelog and the tag list and reports drift — it never edits the document or touches any tag",
"forbidden": [
"git tag",
"git push",
"git commit",
"git add",
"edit the changelog",
"create or move any tag"
]
},
"capabilities": [
{ "kind": "tool", "name": "Read" },
{ "kind": "tool", "name": "Grep" },
{ "kind": "tool", "name": "Glob" },
{ "kind": "tool", "name": "AskUserQuestion" },
{ "kind": "bash", "verb": "git for-each-ref", "scope": "refs/tags*" },
{ "kind": "bash", "verb": "git log", "scope": "*" },
{ "kind": "bash", "verb": "git describe", "scope": "*" },
{ "kind": "bash", "verb": "git status", "scope": "*" }
],
"params": [
{ "name": "doc", "description": "the documented version record to check", "default": "CHANGELOG.md" },
{ "name": "tag_glob", "description": "the tag pattern that is the source of truth", "default": "v*.*.*" }
],
"steps": [
{
"index": 0,
"title": "Read the documented versions",
"body": "Read the doc param (default CHANGELOG.md) and extract the set of versions it records — the section headings of the form `## [x.y.z]` or equivalent. Keep them in document order. Do not edit the file."
},
{
"index": 1,
"title": "List the source-of-truth tags",
"body": "List the release tags with the read-only command below — `git for-each-ref`, never `git tag` (which can create a tag). Filter to the tag_glob pattern (default v*.*.*). This set is the source of truth for what was actually released.",
"runs": [
"git for-each-ref --sort=-version:refname --format '%(refname:short)' refs/tags",
"git describe --tags --always"
]
},
{
"index": 2,
"title": "Diff the two sets",
"body": "Compute the symmetric difference: tags with no changelog section (released but undocumented) and changelog sections with no tag (documented but unreleased, e.g. a stale or premature entry). Normalize the `v` prefix so `v1.2.3` and `1.2.3` match."
},
{
"index": 3,
"title": "Classify each drift",
"body": "For each mismatch, classify it: missing-changelog-entry (a tag with no section — the more serious, a shipped release with no record), or unreleased-section (a section with no tag — usually an in-progress or premature entry). Note an Unreleased/building section separately; it is expected, not drift."
},
{
"index": 4,
"title": "Report, never reconcile",
"body": "Print a table of the drift with its classification, plus a one-line summary (clean, or N drifts). Recommend the fix in prose (add a section, cut a tag) but never apply it: do not edit the changelog and do not create, move, or delete a tag. The operator reconciles."
}
],
"output_format": "Print a drift table (version, classification, note) and a one-line summary. State explicitly that nothing was edited and no tag was touched.",
"maps_to_workflow": "doc-drift"
}
added internal/playbooks/data/handover.json
@@ -0,0 +1,74 @@
{
"name": "handover",
"description": "Generate a dated session-handover note in the workspace docs/handover dir, mirroring the established note structure (header, commits, checks, resume path, resume trigger). Writes the file directly into the gitignored private tree — never stages, never commits, never touches tracked files.",
"intent": {
"guarantee": "This skill writes only into the gitignored private workspace tree, and only ever proposes — the operator commits",
"forbidden": [
"git add",
"git commit",
"git push",
"git tag",
"touch any tracked file"
]
},
"capabilities": [
{ "kind": "tool", "name": "Read" },
{ "kind": "tool", "name": "Write" },
{ "kind": "tool", "name": "Grep" },
{ "kind": "tool", "name": "Glob" },
{ "kind": "tool", "name": "Agent" },
{ "kind": "tool", "name": "Task" },
{ "kind": "tool", "name": "AskUserQuestion" },
{ "kind": "bash", "verb": "git status", "scope": "*" },
{ "kind": "bash", "verb": "git log", "scope": "*" },
{ "kind": "bash", "verb": "git diff", "scope": "*" },
{ "kind": "bash", "verb": "git describe", "scope": "*" },
{ "kind": "bash", "verb": "git branch --show-current", "scope": "*" },
{ "kind": "bash", "verb": "git stash list", "scope": "*" },
{ "kind": "bash", "verb": "date", "scope": "*" },
{ "kind": "bash", "verb": "ls", "scope": "*" },
{ "kind": "bash", "verb": "find", "scope": "*" },
{ "kind": "bash", "verb": "head", "scope": "*" }
],
"steps": [
{
"index": 0,
"title": "Gather session facts",
"body": "Collect the facts the handover note records. Run the read-only commands below, then from the conversation note: what was worked on this session, which commits landed (match against the log), which checks or gates ran and their results, what is unfinished, and any explicit operator decisions made during the session. If the session's work is not visible in git (uncommitted tree, or work that lives only in the private tree), say so in the note — the next session's divergence check depends on an accurate working-tree state.",
"runs": [
"git status --short",
"git log --oneline -15",
"git describe --tags --always",
"git branch --show-current",
"git stash list",
"date +%F"
]
},
{
"index": 1,
"title": "Determine the filename",
"body": "Name the note handover_<YYYY-MM-DD>[_<slug>].md under the workspace handover dir. Take the date from `date +%F`, never from memory. Derive the slug from the session's main outcome (stage + landmark); confirm with the operator if it is ambiguous. If a note with today's date already exists, ask whether to write a second slugged note or extend the existing one."
},
{
"index": 2,
"title": "Mirror the established structure",
"body": "Read the newest existing handover note as the format template and mirror its section structure rather than imposing a fixed skeleton — the format is allowed to drift with use. The stable core is: a title with the date and a status headline; a header block (date, branch @ SHA, working-tree state, goal); a 'this session' section listing each commit (SHA, subject, one-line note) or, when nothing was committed, the working-tree / private-tree state; a working-tree-state section for the next session's divergence check; a checks/gates section naming what ran with results and what did NOT run; a numbered resume path with concrete commands; a 'what not to redo' section; standing constraints and memory references; and a resume trigger.",
"runs": [
"ls -t <handover-dir>/handover_*.md",
"head -120 <newest-handover>"
]
},
{
"index": 3,
"title": "Write the note content",
"body": "Draft the note against the structure from Step 2. Keep every claim concrete — SHAs, exact counts, exact file paths, exact commands — so the next session can act without re-deriving anything. Technical terms, commit subjects, and command lines stay verbatim. Internal jargon and private-tree paths are expected here; this is the private tree, the inverse of a leak-swept public commit message."
},
{
"index": 4,
"title": "Write the file and tidy the dir",
"body": "Write the note with the Write tool to the Step 1 path. If older handover notes in the dir would now shadow the newest one in the resume search, ask the operator whether to move them into an archive subdir — move with `mv`, never delete, and only on explicit confirmation. Print the path written, the status headline, and a one-line reminder that the note is in the gitignored private tree (nothing to commit). Never stage, never commit, never touch a tracked file."
}
],
"output_format": "Print three lines: the path written, the one-line status headline, and a reminder that the note is gitignored (nothing to commit).",
"maps_to_workflow": "handover-refresh"
}
added internal/playbooks/data/memcheck.json
@@ -0,0 +1,58 @@
{
"name": "memcheck",
"description": "Audit the AI memory files for stale anchors — paths, flags, and contract values that no longer match the tree — and report them. Reads and reports only — never edits or deletes a memory, never touches a tracked file.",
"intent": {
"guarantee": "This skill only reads the memory files and the tree and reports stale anchors — it never edits or deletes a memory",
"forbidden": [
"git add",
"git commit",
"git push",
"git tag",
"edit or delete a memory",
"touch any tracked file"
]
},
"capabilities": [
{ "kind": "tool", "name": "Read" },
{ "kind": "tool", "name": "Grep" },
{ "kind": "tool", "name": "Glob" },
{ "kind": "tool", "name": "AskUserQuestion" },
{ "kind": "bash", "verb": "grep", "scope": "*" },
{ "kind": "bash", "verb": "test", "scope": "*" },
{ "kind": "bash", "verb": "find", "scope": "*" }
],
"steps": [
{
"index": 0,
"title": "Locate the memory set",
"body": "Find the memory files: the MEMORY.md index and each individual memory file it points to. Read the index first, then each referenced file. Do not edit anything."
},
{
"index": 1,
"title": "Extract the anchors",
"body": "From each memory, pull the concrete anchors it asserts: file paths, function or symbol names, flag and command names, version or contract values, and links to other memories. These are the claims that can go stale as the code moves underneath them."
},
{
"index": 2,
"title": "Verify against the tree",
"body": "Check each anchor against the current tree with the read-only commands below: does the file or path still exist, does the symbol or flag still appear, does the asserted value still hold. A `test -e` / `grep` that fails marks a candidate stale anchor.",
"runs": [
"test -e <path>",
"grep -rn <symbol> .",
"find . -name <file>"
]
},
{
"index": 3,
"title": "Check the index integrity",
"body": "Verify MEMORY.md itself: every file it lists exists, and every memory file on disk is listed (no orphan, no dangling pointer). A mismatch between the index and the files on disk is its own kind of drift."
},
{
"index": 4,
"title": "Report the stale table",
"body": "Print a table of stale anchors (memory, anchor, why it is stale) and any index mismatches, plus a one-line summary. Recommend the fix in prose but never apply it: do not edit or delete a memory and do not touch any tracked file. The operator reconciles."
}
],
"output_format": "Print a stale-anchor table (memory, anchor, reason) plus any index mismatches and a one-line summary. State explicitly that no memory was edited or deleted.",
"maps_to_workflow": "memory-drift"
}
added internal/playbooks/playbooks.go
@@ -0,0 +1,107 @@
// Package playbooks is eeco's shipped, neutral cockpit-playbook library:
// one embedded JSON source per AI procedure, the single reviewable source
// of truth for the playbooks eeco emits as harness config. It mirrors
// internal/prompts (embedded sources + a registry), but its unit is a
// cockpit.Playbook rather than a text template.
//
// Dependency direction (no cycle): this package imports internal/cockpit
// for the Playbook type; cockpit never imports playbooks. cmd/eeco wires
// the two — playbooks.Get(name) feeds cockpit.Generate.
//
// At C1 the library ships one source: handover. The general and
// parameterized playbooks (commit, doc-drift, …) migrate as additive C2
// slices.
package playbooks
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"sort"
"strings"
"github.com/ajhahnde/eeco/internal/cockpit"
)
//go:embed data/*.json
var dataFS embed.FS
// entry pairs a parsed Playbook with the raw JSON an operator audits via
// `eeco cockpit show`.
type entry struct {
pb cockpit.Playbook
raw string
}
// registry is built once at package load. A malformed shipped source
// panics here on purpose — a playbook source is a build-time artifact, not
// runtime input, so a parse failure must surface immediately (the
// internal/prompts precedent).
var registry = mustLoad()
func mustLoad() map[string]entry {
files, err := fs.ReadDir(dataFS, "data")
if err != nil {
panic("playbooks: read data dir: " + err.Error())
}
reg := make(map[string]entry, len(files))
for _, f := range files {
if f.IsDir() || !strings.HasSuffix(f.Name(), ".json") {
continue
}
body, err := dataFS.ReadFile("data/" + f.Name())
if err != nil {
panic("playbooks: read source " + f.Name() + ": " + err.Error())
}
var pb cockpit.Playbook
if err := json.Unmarshal(body, &pb); err != nil {
panic(fmt.Sprintf("playbooks: parse %s: %v", f.Name(), err))
}
name := strings.TrimSuffix(f.Name(), ".json")
if pb.Name != name {
panic(fmt.Sprintf("playbooks: %s declares name %q (must match file)", f.Name(), pb.Name))
}
reg[name] = entry{pb: pb, raw: string(body)}
}
return reg
}
// Names returns every available playbook name, sorted.
func Names() []string {
out := make([]string, 0, len(registry))
for n := range registry {
out = append(out, n)
}
sort.Strings(out)
return out
}
// All returns every registered Playbook, ordered by Name (mirroring Names),
// so the aggregate renderers receive a deterministic set.
func All() []cockpit.Playbook {
out := make([]cockpit.Playbook, 0, len(registry))
for _, n := range Names() {
out = append(out, registry[n].pb)
}
return out
}
// Get returns the parsed Playbook for name.
func Get(name string) (cockpit.Playbook, error) {
e, ok := registry[name]
if !ok {
return cockpit.Playbook{}, fmt.Errorf("unknown playbook %q (known: %s)", name, strings.Join(Names(), ", "))
}
return e.pb, nil
}
// Raw returns the canonical JSON source for name — the text an operator
// audits via `eeco cockpit show`.
func Raw(name string) (string, error) {
e, ok := registry[name]
if !ok {
return "", fmt.Errorf("unknown playbook %q (known: %s)", name, strings.Join(Names(), ", "))
}
return e.raw, nil
}
added internal/playbooks/playbooks_test.go
@@ -0,0 +1,101 @@
package playbooks
import (
"encoding/json"
"reflect"
"testing"
"github.com/ajhahnde/eeco/internal/cockpit"
)
func TestNames(t *testing.T) {
got := Names()
want := []string{"commit", "doc-drift", "handover", "memcheck"}
if !reflect.DeepEqual(got, want) {
t.Errorf("Names() = %v, want %v", got, want)
}
}
func TestAll_SortedAndComplete(t *testing.T) {
all := All()
if len(all) != len(Names()) {
t.Fatalf("All() has %d, Names() has %d", len(all), len(Names()))
}
for i, n := range Names() {
if all[i].Name != n {
t.Errorf("All()[%d].Name = %q, want %q (must be Name-sorted)", i, all[i].Name, n)
}
}
}
func TestGet_NewPlaybooks(t *testing.T) {
cases := map[string]string{
"commit": "commit-msg",
"doc-drift": "doc-drift",
"memcheck": "memory-drift",
}
for name, wantWF := range cases {
pb, err := Get(name)
if err != nil {
t.Fatalf("Get(%q): %v", name, err)
}
if pb.Name != name {
t.Errorf("%s: Name = %q", name, pb.Name)
}
if pb.MapsToWorkflow != wantWF {
t.Errorf("%s: MapsToWorkflow = %q, want %q", name, pb.MapsToWorkflow, wantWF)
}
if len(pb.Steps) < 5 {
t.Errorf("%s: Steps = %d, want >=5", name, len(pb.Steps))
}
}
}
func TestGet_Handover(t *testing.T) {
pb, err := Get("handover")
if err != nil {
t.Fatalf("Get: %v", err)
}
if pb.Name != "handover" {
t.Errorf("Name = %q", pb.Name)
}
if pb.MapsToWorkflow != "handover-refresh" {
t.Errorf("MapsToWorkflow = %q, want handover-refresh", pb.MapsToWorkflow)
}
if len(pb.Steps) < 5 {
t.Errorf("Steps = %d, want >=5", len(pb.Steps))
}
if len(pb.Intent.Forbidden) == 0 {
t.Error("Intent.Forbidden is empty")
}
}
func TestGet_Unknown(t *testing.T) {
if _, err := Get("nope"); err == nil {
t.Error("expected an error for an unknown playbook")
}
}
func TestRaw_RoundTrips(t *testing.T) {
raw, err := Raw("handover")
if err != nil {
t.Fatalf("Raw: %v", err)
}
var pb cockpit.Playbook
if err := json.Unmarshal([]byte(raw), &pb); err != nil {
t.Fatalf("Raw is not valid JSON: %v", err)
}
got, err := Get("handover")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(pb, got) {
t.Error("Raw JSON does not round-trip to the same Playbook as Get")
}
}
func TestRaw_Unknown(t *testing.T) {
if _, err := Raw("nope"); err == nil {
t.Error("expected an error for an unknown playbook")
}
}
added internal/projecttype/catalog.go
@@ -0,0 +1,127 @@
// Package projecttype classifies a repository into one of eeco's known
// project-type categories and resolves the knowledge-directory set that
// `eeco init` scaffolds for it.
//
// Classification is a four-layer pipeline (see detect.go): a marker-file
// scan, a conventional-directory scan, an interactive operator prompt,
// and a gated AI fallback. The first three need no AI spend; the fourth
// runs only when the caller injects an AIFunc and the operator opts in.
//
// The category set and per-category scaffold data live in the embedded
// catalog (catalog/*.json). The detection heuristics live in this
// package as a tuned, testable table, deliberately kept out of the
// catalog so the catalog stays an operator-reviewable description of
// what each category scaffolds rather than a tuning surface.
package projecttype
import (
"embed"
"encoding/json"
"fmt"
"slices"
)
//go:embed catalog/*.json
var catalogFS embed.FS
// Category is a project-type identifier. The canonical set is exactly
// the filenames under catalog/ (without the .json suffix).
type Category string
const (
CLI Category = "cli"
Library Category = "library"
WebApp Category = "webapp"
WebAPI Category = "webapi"
Fullstack Category = "fullstack"
Mobile Category = "mobile"
Embedded Category = "embedded"
GameDev Category = "gamedev"
ML Category = "ml"
Infra Category = "infra"
Generic Category = "generic"
)
// Entry is one catalog record: the scaffold and human/AI-facing data for
// a category.
type Entry struct {
Category Category `json:"category"`
Description string `json:"description"`
PickWhen string `json:"pick_when"`
Dirs []string `json:"dirs"`
}
// Catalog is the loaded set of category entries, keyed by Category.
type Catalog struct {
entries map[Category]Entry
}
// LoadCatalog parses every embedded catalog/*.json file. It errors if a
// file is malformed, a category appears twice, an entry has no dirs, or
// the generic fallback is absent.
func LoadCatalog() (*Catalog, error) {
ents, err := catalogFS.ReadDir("catalog")
if err != nil {
return nil, fmt.Errorf("read catalog dir: %w", err)
}
c := &Catalog{entries: make(map[Category]Entry, len(ents))}
for _, de := range ents {
if de.IsDir() {
continue
}
b, err := catalogFS.ReadFile("catalog/" + de.Name())
if err != nil {
return nil, fmt.Errorf("read %s: %w", de.Name(), err)
}
var e Entry
if err := json.Unmarshal(b, &e); err != nil {
return nil, fmt.Errorf("parse %s: %w", de.Name(), err)
}
if e.Category == "" {
return nil, fmt.Errorf("%s: empty category", de.Name())
}
if _, dup := c.entries[e.Category]; dup {
return nil, fmt.Errorf("duplicate category %q", e.Category)
}
if len(e.Dirs) == 0 {
return nil, fmt.Errorf("category %q has no dirs", e.Category)
}
c.entries[e.Category] = e
}
if _, ok := c.entries[Generic]; !ok {
return nil, fmt.Errorf("catalog missing the %q fallback entry", Generic)
}
return c, nil
}
// Get returns the entry for cat and whether it is known.
func (c *Catalog) Get(cat Category) (Entry, bool) {
e, ok := c.entries[cat]
return e, ok
}
// Has reports whether cat is a known category.
func (c *Catalog) Has(cat Category) bool {
_, ok := c.entries[cat]
return ok
}
// Categories returns every known category in sorted order.
func (c *Catalog) Categories() []Category {
out := make([]Category, 0, len(c.entries))
for cat := range c.entries {
out = append(out, cat)
}
slices.Sort(out)
return out
}
// DirsFor returns a copy of the scaffold dir-set for cat, or nil if cat
// is unknown.
func (c *Catalog) DirsFor(cat Category) []string {
e, ok := c.entries[cat]
if !ok {
return nil
}
return append([]string(nil), e.Dirs...)
}
added internal/projecttype/catalog/cli.json
@@ -0,0 +1,6 @@
{
"category": "cli",
"description": "Command-line tool or utility whose primary artifact is a terminal executable.",
"pick_when": "The project builds an executable run from a shell, not a long-lived server or a graphical UI.",
"dirs": ["commands", "internals", "docs"]
}
added internal/projecttype/catalog/embedded.json
@@ -0,0 +1,6 @@
{
"category": "embedded",
"description": "Firmware or low-level software targeting microcontrollers or constrained hardware.",
"pick_when": "The project runs on bare metal or an RTOS and is coupled to specific hardware.",
"dirs": ["firmware", "hardware", "drivers", "docs"]
}
added internal/projecttype/catalog/fullstack.json
@@ -0,0 +1,6 @@
{
"category": "fullstack",
"description": "Combined front-end and back-end living in one repository.",
"pick_when": "Both a browser UI and its serving backend are developed together in this tree.",
"dirs": ["frontend", "backend", "database", "infra"]
}
added internal/projecttype/catalog/gamedev.json
@@ -0,0 +1,6 @@
{
"category": "gamedev",
"description": "Interactive game or simulation combining code with art, level, and audio assets.",
"pick_when": "The project pairs gameplay code with non-code assets such as levels, sprites, or audio.",
"dirs": ["gameplay", "assets", "levels", "audio"]
}
added internal/projecttype/catalog/generic.json
@@ -0,0 +1,6 @@
{
"category": "generic",
"description": "Fallback for a project whose shape does not match a known category.",
"pick_when": "No marker or convention identifies the project, or the operator declines to classify it.",
"dirs": ["notes", "docs"]
}
added internal/projecttype/catalog/infra.json
@@ -0,0 +1,6 @@
{
"category": "infra",
"description": "Infrastructure-as-code or operations repository defining environments and pipelines.",
"pick_when": "The project's primary artifact is declarative infrastructure, deployment, or CI/CD config.",
"dirs": ["modules", "environments", "pipelines", "docs"]
}
added internal/projecttype/catalog/library.json
@@ -0,0 +1,6 @@
{
"category": "library",
"description": "Reusable package or SDK consumed by other code rather than run directly.",
"pick_when": "The project's main output is an importable API surface, not a runnable binary or service.",
"dirs": ["api", "internals", "examples", "docs"]
}
added internal/projecttype/catalog/ml.json
@@ -0,0 +1,6 @@
{
"category": "ml",
"description": "Machine-learning or data-science project centred on datasets, models, and training.",
"pick_when": "The project's core work is training, evaluating, or serving models over data.",
"dirs": ["data", "models", "training", "notebooks"]
}
added internal/projecttype/catalog/mobile.json
@@ -0,0 +1,6 @@
{
"category": "mobile",
"description": "Native or cross-platform application targeting phones or tablets.",
"pick_when": "The project ships an app for iOS, Android, or a cross-platform mobile runtime.",
"dirs": ["screens", "models", "platform", "docs"]
}
added internal/projecttype/catalog/webapi.json
@@ -0,0 +1,6 @@
{
"category": "webapi",
"description": "Server-side HTTP service exposing an API with no first-party UI in the same tree.",
"pick_when": "The project's primary artifact is a network service or API, not a browser front-end.",
"dirs": ["endpoints", "models", "database", "docs"]
}
added internal/projecttype/catalog/webapp.json
@@ -0,0 +1,6 @@
{
"category": "webapp",
"description": "Browser-facing front-end application with no first-party server in the same tree.",
"pick_when": "The project is a client-side UI (SPA or static site) and any backend lives elsewhere.",
"dirs": ["frontend", "components", "routing", "docs"]
}
added internal/projecttype/catalog_test.go
@@ -0,0 +1,65 @@
package projecttype
import "testing"
func TestLoadCatalog(t *testing.T) {
cat, err := LoadCatalog()
if err != nil {
t.Fatalf("LoadCatalog: %v", err)
}
want := []Category{CLI, Library, WebApp, WebAPI, Fullstack, Mobile, Embedded, GameDev, ML, Infra, Generic}
if got := len(cat.Categories()); got != len(want) {
t.Fatalf("category count = %d, want %d (%v)", got, len(want), cat.Categories())
}
for _, c := range want {
e, ok := cat.Get(c)
if !ok {
t.Errorf("missing category %q", c)
continue
}
if e.Category != c {
t.Errorf("%q: entry.Category = %q", c, e.Category)
}
if e.Description == "" {
t.Errorf("%q: empty description", c)
}
if e.PickWhen == "" {
t.Errorf("%q: empty pick_when", c)
}
if len(e.Dirs) == 0 {
t.Errorf("%q: no dirs", c)
}
}
}
func TestCategoriesSorted(t *testing.T) {
cat, err := LoadCatalog()
if err != nil {
t.Fatalf("LoadCatalog: %v", err)
}
got := cat.Categories()
for i := 1; i < len(got); i++ {
if got[i-1] > got[i] {
t.Fatalf("categories not sorted: %v", got)
}
}
}
func TestDirsForCopiesAndUnknown(t *testing.T) {
cat, err := LoadCatalog()
if err != nil {
t.Fatalf("LoadCatalog: %v", err)
}
if got := cat.DirsFor(Category("nope")); got != nil {
t.Errorf("DirsFor(unknown) = %v, want nil", got)
}
d1 := cat.DirsFor(CLI)
if len(d1) == 0 {
t.Fatal("DirsFor(cli) empty")
}
d1[0] = "mutated"
d2 := cat.DirsFor(CLI)
if d2[0] == "mutated" {
t.Error("DirsFor did not return a defensive copy")
}
}
added internal/projecttype/detect.go
@@ -0,0 +1,404 @@
package projecttype
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"slices"
"sort"
"strings"
)
// DefaultThreshold is the deterministic-confidence floor at or above
// which Detect accepts the marker-scan result without prompting. It
// backs the init_detection_threshold config key.
const DefaultThreshold = 0.7
// minAIConfidence is the floor below which an AI-fallback classification
// is rejected: the result is re-offered to the operator (when a Prompter
// is available) or degraded to generic.
const minAIConfidence = 0.5
// Source records which pipeline layer produced a Result.
type Source string
const (
SourceMarker Source = "marker-scan"
SourceFlag Source = "type-flag"
SourceInteractive Source = "interactive-prompt"
SourceAI Source = "ai-fallback"
SourceFallback Source = "generic-fallback"
)
// Result is the outcome of Detect.
type Result struct {
Category Category
// Confidence is the marker-scan confidence in [0,1] for a
// deterministic result. An operator pick or a forced --type is 1.0; an
// AI result carries the model's reported confidence.
Confidence float64
// Dirs is the knowledge-directory set to scaffold: the catalog dirs
// for Category, plus any AI-proposed deviations when Source is
// SourceAI.
Dirs []string
Source Source
Justification string
}
// Prompter asks the operator to resolve an ambiguous detection. A nil
// Prompter makes Detect non-interactive (layer 3 is skipped).
type Prompter interface {
// Pick presents the candidate categories best-first and the catalog
// (for descriptions and the generic escape) and returns the operator's
// choice. When describe is true the operator asked to describe the
// project freely; freeText carries that description and Detect routes
// to the AI layer. A non-nil error aborts detection.
Pick(candidates []Category, cat *Catalog) (choice Category, describe bool, freeText string, err error)
}
// AIFunc runs one gated AI pass and returns the model's raw text. A nil
// AIFunc means no AI fallback is available and Detect degrades to
// generic where the pipeline would otherwise call it.
type AIFunc func(ctx context.Context, prompt string) (string, error)
// Options configures one Detect call.
type Options struct {
// RepoRoot is the directory the deterministic layers scan.
RepoRoot string
// Threshold overrides DefaultThreshold when > 0.
Threshold float64
// Forced short-circuits the whole pipeline with an operator-supplied
// --type value. An unknown value is an error.
Forced Category
// ForceAI routes straight to the AI layer (the --ai flag), skipping
// the deterministic accept and the interactive prompt.
ForceAI bool
// Prompter resolves ambiguity interactively; nil disables layer 3.
Prompter Prompter
// AI runs the layer-4 fallback; nil disables layer 4.
AI AIFunc
}
func (o Options) threshold() float64 {
if o.Threshold > 0 {
return o.Threshold
}
return DefaultThreshold
}
// Detect classifies opt.RepoRoot through the four-layer pipeline and
// returns the resolved category and its scaffold dir-set. It never
// errors on an unclassifiable tree: the terminal fallback is generic.
func Detect(ctx context.Context, cat *Catalog, opt Options) (Result, error) {
if cat == nil {
return Result{}, fmt.Errorf("nil catalog")
}
if opt.Forced != "" {
if !cat.Has(opt.Forced) {
return Result{}, fmt.Errorf("unknown project type %q", opt.Forced)
}
return Result{
Category: opt.Forced,
Confidence: 1.0,
Dirs: cat.DirsFor(opt.Forced),
Source: SourceFlag,
}, nil
}
if opt.ForceAI {
return aiLayer(ctx, cat, opt, "")
}
scores := scoreRepo(opt.RepoRoot)
top, second := topTwo(scores)
conf := confidence(scores[top], scores[second])
if top != "" && conf >= opt.threshold() {
return Result{
Category: top,
Confidence: conf,
Dirs: cat.DirsFor(top),
Source: SourceMarker,
}, nil
}
candidates := rankedCandidates(scores)
if opt.Prompter != nil {
choice, describe, freeText, err := opt.Prompter.Pick(candidates, cat)
if err != nil {
return Result{}, err
}
if describe {
return aiLayer(ctx, cat, opt, freeText)
}
if !cat.Has(choice) {
return Result{}, fmt.Errorf("operator chose unknown project type %q", choice)
}
return Result{
Category: choice,
Confidence: 1.0,
Dirs: cat.DirsFor(choice),
Source: SourceInteractive,
}, nil
}
// Non-interactive: accept the best deterministic guess if there is
// one, otherwise fall back to generic.
if top != "" {
return Result{
Category: top,
Confidence: conf,
Dirs: cat.DirsFor(top),
Source: SourceMarker,
}, nil
}
return genericResult(cat, "no marker or convention identified the project"), nil
}
// aiLayer runs the layer-4 fallback. It degrades to generic when no
// AIFunc is wired, the call fails, the response is malformed, or the
// reported confidence is below minAIConfidence and no Prompter can
// re-offer the top candidates.
func aiLayer(ctx context.Context, cat *Catalog, opt Options, desc string) (Result, error) {
if opt.AI == nil {
return genericResult(cat, "AI fallback not configured"), nil
}
tree := topLevelEntries(opt.RepoRoot)
prompt, err := buildDetectPrompt(cat, tree, desc)
if err != nil {
return genericResult(cat, "AI fallback prompt build failed: "+err.Error()), nil
}
raw, err := opt.AI(ctx, prompt)
if err != nil {
return genericResult(cat, "AI fallback unavailable: "+err.Error()), nil
}
parsed, ok := parseAIDetect(raw)
if !ok || !cat.Has(Category(parsed.Category)) {
return genericResult(cat, "AI fallback returned no usable classification"), nil
}
chosen := Category(parsed.Category)
if parsed.Confidence < minAIConfidence {
if opt.Prompter != nil {
choice, describe, _, perr := opt.Prompter.Pick(topThree(cat, parsed), cat)
if perr != nil {
return Result{}, perr
}
if !describe && cat.Has(choice) {
return Result{
Category: choice,
Confidence: 1.0,
Dirs: cat.DirsFor(choice),
Source: SourceInteractive,
}, nil
}
}
return genericResult(cat, "AI fallback confidence too low"), nil
}
return Result{
Category: chosen,
Confidence: clamp01(parsed.Confidence),
Dirs: mergeDirs(cat.DirsFor(chosen), parsed.Dirs),
Source: SourceAI,
Justification: strings.TrimSpace(parsed.Justification),
}, nil
}
func genericResult(cat *Catalog, why string) Result {
return Result{
Category: Generic,
Confidence: 0,
Dirs: cat.DirsFor(Generic),
Source: SourceFallback,
Justification: why,
}
}
// scoreRepo accumulates per-category votes from the marker-file scan
// (layer 1) and the conventional-directory scan (layer 2).
func scoreRepo(repoRoot string) map[Category]float64 {
scores := make(map[Category]float64)
if repoRoot == "" {
return scores
}
for marker, votes := range markerRules {
if rootHas(repoRoot, marker) {
for _, v := range votes {
scores[v.cat] += v.weight
}
}
}
for dir, votes := range signalRules {
if rootHasDir(repoRoot, dir) {
for _, v := range votes {
scores[v.cat] += v.weight
}
}
}
return scores
}
// confidence is the share of the winning score over itself plus the
// runner-up: 1.0 when only one category scores, lower as the runner-up
// closes in. It deliberately ignores the long tail of small votes so a
// clear leader is not diluted by many partial matches.
func confidence(top, second float64) float64 {
if top <= 0 {
return 0
}
return top / (top + second)
}
func topTwo(scores map[Category]float64) (top, second Category) {
var topV, secondV float64
for _, cat := range sortedCats(scores) {
v := scores[cat]
switch {
case v > topV:
second, secondV = top, topV
top, topV = cat, v
case v > secondV:
second, secondV = cat, v
}
}
return top, second
}
func rankedCandidates(scores map[Category]float64) []Category {
cats := sortedCats(scores)
sort.SliceStable(cats, func(i, j int) bool {
return scores[cats[i]] > scores[cats[j]]
})
out := make([]Category, 0, len(cats))
for _, c := range cats {
if scores[c] > 0 {
out = append(out, c)
}
}
return out
}
// sortedCats returns the scored categories in deterministic name order
// so the score walk and tie-breaks do not depend on map iteration order.
func sortedCats(scores map[Category]float64) []Category {
out := make([]Category, 0, len(scores))
for c := range scores {
out = append(out, c)
}
slices.Sort(out)
return out
}
func rootHas(repoRoot, marker string) bool {
if strings.ContainsAny(marker, "*?[") {
matches, err := filepath.Glob(filepath.Join(repoRoot, marker))
return err == nil && len(matches) > 0
}
_, err := os.Stat(filepath.Join(repoRoot, marker))
return err == nil
}
func rootHasDir(repoRoot, name string) bool {
info, err := os.Stat(filepath.Join(repoRoot, name))
return err == nil && info.IsDir()
}
func topLevelEntries(repoRoot string) []string {
var names []string
ents, err := os.ReadDir(repoRoot)
if err != nil {
return names
}
for _, e := range ents {
if e.Name() == ".git" {
continue
}
name := e.Name()
if e.IsDir() {
name += "/"
}
names = append(names, name)
}
sort.Strings(names)
return names
}
func clamp01(v float64) float64 {
switch {
case v < 0:
return 0
case v > 1:
return 1
default:
return v
}
}
// mergeDirs returns base with any extra dirs appended that are not
// already present, preserving order. It backs the AI layer's bounded
// "propose deviations to the dir-set" affordance.
func mergeDirs(base, extra []string) []string {
seen := make(map[string]struct{}, len(base))
out := make([]string, 0, len(base)+len(extra))
for _, d := range base {
seen[d] = struct{}{}
out = append(out, d)
}
for _, d := range extra {
d = strings.TrimSpace(d)
if d == "" {
continue
}
if _, dup := seen[d]; dup {
continue
}
seen[d] = struct{}{}
out = append(out, d)
}
return out
}
type aiDetect struct {
Category string `json:"category"`
Confidence float64 `json:"confidence"`
Dirs []string `json:"dirs"`
Justification string `json:"justification"`
Deviations []string `json:"deviations"`
}
// parseAIDetect extracts the first JSON object from the model's text
// (which may wrap it in prose or a code fence) and unmarshals it.
func parseAIDetect(raw string) (aiDetect, bool) {
start := strings.IndexByte(raw, '{')
end := strings.LastIndexByte(raw, '}')
if start < 0 || end < start {
return aiDetect{}, false
}
var d aiDetect
if err := json.Unmarshal([]byte(raw[start:end+1]), &d); err != nil {
return aiDetect{}, false
}
d.Dirs = mergeDirs(d.Dirs, d.Deviations)
return d, true
}
// topThree returns the AI-chosen category (when known) plus other known
// categories, capped at three, for an operator re-prompt.
func topThree(cat *Catalog, d aiDetect) []Category {
var out []Category
if cat.Has(Category(d.Category)) {
out = append(out, Category(d.Category))
}
for _, c := range cat.Categories() {
if len(out) >= 3 {
break
}
if c == Generic || (len(out) > 0 && c == out[0]) {
continue
}
out = append(out, c)
}
return out
}
added internal/projecttype/detect_test.go
@@ -0,0 +1,276 @@
package projecttype
import (
"context"
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
// makeRepo builds a fake repo tree in a temp dir. An entry ending in "/"
// is created as a directory; anything else is an empty file.
func makeRepo(t *testing.T, entries ...string) string {
t.Helper()
root := t.TempDir()
for _, e := range entries {
p := filepath.Join(root, e)
if strings.HasSuffix(e, "/") {
if err := os.MkdirAll(p, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", e, err)
}
continue
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatalf("mkdir parent %s: %v", e, err)
}
if err := os.WriteFile(p, nil, 0o644); err != nil {
t.Fatalf("write %s: %v", e, err)
}
}
return root
}
func mustCatalog(t *testing.T) *Catalog {
t.Helper()
cat, err := LoadCatalog()
if err != nil {
t.Fatalf("LoadCatalog: %v", err)
}
return cat
}
func TestDetectDeterministicWinner(t *testing.T) {
cat := mustCatalog(t)
cases := []struct {
name string
entries []string
want Category
}{
{"go-cli", []string{"go.mod", "cmd/", "internal/"}, CLI},
{"terraform", []string{"main.tf", "modules/"}, Infra},
{"flutter", []string{"pubspec.yaml"}, Mobile},
{"gamedev", []string{"levels/", "assets/", "scenes/"}, GameDev},
{"ml", []string{"requirements.txt", "notebooks/", "data/"}, ML},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
root := makeRepo(t, tc.entries...)
got, err := Detect(context.Background(), cat, Options{RepoRoot: root})
if err != nil {
t.Fatalf("Detect: %v", err)
}
if got.Category != tc.want {
t.Fatalf("category = %q, want %q (conf %.3f)", got.Category, tc.want, got.Confidence)
}
if got.Source != SourceMarker {
t.Errorf("source = %q, want %q", got.Source, SourceMarker)
}
if got.Confidence < DefaultThreshold {
t.Errorf("confidence = %.3f, want >= %.2f", got.Confidence, DefaultThreshold)
}
if len(got.Dirs) == 0 {
t.Error("no dirs in result")
}
})
}
}
func TestDetectAmbiguousNonInteractiveTakesBestGuess(t *testing.T) {
cat := mustCatalog(t)
root := makeRepo(t, "package.json")
got, err := Detect(context.Background(), cat, Options{RepoRoot: root})
if err != nil {
t.Fatalf("Detect: %v", err)
}
if got.Category != WebApp {
t.Errorf("category = %q, want %q", got.Category, WebApp)
}
if got.Source != SourceMarker {
t.Errorf("source = %q, want %q", got.Source, SourceMarker)
}
if got.Confidence >= DefaultThreshold {
t.Errorf("confidence = %.3f, expected below threshold for an ambiguous tree", got.Confidence)
}
}
func TestDetectNoSignalFallsBackToGeneric(t *testing.T) {
cat := mustCatalog(t)
root := makeRepo(t, "README.md")
got, err := Detect(context.Background(), cat, Options{RepoRoot: root})
if err != nil {
t.Fatalf("Detect: %v", err)
}
if got.Category != Generic {
t.Errorf("category = %q, want generic", got.Category)
}
if got.Source != SourceFallback {
t.Errorf("source = %q, want %q", got.Source, SourceFallback)
}
}
func TestDetectForced(t *testing.T) {
cat := mustCatalog(t)
root := makeRepo(t, "go.mod", "cmd/")
got, err := Detect(context.Background(), cat, Options{RepoRoot: root, Forced: ML})
if err != nil {
t.Fatalf("Detect: %v", err)
}
if got.Category != ML || got.Source != SourceFlag || got.Confidence != 1.0 {
t.Fatalf("forced result = %+v", got)
}
if _, err := Detect(context.Background(), cat, Options{RepoRoot: root, Forced: Category("banana")}); err == nil {
t.Error("expected error for unknown forced type")
}
}
// fakePrompter returns a scripted choice for the layer-3 prompt.
type fakePrompter struct {
choice Category
describe bool
freeText string
gotCands []Category
}
func (f *fakePrompter) Pick(candidates []Category, _ *Catalog) (Category, bool, string, error) {
f.gotCands = candidates
return f.choice, f.describe, f.freeText, nil
}
func TestDetectInteractivePick(t *testing.T) {
cat := mustCatalog(t)
root := makeRepo(t, "package.json")
fp := &fakePrompter{choice: Fullstack}
got, err := Detect(context.Background(), cat, Options{RepoRoot: root, Prompter: fp})
if err != nil {
t.Fatalf("Detect: %v", err)
}
if got.Category != Fullstack || got.Source != SourceInteractive || got.Confidence != 1.0 {
t.Fatalf("interactive result = %+v", got)
}
if len(fp.gotCands) == 0 {
t.Error("prompter received no candidates")
}
}
func TestDetectInteractiveDescribeRoutesToAI(t *testing.T) {
cat := mustCatalog(t)
root := makeRepo(t, "package.json")
fp := &fakePrompter{describe: true, freeText: "a REST service"}
ai := func(_ context.Context, prompt string) (string, error) {
if !strings.Contains(prompt, "a REST service") {
t.Errorf("prompt missing operator description; got:\n%s", prompt)
}
return `here you go: {"category":"webapi","confidence":0.9,"dirs":["endpoints","models"],"deviations":["queue"]}`, nil
}
got, err := Detect(context.Background(), cat, Options{RepoRoot: root, Prompter: fp, AI: ai})
if err != nil {
t.Fatalf("Detect: %v", err)
}
if got.Category != WebAPI || got.Source != SourceAI {
t.Fatalf("AI result = %+v", got)
}
if !containsDir(got.Dirs, "queue") {
t.Errorf("expected AI deviation 'queue' in dirs %v", got.Dirs)
}
if !containsDir(got.Dirs, "database") {
t.Errorf("expected catalog dir 'database' retained in dirs %v", got.Dirs)
}
}
func TestDetectForceAI(t *testing.T) {
cat := mustCatalog(t)
root := makeRepo(t, "go.mod", "cmd/")
ai := func(context.Context, string) (string, error) {
return `{"category":"cli","confidence":0.85,"dirs":["commands"]}`, nil
}
got, err := Detect(context.Background(), cat, Options{RepoRoot: root, ForceAI: true, AI: ai})
if err != nil {
t.Fatalf("Detect: %v", err)
}
if got.Category != CLI || got.Source != SourceAI {
t.Fatalf("force-ai result = %+v", got)
}
}
func TestAIDegradesToGeneric(t *testing.T) {
cat := mustCatalog(t)
root := makeRepo(t, "README.md")
cases := []struct {
name string
ai AIFunc
}{
{"nil-aifunc", nil},
{"low-confidence", func(context.Context, string) (string, error) {
return `{"category":"cli","confidence":0.3}`, nil
}},
{"malformed", func(context.Context, string) (string, error) {
return "I cannot help with that", nil
}},
{"unknown-category", func(context.Context, string) (string, error) {
return `{"category":"banana","confidence":0.99}`, nil
}},
{"provider-error", func(context.Context, string) (string, error) {
return "", context.DeadlineExceeded
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := Detect(context.Background(), cat, Options{RepoRoot: root, ForceAI: true, AI: tc.ai})
if err != nil {
t.Fatalf("Detect: %v", err)
}
if got.Category != Generic || got.Source != SourceFallback {
t.Fatalf("result = %+v, want generic fallback", got)
}
})
}
}
func TestConfidence(t *testing.T) {
cases := []struct {
top, second, want float64
}{
{0, 0, 0},
{1.0, 0, 1.0},
{1.0, 1.0, 0.5},
{0.8, 0.2, 0.8},
}
for _, tc := range cases {
if got := confidence(tc.top, tc.second); got != tc.want {
t.Errorf("confidence(%.2f,%.2f) = %.4f, want %.4f", tc.top, tc.second, got, tc.want)
}
}
}
func TestMergeDirs(t *testing.T) {
got := mergeDirs([]string{"a", "b"}, []string{"b", "c", "", " a "})
want := []string{"a", "b", "c"}
if len(got) != len(want) {
t.Fatalf("mergeDirs = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("mergeDirs = %v, want %v", got, want)
}
}
}
func TestParseAIDetectFromProse(t *testing.T) {
raw := "Sure!\n```json\n{\"category\":\"ml\",\"confidence\":0.7,\"deviations\":[\"data\"]}\n```\nhope that helps"
d, ok := parseAIDetect(raw)
if !ok {
t.Fatal("parseAIDetect failed to extract object")
}
if d.Category != "ml" || d.Confidence != 0.7 {
t.Fatalf("parsed = %+v", d)
}
if !containsDir(d.Dirs, "data") {
t.Errorf("deviation not merged into dirs: %v", d.Dirs)
}
}
func containsDir(dirs []string, want string) bool {
return slices.Contains(dirs, want)
}
added internal/projecttype/prompt.go
@@ -0,0 +1,45 @@
package projecttype
// prompt.go builds the Layer 4 AI detection prompt. The prompt text itself is
// the canonical `get-project-type` entry in the prompt library
// (internal/prompts); this file only maps the catalog + tree + description into
// the library's render input so there is a single reviewable source of truth
// for the instruction string (auditable via `eeco show prompt get-project-type`).
import (
"strings"
"github.com/ajhahnde/eeco/internal/prompts"
)
// buildDetectPrompt renders the full Layer 4 prompt: instructions + the
// canonical-layouts catalog + the tree listing + optional operator free-text.
// It is deterministic for a given (catalog, tree, description) triple so the
// prompt_test.go golden stays stable.
func buildDetectPrompt(cat *Catalog, tree []string, description string) (string, error) {
data := prompts.GetProjectTypeData{
Categories: toPromptCategories(cat),
Tree: tree,
Description: strings.TrimSpace(description),
}
return prompts.Render(prompts.GetProjectType, data)
}
// toPromptCategories maps the embedded catalog into the prompt library's
// render input shape (decoupled so the prompts package takes no projecttype
// dependency). Categories are walked in the catalog's sorted order so the
// rendered prompt is deterministic.
func toPromptCategories(cat *Catalog) []prompts.Category {
cats := cat.Categories()
out := make([]prompts.Category, 0, len(cats))
for _, c := range cats {
e, _ := cat.Get(c)
out = append(out, prompts.Category{
Category: string(e.Category),
Description: e.Description,
PickWhen: e.PickWhen,
Dirs: e.Dirs,
})
}
return out
}
added internal/projecttype/prompt_test.go
@@ -0,0 +1,41 @@
package projecttype
import (
"bytes"
"strings"
"testing"
)
func TestStdinPrompter(t *testing.T) {
cat := mustCatalog(t)
cands := []Category{WebApp, Fullstack}
cases := []struct {
name string
input string
wantChoice Category
wantDescribe bool
wantFree string
}{
{"pick-second", "2\n", Fullstack, false, ""},
{"pick-first", "1\n", WebApp, false, ""},
{"generic", "g\n", Generic, false, ""},
{"describe", "d\na rest api\n", "", true, "a rest api"},
{"eof", "", Generic, false, ""},
{"retry-then-valid", "x\n9\n2\n", Fullstack, false, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var out bytes.Buffer
p := NewStdinPrompter(strings.NewReader(tc.input), &out)
choice, describe, free, err := p.Pick(cands, cat)
if err != nil {
t.Fatalf("Pick: %v", err)
}
if choice != tc.wantChoice || describe != tc.wantDescribe || free != tc.wantFree {
t.Fatalf("Pick = (%q, %v, %q), want (%q, %v, %q)",
choice, describe, free, tc.wantChoice, tc.wantDescribe, tc.wantFree)
}
})
}
}
added internal/projecttype/prompter.go
@@ -0,0 +1,79 @@
package projecttype
// prompter.go is the Layer-3 interactive selection: when the deterministic
// scan is ambiguous and no --type/--ai flag forced the outcome, eeco asks the
// operator to pick a category, describe the project (routing to Layer 4), or
// fall back to generic. It is AI-free and reads a single readline.
import (
"bufio"
"fmt"
"io"
"strconv"
"strings"
)
// stdinPrompter is the deterministic, AI-free layer-3 prompter: a single
// readline question with numbered candidates and two escapes — describe
// the project freely (routes to the AI layer) or pick the generic
// fallback.
type stdinPrompter struct {
in *bufio.Scanner
out io.Writer
}
// NewStdinPrompter builds a Prompter that reads operator choices from in
// and writes its question to out. It is the prompter `eeco init` wires
// to os.Stdin / os.Stderr; tests inject buffers.
func NewStdinPrompter(in io.Reader, out io.Writer) Prompter {
return &stdinPrompter{in: bufio.NewScanner(in), out: out}
}
const maxPromptRetries = 5
func (p *stdinPrompter) Pick(candidates []Category, cat *Catalog) (Category, bool, string, error) {
fmt.Fprintln(p.out, "eeco init: project type is ambiguous.")
for i, c := range candidates {
desc := ""
if e, ok := cat.Get(c); ok {
desc = e.Description
}
fmt.Fprintf(p.out, " %d) %s — %s\n", i+1, c, desc)
}
fmt.Fprintln(p.out, " d) describe the project in your own words")
fmt.Fprintln(p.out, " g) generic (no project-specific scaffold)")
for range maxPromptRetries {
if len(candidates) > 0 {
fmt.Fprintf(p.out, "pick [1-%d/d/g]: ", len(candidates))
} else {
fmt.Fprint(p.out, "pick [d/g]: ")
}
if !p.in.Scan() {
// No more input (EOF / closed stdin): degrade to generic so a
// piped, non-interactive `eeco init` still completes.
return Generic, false, "", nil
}
line := strings.TrimSpace(p.in.Text())
switch strings.ToLower(line) {
case "d":
fmt.Fprint(p.out, "describe the project: ")
free := ""
if p.in.Scan() {
free = strings.TrimSpace(p.in.Text())
}
return "", true, free, nil
case "g", "":
if line == "" {
continue
}
return Generic, false, "", nil
}
n, err := strconv.Atoi(line)
if err == nil && n >= 1 && n <= len(candidates) {
return candidates[n-1], false, "", nil
}
fmt.Fprintln(p.out, " unrecognised choice; try again.")
}
return Generic, false, "", nil
}
added internal/projecttype/rules.go
@@ -0,0 +1,63 @@
package projecttype
// vote is one category's weighted score contribution from a matched
// marker file or conventional directory.
type vote struct {
cat Category
weight float64
}
// markerRules maps a root-level marker file to the categories its
// presence votes for. A key containing a glob metacharacter (* ? [) is
// matched with filepath.Glob against the repo root; any other key is an
// exact filename. Weights are heuristic and tuned against the tests in
// this package — a single ecosystem marker rarely settles a category on
// its own, so the conventional-directory signals below usually decide
// the winner.
var markerRules = map[string][]vote{
"go.mod": {{CLI, 0.4}, {Library, 0.25}, {WebAPI, 0.25}, {Fullstack, 0.2}},
"Cargo.toml": {{CLI, 0.4}, {Library, 0.3}, {Embedded, 0.3}},
"package.json": {{WebApp, 0.4}, {Fullstack, 0.3}, {Library, 0.2}, {CLI, 0.15}},
"pyproject.toml": {{CLI, 0.3}, {Library, 0.3}, {ML, 0.3}},
"setup.py": {{CLI, 0.3}, {Library, 0.3}, {ML, 0.3}},
"requirements*.txt": {{ML, 0.4}, {WebAPI, 0.25}},
"pubspec.yaml": {{Mobile, 0.8}},
"build.zig": {{Embedded, 0.4}, {CLI, 0.3}, {GameDev, 0.2}},
"*.csproj": {{CLI, 0.3}, {WebApp, 0.25}, {Fullstack, 0.25}, {GameDev, 0.25}},
"*.sln": {{CLI, 0.3}, {WebApp, 0.25}, {Fullstack, 0.25}, {GameDev, 0.25}},
"Dockerfile": {{Infra, 0.2}, {WebAPI, 0.2}},
"*.tf": {{Infra, 0.8}},
"Gemfile": {{WebApp, 0.35}, {WebAPI, 0.35}},
"pom.xml": {{WebAPI, 0.35}, {Fullstack, 0.25}, {Mobile, 0.2}},
"build.gradle": {{WebAPI, 0.3}, {Fullstack, 0.25}, {Mobile, 0.3}},
"CMakeLists.txt": {{Embedded, 0.35}, {CLI, 0.25}, {GameDev, 0.25}},
}
// signalRules maps a root-level conventional directory to the categories
// its presence confirms. These are the layer-2 disambiguators that lift
// a clear winner above the layer-1 marker noise.
var signalRules = map[string][]vote{
"cmd": {{CLI, 0.4}},
"internal": {{CLI, 0.2}, {WebAPI, 0.15}},
"pkg": {{Library, 0.3}},
"frontend": {{Fullstack, 0.6}, {WebApp, 0.2}},
"backend": {{Fullstack, 0.6}, {WebAPI, 0.2}},
"src": {{WebApp, 0.15}, {Library, 0.1}},
"public": {{WebApp, 0.25}},
"firmware": {{Embedded, 0.6}},
"drivers": {{Embedded, 0.4}},
"hardware": {{Embedded, 0.4}},
"assets": {{GameDev, 0.4}},
"levels": {{GameDev, 0.6}},
"scenes": {{GameDev, 0.4}},
"notebooks": {{ML, 0.5}},
"data": {{ML, 0.25}},
"models": {{ML, 0.3}, {WebAPI, 0.15}},
"terraform": {{Infra, 0.6}},
"modules": {{Infra, 0.3}},
"environments": {{Infra, 0.4}},
"api": {{WebAPI, 0.3}},
"ios": {{Mobile, 0.6}},
"android": {{Mobile, 0.6}},
"screens": {{Mobile, 0.3}},
}
added internal/prompts/prompts.go
@@ -0,0 +1,127 @@
package prompts
// Package prompts is eeco's versioned canonical prompt library: one named,
// embedded text/template per AI-using workflow, the single reviewable source
// of truth for the instruction strings eeco sends to a model. The bodies live
// in templates/*.tmpl; the prompt name is the file name without the extension.
//
// At v1.0.0 the library ships two prompts: GetProjectType (Layer 4 init
// detection) and ManifestSummary (per-directory .ai.json body generation).
// Existing scattered prompts (evolve, understand, ...) migrate as additive
// v1.x slices.
import (
"bytes"
"embed"
"fmt"
"io/fs"
"sort"
"strings"
"text/template"
)
//go:embed templates/*.tmpl
var templatesFS embed.FS
// Prompt names. Each MUST match a templates/<name>.tmpl file.
const (
GetProjectType = "get-project-type"
ManifestSummary = "manifest-summary"
)
// funcs are the template helpers available to every prompt template.
var funcs = template.FuncMap{"join": strings.Join}
type entry struct {
raw string
tmpl *template.Template
}
// registry is built once at package load. A malformed shipped template panics
// here on purpose — a prompt template is a build-time artifact, not runtime
// input, so a parse failure must surface immediately, not at first render.
var registry = mustLoad()
func mustLoad() map[string]entry {
files, err := fs.ReadDir(templatesFS, "templates")
if err != nil {
panic("prompts: read templates dir: " + err.Error())
}
reg := make(map[string]entry, len(files))
for _, f := range files {
if f.IsDir() || !strings.HasSuffix(f.Name(), ".tmpl") {
continue
}
body, err := templatesFS.ReadFile("templates/" + f.Name())
if err != nil {
panic("prompts: read template " + f.Name() + ": " + err.Error())
}
name := strings.TrimSuffix(f.Name(), ".tmpl")
t, err := template.New(name).Funcs(funcs).Parse(string(body))
if err != nil {
panic(fmt.Sprintf("prompts: parse %s: %v", name, err))
}
reg[name] = entry{raw: string(body), tmpl: t}
}
return reg
}
// Names returns every available prompt name, sorted.
func Names() []string {
out := make([]string, 0, len(registry))
for n := range registry {
out = append(out, n)
}
sort.Strings(out)
return out
}
// Get returns the raw template body for a prompt — the canonical text an
// operator audits via `eeco show prompt <name>`.
func Get(name string) (string, error) {
e, ok := registry[name]
if !ok {
return "", fmt.Errorf("unknown prompt %q", name)
}
return e.raw, nil
}
// Render executes a prompt template against data and returns the result.
func Render(name string, data any) (string, error) {
e, ok := registry[name]
if !ok {
return "", fmt.Errorf("unknown prompt %q", name)
}
var b bytes.Buffer
if err := e.tmpl.Execute(&b, data); err != nil {
return "", fmt.Errorf("render %s: %w", name, err)
}
return b.String(), nil
}
// GetProjectTypeData is the render input for the GetProjectType prompt.
type GetProjectTypeData struct {
Categories []Category
Tree []string
Description string
}
// Category is one catalog entry rendered into the GetProjectType prompt.
type Category struct {
Category string
Description string
PickWhen string
Dirs []string
}
// ManifestSummaryData is the render input for the ManifestSummary prompt.
type ManifestSummaryData struct {
Dir string
Items []ManifestItem
}
// ManifestItem is one directory entry rendered into the ManifestSummary prompt.
type ManifestItem struct {
Path string
Kind string
}
added internal/prompts/prompts_test.go
@@ -0,0 +1,83 @@
package prompts
import (
"strings"
"testing"
)
func TestNames(t *testing.T) {
got := Names()
want := []string{"get-project-type", "manifest-summary"}
if len(got) != len(want) {
t.Fatalf("Names() = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("Names() = %v, want %v", got, want)
}
}
}
func TestGetRaw(t *testing.T) {
body, err := Get(GetProjectType)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(body, "{{") {
t.Fatal("raw template body should retain template markers")
}
if !strings.Contains(body, "classifier") {
t.Fatalf("unexpected body: %q", body)
}
}
func TestGetUnknown(t *testing.T) {
if _, err := Get("does-not-exist"); err == nil {
t.Fatal("want error for unknown prompt")
}
}
func TestRenderGetProjectType(t *testing.T) {
data := GetProjectTypeData{
Categories: []Category{
{Category: "cli", Description: "command-line tool", PickWhen: "binary entrypoint", Dirs: []string{"cmd", "internal"}},
{Category: "generic", Description: "fallback"},
},
Tree: []string{"go.mod", "cmd/"},
Description: "free text",
}
out, err := Render(GetProjectType, data)
if err != nil {
t.Fatal(err)
}
for _, w := range []string{"command-line tool", "cmd, internal", "go.mod", "free text", "JSON object", "generic"} {
if !strings.Contains(out, w) {
t.Fatalf("rendered prompt missing %q in:\n%s", w, out)
}
}
if strings.Contains(out, "{{") {
t.Fatalf("rendered output still has template markers:\n%s", out)
}
}
func TestRenderManifestSummary(t *testing.T) {
data := ManifestSummaryData{
Dir: "frontend",
Items: []ManifestItem{{Path: "App.tsx", Kind: "file"}, {Path: "routes/", Kind: "dir"}},
}
out, err := Render(ManifestSummary, data)
if err != nil {
t.Fatal(err)
}
for _, w := range []string{"frontend", "App.tsx", "routes/", "purpose", "find_when"} {
if !strings.Contains(out, w) {
t.Fatalf("rendered prompt missing %q in:\n%s", w, out)
}
}
}
func TestRenderUnknown(t *testing.T) {
if _, err := Render("does-not-exist", nil); err == nil {
t.Fatal("want error for unknown prompt")
}
}
added internal/prompts/templates/get-project-type.tmpl
@@ -0,0 +1,35 @@
You are a project-structure classifier. Given a repository's top-level
file tree and an optional operator description, classify the project into
exactly one category from the catalog below.
CATEGORIES (you MUST pick one of these or "generic"):
{{- range .Categories }}
- {{ .Category }}: {{ .Description }}
{{- if .PickWhen }}
pick when: {{ .PickWhen }}
{{- end }}
{{- if .Dirs }}
canonical dirs: {{ join .Dirs ", " }}
{{- end }}
{{- end }}
PROJECT TREE (top-level entries):
{{- range .Tree }}
- {{ . }}
{{- end }}
{{- if .Description }}
OPERATOR DESCRIPTION:
{{ .Description }}
{{- end }}
Return ONLY a JSON object on a single line with this exact shape:
{"category":"<one of the catalog categories or generic>","confidence":<0.0-1.0>,"dirs":["dir1","dir2"],"justification":"<one sentence>","deviations":["..."]}
Rules:
- category MUST be from the catalog or "generic".
- confidence is your certainty in [0,1].
- dirs is the knowledge-dir set you recommend (subset/superset of the catalog dirs allowed).
- justification is one sentence.
- deviations lists any dirs you added beyond the catalog, with reason; empty array if none.
- Output the JSON object and nothing else.
added internal/prompts/templates/manifest-summary.tmpl
@@ -0,0 +1,20 @@
You are documenting one directory of a software project so an AI assistant
can navigate it quickly. You are given the directory name and a listing of
its immediate entries (files and subdirectories).
DIRECTORY: {{ .Dir }}
ENTRIES:
{{- range .Items }}
- {{ .Path }} ({{ .Kind }})
{{- end }}
Return ONLY a JSON object on a single line with this exact shape:
{"purpose":"<one sentence: what this directory is for>","items":[{"path":"<entry path>","desc":"<one short sentence>","find_when":"<when to look here>"}]}
Rules:
- Include one items entry per entry listed above, preserving each path verbatim.
- purpose is one sentence describing the directory's role.
- desc is one short sentence describing the entry.
- find_when completes the phrase "look here when ..." in a few words.
- Output the JSON object and nothing else.
added internal/queue/lock.go
@@ -0,0 +1,170 @@
package queue
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// LockName is the presence-based lock file written next to queue.md
// while Append is mid-flight. It is portable (no flock / fcntl) and
// recovers from a crashed eeco via the staleness check in acquireLock.
const LockName = ".queue.lock"
// ErrLocked is returned by Append when the queue lock is already held
// by another live eeco invocation in the same workspace. Callers that
// must persist their item propagate it so the top-level CLI surfaces a
// non-zero exit with a clear message; "fire-and-forget" sites already
// discard via `_ = queue.Append(...)`. The lock guarantees the queue
// file is never partially written: a contender either holds the lock
// and writes cleanly, or fails up-front without touching queue.md.
var ErrLocked = errors.New("queue is locked by another eeco invocation")
// staleLockAge is the conservative window after which a lock left over
// by a crashed run is considered abandoned. Append itself is
// millisecond-scale, so any healthy run releases the lock long before
// this; only a hard crash mid-write reaches the takeover path.
const staleLockAge = 5 * time.Minute
// acquireLock claims <stateDir>/.queue.lock for the lifetime of one
// Append. On success it returns a release closure that removes the
// lock; the caller defers it. On contention it returns ErrLocked.
// A stale lock (older than staleLockAge or owned by a dead PID on
// Unix) is forcibly reclaimed once before the takeover is reported as
// ErrLocked.
func acquireLock(stateDir string) (release func(), err error) {
lockPath := filepath.Join(stateDir, LockName)
claimed, err := tryClaim(lockPath)
if err != nil {
return func() {}, err
}
if claimed {
return func() { removeLock(lockPath) }, nil
}
if !lockIsStale(lockPath) {
return func() {}, ErrLocked
}
if err := os.Remove(lockPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
return func() {}, fmt.Errorf("queue.lock: remove stale: %w", err)
}
claimed, err = tryClaim(lockPath)
if err != nil {
return func() {}, err
}
if !claimed {
return func() {}, ErrLocked
}
return func() { removeLock(lockPath) }, nil
}
// tryClaim makes the exclusive O_CREATE|O_EXCL claim on the lock file
// and writes its contents on success. It returns:
//
// (true, nil) — the lock was created cleanly and is now held;
// (false, nil) — the lock already exists (genuine contention), so the
// caller should run the staleness check;
// (false, err) — a real I/O error.
//
// On Windows a lock file in "delete pending" state — a contender just
// released it while a staleness probe (readLockPID) still held an open
// read handle — makes CreateFile reject the exclusive create with
// ERROR_ACCESS_DENIED rather than reporting the file as existing. That
// window clears in microseconds once the stray handle closes, so the
// create is retried briefly; if it never clears it is reported as
// contention so the caller falls through to the staleness path instead
// of erroring out. On Unix isPendingDelete is always false and the loop
// runs exactly once.
func tryClaim(lockPath string) (bool, error) {
deadline := time.Now().Add(2 * time.Second)
for {
f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err == nil {
writeLockContents(f)
_ = f.Close()
return true, nil
}
if errors.Is(err, fs.ErrExist) {
return false, nil
}
if isPendingDelete(err) {
if time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
continue
}
return false, nil
}
return false, fmt.Errorf("queue.lock: %w", err)
}
}
// removeLock deletes the lock file, retrying briefly past the transient
// sharing violation Windows raises when a contender's staleness probe
// (readLockPID's os.ReadFile) holds an open handle at the moment of
// deletion. Go opens files without FILE_SHARE_DELETE on Windows, so a
// concurrent reader blocks DeleteFile until its handle closes —
// microseconds later. Without the retry the releaser's os.Remove fails,
// the lock file is orphaned, and the queue stays locked out until the
// staleLockAge window elapses. On Unix the first Remove always wins.
func removeLock(path string) {
deadline := time.Now().Add(2 * time.Second)
for {
err := os.Remove(path)
if err == nil || errors.Is(err, fs.ErrNotExist) {
return
}
if time.Now().After(deadline) {
return
}
time.Sleep(20 * time.Millisecond)
}
}
func writeLockContents(f *os.File) {
_, _ = fmt.Fprintf(f, "pid=%d\ntime=%s\n", os.Getpid(), time.Now().UTC().Format(time.RFC3339))
}
// lockIsStale returns true when the existing lock at path is safe to
// reclaim: either older than staleLockAge by mtime, or owned by a PID
// that no longer exists (Unix only — see processAlive).
func lockIsStale(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
if time.Since(info.ModTime()) > staleLockAge {
return true
}
pid, ok := readLockPID(path)
if !ok {
return false
}
return !processAlive(pid)
}
func readLockPID(path string) (int, bool) {
b, err := os.ReadFile(path)
if err != nil {
return 0, false
}
for line := range strings.SplitSeq(string(b), "\n") {
v, ok := strings.CutPrefix(strings.TrimSpace(line), "pid=")
if !ok {
continue
}
pid, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil || pid <= 0 {
return 0, false
}
return pid, true
}
return 0, false
}
added internal/queue/lock_test.go
@@ -0,0 +1,305 @@
package queue
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func validItem(title string) Item {
return Item{
Kind: "lock-test",
Title: title,
Project: "p",
Date: time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC),
}
}
func TestAcquireLock_HeldLockReturnsErrLocked(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
// Write a lock owned by a live pid (this process) with a fresh mtime.
lockPath := filepath.Join(dir, LockName)
contents := []byte("pid=" + fmt.Sprint(os.Getpid()) + "\ntime=2099-01-01T00:00:00Z\n")
if err := os.WriteFile(lockPath, contents, 0o644); err != nil {
t.Fatal(err)
}
err := Append(dir, validItem("blocked"))
if !errors.Is(err, ErrLocked) {
t.Fatalf("expected ErrLocked, got %v", err)
}
if _, err := os.Stat(filepath.Join(dir, Filename)); !errors.Is(err, os.ErrNotExist) {
t.Errorf("queue.md should not exist on contention; stat err=%v", err)
}
if _, err := os.Stat(lockPath); err != nil {
t.Errorf("contender removed an active lock: %v", err)
}
}
func TestAcquireLock_StaleLockByMtimeIsTakenOver(t *testing.T) {
dir := t.TempDir()
lockPath := filepath.Join(dir, LockName)
// Use a pid that very likely exists (init / launchd is pid 1 on
// Unix, always alive). The mtime is the takeover signal.
if err := os.WriteFile(lockPath, []byte("pid=1\ntime=2099-01-01T00:00:00Z\n"), 0o644); err != nil {
t.Fatal(err)
}
old := time.Now().Add(-10 * time.Minute)
if err := os.Chtimes(lockPath, old, old); err != nil {
t.Fatal(err)
}
if err := Append(dir, validItem("taken-over")); err != nil {
t.Fatalf("Append after stale lock: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, Filename)); err != nil {
t.Errorf("queue.md missing after takeover: %v", err)
}
if _, err := os.Stat(lockPath); !errors.Is(err, os.ErrNotExist) {
t.Errorf("lock not released after takeover (stat err=%v)", err)
}
}
func TestAcquireLock_DeadPIDOnUnixIsTakenOver(t *testing.T) {
// On Windows processAlive is always true, so this branch only
// exercises the Unix kill(pid, 0) path. The mtime is fresh so only
// the dead-PID path can succeed.
if !canProbePIDs() {
t.Skip("PID liveness probe unavailable on this platform")
}
dir := t.TempDir()
lockPath := filepath.Join(dir, LockName)
deadPID := pickDeadPID(t)
contents := fmt.Sprintf("pid=%d\ntime=%s\n", deadPID, time.Now().UTC().Format(time.RFC3339))
if err := os.WriteFile(lockPath, []byte(contents), 0o644); err != nil {
t.Fatal(err)
}
if err := Append(dir, validItem("dead-pid-takeover")); err != nil {
t.Fatalf("Append after dead-pid lock: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, Filename)); err != nil {
t.Errorf("queue.md missing after dead-pid takeover: %v", err)
}
}
func TestAppend_ReleaseLockAfterSuccess(t *testing.T) {
dir := t.TempDir()
if err := Append(dir, validItem("first")); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, LockName)); !errors.Is(err, os.ErrNotExist) {
t.Errorf("lock not released after successful Append: %v", err)
}
// Second Append must succeed in the same dir.
if err := Append(dir, validItem("second")); err != nil {
t.Fatalf("second Append: %v", err)
}
}
func TestAppend_RealConcurrencyNoCorruption(t *testing.T) {
dir := t.TempDir()
const goroutines = 30
var wg sync.WaitGroup
var mu sync.Mutex
var nilCount, lockedCount, otherErrs int
wg.Add(goroutines)
for i := range goroutines {
go func(i int) {
defer wg.Done()
err := Append(dir, validItem(fmt.Sprintf("concurrent-%02d", i)))
mu.Lock()
defer mu.Unlock()
switch {
case err == nil:
nilCount++
case errors.Is(err, ErrLocked):
lockedCount++
default:
otherErrs++
t.Errorf("unexpected error: %v", err)
}
}(i)
}
wg.Wait()
if otherErrs > 0 {
t.Fatalf("unexpected errors: %d", otherErrs)
}
if nilCount+lockedCount != goroutines {
t.Fatalf("accounting: nil=%d locked=%d total=%d", nilCount, lockedCount, goroutines)
}
b, err := os.ReadFile(filepath.Join(dir, Filename))
if err != nil {
t.Fatal(err)
}
got := string(b)
// Count "- [ ]" lines matches nil-returning writers.
checkedLines := 0
for line := range strings.SplitSeq(got, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "- [ ]") {
checkedLines++
}
}
if checkedLines != nilCount {
t.Errorf("queue items %d does not match successful writers %d:\n%s",
checkedLines, nilCount, got)
}
// Exactly one header.
if strings.Count(got, "# eeco queue") != 1 {
t.Errorf("header count != 1:\n%s", got)
}
// No malformed line: every item line is a "- [ ] **kind** — title _(p, date)_" pattern.
for line := range strings.SplitSeq(got, "\n") {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "- [ ]") {
continue
}
if !strings.Contains(trimmed, "**lock-test**") || !strings.Contains(trimmed, "_(p, 2026-05-19)_") {
t.Errorf("malformed item line (possible torn write): %q", trimmed)
}
}
// Lock is removed (every writer released its successful claim).
// Windows can leave the dir entry briefly in "pending delete" after
// the last handle closes; poll for the entry to disappear before
// asserting. On Unix the first iteration breaks immediately.
deadline := time.Now().Add(2 * time.Second)
for {
_, err := os.Stat(filepath.Join(dir, LockName))
if errors.Is(err, os.ErrNotExist) {
break
}
if time.Now().After(deadline) {
t.Errorf("lock not removed after concurrent run: stat err=%v", err)
break
}
time.Sleep(20 * time.Millisecond)
}
}
// canProbePIDs reports whether processAlive can distinguish alive from
// dead PIDs. On Windows it cannot (always returns true), so dead-PID
// tests are skipped there; the mtime path is the takeover signal.
func canProbePIDs() bool {
// pid 1 always exists on Unix; on Windows processAlive is hard-coded
// to true so dead detection is impossible. We probe a clearly dead
// pid (max int32) and expect false on Unix, true on Windows.
const obviouslyDead = 0x7fffff00
return !processAlive(obviouslyDead)
}
func pickDeadPID(t *testing.T) int {
t.Helper()
candidates := []int{0x7fffff00, 0x7ffffff0, 0x7fffffff - 1}
for _, c := range candidates {
if !processAlive(c) {
return c
}
}
t.Fatal("could not find a dead pid")
return 0
}
// lockStateDirIsFile returns a stateDir that is itself a regular FILE, so
// the lock path's parent is a non-directory and tryClaim's
// O_CREATE|O_EXCL open fails with ENOTDIR (a real I/O error, not
// fs.ErrExist). A directory placed where the lock file goes would instead
// return fs.ErrExist and route through the contention path, so the
// file-parent trick is what reaches the error-wrap. No chmod, so root CI
// cannot bypass it.
func lockStateDirIsFile(t *testing.T) string {
t.Helper()
stateDir := filepath.Join(t.TempDir(), "statefile")
if err := os.WriteFile(stateDir, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
return stateDir
}
// lockParentIsFile returns a stateDir whose parent is a regular file, so
// os.MkdirAll(stateDir) fails with ENOTDIR.
func lockParentIsFile(t *testing.T) string {
t.Helper()
parent := filepath.Join(t.TempDir(), "afile")
if err := os.WriteFile(parent, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
return filepath.Join(parent, "state")
}
func TestAcquireLock_OpenErrWrapped(t *testing.T) {
// Direct white-box call: a file-parented lock path makes tryClaim's
// O_CREATE|O_EXCL open fail with ENOTDIR → isPendingDelete(err) false
// on unix → the "queue.lock:" wrap, propagated by acquireLock.
stateDir := lockStateDirIsFile(t)
release, err := acquireLock(stateDir)
if err == nil || !strings.Contains(err.Error(), "queue.lock:") {
t.Fatalf("acquireLock err = %v, want 'queue.lock:'", err)
}
// The returned release must be a safe no-op on the error path.
release()
}
func TestAppend_MkdirStateDirFails(t *testing.T) {
err := Append(lockParentIsFile(t), validItem("x"))
if err == nil || !strings.Contains(err.Error(), "queue.Append: create state dir:") {
t.Fatalf("Append err = %v, want 'queue.Append: create state dir:'", err)
}
}
func TestAppendUnique_MkdirStateDirFails(t *testing.T) {
_, err := AppendUnique(lockParentIsFile(t), validItem("x"))
if err == nil || !strings.Contains(err.Error(), "queue.AppendUnique: create state dir:") {
t.Fatalf("AppendUnique err = %v, want 'queue.AppendUnique: create state dir:'", err)
}
}
func TestLockIsStale_StatErr(t *testing.T) {
if lockIsStale(filepath.Join(t.TempDir(), "nope")) {
t.Error("lockIsStale(missing) = true, want false")
}
}
func TestLockIsStale_FreshNoPID(t *testing.T) {
p := filepath.Join(t.TempDir(), LockName)
if err := os.WriteFile(p, []byte("no pid here\n"), 0o644); err != nil {
t.Fatal(err)
}
// Fresh mtime + unreadable pid → not safe to reclaim.
if lockIsStale(p) {
t.Error("lockIsStale(fresh, no pid) = true, want false")
}
}
func TestReadLockPID_Errors(t *testing.T) {
// Read error: the path is a directory.
if pid, ok := readLockPID(t.TempDir()); ok || pid != 0 {
t.Errorf("readLockPID(dir) = (%d,%v), want (0,false)", pid, ok)
}
// Malformed / non-positive pid values and a missing pid line.
for _, body := range []string{"pid=-5\n", "pid=abc\n", "pid=0\n", "no-pid-line\n"} {
p := filepath.Join(t.TempDir(), LockName)
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
if pid, ok := readLockPID(p); ok || pid != 0 {
t.Errorf("readLockPID(%q) = (%d,%v), want (0,false)", body, pid, ok)
}
}
}
added internal/queue/lock_unix.go
@@ -0,0 +1,34 @@
//go:build !windows
package queue
import (
"errors"
"syscall"
)
// processAlive reports whether the given pid currently exists. A pid
// of an exited process returns ESRCH from kill(pid, 0); any other
// error (notably EPERM, which still proves the pid is alive but
// owned by another user) is treated as alive.
func processAlive(pid int) bool {
if pid <= 0 {
return false
}
err := syscall.Kill(pid, 0)
if err == nil {
return true
}
if errors.Is(err, syscall.ESRCH) {
return false
}
return true
}
// isPendingDelete reports whether err is the transient Windows "delete
// pending" rejection of an exclusive create (see the windows build's
// implementation). No equivalent state exists on Unix, so it is always
// false here and tryClaim's create runs exactly once.
func isPendingDelete(error) bool {
return false
}
added internal/queue/lock_unix_test.go
@@ -0,0 +1,19 @@
//go:build !windows
package queue
import "testing"
func TestProcessAlive_Guards(t *testing.T) {
if processAlive(0) {
t.Error("processAlive(0) = true, want false (pid<=0 guard)")
}
if processAlive(-1) {
t.Error("processAlive(-1) = true, want false (pid<=0 guard)")
}
// PID 1 (init/launchd) always exists. As non-root the kill(0) probe
// returns EPERM (still alive); as root it returns nil — both → true.
if !processAlive(1) {
t.Error("processAlive(1) = false, want true (pid 1 always exists)")
}
}
added internal/queue/lock_windows.go
@@ -0,0 +1,32 @@
//go:build windows
package queue
import (
"errors"
"syscall"
)
// processAlive on Windows cannot use the kill(0) trick: os.FindProcess
// always returns a non-nil process and Signal(0) is unsupported. We
// conservatively report every pid as alive and rely on the mtime
// staleness window in lockIsStale to recover from a crashed run.
func processAlive(pid int) bool {
return true
}
// errorSharingViolation (ERROR_SHARING_VIOLATION, 0x20) is not exported
// by the syscall package, so it is named locally.
const errorSharingViolation = syscall.Errno(0x20)
// isPendingDelete reports whether err is the transient rejection Windows
// raises when an exclusive create targets a lock file still in "delete
// pending" state — another invocation released it microseconds ago while
// a staleness probe held an open read handle. CreateFile returns
// ERROR_ACCESS_DENIED (or ERROR_SHARING_VIOLATION while a stray handle is
// still open) rather than reporting the file as existing, so acquireLock
// must treat it as contention, not a hard error.
func isPendingDelete(err error) bool {
return errors.Is(err, syscall.ERROR_ACCESS_DENIED) ||
errors.Is(err, errorSharingViolation)
}
added internal/queue/queue.go
@@ -0,0 +1,246 @@
// Package queue is eeco's single decision channel.
//
// Items are appended to <workspace>/state/queue.md as a Markdown
// checklist. The user resolves an item by checking its box; nothing in
// the engine deletes user data. Workflows and GC append; later
// milestones add list/resolve helpers.
package queue
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// Filename is the queue file name inside <workspace>/state/.
const Filename = "queue.md"
// Item is one queue entry. Kind is a short tag ("gc-review", "evolve",
// etc.). Title is a one-line summary that fits on the checklist row.
// Project is a short project handle (typically the repo basename).
// Detail is an optional one- or few-line elaboration printed as an
// indented continuation line beneath the checklist row.
type Item struct {
Kind string
Title string
Project string
Detail string
Date time.Time
}
// Append writes item to <stateDir>/queue.md, creating the file and
// parent directory if missing. The format follows PLAN.md:
//
// - [ ] **<kind>** — <title> _(<project>, <date>)_
// <detail>
//
// A trailing newline is added if the existing file lacked one so the
// new item starts on its own line.
func Append(stateDir string, item Item) error {
if err := validateItem(stateDir, &item); err != nil {
return err
}
if err := os.MkdirAll(stateDir, 0o755); err != nil {
return fmt.Errorf("queue.Append: create state dir: %w", err)
}
release, err := acquireLock(stateDir)
if err != nil {
return err
}
defer release()
existing, err := readQueue(stateDir)
if err != nil {
return err
}
return writeAppended(stateDir, existing, item)
}
// AppendUnique behaves like Append but skips the write when an open
// (unchecked) item with the same Kind and Title already sits in the
// queue, returning appended=false. It exists so a workflow that may run
// repeatedly — for example a drift check wired into a git hook — does
// not pile up duplicate items for the same unresolved finding. The
// dedup key is Kind+Title only: Project and Date are deliberately
// excluded, so the same finding reported on two different days still
// collapses to one open item. A resolved (checked) item never blocks a
// re-file: if the operator ticked it off and the finding persists,
// filing it again is the correct signal.
func AppendUnique(stateDir string, item Item) (appended bool, err error) {
if verr := validateItem(stateDir, &item); verr != nil {
return false, verr
}
if merr := os.MkdirAll(stateDir, 0o755); merr != nil {
return false, fmt.Errorf("queue.AppendUnique: create state dir: %w", merr)
}
release, err := acquireLock(stateDir)
if err != nil {
return false, err
}
defer release()
existing, err := readQueue(stateDir)
if err != nil {
return false, err
}
if hasOpenItem(existing, item.Kind, item.Title) {
return false, nil
}
if werr := writeAppended(stateDir, existing, item); werr != nil {
return false, werr
}
return true, nil
}
// validateItem checks the shared preconditions and defaults the date.
func validateItem(stateDir string, item *Item) error {
if stateDir == "" {
return errors.New("queue: stateDir is empty")
}
if item.Kind == "" || item.Title == "" {
return errors.New("queue: kind and title are required")
}
if item.Date.IsZero() {
item.Date = time.Now()
}
return nil
}
// readQueue reads the queue file, treating a missing file as empty.
// Callers hold the lock.
func readQueue(stateDir string) ([]byte, error) {
b, err := os.ReadFile(filepath.Join(stateDir, Filename))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("queue: read: %w", err)
}
return b, nil
}
// writeAppended renders item onto existing and writes the queue file.
// Callers hold the lock.
func writeAppended(stateDir string, existing []byte, item Item) error {
var buf bytes.Buffer
if len(existing) == 0 {
buf.WriteString("# eeco queue\n\n")
} else {
buf.Write(existing)
if !bytes.HasSuffix(existing, []byte("\n")) {
buf.WriteByte('\n')
}
}
fmt.Fprintf(&buf, "- [ ] **%s** — %s _(%s, %s)_\n",
item.Kind, item.Title, item.Project, item.Date.UTC().Format("2006-01-02"))
if d := strings.TrimSpace(item.Detail); d != "" {
for _, line := range strings.Split(d, "\n") {
fmt.Fprintf(&buf, " %s\n", strings.TrimRight(line, " \t\r"))
}
}
return os.WriteFile(filepath.Join(stateDir, Filename), buf.Bytes(), 0o644)
}
// hasOpenItem reports whether content carries an unchecked item whose
// kind and title match. Resolved (checked) items are ignored.
func hasOpenItem(content []byte, kind, title string) bool {
for _, line := range strings.Split(string(content), "\n") {
k, t, ok := parseOpenRow(line)
if ok && k == kind && t == title {
return true
}
}
return false
}
// parseOpenRow extracts the kind and title from an open checklist row in
// the frozen format `- [ ] **<kind>** — <title> _(<project>, <date>)_`.
// It returns ok=false for resolved rows, detail/continuation lines, and
// anything not matching the row shape.
func parseOpenRow(line string) (kind, title string, ok bool) {
return parseRow(line, "- [ ] **")
}
// parseResolvedRow is the resolved-checkbox counterpart of parseOpenRow.
// It returns ok=false for unresolved rows and non-row lines.
func parseResolvedRow(line string) (kind, title string, ok bool) {
return parseRow(line, "- [x] **")
}
// parseRow is the shared row parser. prefix selects the checkbox state:
// `- [ ] **` for open, `- [x] **` for resolved.
func parseRow(line, prefix string) (kind, title string, ok bool) {
rest := strings.TrimSpace(line)
if !strings.HasPrefix(rest, prefix) {
return "", "", false
}
rest = rest[len(prefix):]
end := strings.Index(rest, "**")
if end < 0 {
return "", "", false
}
kind = rest[:end]
rest = rest[end+2:]
const sep = " — "
if !strings.HasPrefix(rest, sep) {
return "", "", false
}
rest = rest[len(sep):]
// The title runs up to the trailing ` _(<project>, <date>)_` suffix.
// Trim from the last ` _(` so a title that itself contains "_(" is
// handled correctly.
if cut := strings.LastIndex(rest, " _("); cut >= 0 {
rest = rest[:cut]
}
title = rest
if kind == "" || title == "" {
return "", "", false
}
return kind, title, true
}
// Resolved reports whether <stateDir>/queue.md carries a resolved
// (checked) item with the given kind and title. A missing queue file
// is reported as not resolved with no error. Counterpart to the
// internal hasOpenItem used by AppendUnique; used by the evolve
// repetition ledger to reconcile its records against operator
// resolution.
func Resolved(stateDir, kind, title string) (bool, error) {
b, err := os.ReadFile(filepath.Join(stateDir, Filename))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, fmt.Errorf("queue.Resolved: %w", err)
}
for _, line := range strings.Split(string(b), "\n") {
k, t, ok := parseResolvedRow(line)
if ok && k == kind && t == title {
return true, nil
}
}
return false, nil
}
// Count returns the number of unchecked items in <stateDir>/queue.md.
// A missing file is reported as zero.
func Count(stateDir string) (int, error) {
path := filepath.Join(stateDir, Filename)
b, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return 0, nil
}
return 0, fmt.Errorf("queue.Count: %w", err)
}
n := 0
for _, line := range strings.Split(string(b), "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "- [ ]") {
n++
}
}
return n, nil
}
added internal/queue/queue_test.go
@@ -0,0 +1,309 @@
package queue
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestAppend_CreatesFileWithHeader(t *testing.T) {
dir := t.TempDir()
item := Item{
Kind: "gc-review",
Title: "stale ref",
Project: "demo",
Detail: "fact `foo` references missing path",
Date: time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC),
}
if err := Append(dir, item); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(dir, Filename))
if err != nil {
t.Fatal(err)
}
got := string(b)
if !strings.HasPrefix(got, "# eeco queue\n") {
t.Errorf("missing header:\n%s", got)
}
want := "- [ ] **gc-review** — stale ref _(demo, 2026-05-19)_\n fact `foo` references missing path\n"
if !strings.Contains(got, want) {
t.Errorf("missing entry, got:\n%s\n\nwant contains:\n%s", got, want)
}
}
func TestAppend_AppendsToExisting(t *testing.T) {
dir := t.TempDir()
first := Item{Kind: "k", Title: "first", Project: "p", Date: time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC)}
second := Item{Kind: "k", Title: "second", Project: "p", Date: time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC)}
if err := Append(dir, first); err != nil {
t.Fatal(err)
}
if err := Append(dir, second); err != nil {
t.Fatal(err)
}
b, _ := os.ReadFile(filepath.Join(dir, Filename))
got := string(b)
if !strings.Contains(got, "first") || !strings.Contains(got, "second") {
t.Errorf("missing entries:\n%s", got)
}
if strings.Count(got, "# eeco queue") != 1 {
t.Errorf("header duplicated:\n%s", got)
}
}
func TestAppend_NoTrailingNewlineGetsFixed(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, Filename)
if err := os.WriteFile(path, []byte("# eeco queue\n\n- [x] **k** — old _(p, 2026-05-19)_"), 0o644); err != nil {
t.Fatal(err)
}
item := Item{Kind: "k", Title: "new", Project: "p", Date: time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC)}
if err := Append(dir, item); err != nil {
t.Fatal(err)
}
b, _ := os.ReadFile(path)
got := string(b)
if !strings.Contains(got, "old _(p, 2026-05-19)_\n- [ ] **k** — new") {
t.Errorf("appended entry stuck to previous line:\n%s", got)
}
}
func TestAppend_RejectsMissingFields(t *testing.T) {
dir := t.TempDir()
cases := []Item{
{Title: "no kind", Project: "p"},
{Kind: "k", Project: "p"},
}
for _, item := range cases {
if err := Append(dir, item); err == nil {
t.Errorf("Append(%+v) succeeded; expected error", item)
}
}
}
func TestAppend_CreatesStateDir(t *testing.T) {
dir := filepath.Join(t.TempDir(), "deeper", "state")
item := Item{Kind: "k", Title: "t", Project: "p", Date: time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC)}
if err := Append(dir, item); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, Filename)); err != nil {
t.Errorf("queue.md not created: %v", err)
}
}
func TestAppendUnique_SkipsDuplicateOpenItem(t *testing.T) {
dir := t.TempDir()
item := Item{Kind: "memory-drift", Title: "fact x may be stale", Project: "p",
Date: time.Date(2026, 5, 22, 0, 0, 0, 0, time.UTC)}
added, err := AppendUnique(dir, item)
if err != nil || !added {
t.Fatalf("first AppendUnique: added=%v err=%v", added, err)
}
// Same finding on a later day: Project/Date differ but Kind+Title match.
dup := item
dup.Date = time.Date(2026, 5, 23, 0, 0, 0, 0, time.UTC)
added, err = AppendUnique(dir, dup)
if err != nil {
t.Fatalf("second AppendUnique: %v", err)
}
if added {
t.Error("duplicate open item was appended; want skip")
}
b, _ := os.ReadFile(filepath.Join(dir, Filename))
if n := strings.Count(string(b), "fact x may be stale"); n != 1 {
t.Errorf("item present %d times, want 1:\n%s", n, b)
}
}
func TestAppendUnique_AppendsWhenTitleDiffers(t *testing.T) {
dir := t.TempDir()
base := Item{Kind: "memory-drift", Project: "p", Date: time.Date(2026, 5, 22, 0, 0, 0, 0, time.UTC)}
a := base
a.Title = "fact a may be stale"
b := base
b.Title = "fact b may be stale"
if added, err := AppendUnique(dir, a); err != nil || !added {
t.Fatalf("append a: added=%v err=%v", added, err)
}
if added, err := AppendUnique(dir, b); err != nil || !added {
t.Fatalf("append b: added=%v err=%v", added, err)
}
content, _ := os.ReadFile(filepath.Join(dir, Filename))
if !strings.Contains(string(content), "fact a") || !strings.Contains(string(content), "fact b") {
t.Errorf("both distinct items should be present:\n%s", content)
}
}
func TestAppendUnique_ResolvedItemDoesNotBlockRefile(t *testing.T) {
dir := t.TempDir()
item := Item{Kind: "doc-drift", Title: "tag v1.2.3 not documented", Project: "p",
Date: time.Date(2026, 5, 22, 0, 0, 0, 0, time.UTC)}
if added, err := AppendUnique(dir, item); err != nil || !added {
t.Fatalf("first append: added=%v err=%v", added, err)
}
// Operator resolves it (checks the box) but the drift persists.
path := filepath.Join(dir, Filename)
b, _ := os.ReadFile(path)
resolved := strings.Replace(string(b), "- [ ] **doc-drift**", "- [x] **doc-drift**", 1)
if err := os.WriteFile(path, []byte(resolved), 0o644); err != nil {
t.Fatal(err)
}
added, err := AppendUnique(dir, item)
if err != nil {
t.Fatal(err)
}
if !added {
t.Error("re-file after resolve was skipped; a resolved item must not block a re-file")
}
}
func TestParseOpenRow(t *testing.T) {
cases := []struct {
line string
wantKind, wantTitle string
wantOK bool
}{
{"- [ ] **memory-drift** — fact x may be stale _(p, 2026-05-22)_", "memory-drift", "fact x may be stale", true},
{" - [ ] **k** — t _(proj, 2026-05-22)_", "k", "t", true},
{"- [x] **k** — resolved _(p, 2026-05-22)_", "", "", false},
{" indented detail line", "", "", false},
{"# eeco queue", "", "", false},
{"- [ ] no bold kind _(p, 2026-05-22)_", "", "", false},
// A title that itself contains "_(" trims at the last suffix marker.
{"- [ ] **k** — weird _(x)_ title _(p, 2026-05-22)_", "k", "weird _(x)_ title", true},
// No closing "**" after the kind.
{"- [ ] **k — t _(p, 2026-05-22)_", "", "", false},
// No " — " separator between kind and title.
{"- [ ] **k** t _(p, 2026-05-22)_", "", "", false},
// Empty kind (matched "****" gives an empty kind → rejected).
{"- [ ] **** — t _(p, 2026-05-22)_", "", "", false},
// Empty title (nothing between the separator and the suffix).
{"- [ ] **k** — _(p, 2026-05-22)_", "", "", false},
}
for _, c := range cases {
k, ti, ok := parseOpenRow(c.line)
if ok != c.wantOK || k != c.wantKind || ti != c.wantTitle {
t.Errorf("parseOpenRow(%q) = (%q, %q, %v), want (%q, %q, %v)",
c.line, k, ti, ok, c.wantKind, c.wantTitle, c.wantOK)
}
}
}
func TestAppendUnique_RejectsMissingFields(t *testing.T) {
dir := t.TempDir()
if _, err := AppendUnique(dir, Item{Title: "no kind", Project: "p"}); err == nil {
t.Error("AppendUnique with no kind succeeded; expected error")
}
}
func TestCount(t *testing.T) {
dir := t.TempDir()
if n, err := Count(dir); err != nil || n != 0 {
t.Errorf("missing queue: n=%d err=%v", n, err)
}
for i, kind := range []string{"a", "b", "c"} {
_ = i
if err := Append(dir, Item{Kind: kind, Title: kind, Project: "p", Date: time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC)}); err != nil {
t.Fatal(err)
}
}
// Resolve one item manually.
path := filepath.Join(dir, Filename)
b, _ := os.ReadFile(path)
s := strings.Replace(string(b), "- [ ] **a**", "- [x] **a**", 1)
if err := os.WriteFile(path, []byte(s), 0o644); err != nil {
t.Fatal(err)
}
n, err := Count(dir)
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Errorf("Count = %d, want 2", n)
}
}
func TestResolved(t *testing.T) {
t.Run("missing file", func(t *testing.T) {
got, err := Resolved(t.TempDir(), "evolve", "done")
if err != nil || got {
t.Fatalf("missing file → (%v, %v), want (false, nil)", got, err)
}
})
dir := t.TempDir()
content := "# eeco queue\n\n" +
"- [x] **evolve** — done _(p, 2026-05-22)_\n" +
"- [ ] **gc** — open one _(p, 2026-05-22)_\n"
if err := os.WriteFile(filepath.Join(dir, Filename), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
cases := []struct {
kind, title string
want bool
}{
{"evolve", "done", true},
{"evolve", "wrong-title", false},
{"wrong-kind", "done", false},
{"gc", "open one", false}, // an open row is not resolved
}
for _, c := range cases {
got, err := Resolved(dir, c.kind, c.title)
if err != nil {
t.Errorf("Resolved(%q,%q) err = %v", c.kind, c.title, err)
}
if got != c.want {
t.Errorf("Resolved(%q,%q) = %v, want %v", c.kind, c.title, got, c.want)
}
}
}
func TestResolved_ReadErrWrapped(t *testing.T) {
dir := t.TempDir()
// queue.md as a directory → ReadFile returns a non-NotExist error.
if err := os.MkdirAll(filepath.Join(dir, Filename), 0o755); err != nil {
t.Fatal(err)
}
if _, err := Resolved(dir, "k", "t"); err == nil || !strings.Contains(err.Error(), "queue.Resolved:") {
t.Fatalf("err = %v, want 'queue.Resolved:'", err)
}
}
func TestValidateItem_Defaults(t *testing.T) {
if err := validateItem("", &Item{Kind: "k", Title: "t"}); err == nil ||
!strings.Contains(err.Error(), "queue: stateDir is empty") {
t.Fatalf("empty stateDir err = %v, want 'queue: stateDir is empty'", err)
}
item := &Item{Kind: "k", Title: "t"} // zero Date
if err := validateItem("somedir", item); err != nil {
t.Fatalf("validateItem: %v", err)
}
if item.Date.IsZero() {
t.Error("zero Date was not defaulted to now")
}
}
func TestReadQueue_NonNotExistErrWrapped(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, Filename), 0o755); err != nil {
t.Fatal(err)
}
if _, err := readQueue(dir); err == nil || !strings.Contains(err.Error(), "queue: read:") {
t.Fatalf("err = %v, want 'queue: read:'", err)
}
}
func TestCount_ReadErrWrapped(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, Filename), 0o755); err != nil {
t.Fatal(err)
}
if _, err := Count(dir); err == nil || !strings.Contains(err.Error(), "queue.Count:") {
t.Fatalf("err = %v, want 'queue.Count:'", err)
}
}
added internal/selfupdate/download.go
@@ -0,0 +1,95 @@
package selfupdate
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"strings"
)
func download(client *http.Client, url, dst string) error {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "eeco-update/1")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("http %d", resp.StatusCode)
}
tmp, err := os.CreateTemp(parentDir(dst), ".eeco-dl-*")
if err != nil {
return err
}
tmpName := tmp.Name()
if _, err := io.Copy(tmp, resp.Body); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return err
}
return os.Rename(tmpName, dst)
}
func parentDir(path string) string {
i := strings.LastIndexAny(path, `/\`)
if i < 0 {
return "."
}
return path[:i]
}
func sha256File(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// checksumFor reads sumsPath (the `shasum -a 256` SHA256SUMS file) and
// returns the recorded hash for archiveBasename. Each line is:
//
// <64-hex> <basename>
//
// (two spaces between, per shasum's default text mode).
func checksumFor(sumsPath, archiveBasename string) (string, error) {
b, err := os.ReadFile(sumsPath)
if err != nil {
return "", err
}
for _, raw := range strings.Split(string(b), "\n") {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
hash := fields[0]
name := strings.TrimPrefix(fields[len(fields)-1], "*")
if name == archiveBasename {
if len(hash) != 64 {
return "", fmt.Errorf("SHA256SUMS: bad hash for %s", archiveBasename)
}
return strings.ToLower(hash), nil
}
}
return "", fmt.Errorf("SHA256SUMS: no entry for %s", archiveBasename)
}
added internal/selfupdate/extract.go
@@ -0,0 +1,141 @@
package selfupdate
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
)
// extract unpacks archivePath into dstDir and returns the absolute path
// to the eeco binary inside. The archives produced by scripts/build.sh
// place a single directory `${GOOS}_${GOARCH}/` at the root containing
// `eeco{.exe}` + README + LICENSE; extract resolves whichever entry
// matches the binary name and ignores the others.
func extract(archivePath, dstDir, goos string) (string, error) {
binName := "eeco"
if goos == "windows" {
binName = "eeco.exe"
}
switch {
case strings.HasSuffix(archivePath, ".tar.gz"):
return extractTarGz(archivePath, dstDir, binName)
case strings.HasSuffix(archivePath, ".zip"):
return extractZip(archivePath, dstDir, binName)
default:
return "", fmt.Errorf("unknown archive format: %s", archivePath)
}
}
func extractTarGz(archivePath, dstDir, binName string) (string, error) {
f, err := os.Open(archivePath)
if err != nil {
return "", err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return "", err
}
defer gz.Close()
tr := tar.NewReader(gz)
for {
h, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return "", err
}
if h.Typeflag != tar.TypeReg {
continue
}
if path.Base(h.Name) != binName {
continue
}
dst := filepath.Join(dstDir, binName)
if err := writeBinary(dst, tr, modeFromHeader(h.Mode)); err != nil {
return "", err
}
return dst, nil
}
return "", fmt.Errorf("binary %s not found in archive", binName)
}
func extractZip(archivePath, dstDir, binName string) (string, error) {
zr, err := zip.OpenReader(archivePath)
if err != nil {
return "", err
}
defer zr.Close()
for _, zf := range zr.File {
if path.Base(zf.Name) != binName {
continue
}
rc, err := zf.Open()
if err != nil {
return "", err
}
dst := filepath.Join(dstDir, binName)
err = writeBinary(dst, rc, modeFromHeader(int64(zf.Mode())))
rc.Close()
if err != nil {
return "", err
}
return dst, nil
}
return "", fmt.Errorf("binary %s not found in archive", binName)
}
func writeBinary(dst string, src io.Reader, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err := io.Copy(f, src); err != nil {
f.Close()
return err
}
return f.Close()
}
func modeFromHeader(mode int64) os.FileMode {
m := os.FileMode(mode & int64(os.ModePerm))
if m == 0 {
m = 0o755
}
return m | 0o100
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
info, err := in.Stat()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
added internal/selfupdate/ledger.go
@@ -0,0 +1,65 @@
package selfupdate
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/ajhahnde/eeco/internal/config"
)
// ledgerName is the reversibility record for binary swaps; sibling to
// state/hooks.json so the same shape applies.
const ledgerName = "binary.json"
// Ledger is the persisted state of the most recent binary swap. A swap
// is the one allowed write outside the workspace (Constraint 1), so
// every recorded field lets the operator reverse it by hand.
type Ledger struct {
Installed bool `json:"installed"`
FromVersion string `json:"from_version,omitempty"`
ToVersion string `json:"to_version,omitempty"`
RunningPath string `json:"running_path,omitempty"`
Backup string `json:"backup,omitempty"`
SHA256 string `json:"sha256,omitempty"`
At string `json:"at,omitempty"`
}
func ledgerPath(cfg *config.Config) string {
return filepath.Join(cfg.Workspace, "state", ledgerName)
}
func writeLedger(cfg *config.Config, l Ledger) error {
dir := filepath.Join(cfg.Workspace, "state")
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("binary ledger dir: %w", err)
}
b, err := json.MarshalIndent(l, "", " ")
if err != nil {
return err
}
return os.WriteFile(ledgerPath(cfg), append(b, '\n'), 0o644)
}
// LoadLedger reads the current ledger record. Used by callers that
// surface "what was the last swap" (doctor probes, status digest).
// A missing file is not an error.
func LoadLedger(cfg *config.Config) (Ledger, error) {
var l Ledger
b, err := os.ReadFile(ledgerPath(cfg))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return l, nil
}
return l, err
}
if len(b) == 0 {
return l, nil
}
if err := json.Unmarshal(b, &l); err != nil {
return Ledger{}, nil
}
return l, nil
}
added internal/selfupdate/pkgmgr.go
@@ -0,0 +1,34 @@
package selfupdate
import (
"strings"
)
// detectPackageManager returns a short label and a one-line operator
// hint when the running binary appears to live under a known package-
// manager prefix. Heuristic-only — we never rewrite a brew-managed or
// scoop-managed install. Empty kind means "unknown / hand-installed."
//
// The check works off the running path verbatim with backslashes
// normalised to slashes (filepath.ToSlash only swaps separators on
// Windows; an explicit ReplaceAll keeps the check platform-independent
// so a Windows-style fixture path tests the same on every host). We
// deliberately skip filepath.Abs — calling it on Windows would prepend
// a drive letter to a Unix-style test fixture and break the prefix
// match.
func detectPackageManager(running string) (kind, hint string) {
low := strings.ToLower(strings.ReplaceAll(running, `\`, "/"))
switch {
case strings.Contains(low, "/cellar/eeco/"),
strings.HasPrefix(low, "/opt/homebrew/"),
strings.HasPrefix(low, "/home/linuxbrew/.linuxbrew/"),
strings.HasPrefix(low, "/usr/local/cellar/"):
return "Homebrew", "use 'brew upgrade eeco' instead"
case strings.Contains(low, "/.linuxbrew/"):
return "Homebrew", "use 'brew upgrade eeco' instead"
case strings.Contains(low, "/scoop/apps/eeco/"):
return "Scoop", "use 'scoop update eeco' instead"
}
return "", ""
}
added internal/selfupdate/selfupdate.go
@@ -0,0 +1,236 @@
// Package selfupdate implements eeco's opt-in self-replace path for
// `eeco update --apply`. It downloads the platform release archive,
// verifies the SHA256SUMS keyless cosign signature, verifies the
// GitHub build-provenance attestation, then atomically replaces the
// running binary with the verified one. The binary swap is the one
// allowed write outside the workspace (Constraint 1); every other
// write — the download staging area, the backup copy, the ledger
// entry — lands inside <workspace>/state/.
//
// The bare `eeco update` (no flag) keeps its read-only behaviour;
// this package is invoked only when --apply is set.
package selfupdate
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"github.com/ajhahnde/eeco/internal/config"
)
// DefaultBaseURL is the production GitHub Releases base URL.
const DefaultBaseURL = "https://github.com/ajhahnde/eeco/releases/download"
// CosignIdentityRegexp matches the same identity baked into the
// release-notes block of .github/workflows/release.yml. The contract
// is: keyless signatures only come from a tag-push run of the release
// workflow in this repo.
const CosignIdentityRegexp = `^https://github.com/ajhahnde/eeco/\.github/workflows/release\.yml@refs/tags/v`
// CosignOIDCIssuer is the OIDC issuer the keyless cosign signature
// trusts. Matches the release-notes verification line.
const CosignOIDCIssuer = "https://token.actions.githubusercontent.com"
// ProvenanceRepo is the repository slug used by `gh attestation verify`.
const ProvenanceRepo = "ajhahnde/eeco"
// Options injects collaborators for testing. All fields are optional;
// zero values are replaced with production defaults at run time.
type Options struct {
BaseURL string
HTTPClient *http.Client
Executable func() (string, error)
RunCmd func(name string, args ...string) (combined string, err error)
Now func() time.Time
GOOS string
GOARCH string
}
// Apply performs the verified self-replace. cfg supplies the workspace
// (where staging, backup, and ledger live). currentVersion is the
// running binary's version string (e.g. "v1.4.1"). latestTag is the
// release tag to apply (e.g. "v1.5.0"). Exit code conventions match
// the rest of the CLI: 0 success, 1 finding/failure, 2 blocked.
func Apply(cfg *config.Config, currentVersion, latestTag string, stdout, stderr io.Writer, opt Options) int {
o := withDefaults(opt)
running, err := o.Executable()
if err != nil {
fmt.Fprintln(stderr, "eeco update --apply: cannot resolve running binary:", err)
return 1
}
if kind, hint := detectPackageManager(running); kind != "" {
fmt.Fprintf(stdout, "eeco update --apply: this build appears to be installed via %s.\n", kind)
fmt.Fprintf(stdout, " %s\n", hint)
return 2
}
stagingDir := filepath.Join(cfg.Workspace, "state", "update-"+latestTag)
if err := os.MkdirAll(stagingDir, 0o755); err != nil {
fmt.Fprintln(stderr, "eeco update --apply: prepare staging dir:", err)
return 1
}
archiveName := archiveBasename(latestTag, o.GOOS, o.GOARCH)
files := []struct {
url string
dst string
}{
{o.BaseURL + "/" + latestTag + "/" + archiveName, filepath.Join(stagingDir, archiveName)},
{o.BaseURL + "/" + latestTag + "/SHA256SUMS", filepath.Join(stagingDir, "SHA256SUMS")},
{o.BaseURL + "/" + latestTag + "/SHA256SUMS.sig", filepath.Join(stagingDir, "SHA256SUMS.sig")},
{o.BaseURL + "/" + latestTag + "/SHA256SUMS.pem", filepath.Join(stagingDir, "SHA256SUMS.pem")},
}
fmt.Fprintln(stdout, "eeco update --apply: downloading", latestTag)
for _, f := range files {
if err := download(o.HTTPClient, f.url, f.dst); err != nil {
fmt.Fprintf(stderr, " download %s: %v\n", filepath.Base(f.dst), err)
return 1
}
}
archivePath := files[0].dst
sumsPath := files[1].dst
sigPath := files[2].dst
certPath := files[3].dst
fmt.Fprintln(stdout, " verifying SHA256SUMS signature (cosign)")
if err := verifyCosign(o.RunCmd, sumsPath, sigPath, certPath); err != nil {
if errors.Is(err, exec.ErrNotFound) {
fmt.Fprintln(stderr, " cosign is not on PATH (required for --apply).")
return 2
}
fmt.Fprintln(stderr, " cosign verify-blob failed:", err)
return 1
}
fmt.Fprintln(stdout, " verifying archive sha256 against SHA256SUMS")
wantHash, err := checksumFor(sumsPath, archiveName)
if err != nil {
fmt.Fprintln(stderr, " read SHA256SUMS:", err)
return 1
}
gotHash, err := sha256File(archivePath)
if err != nil {
fmt.Fprintln(stderr, " hash archive:", err)
return 1
}
if gotHash != wantHash {
fmt.Fprintf(stderr, " archive sha256 mismatch: want %s, got %s\n", wantHash, gotHash)
return 1
}
fmt.Fprintln(stdout, " verifying build-provenance attestation (gh)")
if err := verifyAttestation(o.RunCmd, archivePath); err != nil {
if errors.Is(err, exec.ErrNotFound) {
fmt.Fprintln(stderr, " gh is not on PATH (required for --apply).")
return 2
}
fmt.Fprintln(stderr, " gh attestation verify failed:", err)
return 1
}
stagedDir := filepath.Join(stagingDir, "staged")
if err := os.MkdirAll(stagedDir, 0o755); err != nil {
fmt.Fprintln(stderr, " prepare staged dir:", err)
return 1
}
newBin, err := extract(archivePath, stagedDir, o.GOOS)
if err != nil {
fmt.Fprintln(stderr, " extract:", err)
return 1
}
backup := filepath.Join(stagingDir, backupName(o.GOOS))
if err := copyFile(running, backup); err != nil {
fmt.Fprintln(stderr, " backup running binary:", err)
return 1
}
if err := swap(newBin, running); err != nil {
fmt.Fprintln(stderr, " swap binary:", err)
return 1
}
if err := writeLedger(cfg, Ledger{
Installed: true,
FromVersion: currentVersion,
ToVersion: latestTag,
RunningPath: running,
Backup: backup,
SHA256: gotHash,
At: o.Now().UTC().Format(time.RFC3339),
}); err != nil {
fmt.Fprintln(stderr, " write ledger:", err)
return 1
}
fmt.Fprintf(stdout, "eeco upgraded: %s -> %s (backup: %s)\n", currentVersion, latestTag, backup)
return 0
}
func withDefaults(o Options) Options {
if o.BaseURL == "" {
o.BaseURL = DefaultBaseURL
}
if o.HTTPClient == nil {
o.HTTPClient = &http.Client{Timeout: 5 * time.Minute}
}
if o.Executable == nil {
o.Executable = ResolveRunning
}
if o.RunCmd == nil {
o.RunCmd = defaultRunCmd
}
if o.Now == nil {
o.Now = time.Now
}
if o.GOOS == "" {
o.GOOS = runtime.GOOS
}
if o.GOARCH == "" {
o.GOARCH = runtime.GOARCH
}
return o
}
// ResolveRunning returns the absolute path of the running binary, with
// symlinks resolved. Mirrors the helper in internal/hooks/hooks.go so
// the swap operates on the same path the operator's `eeco` resolves to.
func ResolveRunning() (string, error) {
p, err := os.Executable()
if err != nil {
return "", err
}
if r, rerr := filepath.EvalSymlinks(p); rerr == nil {
p = r
}
return p, nil
}
func defaultRunCmd(name string, args ...string) (string, error) {
out, err := exec.Command(name, args...).CombinedOutput()
return string(out), err
}
func archiveBasename(tag, goos, goarch string) string {
ext := "tar.gz"
if goos == "windows" {
ext = "zip"
}
return fmt.Sprintf("eeco_%s_%s_%s.%s", tag, goos, goarch, ext)
}
func backupName(goos string) string {
if goos == "windows" {
return "eeco.exe.bak"
}
return "eeco.bak"
}
added internal/selfupdate/selfupdate_test.go
@@ -0,0 +1,1100 @@
package selfupdate
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/ajhahnde/eeco/internal/config"
)
// fixtureRelease returns a serving handler that publishes the four
// release artefacts (archive, SHA256SUMS, .sig, .pem) for a single tag.
// The archive contains a single eeco binary with `payload` as its
// contents and is built in the same layout as scripts/build.sh.
func fixtureRelease(t *testing.T, tag, goos, goarch string, payload []byte) (*httptest.Server, map[string][]byte) {
t.Helper()
archiveName := archiveBasename(tag, goos, goarch)
binName := "eeco"
if goos == "windows" {
binName = "eeco.exe"
}
dirInArchive := goos + "_" + goarch
var archive []byte
if goos == "windows" {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, err := zw.CreateHeader(&zip.FileHeader{Name: dirInArchive + "/" + binName, Method: zip.Deflate})
if err != nil {
t.Fatalf("zip header: %v", err)
}
if _, err := w.Write(payload); err != nil {
t.Fatalf("zip write: %v", err)
}
if err := zw.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
archive = buf.Bytes()
} else {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
hdr := &tar.Header{Name: dirInArchive + "/" + binName, Mode: 0o755, Size: int64(len(payload)), Typeflag: tar.TypeReg}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("tar header: %v", err)
}
if _, err := tw.Write(payload); err != nil {
t.Fatalf("tar write: %v", err)
}
if err := tw.Close(); err != nil {
t.Fatalf("tar close: %v", err)
}
if err := gz.Close(); err != nil {
t.Fatalf("gz close: %v", err)
}
archive = buf.Bytes()
}
sum := sha256.Sum256(archive)
sumsLine := hex.EncodeToString(sum[:]) + " " + archiveName + "\n"
assets := map[string][]byte{
archiveName: archive,
"SHA256SUMS": []byte(sumsLine),
"SHA256SUMS.sig": []byte("fake-sig\n"),
"SHA256SUMS.pem": []byte("fake-cert\n"),
}
prefix := "/" + tag + "/"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, prefix) {
http.NotFound(w, r)
return
}
name := strings.TrimPrefix(r.URL.Path, prefix)
body, ok := assets[name]
if !ok {
http.NotFound(w, r)
return
}
_, _ = w.Write(body)
}))
t.Cleanup(srv.Close)
return srv, assets
}
// newTestCfg builds a config.Config rooted at a fresh temp directory
// with a .eeco workspace, matching what `eeco init` produces.
func newTestCfg(t *testing.T) *config.Config {
t.Helper()
root := t.TempDir()
ws := filepath.Join(root, ".eeco")
if err := os.MkdirAll(filepath.Join(ws, "state"), 0o755); err != nil {
t.Fatalf("mkdir workspace: %v", err)
}
return &config.Config{
RepoRoot: root,
WorkspaceName: ".eeco",
Workspace: ws,
}
}
func TestApply_HappyPath(t *testing.T) {
cfg := newTestCfg(t)
tag := "v9.0.0"
payload := []byte("FAKE-EECO-BINARY-PAYLOAD")
srv, _ := fixtureRelease(t, tag, runtime.GOOS, runtime.GOARCH, payload)
binDir := t.TempDir()
binName := "eeco"
if runtime.GOOS == "windows" {
binName = "eeco.exe"
}
running := filepath.Join(binDir, binName)
if err := os.WriteFile(running, []byte("OLD"), 0o755); err != nil {
t.Fatalf("write running: %v", err)
}
var stdout, stderr bytes.Buffer
code := Apply(cfg, "v1.4.1", tag, &stdout, &stderr, Options{
BaseURL: srv.URL,
HTTPClient: srv.Client(),
Executable: func() (string, error) { return running, nil },
RunCmd: func(name string, args ...string) (string, error) { return "ok", nil },
Now: func() time.Time { return time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC) },
})
if code != 0 {
t.Fatalf("Apply -> %d, want 0\nstdout:\n%s\nstderr:\n%s", code, stdout.String(), stderr.String())
}
got, err := os.ReadFile(running)
if err != nil {
t.Fatalf("read running after swap: %v", err)
}
if !bytes.Equal(got, payload) {
t.Fatalf("running binary not swapped: got %q, want %q", got, payload)
}
if !strings.Contains(stdout.String(), "eeco upgraded: v1.4.1 -> "+tag) {
t.Errorf("missing upgrade confirmation:\n%s", stdout.String())
}
led, err := LoadLedger(cfg)
if err != nil {
t.Fatalf("LoadLedger: %v", err)
}
if !led.Installed || led.ToVersion != tag || led.FromVersion != "v1.4.1" {
t.Errorf("ledger: %+v", led)
}
bak := filepath.Join(cfg.Workspace, "state", "update-"+tag, backupName(runtime.GOOS))
bakBytes, err := os.ReadFile(bak)
if err != nil {
t.Fatalf("read backup: %v", err)
}
if string(bakBytes) != "OLD" {
t.Errorf("backup contents: %q, want OLD", bakBytes)
}
}
func TestApply_CosignMissing(t *testing.T) {
cfg := newTestCfg(t)
tag := "v9.0.0"
srv, _ := fixtureRelease(t, tag, runtime.GOOS, runtime.GOARCH, []byte("PAYLOAD"))
binDir := t.TempDir()
running := filepath.Join(binDir, "eeco")
_ = os.WriteFile(running, []byte("OLD"), 0o755)
var stdout, stderr bytes.Buffer
code := Apply(cfg, "v1.4.1", tag, &stdout, &stderr, Options{
BaseURL: srv.URL,
HTTPClient: srv.Client(),
Executable: func() (string, error) { return running, nil },
RunCmd: func(name string, args ...string) (string, error) {
if name == "cosign" {
return "", &exec.Error{Name: "cosign", Err: exec.ErrNotFound}
}
return "ok", nil
},
})
if code != 2 {
t.Fatalf("cosign-missing -> %d, want 2\nstdout:\n%s\nstderr:\n%s", code, stdout.String(), stderr.String())
}
if !strings.Contains(stderr.String(), "cosign is not on PATH") {
t.Errorf("missing cosign hint: %s", stderr.String())
}
}
func TestApply_GhMissing(t *testing.T) {
cfg := newTestCfg(t)
tag := "v9.0.0"
srv, _ := fixtureRelease(t, tag, runtime.GOOS, runtime.GOARCH, []byte("PAYLOAD"))
binDir := t.TempDir()
running := filepath.Join(binDir, "eeco")
_ = os.WriteFile(running, []byte("OLD"), 0o755)
var stdout, stderr bytes.Buffer
code := Apply(cfg, "v1.4.1", tag, &stdout, &stderr, Options{
BaseURL: srv.URL,
HTTPClient: srv.Client(),
Executable: func() (string, error) { return running, nil },
RunCmd: func(name string, args ...string) (string, error) {
if name == "gh" {
return "", &exec.Error{Name: "gh", Err: exec.ErrNotFound}
}
return "ok", nil
},
})
if code != 2 {
t.Fatalf("gh-missing -> %d, want 2\nstdout:\n%s\nstderr:\n%s", code, stdout.String(), stderr.String())
}
if !strings.Contains(stderr.String(), "gh is not on PATH") {
t.Errorf("missing gh hint: %s", stderr.String())
}
}
func TestApply_CosignFails(t *testing.T) {
cfg := newTestCfg(t)
tag := "v9.0.0"
srv, _ := fixtureRelease(t, tag, runtime.GOOS, runtime.GOARCH, []byte("PAYLOAD"))
binDir := t.TempDir()
running := filepath.Join(binDir, "eeco")
_ = os.WriteFile(running, []byte("OLD"), 0o755)
var stdout, stderr bytes.Buffer
code := Apply(cfg, "v1.4.1", tag, &stdout, &stderr, Options{
BaseURL: srv.URL,
HTTPClient: srv.Client(),
Executable: func() (string, error) { return running, nil },
RunCmd: func(name string, args ...string) (string, error) {
if name == "cosign" {
return "signature mismatch", errors.New("exit 1")
}
return "ok", nil
},
})
if code != 1 {
t.Fatalf("cosign-fail -> %d, want 1\nstdout:\n%s\nstderr:\n%s", code, stdout.String(), stderr.String())
}
if !strings.Contains(stderr.String(), "cosign verify-blob failed") {
t.Errorf("missing cosign failure hint: %s", stderr.String())
}
got, _ := os.ReadFile(running)
if string(got) != "OLD" {
t.Errorf("running binary unexpectedly swapped after cosign failure: %q", got)
}
}
func TestApply_PackageManagerRefusal(t *testing.T) {
cfg := newTestCfg(t)
tag := "v9.0.0"
// Use a path that looks brew-managed regardless of the host OS.
binDir := t.TempDir()
brewish := filepath.Join(binDir, "Cellar", "eeco", "1.4.1", "bin", "eeco")
if err := os.MkdirAll(filepath.Dir(brewish), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(brewish, []byte("OLD"), 0o755); err != nil {
t.Fatalf("write: %v", err)
}
// Force the detected path through pkgmgr by lying about the location.
var stdout, stderr bytes.Buffer
code := Apply(cfg, "v1.4.1", tag, &stdout, &stderr, Options{
BaseURL: "http://127.0.0.1:0",
Executable: func() (string, error) {
return "/opt/homebrew/bin/eeco", nil
},
})
if code != 2 {
t.Fatalf("brew-refusal -> %d, want 2\nstdout:\n%s", code, stdout.String())
}
if !strings.Contains(stdout.String(), "brew upgrade eeco") {
t.Errorf("missing brew hint:\n%s", stdout.String())
}
}
func TestApply_ChecksumMismatch(t *testing.T) {
cfg := newTestCfg(t)
tag := "v9.0.0"
// Serve a SHA256SUMS that records a wrong hash for the archive.
payload := []byte("REAL-PAYLOAD")
srv, assets := fixtureRelease(t, tag, runtime.GOOS, runtime.GOARCH, payload)
archiveName := archiveBasename(tag, runtime.GOOS, runtime.GOARCH)
assets["SHA256SUMS"] = []byte(strings.Repeat("0", 64) + " " + archiveName + "\n")
binDir := t.TempDir()
running := filepath.Join(binDir, "eeco")
_ = os.WriteFile(running, []byte("OLD"), 0o755)
var stdout, stderr bytes.Buffer
code := Apply(cfg, "v1.4.1", tag, &stdout, &stderr, Options{
BaseURL: srv.URL,
HTTPClient: srv.Client(),
Executable: func() (string, error) { return running, nil },
RunCmd: func(name string, args ...string) (string, error) { return "ok", nil },
})
if code != 1 {
t.Fatalf("checksum-mismatch -> %d, want 1\nstdout:\n%s\nstderr:\n%s", code, stdout.String(), stderr.String())
}
if !strings.Contains(stderr.String(), "archive sha256 mismatch") {
t.Errorf("missing mismatch hint:\n%s", stderr.String())
}
got, _ := os.ReadFile(running)
if string(got) != "OLD" {
t.Errorf("running binary unexpectedly swapped after checksum mismatch: %q", got)
}
}
func TestDetectPackageManager(t *testing.T) {
cases := []struct {
in string
kind string
}{
{"/opt/homebrew/bin/eeco", "Homebrew"},
{"/usr/local/Cellar/eeco/1.4.1/bin/eeco", "Homebrew"},
{"/home/linuxbrew/.linuxbrew/bin/eeco", "Homebrew"},
{"/home/user/.linuxbrew/bin/eeco", "Homebrew"},
{`C:\Users\foo\scoop\apps\eeco\current\eeco.exe`, "Scoop"},
{"/home/foo/scoop/apps/eeco/current/eeco", "Scoop"},
{"/usr/local/bin/eeco", ""},
{"/Users/foo/bin/eeco", ""},
}
for _, c := range cases {
got, _ := detectPackageManager(c.in)
if got != c.kind {
t.Errorf("detectPackageManager(%q) = %q, want %q", c.in, got, c.kind)
}
}
}
func TestArchiveBasename(t *testing.T) {
got := archiveBasename("v1.5.0", "darwin", "arm64")
want := "eeco_v1.5.0_darwin_arm64.tar.gz"
if got != want {
t.Errorf("darwin: got %q want %q", got, want)
}
got = archiveBasename("v1.5.0", "windows", "amd64")
want = "eeco_v1.5.0_windows_amd64.zip"
if got != want {
t.Errorf("windows: got %q want %q", got, want)
}
}
func TestChecksumFor(t *testing.T) {
dir := t.TempDir()
sums := filepath.Join(dir, "SHA256SUMS")
data := "" +
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa eeco_v1.5.0_darwin_amd64.tar.gz\n" +
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb eeco_v1.5.0_darwin_arm64.tar.gz\n"
if err := os.WriteFile(sums, []byte(data), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
got, err := checksumFor(sums, "eeco_v1.5.0_darwin_arm64.tar.gz")
if err != nil {
t.Fatalf("checksumFor: %v", err)
}
if got != strings.Repeat("b", 64) {
t.Errorf("got %q", got)
}
if _, err := checksumFor(sums, "missing.tar.gz"); err == nil {
t.Error("expected error for missing entry")
}
// A short (non-64-char) hash for a matching entry is an explicit error.
badHash := filepath.Join(dir, "SHA256SUMS.bad")
if err := os.WriteFile(badHash, []byte("0123456789 eeco_v1.5.0_darwin_arm64.tar.gz\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := checksumFor(badHash, "eeco_v1.5.0_darwin_arm64.tar.gz"); err == nil ||
!strings.Contains(err.Error(), "SHA256SUMS: bad hash") {
t.Errorf("bad-hash err = %v, want 'SHA256SUMS: bad hash'", err)
}
// A read error (the sums path is a directory) propagates.
if _, err := checksumFor(dir, "anything.tar.gz"); err == nil {
t.Error("expected error reading a directory as SHA256SUMS")
}
}
func TestSwap_AtomicRename(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "binary")
if err := os.WriteFile(target, []byte("OLD"), 0o755); err != nil {
t.Fatalf("write target: %v", err)
}
newPath := filepath.Join(dir, "binary.new")
if err := os.WriteFile(newPath, []byte("NEW"), 0o755); err != nil {
t.Fatalf("write new: %v", err)
}
if err := swap(newPath, target); err != nil {
t.Fatalf("swap: %v", err)
}
got, err := os.ReadFile(target)
if err != nil {
t.Fatalf("read target: %v", err)
}
if string(got) != "NEW" {
t.Errorf("target = %q, want NEW", got)
}
}
func TestExtract_TarGz(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("tar.gz extraction tested on unix matrix")
}
dir := t.TempDir()
archive := filepath.Join(dir, "eeco.tar.gz")
payload := []byte("BINARY")
if err := os.WriteFile(archive, buildTarGz(t, "linux_amd64/eeco", payload), 0o644); err != nil {
t.Fatalf("write archive: %v", err)
}
got, err := extract(archive, dir, "linux")
if err != nil {
t.Fatalf("extract: %v", err)
}
if filepath.Base(got) != "eeco" {
t.Errorf("extracted basename = %q", filepath.Base(got))
}
b, _ := os.ReadFile(got)
if !bytes.Equal(b, payload) {
t.Errorf("payload mismatch: %q", b)
}
}
func buildTarGz(t *testing.T, name string, payload []byte) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
hdr := &tar.Header{Name: name, Mode: 0o755, Size: int64(len(payload)), Typeflag: tar.TypeReg}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(payload); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func TestExtract_Zip(t *testing.T) {
dir := t.TempDir()
archive := filepath.Join(dir, "eeco.zip")
payload := []byte("BINARY")
if err := os.WriteFile(archive, buildZip(t, "windows_amd64/eeco.exe", payload), 0o644); err != nil {
t.Fatalf("write archive: %v", err)
}
got, err := extract(archive, dir, "windows")
if err != nil {
t.Fatalf("extract: %v", err)
}
if filepath.Base(got) != "eeco.exe" {
t.Errorf("extracted basename = %q", filepath.Base(got))
}
b, _ := os.ReadFile(got)
if !bytes.Equal(b, payload) {
t.Errorf("payload mismatch: %q", b)
}
}
func buildZip(t *testing.T, name string, payload []byte) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, err := zw.CreateHeader(&zip.FileHeader{Name: name, Method: zip.Deflate})
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(payload); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func TestLedger_RoundTrip(t *testing.T) {
cfg := newTestCfg(t)
want := Ledger{
Installed: true,
FromVersion: "v1.4.1",
ToVersion: "v1.5.0",
RunningPath: "/usr/local/bin/eeco",
Backup: "/tmp/eeco.bak",
SHA256: strings.Repeat("a", 64),
At: "2026-05-21T12:00:00Z",
}
if err := writeLedger(cfg, want); err != nil {
t.Fatalf("writeLedger: %v", err)
}
got, err := LoadLedger(cfg)
if err != nil {
t.Fatalf("LoadLedger: %v", err)
}
if fmt.Sprintf("%+v", got) != fmt.Sprintf("%+v", want) {
t.Errorf("round-trip mismatch:\n got %+v\n want %+v", got, want)
}
}
// newTestCfgStateFile is newTestCfg's sibling: it writes <ws>/state as a
// regular FILE rather than a directory, so any MkdirAll under state/
// (staging dir, ledger dir) fails with ENOTDIR — the root-immune
// file-where-a-dir-is-expected trick (no chmod, so root CI can't bypass).
func newTestCfgStateFile(t *testing.T) *config.Config {
t.Helper()
root := t.TempDir()
ws := filepath.Join(root, ".eeco")
if err := os.MkdirAll(ws, 0o755); err != nil {
t.Fatalf("mkdir workspace: %v", err)
}
if err := os.WriteFile(filepath.Join(ws, "state"), []byte("x"), 0o644); err != nil {
t.Fatalf("write state file: %v", err)
}
return &config.Config{
RepoRoot: root,
WorkspaceName: ".eeco",
Workspace: ws,
}
}
// fileInTheWay drops a regular file at path so a later MkdirAll/OpenFile
// that expects a directory there hits ENOTDIR (no chmod).
func fileInTheWay(t *testing.T, path string) {
t.Helper()
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatalf("fileInTheWay %s: %v", path, err)
}
}
// --- A. DI seam + pure functions ---
func TestResolveRunning_ReturnsAbsResolvedPath(t *testing.T) {
got, err := ResolveRunning()
if err != nil {
t.Fatalf("ResolveRunning: %v", err)
}
if !filepath.IsAbs(got) {
t.Errorf("ResolveRunning returned non-absolute path: %q", got)
}
}
func TestDefaultRunCmd_Runs(t *testing.T) {
name, args := "true", []string(nil)
if runtime.GOOS == "windows" {
name, args = "cmd", []string{"/c", "exit", "0"}
}
if out, err := defaultRunCmd(name, args...); err != nil {
t.Fatalf("defaultRunCmd(%q) err = %v (out=%q)", name, err, out)
}
}
func TestBackupName_ByGOOS(t *testing.T) {
cases := []struct{ goos, want string }{
{"windows", "eeco.exe.bak"},
{"linux", "eeco.bak"},
{"darwin", "eeco.bak"},
}
for _, c := range cases {
if got := backupName(c.goos); got != c.want {
t.Errorf("backupName(%q) = %q, want %q", c.goos, got, c.want)
}
}
}
func TestWithDefaults_FillsNilFields(t *testing.T) {
o := withDefaults(Options{})
if o.BaseURL != DefaultBaseURL {
t.Errorf("BaseURL = %q, want %q", o.BaseURL, DefaultBaseURL)
}
if o.HTTPClient == nil {
t.Error("HTTPClient not filled")
}
if o.Executable == nil {
t.Error("Executable not filled")
}
if o.RunCmd == nil {
t.Error("RunCmd not filled")
}
if o.Now == nil {
t.Error("Now not filled")
}
if o.GOOS != runtime.GOOS {
t.Errorf("GOOS = %q, want %q", o.GOOS, runtime.GOOS)
}
if o.GOARCH != runtime.GOARCH {
t.Errorf("GOARCH = %q, want %q", o.GOARCH, runtime.GOARCH)
}
}
// --- B. extract.go ---
func buildTarGzNoBinary(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
if err := tw.WriteHeader(&tar.Header{Name: "linux_amd64/", Mode: 0o755, Typeflag: tar.TypeDir}); err != nil {
t.Fatal(err)
}
readme := []byte("readme")
if err := tw.WriteHeader(&tar.Header{Name: "linux_amd64/README.md", Mode: 0o644, Size: int64(len(readme)), Typeflag: tar.TypeReg}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(readme); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func buildZipNoBinary(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, err := zw.CreateHeader(&zip.FileHeader{Name: "windows_amd64/README.md", Method: zip.Deflate})
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte("readme")); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func TestExtract_Errors(t *testing.T) {
dir := t.TempDir()
t.Run("unknown format", func(t *testing.T) {
p := filepath.Join(dir, "eeco.rar")
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := extract(p, dir, "linux"); err == nil || !strings.Contains(err.Error(), "unknown archive format") {
t.Fatalf("err = %v, want 'unknown archive format'", err)
}
})
t.Run("missing tar.gz", func(t *testing.T) {
if _, err := extract(filepath.Join(dir, "nope.tar.gz"), dir, "linux"); err == nil {
t.Fatal("expected error for missing tar.gz")
}
})
t.Run("missing zip", func(t *testing.T) {
if _, err := extract(filepath.Join(dir, "nope.zip"), dir, "windows"); err == nil {
t.Fatal("expected error for missing zip")
}
})
t.Run("bad gzip", func(t *testing.T) {
p := filepath.Join(dir, "bad.tar.gz")
if err := os.WriteFile(p, []byte("not gzip data"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := extract(p, dir, "linux"); err == nil {
t.Fatal("expected error for bad gzip")
}
})
t.Run("truncated tar", func(t *testing.T) {
// Valid gzip stream wrapping bytes that are not a valid tar header,
// so tr.Next() returns a non-EOF error.
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
if _, err := gz.Write([]byte(strings.Repeat("x", 50))); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
p := filepath.Join(dir, "trunc.tar.gz")
if err := os.WriteFile(p, buf.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
if _, err := extract(p, dir, "linux"); err == nil {
t.Fatal("expected error for truncated tar")
}
})
t.Run("binary not found tar.gz", func(t *testing.T) {
p := filepath.Join(dir, "nobin.tar.gz")
if err := os.WriteFile(p, buildTarGzNoBinary(t), 0o644); err != nil {
t.Fatal(err)
}
if _, err := extract(p, dir, "linux"); err == nil || !strings.Contains(err.Error(), "binary eeco not found in archive") {
t.Fatalf("err = %v, want 'binary eeco not found in archive'", err)
}
})
t.Run("binary not found zip", func(t *testing.T) {
p := filepath.Join(dir, "nobin.zip")
if err := os.WriteFile(p, buildZipNoBinary(t), 0o644); err != nil {
t.Fatal(err)
}
if _, err := extract(p, dir, "windows"); err == nil || !strings.Contains(err.Error(), "binary eeco.exe not found in archive") {
t.Fatalf("err = %v, want 'binary eeco.exe not found in archive'", err)
}
})
}
func TestModeFromHeader(t *testing.T) {
if m := modeFromHeader(0); m&0o100 == 0 {
t.Errorf("modeFromHeader(0) = %v, want exec bit set", m)
}
if m := modeFromHeader(0o644); m&0o100 == 0 {
t.Errorf("modeFromHeader(0o644) = %v, want exec bit set", m)
}
}
func TestExtract_ZeroModeSetsExecBit(t *testing.T) {
dir := t.TempDir()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
payload := []byte("BIN")
if err := tw.WriteHeader(&tar.Header{Name: "linux_amd64/eeco", Mode: 0, Size: int64(len(payload)), Typeflag: tar.TypeReg}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(payload); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
archive := filepath.Join(dir, "zero.tar.gz")
if err := os.WriteFile(archive, buf.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
got, err := extract(archive, dir, "linux")
if err != nil {
t.Fatalf("extract: %v", err)
}
if runtime.GOOS != "windows" {
info, serr := os.Stat(got)
if serr != nil {
t.Fatal(serr)
}
if info.Mode()&0o100 == 0 {
t.Errorf("exec bit not set on zero-mode entry: %v", info.Mode())
}
}
}
func TestWriteBinary_Errors(t *testing.T) {
t.Run("mkdir parent is a file", func(t *testing.T) {
dir := t.TempDir()
f := filepath.Join(dir, "afile")
fileInTheWay(t, f)
if err := writeBinary(filepath.Join(f, "sub", "eeco"), strings.NewReader("x"), 0o755); err == nil {
t.Fatal("expected error when parent is a file")
}
})
t.Run("dst is a directory", func(t *testing.T) {
dst := filepath.Join(t.TempDir(), "isdir")
if err := os.MkdirAll(dst, 0o755); err != nil {
t.Fatal(err)
}
if err := writeBinary(dst, strings.NewReader("x"), 0o755); err == nil {
t.Fatal("expected error when dst is a directory")
}
})
}
func TestCopyFile_Errors(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src")
if err := os.WriteFile(src, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
t.Run("src missing", func(t *testing.T) {
if err := copyFile(filepath.Join(dir, "nope"), filepath.Join(dir, "out")); err == nil {
t.Fatal("expected error for missing source")
}
})
t.Run("dst parent is a file", func(t *testing.T) {
f := filepath.Join(dir, "afile")
fileInTheWay(t, f)
if err := copyFile(src, filepath.Join(f, "sub", "out")); err == nil {
t.Fatal("expected error when dst parent is a file")
}
})
t.Run("dst is a directory", func(t *testing.T) {
dst := filepath.Join(dir, "outdir")
if err := os.MkdirAll(dst, 0o755); err != nil {
t.Fatal(err)
}
if err := copyFile(src, dst); err == nil {
t.Fatal("expected error when dst is a directory")
}
})
}
// --- C. download.go ---
func TestDownload_NonOK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
defer srv.Close()
err := download(srv.Client(), srv.URL+"/x", filepath.Join(t.TempDir(), "out"))
if err == nil || !strings.Contains(err.Error(), "http 404") {
t.Fatalf("download err = %v, want 'http 404'", err)
}
}
func TestDownload_CreateTempFails(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("BODY"))
}))
defer srv.Close()
f := filepath.Join(t.TempDir(), "afile")
fileInTheWay(t, f)
// dst's parent is a regular file → CreateTemp(parentDir(dst)) ENOTDIRs.
if err := download(srv.Client(), srv.URL+"/x", filepath.Join(f, "out")); err == nil {
t.Fatal("expected error when dst parent is a file")
}
}
func TestParentDir(t *testing.T) {
cases := []struct{ in, want string }{
{"file", "."},
{"a/b", "a"},
{`a\b`, `a`},
{"a/b/c", "a/b"},
}
for _, c := range cases {
if got := parentDir(c.in); got != c.want {
t.Errorf("parentDir(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestSha256File_OpenErr(t *testing.T) {
if _, err := sha256File(filepath.Join(t.TempDir(), "nope")); err == nil {
t.Fatal("expected error for missing file")
}
}
// --- D. ledger.go ---
func TestLoadLedger_EdgeCases(t *testing.T) {
t.Run("binary.json is a directory", func(t *testing.T) {
cfg := newTestCfg(t)
if err := os.MkdirAll(filepath.Join(cfg.Workspace, "state", ledgerName), 0o755); err != nil {
t.Fatal(err)
}
// LoadLedger returns the raw (non-NotExist) read error here.
if _, err := LoadLedger(cfg); err == nil {
t.Fatal("expected error when binary.json is a directory")
}
})
t.Run("empty file", func(t *testing.T) {
cfg := newTestCfg(t)
if err := os.WriteFile(filepath.Join(cfg.Workspace, "state", ledgerName), nil, 0o644); err != nil {
t.Fatal(err)
}
led, err := LoadLedger(cfg)
if err != nil {
t.Fatalf("LoadLedger empty: %v", err)
}
if led != (Ledger{}) {
t.Errorf("empty file → %+v, want zero Ledger", led)
}
})
t.Run("malformed JSON", func(t *testing.T) {
cfg := newTestCfg(t)
if err := os.WriteFile(filepath.Join(cfg.Workspace, "state", ledgerName), []byte("{not json"), 0o644); err != nil {
t.Fatal(err)
}
led, err := LoadLedger(cfg)
if err != nil {
t.Fatalf("LoadLedger malformed: %v", err)
}
if led != (Ledger{}) {
t.Errorf("malformed → %+v, want zero Ledger", led)
}
})
}
func TestWriteLedger_MkdirFails(t *testing.T) {
cfg := newTestCfgStateFile(t)
if err := writeLedger(cfg, Ledger{Installed: true}); err == nil ||
!strings.Contains(err.Error(), "binary ledger dir:") {
t.Fatalf("writeLedger err = %v, want 'binary ledger dir:'", err)
}
}
// --- F. Apply error paths (via Options DI + fixtures) ---
func TestApply_ExecutableErr(t *testing.T) {
cfg := newTestCfg(t)
var stdout, stderr bytes.Buffer
code := Apply(cfg, "v1.4.1", "v9.0.0", &stdout, &stderr, Options{
BaseURL: "http://127.0.0.1:0",
Executable: func() (string, error) { return "", errors.New("boom") },
})
if code != 1 {
t.Fatalf("code = %d, want 1\nstderr: %s", code, stderr.String())
}
if !strings.Contains(stderr.String(), "cannot resolve running binary") {
t.Errorf("stderr = %q", stderr.String())
}
}
func TestApply_StagingMkdirFails(t *testing.T) {
cfg := newTestCfgStateFile(t)
running := filepath.Join(t.TempDir(), "eeco")
if err := os.WriteFile(running, []byte("OLD"), 0o755); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
code := Apply(cfg, "v1.4.1", "v9.0.0", &stdout, &stderr, Options{
BaseURL: "http://127.0.0.1:0",
Executable: func() (string, error) { return running, nil },
})
if code != 1 {
t.Fatalf("code = %d, want 1\nstderr: %s", code, stderr.String())
}
if !strings.Contains(stderr.String(), "prepare staging dir:") {
t.Errorf("stderr = %q", stderr.String())
}
}
func TestApply_DownloadFails(t *testing.T) {
cfg := newTestCfg(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
defer srv.Close()
running := filepath.Join(t.TempDir(), "eeco")
if err := os.WriteFile(running, []byte("OLD"), 0o755); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
code := Apply(cfg, "v1.4.1", "v9.0.0", &stdout, &stderr, Options{
BaseURL: srv.URL,
HTTPClient: srv.Client(),
Executable: func() (string, error) { return running, nil },
})
if code != 1 {
t.Fatalf("code = %d, want 1\nstderr: %s", code, stderr.String())
}
if !strings.Contains(stderr.String(), "download ") {
t.Errorf("stderr = %q", stderr.String())
}
}
func TestApply_ChecksumReadFails(t *testing.T) {
cfg := newTestCfg(t)
tag := "v9.0.0"
srv, assets := fixtureRelease(t, tag, runtime.GOOS, runtime.GOARCH, []byte("PAYLOAD"))
assets["SHA256SUMS"] = []byte("") // served empty → no entry for the archive
running := filepath.Join(t.TempDir(), "eeco")
if err := os.WriteFile(running, []byte("OLD"), 0o755); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
code := Apply(cfg, "v1.4.1", tag, &stdout, &stderr, Options{
BaseURL: srv.URL,
HTTPClient: srv.Client(),
Executable: func() (string, error) { return running, nil },
RunCmd: func(name string, args ...string) (string, error) { return "ok", nil },
})
if code != 1 {
t.Fatalf("code = %d, want 1\nstderr: %s", code, stderr.String())
}
if !strings.Contains(stderr.String(), "read SHA256SUMS:") {
t.Errorf("stderr = %q", stderr.String())
}
}
func TestApply_GhVerifyFails(t *testing.T) {
cfg := newTestCfg(t)
tag := "v9.0.0"
srv, _ := fixtureRelease(t, tag, runtime.GOOS, runtime.GOARCH, []byte("PAYLOAD"))
running := filepath.Join(t.TempDir(), "eeco")
if err := os.WriteFile(running, []byte("OLD"), 0o755); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
code := Apply(cfg, "v1.4.1", tag, &stdout, &stderr, Options{
BaseURL: srv.URL,
HTTPClient: srv.Client(),
Executable: func() (string, error) { return running, nil },
RunCmd: func(name string, args ...string) (string, error) {
if name == "gh" {
return "denied", errors.New("exit 1")
}
return "ok", nil
},
})
if code != 1 {
t.Fatalf("code = %d, want 1\nstderr: %s", code, stderr.String())
}
if !strings.Contains(stderr.String(), "gh attestation verify failed") {
t.Errorf("stderr = %q", stderr.String())
}
}
func TestApply_StagedMkdirFails(t *testing.T) {
cfg := newTestCfg(t)
tag := "v9.0.0"
srv, _ := fixtureRelease(t, tag, runtime.GOOS, runtime.GOARCH, []byte("PAYLOAD"))
// stagingDir created up-front with a regular file where `staged` (a
// directory) is expected, so its MkdirAll ENOTDIRs after verification.
stagingDir := filepath.Join(cfg.Workspace, "state", "update-"+tag)
if err := os.MkdirAll(stagingDir, 0o755); err != nil {
t.Fatal(err)
}
fileInTheWay(t, filepath.Join(stagingDir, "staged"))
running := filepath.Join(t.TempDir(), "eeco")
if err := os.WriteFile(running, []byte("OLD"), 0o755); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
code := Apply(cfg, "v1.4.1", tag, &stdout, &stderr, Options{
BaseURL: srv.URL,
HTTPClient: srv.Client(),
Executable: func() (string, error) { return running, nil },
RunCmd: func(name string, args ...string) (string, error) { return "ok", nil },
})
if code != 1 {
t.Fatalf("code = %d, want 1\nstderr: %s", code, stderr.String())
}
if !strings.Contains(stderr.String(), "prepare staged dir:") {
t.Errorf("stderr = %q", stderr.String())
}
}
func TestApply_ExtractFails(t *testing.T) {
cfg := newTestCfg(t)
tag := "v9.0.0"
srv, assets := fixtureRelease(t, tag, runtime.GOOS, runtime.GOARCH, []byte("PAYLOAD"))
archiveName := archiveBasename(tag, runtime.GOOS, runtime.GOARCH)
// Serve a corrupt archive and record its true sha so the checksum
// check passes (cosign+gh forced ok), leaving extract to fail.
corrupt := []byte("this is not a valid archive")
assets[archiveName] = corrupt
sum := sha256.Sum256(corrupt)
assets["SHA256SUMS"] = []byte(hex.EncodeToString(sum[:]) + " " + archiveName + "\n")
running := filepath.Join(t.TempDir(), "eeco")
if err := os.WriteFile(running, []byte("OLD"), 0o755); err != nil {
t.Fatal(err)
}
var stdout, stderr bytes.Buffer
code := Apply(cfg, "v1.4.1", tag, &stdout, &stderr, Options{
BaseURL: srv.URL,
HTTPClient: srv.Client(),
Executable: func() (string, error) { return running, nil },
RunCmd: func(name string, args ...string) (string, error) { return "ok", nil },
})
if code != 1 {
t.Fatalf("code = %d, want 1\nstderr: %s", code, stderr.String())
}
if !strings.Contains(stderr.String(), "extract:") {
t.Errorf("stderr = %q", stderr.String())
}
}
// --- G. one-liners ---
func TestTrimOutput_Truncates(t *testing.T) {
got := trimOutput(strings.Repeat("x", 300))
if len(got) != 243 {
t.Errorf("len = %d, want 243", len(got))
}
if !strings.HasSuffix(got, "...") {
t.Errorf("got %q, want '...' suffix", got)
}
if trimOutput("short") != "short" {
t.Error("short input must be returned unchanged")
}
}
added internal/selfupdate/swap_unix.go
@@ -0,0 +1,37 @@
//go:build !windows
package selfupdate
import (
"os"
"path/filepath"
)
// swap atomically replaces target with newPath. The temp file is written
// in the same directory as target so the rename is a same-filesystem
// move (POSIX guarantees atomicity for those).
func swap(newPath, target string) error {
info, err := os.Stat(target)
if err != nil {
return err
}
dir := filepath.Dir(target)
tmp, err := os.CreateTemp(dir, ".eeco-new-*")
if err != nil {
return err
}
tmpName := tmp.Name()
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return err
}
if err := copyFile(newPath, tmpName); err != nil {
os.Remove(tmpName)
return err
}
if err := os.Chmod(tmpName, info.Mode()); err != nil {
os.Remove(tmpName)
return err
}
return os.Rename(tmpName, target)
}
added internal/selfupdate/swap_unix_test.go
@@ -0,0 +1,41 @@
//go:build !windows
package selfupdate
import (
"os"
"path/filepath"
"testing"
)
func TestSwap_StatErr(t *testing.T) {
dir := t.TempDir()
newPath := filepath.Join(dir, "new")
if err := os.WriteFile(newPath, []byte("NEW"), 0o755); err != nil {
t.Fatal(err)
}
// swap stats the target first; a nonexistent target fails up-front.
if err := swap(newPath, filepath.Join(dir, "does-not-exist", "target")); err == nil {
t.Fatal("expected error when target stat fails")
}
}
func TestSwap_CopyFileFails(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target")
if err := os.WriteFile(target, []byte("ORIG"), 0o755); err != nil {
t.Fatal(err)
}
// A nonexistent newPath makes copyFile's open fail after the temp file
// is created → swap returns the error and the target is left untouched.
if err := swap(filepath.Join(dir, "missing-new"), target); err == nil {
t.Fatal("expected error when copyFile source is missing")
}
got, err := os.ReadFile(target)
if err != nil {
t.Fatalf("read target: %v", err)
}
if string(got) != "ORIG" {
t.Errorf("target changed after a failed swap: %q, want ORIG", got)
}
}
added internal/selfupdate/swap_windows.go
@@ -0,0 +1,51 @@
//go:build windows
package selfupdate
import (
"os"
"path/filepath"
)
// swap replaces target with newPath. Windows cannot rename a file onto
// a running executable, but it can rename the running executable out of
// the way first: move target -> target.old, then move new -> target.
// target.old is removed best-effort; if Windows holds it open, the file
// remains and the caller should print a one-line hint.
func swap(newPath, target string) error {
info, err := os.Stat(target)
if err != nil {
return err
}
dir := filepath.Dir(target)
tmp, err := os.CreateTemp(dir, ".eeco-new-*")
if err != nil {
return err
}
tmpName := tmp.Name()
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return err
}
if err := copyFile(newPath, tmpName); err != nil {
os.Remove(tmpName)
return err
}
if err := os.Chmod(tmpName, info.Mode()); err != nil {
os.Remove(tmpName)
return err
}
old := target + ".old"
_ = os.Remove(old)
if err := os.Rename(target, old); err != nil {
os.Remove(tmpName)
return err
}
if err := os.Rename(tmpName, target); err != nil {
_ = os.Rename(old, target)
os.Remove(tmpName)
return err
}
_ = os.Remove(old)
return nil
}
added internal/selfupdate/verify.go
@@ -0,0 +1,38 @@
package selfupdate
import "fmt"
func verifyCosign(run func(name string, args ...string) (string, error), sumsPath, sigPath, certPath string) error {
args := []string{
"verify-blob",
"--signature", sigPath,
"--certificate", certPath,
"--certificate-identity-regexp", CosignIdentityRegexp,
"--certificate-oidc-issuer", CosignOIDCIssuer,
sumsPath,
}
out, err := run("cosign", args...)
if err != nil {
return fmt.Errorf("%w (%s)", err, trimOutput(out))
}
return nil
}
func verifyAttestation(run func(name string, args ...string) (string, error), archivePath string) error {
args := []string{
"attestation", "verify", archivePath,
"--repo", ProvenanceRepo,
}
out, err := run("gh", args...)
if err != nil {
return fmt.Errorf("%w (%s)", err, trimOutput(out))
}
return nil
}
func trimOutput(s string) string {
if len(s) > 240 {
return s[:240] + "..."
}
return s
}
added internal/tui/commands.go
@@ -0,0 +1,559 @@
package tui
import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/ajhahnde/eeco/internal/ai"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/hooks"
"github.com/ajhahnde/eeco/internal/memory"
"github.com/ajhahnde/eeco/internal/workflow"
)
// cmdEntry pairs a slash command with the one-line purpose surfaced by the
// `/` palette and the ? overlay. Single source of truth so the dispatcher,
// Tab completion, the palette, and opHelp never drift.
type cmdEntry struct {
name string // with leading slash
purpose string
}
// commandIndex is the canonical, sorted command set. The `/` palette
// renders this list directly; slashCommands and the ? overlay (via
// opHelp) derive from it so a new command lands in one place.
var commandIndex = []cmdEntry{
{"/gc", "run memory garbage collection"},
{"/help", "command and key reference"},
{"/hooks", "show or toggle reversible hooks"},
{"/memory", "list stored facts"},
{"/new", "scaffold a new workflow"},
{"/queue", "show items awaiting a decision"},
{"/quit", "leave the control center"},
{"/run", "run a workflow (--ai for one gated pass)"},
{"/settings", "view or set the AI config (config.local)"},
}
// slashCommands is the name-only projection of commandIndex used by the
// dispatcher and Tab completion. Build order matches commandIndex.
var slashCommands = func() []string {
out := make([]string, len(commandIndex))
for i, e := range commandIndex {
out[i] = e.name
}
return out
}()
// parsedCmd is one line of input resolved to either a slash command or a
// free-text request. Exactly one of name / free is set.
type parsedCmd struct {
name string // command without the leading slash; empty for free text
args []string // positional arguments (flags removed)
ai bool // --ai was present (meaningful for `run`)
free string // the raw line when it is a free-text request
}
// parseInput classifies a submitted line. A leading slash makes it a
// command; anything else is a free-text request routed through the
// gated AI provider. Empty input yields a no-op (name and free empty).
func parseInput(s string) parsedCmd {
t := strings.TrimSpace(s)
if t == "" {
return parsedCmd{}
}
if !strings.HasPrefix(t, "/") {
return parsedCmd{free: t}
}
fields := strings.Fields(t)
p := parsedCmd{name: strings.TrimPrefix(fields[0], "/")}
for _, a := range fields[1:] {
if a == "--ai" {
p.ai = true
continue
}
p.args = append(p.args, a)
}
return p
}
// runNames lists the names a `/run` argument may complete to: the
// builtins plus any workflow scaffolded into the workspace.
func runNames(cfg *config.Config) []string {
set := map[string]struct{}{}
for _, n := range workflow.DefaultRegistry().Names() {
set[n] = struct{}{}
}
if cfg != nil {
if ents, err := os.ReadDir(filepath.Join(cfg.Workspace, "workflows")); err == nil {
for _, e := range ents {
if e.IsDir() {
set[e.Name()] = struct{}{}
}
}
}
}
out := make([]string, 0, len(set))
for n := range set {
out = append(out, n)
}
sort.Strings(out)
return out
}
// complete performs Tab completion on the current input. It returns the
// possibly-extended input and, when the choice is ambiguous, the
// candidate list to echo. Only slash input completes: free text does
// not. The command token completes against slashCommands; a `/run`
// argument completes against runNames.
func complete(input string, names []string) (string, []string) {
if !strings.HasPrefix(input, "/") {
return input, nil
}
if !strings.Contains(input, " ") {
return completeToken(input, slashCommands, "")
}
cmd := strings.Fields(input)[0]
if cmd != "/run" {
return input, nil
}
// Completing the workflow argument. Preserve everything up to the
// last token and complete that token against the workflow names.
cut := strings.LastIndex(input, " ")
prefix := input[:cut+1]
tok := input[cut+1:]
return completeToken(tok, names, prefix)
}
// completeToken extends tok against candidates. A single match completes
// fully (with a trailing space); several matches extend to the longest
// common prefix and return the candidates for display.
func completeToken(tok string, candidates []string, prefix string) (string, []string) {
var matches []string
for _, c := range candidates {
if strings.HasPrefix(c, tok) {
matches = append(matches, c)
}
}
switch len(matches) {
case 0:
return prefix + tok, nil
case 1:
return prefix + matches[0] + " ", nil
default:
return prefix + longestCommonPrefix(matches), matches
}
}
func longestCommonPrefix(xs []string) string {
if len(xs) == 0 {
return ""
}
p := xs[0]
for _, s := range xs[1:] {
for !strings.HasPrefix(s, p) {
p = p[:len(p)-1]
if p == "" {
return ""
}
}
}
return p
}
// dispatch resolves a non-AI command to its output lines and control
// flags. AI-bearing work (free text, an opted-in `/run`) is handled by
// the model as an interruptible background command; dispatch covers the
// synchronous, AI-free commands and reports an unknown one.
type dispatchResult struct {
lines []string
quit bool
// async, when set, names a long operation the caller must run off
// the UI goroutine (so Esc can interrupt it). args carry its input.
async string
asyncAI bool
asyncS string
}
func dispatch(cfg *config.Config, st styles, width int, p parsedCmd) dispatchResult {
switch {
case p.name == "" && p.free == "":
return dispatchResult{}
case p.free != "":
// Free-text chat is retired (C5): eeco configures the harness that
// runs AI, it no longer runs a chat turn itself. Echo a synchronous
// hint — no gate, no goroutine, no spend.
return dispatchResult{lines: []string{st.dim.Render(
"free-text chat is retired — type / for commands (/run, /memory, …) or ? for help")}}
}
switch p.name {
case "quit", "q":
return dispatchResult{quit: true}
case "help":
return dispatchResult{lines: opHelp(st, width)}
case "hooks":
return dispatchResult{lines: opHooks(cfg, st, width, p.args)}
case "settings":
return dispatchResult{lines: opSettings(cfg, st, width, p.args)}
case "queue":
return dispatchResult{lines: opQueue(cfg, st, width)}
case "memory":
return dispatchResult{lines: opMemory(cfg, st, width)}
case "gc":
return dispatchResult{async: "gc"}
case "new":
if len(p.args) != 1 {
return dispatchResult{lines: renderError(st, "new", "usage: /new <workflow>")}
}
return dispatchResult{lines: opNew(cfg, st, width, p.args[0])}
case "run":
if len(p.args) != 1 {
return dispatchResult{lines: renderSection(width, st, section{
title: "run",
subtitle: "usage",
body: []string{
" /run [--ai] <workflow>",
" builtins: " + strings.Join(workflow.DefaultRegistry().Names(), ", "),
},
})}
}
return dispatchResult{async: "run", asyncS: p.args[0], asyncAI: p.ai}
default:
return dispatchResult{lines: renderError(st, "tui",
fmt.Sprintf("unknown command %q — type /help or ? for the reference", "/"+p.name))}
}
}
// opRun executes one workflow through the existing engine and formats
// the report exactly as `eeco run` does. Returns a one-line summary
// (the value the status bar's `run:` field surfaces), the full styled
// section to print, and the workflow exit code. It introduces no write
// path of its own and honours the same exit-code contract.
func opRun(cfg *config.Config, st styles, width int, name string, aiFlag bool) (summary string, lines []string, code int) {
det, derr := workflow.NewDetector(cfg.AttributionPatterns)
if derr != nil {
summary = "run " + name + ": " + derr.Error()
return summary, renderError(st, "run "+name, derr.Error()), workflow.CodeFinding
}
gate := ai.NewGate(cfg, aiFlag, det.ScanResponse)
env := workflow.Env{Config: cfg, AI: gate.Consent, Gate: gate}
reg := workflow.DefaultRegistry()
var (
res workflow.Result
err error
)
if w, ok := reg.Get(name); ok {
res, err = workflow.Run(w, env)
} else {
res, err = workflow.ScriptRun(name, env)
}
if err != nil {
summary = "run " + name + ": " + err.Error()
return summary, renderError(st, "run "+name, err.Error()), workflow.CodeFinding
}
summary = fmt.Sprintf("run %s: %s (exit %d)", name, res.Summary, res.Code)
body := make([]string, 0, len(res.Findings))
for _, f := range res.Findings {
if f.Line > 0 {
body = append(body, fmt.Sprintf(" %s:%d: %s", f.Path, f.Line, f.Msg))
} else {
body = append(body, fmt.Sprintf(" %s: %s", f.Path, f.Msg))
}
}
if len(body) == 0 {
body = []string{" " + st.dim.Render("no findings")}
}
return summary, renderSection(width, st, section{
title: "run " + name,
subtitle: fmt.Sprintf("%s (exit %d)", res.Summary, res.Code),
body: body,
}), res.Code
}
// opQueue shows the unresolved queue. It only reads the queue file.
func opQueue(cfg *config.Config, st styles, width int) []string {
n := queueCount(cfg)
b, err := os.ReadFile(filepath.Join(cfg.Workspace, "state", "queue.md"))
if err != nil || len(strings.TrimSpace(string(b))) == 0 {
return renderSection(width, st, section{
title: "queue",
subtitle: "empty",
body: []string{" nothing needs a decision"},
})
}
body := make([]string, 0)
for _, ln := range strings.Split(strings.TrimRight(string(b), "\n"), "\n") {
body = append(body, " "+ln)
}
return renderSection(width, st, section{
title: "queue",
subtitle: fmt.Sprintf("%d open", n),
body: body,
})
}
// opMemory lists the stored facts. It reads the store; it changes
// nothing on disk.
func opMemory(cfg *config.Config, st styles, width int) []string {
store, err := memory.Open(cfg)
if err != nil {
return renderError(st, "memory", err.Error())
}
facts, err := store.LoadAll()
if err != nil {
return renderError(st, "memory", err.Error())
}
if len(facts) == 0 {
return renderSection(width, st, section{
title: "memory",
subtitle: "empty",
body: []string{" no facts stored"},
})
}
rows := make([]sectionRow, 0, len(facts))
for _, f := range facts {
typeCol := string(f.Type)
if f.Pin {
typeCol += " [pinned]"
}
if f.Disabled {
typeCol += " [off]"
}
rows = append(rows, sectionRow{key: f.Name, value: f.Description, note: typeCol})
}
body := tableBody(st, [3]string{"fact", "description", "type"}, rows)
return renderSection(width, st, section{
title: "memory",
subtitle: fmt.Sprintf("%d fact(s)", len(facts)),
body: body,
})
}
// opGC runs memory garbage collection — the same engine operation as
// `eeco gc`, writing only inside the workspace. It requires an
// initialised workspace, mirroring the CLI guard.
func opGC(cfg *config.Config, st styles, width int) []string {
if !config.IsInitialized(cfg) {
return renderError(st, "gc", "workspace not initialised — run `eeco init` first")
}
store, err := memory.Open(cfg)
if err != nil {
return renderError(st, "gc", err.Error())
}
res, err := store.GC()
if err != nil {
return renderError(st, "gc", err.Error())
}
body := make([]string, 0, len(res.Actions))
for _, a := range res.Actions {
if a.Action == "kept" {
continue
}
body = append(body, fmt.Sprintf(" %-9s %s (%s) — %s", a.Action, a.Name, a.Type, a.Reason))
}
if len(body) == 0 {
body = []string{" " + st.dim.Render("no changes")}
}
return renderSection(width, st, section{
title: "gc",
subtitle: fmt.Sprintf("archived %d · queued %d · kept %d", res.Archived, res.Queued, res.Kept),
body: body,
})
}
// opNew scaffolds a workflow into the workspace — the same engine
// operation as `eeco new`. It requires an initialised workspace.
func opNew(cfg *config.Config, st styles, width int, name string) []string {
if !config.IsInitialized(cfg) {
return renderError(st, "new", "workspace not initialised — run `eeco init` first")
}
dir, err := workflow.Scaffold(cfg, name)
if err != nil {
return renderError(st, "new", err.Error())
}
return renderSection(width, st, section{
title: "new",
subtitle: fmt.Sprintf("scaffolded %q", name),
body: []string{" " + dir},
footer: []string{" next: edit run to implement the check, then /run " + name},
})
}
// opHooks shows or toggles the opt-in, reversible hooks — the same
// engine operation as `eeco hooks`. With no argument it reports state;
// with `<name> on|off` it toggles. It introduces no new write path: the
// only touches are the sanctioned reversible ones the user asked for.
func opHooks(cfg *config.Config, st styles, width int, args []string) []string {
hooksUsage := []string{
" /hooks [status]",
" /hooks <name> <on|off>",
" names: " + hooks.PreCommit + ", " + hooks.SessionStart + ", machinery",
}
if len(args) == 0 || (len(args) == 1 && args[0] == "status") {
raw := hooks.Status(cfg)
raw = append(raw, hooks.CockpitMachineryStatus(cfg)...)
body := make([]string, len(raw))
for i, ln := range raw {
body[i] = " " + ln
}
return renderSection(width, st, section{
title: "hooks",
body: body,
})
}
if len(args) != 2 {
return renderSection(width, st, section{
title: "hooks",
subtitle: "usage",
body: hooksUsage,
})
}
name, action := args[0], args[1]
var (
msg string
err error
)
switch {
case name == hooks.PreCommit && action == "on":
msg, err = hooks.EnablePreCommit(cfg)
case name == hooks.PreCommit && action == "off":
msg, err = hooks.DisablePreCommit(cfg)
case name == hooks.SessionStart && action == "on":
msg, err = hooks.EnableSessionStart(cfg)
case name == hooks.SessionStart && action == "off":
msg, err = hooks.DisableSessionStart(cfg)
case name == "machinery" && action == "on":
msg, err = hooks.EnableCockpitMachinery(cfg)
case name == "machinery" && action == "off":
msg, err = hooks.DisableCockpitMachinery(cfg)
default:
return renderSection(width, st, section{
title: "hooks",
subtitle: "usage",
body: hooksUsage,
})
}
if err != nil {
return renderError(st, "hooks", err.Error())
}
return []string{st.ok.Render("hooks:") + " " + msg}
}
// opSettings views and edits the AI configuration knobs, persisting
// changes to <workspace>/config.local (inside the gitignored workspace
// — write-scope safe; no brand baked in). It changes nothing in the
// tracked tree and adds no new write path. A change applies the next
// time eeco starts: the long-lived session deliberately keeps the gate
// and budget cap it began with (a mid-session silent re-spend is
// impossible).
func opSettings(cfg *config.Config, st styles, width int, args []string) []string {
if !config.IsInitialized(cfg) {
return renderError(st, "settings", "workspace not initialised — run `eeco init` first")
}
if len(args) == 0 {
provider := "not configured (every AI pass is parked)"
if ai.Select(cfg).Name() != "none" {
provider = "configured"
}
cmd := "(unset)"
if len(cfg.AICommand) > 0 {
cmd = strings.Join(cfg.AICommand, " ")
}
body := tableBody(st, [3]string{"key", "value", "note"}, []sectionRow{
{key: "automation", value: string(cfg.Automation), note: "only `auto` is standing AI consent"},
{key: "ai_budget", value: strconv.Itoa(cfg.AIBudget), note: "gated passes per invocation; 0 disables AI"},
{key: "ai_command", value: cmd, note: "argv of the provider CLI"},
{key: "provider", value: provider, note: "configure via `/settings ai_command`"},
})
footer := append(
[]string{" " + st.tableHeader.Render("set a value")},
tableBody(st, [3]string{}, []sectionRow{
{key: "/settings automation", value: "<manual|propose|scaffold|auto>", note: "background-AI policy"},
{key: "/settings ai_budget", value: "<n>", note: "0 disables AI for the session"},
{key: "/settings ai_command", value: "<argv…>", note: "e.g. claude --print"},
})...,
)
footer = append(footer,
"",
" "+st.dim.Render("applies on next `eeco` start; saved to config.local"),
)
return renderSection(width, st, section{
title: "settings",
subtitle: "AI",
body: body,
footer: footer,
})
}
key := args[0]
val := strings.TrimSpace(strings.Join(args[1:], " "))
switch key {
case "automation":
switch config.Automation(val) {
case config.AutomationManual, config.AutomationPropose,
config.AutomationScaffold, config.AutomationAuto:
default:
return renderError(st, "settings", "automation must be manual|propose|scaffold|auto")
}
case "ai_budget":
if n, err := strconv.Atoi(val); err != nil || n < 0 {
return renderError(st, "settings", "ai_budget must be a non-negative integer")
}
case "ai_command":
if val == "" {
return renderError(st, "settings", "ai_command needs an argv, e.g. /settings ai_command yourcli --print")
}
default:
return renderSection(width, st, section{
title: "settings",
subtitle: "usage",
body: []string{
" unknown key " + strconv.Quote(key),
" /settings [automation|ai_budget|ai_command] <value>",
},
})
}
if err := config.WriteLocalKeys(cfg, map[string]string{key: val}); err != nil {
return renderError(st, "settings", err.Error())
}
return []string{
st.ok.Render("settings:") + " " + fmt.Sprintf("%s set to %q", key, val),
" " + st.dim.Render("applies on next `eeco` start; this session keeps its current gate."),
}
}
// opHelp wraps the in-session command and key reference in the unified
// section frame. It names no external tool and uses no first person
// (Constraint 4).
func opHelp(st styles, width int) []string {
body := []string{
" " + st.key.Render("commands:"),
" /run [--ai] <wf> run a workflow (--ai opts into one gated pass)",
" /queue show items awaiting a decision",
" /memory list stored facts",
" /gc run memory garbage collection",
" /new <wf> scaffold a new workflow",
" /hooks [<name> on|off] show or toggle reversible hooks",
" /settings [<k> <v>] view or set the AI config (config.local)",
" /help this reference",
" /quit leave the control center",
"",
" " + st.key.Render("keys:"),
" Up/Down command history Tab complete command/workflow",
" ? toggle this overlay Esc interrupt a running task",
" Ctrl-C quit q quit (empty input)",
"",
" " + st.dim.Render("type / for commands; free-text chat is retired —"),
" " + st.dim.Render("eeco configures the harness that runs AI, it does not chat itself."),
}
return renderSection(width, st, section{
title: "help",
body: body,
})
}
added internal/tui/digest.go
@@ -0,0 +1,139 @@
package tui
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/hooks"
"github.com/ajhahnde/eeco/internal/queue"
)
// hooksDigest is the compact, live hook-wiring state ("pre-commit:on
// session:off"), recomputed per render so a toggle shows immediately.
func hooksDigest(cfg *config.Config) string {
return hooks.ShortState(cfg)
}
// memoryCount returns the number of fact files under <workspace>/memory,
// excluding the regenerated index and the attic. It is strictly
// read-only and never creates the directory: a missing store is zero.
func memoryCount(cfg *config.Config) int {
if cfg == nil {
return 0
}
ents, err := os.ReadDir(filepath.Join(cfg.Workspace, "memory"))
if err != nil {
return 0
}
n := 0
for _, e := range ents {
if e.IsDir() {
continue
}
name := e.Name()
if name == "MEMORY.md" || !strings.HasSuffix(name, ".md") {
continue
}
n++
}
return n
}
// queueCount returns the number of unresolved queue items. A missing
// queue file is reported as zero (queue.Count already handles this).
func queueCount(cfg *config.Config) int {
if cfg == nil {
return 0
}
n, err := queue.Count(filepath.Join(cfg.Workspace, "state"))
if err != nil {
return 0
}
return n
}
// gateText renders the resolved gate chain — its steps joined by
// " && " — or a neutral placeholder when the profile has none.
func gateText(cfg *config.Config) string {
if cfg == nil || len(cfg.Gate) == 0 {
return "(none)"
}
return strings.Join(config.GateSteps(cfg.Gate), " && ")
}
// OneScreen is the plain, non-interactive status digest. It is what
// `eeco` prints when stdout is not a terminal (piped or CI): one screen,
// exit 0, no interactive loop. It reads only; it changes nothing.
func OneScreen(cfg *config.Config, version string) string {
var b strings.Builder
fmt.Fprintf(&b, "eeco %s\n", version)
fmt.Fprintf(&b, " repo %s\n", cfg.RepoRoot)
fmt.Fprintf(&b, " profile %s\n", cfg.Profile)
fmt.Fprintf(&b, " gate %s\n", gateText(cfg))
fmt.Fprintf(&b, " automation %s\n", cfg.Automation)
fmt.Fprintf(&b, " memory %d fact(s)\n", memoryCount(cfg))
fmt.Fprintf(&b, " queue %d open\n", queueCount(cfg))
fmt.Fprintf(&b, " hooks %s\n", hooksDigest(cfg))
if config.IsInitialized(cfg) {
fmt.Fprintf(&b, " workspace %s/ (initialised)\n", cfg.WorkspaceName)
} else {
fmt.Fprintf(&b, " workspace %s/ (missing — run `eeco init`)\n", cfg.WorkspaceName)
}
if hint, ok := doctorHintLine(cfg); ok {
fmt.Fprintln(&b, hint)
}
return b.String()
}
// doctorHintLine returns the fresh-workspace nudge and true iff the
// workspace is initialised but has no observable activity yet — no
// memory facts, no queue items, no scaffolded user workflows. The
// hint suppresses itself as soon as any of those exist; no marker
// file is required.
func doctorHintLine(cfg *config.Config) (string, bool) {
if cfg == nil || !config.IsInitialized(cfg) {
return "", false
}
if memoryCount(cfg) > 0 || queueCount(cfg) > 0 {
return "", false
}
ents, _ := os.ReadDir(filepath.Join(cfg.Workspace, "workflows"))
for _, e := range ents {
if e.IsDir() {
return "", false
}
}
return " hint run `eeco doctor` for a workspace health check", true
}
// barLine is the compact, always-current digest rendered above the
// input line in the interactive control center: one line, dot-separated,
// recomputed on each render so counts stay live. lastRun is the headline
// of the most recent workflow run this session; it is omitted entirely
// until the first run lands so the bar carries no placeholder noise.
// The version string is intentionally elided — it already prints once
// in the home block on session start.
func barLine(cfg *config.Config, version, lastRun string) string {
_ = version
ws := "no workspace"
if config.IsInitialized(cfg) {
ws = cfg.WorkspaceName + "/"
}
fields := []string{
filepath.Base(cfg.RepoRoot),
string(cfg.Profile),
ws,
"gate:" + gateText(cfg),
"auto:" + string(cfg.Automation),
fmt.Sprintf("mem:%d", memoryCount(cfg)),
fmt.Sprintf("q:%d", queueCount(cfg)),
hooksDigest(cfg),
}
if lastRun != "" {
fields = append(fields, "run:"+lastRun)
}
return strings.Join(fields, " · ")
}
added internal/tui/home.go
@@ -0,0 +1,109 @@
package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
// eecoLogo is the home-view banner. Heavy box-drawing glyphs trace
// the lowercase outline wordmark used in assets/eeco_logo_{dark,light}.png;
// the rendering pipeline splits the first 'e' from the rest so the two
// halves can be coloured independently (mirrors the dark/indigo split
// in the PNG marks).
const eecoLogo = `┏━━━━━━━┓ ┏━━━━━━━┓ ┏━━━━━━━━ ┏━━━━━━━┓
┃ ┃ ┃ ┃ ┃ ┃ ┃
┣━━━━━━━┛ ┣━━━━━━━┛ ┃ ┃ ┃
┃ ┃ ┃ ┃ ┃
┗━━━━━━━ ┗━━━━━━━ ┗━━━━━━━━ ┗━━━━━━━┛`
// logoSplit is the rune offset that separates the leading "e" glyph
// from the "eco" remainder on every row of eecoLogo. Each glyph is 9
// cells wide with a 1-cell gap; the leading glyph therefore ends at
// rune 9.
const logoSplit = 9
// hintLine is the dim affordance at the foot of the home view. Names no
// external tool and uses no first person (Constraint 4).
const hintLine = "type /<module>"
// renderHome builds the first-impression home view: the centred logo, a
// dim version line, one rotating usage tip, and the hint, stacked with
// generous breathing room. Command discovery lives in the `/` palette and
// the `?` overlay, so the home no longer lists commands. The caller
// concatenates the prompt input and dim footer below.
func renderHome(width int, st styles, version, tip string) string {
var b strings.Builder
b.WriteString(renderLogo(width, st))
b.WriteByte('\n')
b.WriteByte('\n')
b.WriteString(renderVersion(width, st, version))
b.WriteByte('\n')
b.WriteByte('\n')
b.WriteByte('\n')
b.WriteString(renderTip(width, st, tip))
b.WriteByte('\n')
b.WriteByte('\n')
b.WriteByte('\n')
b.WriteString(renderHint(width, st))
b.WriteByte('\n')
return b.String()
}
// renderLogo centres each line of eecoLogo on width and applies the
// two-tone colouring: the leading "e" is rendered in the muted style
// (the dark half of the README mark), the rest in brand. With width <=
// 0 (no WindowSizeMsg yet) the logo renders left-flush, which is the
// pass-through behaviour the rest of the View already uses.
func renderLogo(width int, st styles) string {
lines := strings.Split(eecoLogo, "\n")
for i, ln := range lines {
runes := []rune(ln)
var head, tail string
if len(runes) >= logoSplit {
head = string(runes[:logoSplit])
tail = string(runes[logoSplit:])
} else {
tail = ln
}
out := st.logoMuted.Render(head) + st.brand.Render(tail)
if width > 0 {
out = lipgloss.PlaceHorizontal(width, lipgloss.Center, out)
}
lines[i] = out
}
return strings.Join(lines, "\n")
}
// renderVersion is the dim, centred version line shown once below the
// logo on the home view. It is the only place the running version is
// surfaced: the status footer (barLine) elides it deliberately, so the
// banner carries it.
func renderVersion(width int, st styles, version string) string {
out := st.dim.Render(version)
if width > 0 {
out = lipgloss.PlaceHorizontal(width, lipgloss.Center, out)
}
return out
}
// renderTip formats one rotating usage hint: a `tip` label in the key
// style and the hint body in dim, centred as a whole at width. The hint
// is chosen once per session (pickTip); discovery of the commands
// themselves lives in the `/` palette and the `?` overlay.
func renderTip(width int, st styles, tip string) string {
out := st.key.Render("tip") + st.dim.Render(" · "+tip)
if width > 0 {
out = lipgloss.PlaceHorizontal(width, lipgloss.Center, out)
}
return out
}
// renderHint is the dim one-liner at the foot of the home view.
func renderHint(width int, st styles) string {
out := st.dim.Render(hintLine)
if width > 0 {
out = lipgloss.PlaceHorizontal(width, lipgloss.Center, out)
}
return out
}
added internal/tui/model.go
@@ -0,0 +1,444 @@
package tui
import (
"context"
"strings"
"github.com/ajhahnde/eeco/internal/config"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// model is the control-center state. The interactive surface is a hybrid
// command box: a sticky multi-line composer with a live status digest
// above it, rendered inline in the terminal scrollback (no alt-screen
// takeover). It only orchestrates engine operations that already exist;
// it adds no write path and obeys the same exit-code contract.
type model struct {
cfg *config.Config
version string
tip string
styles styles
ta textarea.Model
sp spinner.Model
history []string
histPos int // index into history; len(history) means the live draft
draft string // the in-progress line saved while browsing history
overlay bool // the ? shortcut overlay is open
width int
// pal holds the slash-command palette cursor. Whether the palette is
// open is derived from the input (paletteOpen), so the cursor is the
// only stored palette state.
pal palette
// homePrinted is set once the home block (logo + version + tip + hint)
// has been emitted to scrollback. The block prints on the first
// WindowSizeMsg so it lands centred for the known terminal width;
// from then on View renders only the sticky input + footer, and the
// home block scrolls off naturally as content fills.
homePrinted bool
// gen invalidates the result of an interrupted background op: a
// result whose generation no longer matches is discarded.
gen int
running bool
cancel context.CancelFunc
lastRun string
quitting bool
}
// inputMaxRows caps how tall the composer grows before it scrolls
// internally. The box starts at one row and grows with the draft up to
// this many rows (reflowHeight), so a short request keeps a single-line
// prompt and a long paste stays bounded.
const inputMaxRows = 8
// asyncResultMsg carries the output of a background operation back to
// the UI goroutine. A result whose gen mismatches the model's current
// gen was interrupted (Esc) and is dropped.
type asyncResultMsg struct {
gen int
lines []string
isRun bool
summary string
}
func newModel(cfg *config.Config, version string) model {
st := newStyles(colorEnabled())
ta := textarea.New()
ta.Placeholder = "type / for commands"
ta.ShowLineNumbers = false
// The textarea defaults to a six-row box; a prompt should idle at one
// row and grow with the draft (reflowHeight).
ta.SetHeight(1)
// One prompt glyph on line 0, aligned blanks on continuation lines, so a
// multi-line draft reads as one composer rather than a stack of prompts.
ta.SetPromptFunc(2, func(i int) string {
if i == 0 {
return "» "
}
return " "
})
ta.FocusedStyle.Prompt = st.prompt
ta.BlurredStyle.Prompt = st.prompt
// Bubbles' default placeholder style is dim grey (240) — readable on a
// pure-black terminal but invisible on a translucent / image-backed
// background. Pin to `dim` (250) so the affordance survives common
// terminal themes.
ta.FocusedStyle.Placeholder = st.dim
ta.BlurredStyle.Placeholder = st.dim
// Drop the default cursor-line background bar: a highlighted active row
// reads as a code editor, not a prompt.
ta.FocusedStyle.CursorLine = lipgloss.NewStyle()
ta.BlurredStyle.CursorLine = lipgloss.NewStyle()
// Enter submits (unchanged muscle memory, matches palette accept). A
// newline is Alt+Enter or Ctrl+J; Ctrl+J (literal LF) is the reliable
// fallback where a terminal swallows Alt+Enter. Never ctrl+m — it is
// Enter.
ta.KeyMap.InsertNewline = key.NewBinding(key.WithKeys("alt+enter", "ctrl+j"))
ta.Focus()
sp := spinner.New()
sp.Spinner = spinner.MiniDot
sp.Style = st.brand
return model{
cfg: cfg,
version: version,
tip: pickTip(),
styles: st,
ta: ta,
sp: sp,
}
}
func (m model) Init() tea.Cmd { return textarea.Blink }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
// The textarea needs an explicit width to wrap (textinput auto-sized);
// set it on every size message, including a resize.
m.ta.SetWidth(msg.Width)
if !m.homePrinted {
m.homePrinted = true
return m, tea.Println(renderHome(m.width, m.styles, m.version, m.tip))
}
return m, nil
case asyncResultMsg:
if msg.gen != m.gen {
return m, nil // interrupted; discard stale output
}
m.running = false
m.cancel = nil
if msg.isRun {
m.lastRun = msg.summary
}
return m, printLines(msg.lines)
case spinner.TickMsg:
// Advance the spinner only while work is in flight; once running
// clears, returning nil lets the tick loop die.
if !m.running {
return m, nil
}
var cmd tea.Cmd
m.sp, cmd = m.sp.Update(msg)
return m, cmd
case tea.KeyMsg:
return m.onKey(msg)
}
var cmd tea.Cmd
m.ta, cmd = m.ta.Update(msg)
return m, cmd
}
func (m model) onKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
// Ctrl-C always quits and restores the terminal.
if k.Type == tea.KeyCtrlC {
m.quitting = true
return m, tea.Quit
}
// The overlay swallows the next key (any key dismisses it).
if m.overlay {
m.overlay = false
return m, nil
}
// Slash-command palette: while open ("/" with no space yet) four keys
// drive the dropdown. Every other key falls through, so typing/deleting
// filters live and Esc still clears. Up/Down move the highlight here
// instead of browsing history; Tab/Enter accept the selection (Enter
// does not submit — the next Enter does).
if m.paletteOpen() {
items := m.paletteItems()
switch k.Type {
case tea.KeyUp:
m.pal.cursor--
m.clampPaletteCursor(len(items))
return m, nil
case tea.KeyDown:
m.pal.cursor++
m.clampPaletteCursor(len(items))
return m, nil
case tea.KeyTab:
return m.acceptPalette(items), nil
case tea.KeyEnter:
// Plain Enter accepts the highlighted row; Alt+Enter is a
// newline (handled in the main switch below), never a palette
// accept — consistent with Ctrl+J, which already falls through.
if !k.Alt {
return m.acceptPalette(items), nil
}
}
}
switch k.Type {
case tea.KeyEsc:
if m.running {
if m.cancel != nil {
m.cancel()
}
m.gen++ // invalidate the in-flight result
m.running = false
m.cancel = nil
return m, tea.Println(m.styles.dim.Render("interrupted"))
}
m.ta.SetValue("")
m.reflowHeight()
return m, nil
case tea.KeyUp:
// History only at the top of the draft; otherwise move the cursor up
// within a multi-line composer (fall through to ta.Update). A
// single-line draft has Line()==0, so today's history behaviour holds.
if m.ta.Line() == 0 {
m.historyPrev()
return m, nil
}
case tea.KeyDown:
// History only at the bottom of the draft; otherwise move the cursor
// down. A single-line draft has Line()==LineCount()-1==0.
if m.ta.Line() == m.ta.LineCount()-1 {
m.historyNext()
return m, nil
}
case tea.KeyTab:
newVal, candidates := complete(m.ta.Value(), runNames(m.cfg))
m.ta.SetValue(newVal)
m.ta.CursorEnd()
m.reflowHeight()
if len(candidates) > 0 {
return m, tea.Println(m.styles.dim.Render(" " + strings.Join(candidates, " ")))
}
return m, nil
case tea.KeyEnter:
// Plain Enter submits; Alt+Enter falls through to the textarea, which
// inserts a newline via the rebound InsertNewline binding.
if k.Alt {
break
}
return m.submit()
case tea.KeyRunes:
// `?` on an empty line opens the shortcut overlay; otherwise it
// is an ordinary character.
if string(k.Runes) == "?" && m.ta.Value() == "" {
m.overlay = true
return m, nil
}
// `q` on an empty line quits (a REPL convention); when there is
// text to edit, `q` is a literal character.
if string(k.Runes) == "q" && m.ta.Value() == "" && !m.running {
m.quitting = true
return m, tea.Quit
}
}
var cmd tea.Cmd
m.ta, cmd = m.ta.Update(k)
m.reflowHeight()
// A filter change (typing or backspace) while the palette is open snaps
// the highlight back to the top match, matching the Claude Code palette.
if m.paletteOpen() {
m.pal.cursor = 0
}
return m, cmd
}
func (m model) submit() (tea.Model, tea.Cmd) {
raw := m.ta.Value()
line := strings.TrimSpace(raw)
m.ta.SetValue("")
m.reflowHeight()
if line == "" {
return m, nil
}
if len(m.history) == 0 || m.history[len(m.history)-1] != line {
m.history = append(m.history, line)
}
m.histPos = len(m.history)
m.draft = ""
echo := tea.Println(m.styles.prompt.Render("» ") + line)
res := dispatch(m.cfg, m.styles, m.width, parseInput(line))
if res.quit {
m.quitting = true
return m, tea.Sequence(echo, tea.Quit)
}
if res.async != "" {
m.gen++
m.running = true
ctx, cancel := context.WithCancel(context.Background())
m.cancel = cancel
// Kick the spinner alongside the work so the footer animates while
// the request is in flight.
return m, tea.Batch(
tea.Sequence(echo, m.startAsync(ctx, m.gen, res)),
m.sp.Tick,
)
}
if len(res.lines) > 0 {
return m, tea.Sequence(echo, printLines(res.lines))
}
return m, echo
}
// startAsync runs a long operation off the UI goroutine so Esc can
// interrupt it. A `/run --ai` is genuinely cancellable through ctx; a
// native builtin run completes quickly, and Esc still detaches its (now
// stale) result via the generation token.
func (m model) startAsync(ctx context.Context, gen int, res dispatchResult) tea.Cmd {
cfg := m.cfg
st := m.styles
width := m.width
return func() tea.Msg {
switch res.async {
case "gc":
return asyncResultMsg{gen: gen, lines: opGC(cfg, st, width)}
case "run":
summary, lines, _ := opRun(cfg, st, width, res.asyncS, res.asyncAI)
return asyncResultMsg{gen: gen, lines: lines, isRun: true, summary: summary}
}
return asyncResultMsg{gen: gen}
}
}
func (m *model) historyPrev() {
if len(m.history) == 0 || m.histPos == 0 {
return
}
if m.histPos == len(m.history) {
m.draft = m.ta.Value()
}
m.histPos--
m.ta.SetValue(m.history[m.histPos])
m.ta.CursorEnd()
m.reflowHeight()
}
func (m *model) historyNext() {
if m.histPos >= len(m.history) {
return
}
m.histPos++
if m.histPos == len(m.history) {
m.ta.SetValue(m.draft)
} else {
m.ta.SetValue(m.history[m.histPos])
}
m.ta.CursorEnd()
m.reflowHeight()
}
// reflowHeight resizes the composer to fit the current draft, from one row
// up to inputMaxRows; past the cap the textarea scrolls internally. Called
// after any value change so the box grows and shrinks with the content.
func (m *model) reflowHeight() {
h := m.ta.LineCount()
if h < 1 {
h = 1
}
if h > inputMaxRows {
h = inputMaxRows
}
m.ta.SetHeight(h)
}
func (m model) View() string {
if m.quitting {
return ""
}
if m.overlay {
var b strings.Builder
for _, ln := range opHelp(m.styles, m.width) {
b.WriteString(ln)
b.WriteByte('\n')
}
b.WriteString(m.styles.key.Render("(press any key to close)"))
b.WriteByte('\n')
return b.String()
}
bar := barLine(m.cfg, m.version, m.lastRun)
if m.width > 0 {
bar = truncate(bar, m.width)
}
footer := m.styles.dimmer.Render(bar)
if m.running {
footer += " " + m.sp.View() + m.styles.dimmer.Render(" working (Esc to interrupt)")
}
// The live View is a constant-height region (input + footer) for the
// whole session. The home block (logo + version + tip + hint) is
// printed once via tea.Println on the first WindowSizeMsg, so it
// lives in scrollback and scrolls off the top naturally as content
// fills — the Claude Code TUI behaviour the operator targeted.
//
// When the slash-command palette is open the dropdown renders between
// the input and the footer; the region grows while open and shrinks
// when it closes (safe under inline, no-alt-screen Bubble Tea).
if m.paletteOpen() {
block := renderPalette(m.paletteItems(), m.pal.cursor, m.width, m.styles)
return m.ta.View() + "\n" + block + "\n" + footer + "\n"
}
return m.ta.View() + "\n" + footer + "\n"
}
// printLines emits content into the terminal scrollback above the live
// input region. Bubble Tea's Println keeps this output in the terminal
// after the program exits (no alt-screen), which is the inline-history
// behaviour PLAN.md §TUI requires.
func printLines(lines []string) tea.Cmd {
if len(lines) == 0 {
return nil
}
return tea.Println(strings.Join(lines, "\n"))
}
// truncate clips s to w display columns, appending an ellipsis when it
// had to cut. It operates on runes; the digest carries no escape codes.
func truncate(s string, w int) string {
if w <= 0 {
return ""
}
r := []rune(s)
if len(r) <= w {
return s
}
if w <= 1 {
return string(r[:w])
}
return string(r[:w-1]) + "…"
}
added internal/tui/palette.go
@@ -0,0 +1,140 @@
package tui
import (
"fmt"
"strings"
)
// palette is the slash-command dropdown state. Typing "/" opens an instant,
// navigable list of the slash commands, each with its one-line purpose —
// the terminal command palette the operator targeted. The open/closed
// state is derived from the current input (see paletteOpen), so the cursor
// is the only stored palette state.
type palette struct {
cursor int // selected row within the current filtered set
}
// paletteMaxRows caps the visible dropdown height; the remainder is folded
// into a "+N more" line. Nine commands rarely overflow, so this is mostly
// defensive against a future-growing commandIndex.
const paletteMaxRows = 8
// paletteOpen reports whether the slash-command palette should show. It is
// open exactly while the input is a command token still being typed: a
// leading slash with no space yet. This reuses the command-vs-argument
// boundary complete() already relies on (commands.go) — "/" and "/me" open;
// "/run " (a committed command plus a space) closes into argument entry;
// empty or plain text never opens. A space, newline, or tab in the value is
// free text (a multi-line draft is never a command token), so any of them
// closes the palette.
func (m model) paletteOpen() bool {
v := m.ta.Value()
return strings.HasPrefix(v, "/") && !strings.ContainsAny(v, " \n\t")
}
// paletteItems returns the commandIndex rows whose name matches the current
// input token by prefix — the same rule completeToken uses (commands.go).
// commandIndex is the single source of truth and already sorted, so the
// rows need no further ordering.
func (m model) paletteItems() []cmdEntry {
v := m.ta.Value()
out := make([]cmdEntry, 0, len(commandIndex))
for _, e := range commandIndex {
if strings.HasPrefix(e.name, v) {
out = append(out, e)
}
}
return out
}
// clampPaletteCursor keeps pal.cursor within [0, n-1]; an empty set parks it
// at 0. Callers clamp after a cursor move so the highlight never points past
// the visible rows.
func (m *model) clampPaletteCursor(n int) {
if n <= 0 || m.pal.cursor < 0 {
m.pal.cursor = 0
return
}
if m.pal.cursor > n-1 {
m.pal.cursor = n - 1
}
}
// acceptPalette commits the highlighted row: it fills the input with the
// command name plus a trailing space (which trips the open predicate and
// closes the palette) and moves the cursor to the end. It never submits —
// the subsequent Enter does. An empty filtered set is a no-op.
func (m model) acceptPalette(items []cmdEntry) model {
if len(items) == 0 {
return m
}
idx := m.pal.cursor
if idx < 0 || idx >= len(items) {
idx = 0
}
m.ta.SetValue(items[idx].name + " ")
m.ta.CursorEnd()
m.pal.cursor = 0
return m
}
// renderPalette formats the dropdown as a single multi-line string (no
// trailing newline; the caller frames it). The selected row carries a brand
// "› " marker and a brand-coloured name; the rest use the blue command-name
// style. Names pad to a column so purposes align. Only the purpose is
// width-clipped (it is the variable-length part), which keeps the per-segment
// styling intact under colour. An empty set renders a single dim "no match"
// row. No new colours: brand/key/dim are reused from style.go.
func renderPalette(items []cmdEntry, cursor, width int, st styles) string {
if len(items) == 0 {
return " " + st.dim.Render("no match")
}
nameCol := 0
for _, e := range items {
if n := len(e.name); n > nameCol {
nameCol = n
}
}
if cursor < 0 {
cursor = 0
}
if cursor > len(items)-1 {
cursor = len(items) - 1
}
// Scroll a fixed-height window so the highlighted row is always shown:
// without this the selected item could fall into the hidden overflow
// (e.g. cursor on the 9th of 9 commands with an 8-row cap).
start := 0
if cursor >= paletteMaxRows {
start = cursor - paletteMaxRows + 1
}
end := start + paletteMaxRows
if end > len(items) {
end = len(items)
}
shown := items[start:end]
rows := make([]string, 0, len(shown)+1)
for i, e := range shown {
marker := " "
nameStyle := st.key
if start+i == cursor {
marker = st.brand.Render("› ")
nameStyle = st.brand
}
pad := strings.Repeat(" ", nameCol-len(e.name))
purpose := e.purpose
if width > 0 {
// Columns before the purpose: 2 (marker) + nameCol + 2 (gap).
avail := width - 2 - nameCol - 2
if avail < 0 {
avail = 0
}
purpose = truncate(purpose, avail)
}
rows = append(rows, marker+nameStyle.Render(e.name)+pad+" "+st.dim.Render(purpose))
}
if hidden := len(items) - len(shown); hidden > 0 {
rows = append(rows, " "+st.dim.Render(fmt.Sprintf("+%d more", hidden)))
}
return strings.Join(rows, "\n")
}
added internal/tui/pty_test.go
@@ -0,0 +1,214 @@
//go:build !windows
package tui
import (
"bytes"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
"testing"
"time"
"github.com/creack/pty"
)
// repoRoot returns the absolute path of the eeco repository root,
// derived from this test file's source location so it works regardless
// of the test runner's CWD.
func repoRoot(t *testing.T) string {
t.Helper()
_, here, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
return filepath.Clean(filepath.Join(filepath.Dir(here), "..", ".."))
}
var (
buildOnce sync.Once
buildPath string
buildErr error
)
// buildBinary compiles cmd/eeco to a session-shared temp file. Building
// once per `go test` invocation amortises the cost across multiple PTY
// scenarios (currently one, but the pattern is cheap to extend).
func buildBinary(t *testing.T) string {
t.Helper()
buildOnce.Do(func() {
dir, err := os.MkdirTemp("", "eeco-pty-")
if err != nil {
buildErr = err
return
}
bin := filepath.Join(dir, "eeco")
cmd := exec.Command("go", "build", "-o", bin, "./cmd/eeco")
cmd.Dir = repoRoot(t)
if out, err := cmd.CombinedOutput(); err != nil {
buildErr = errors.New("go build: " + err.Error() + "\n" + string(out))
return
}
buildPath = bin
})
if buildErr != nil {
t.Fatal(buildErr)
}
return buildPath
}
// scratchInitRepo creates a temp dir, makes it a git repo, runs
// `eeco init`, and returns the path. The returned dir is the CWD the
// subsequent PTY invocation runs in.
func scratchInitRepo(t *testing.T, bin string) string {
t.Helper()
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil {
t.Fatal(err)
}
cmd := exec.Command(bin, "init")
cmd.Dir = root
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("eeco init: %v\n%s", err, out)
}
return root
}
// readUntil consumes from r in a goroutine until marker appears in the
// accumulated buffer or timeout elapses. The PTY master never returns
// EOF until the child exits, so a synchronous Read would block past
// the deadline; the goroutine pattern lets us bound the wait.
func readUntil(t *testing.T, r io.Reader, marker string, timeout time.Duration) string {
t.Helper()
var (
mu sync.Mutex
buf bytes.Buffer
done = make(chan struct{})
)
go func() {
chunk := make([]byte, 4096)
for {
n, err := r.Read(chunk)
if n > 0 {
mu.Lock()
buf.Write(chunk[:n])
if bytes.Contains(buf.Bytes(), []byte(marker)) {
mu.Unlock()
close(done)
return
}
mu.Unlock()
}
if err != nil {
return
}
}
}()
select {
case <-done:
case <-time.After(timeout):
}
mu.Lock()
defer mu.Unlock()
return buf.String()
}
func TestPTY_DigestRendersAndQuitExitsCleanly(t *testing.T) {
if testing.Short() {
t.Skip("PTY test skipped under -short")
}
bin := buildBinary(t)
root := scratchInitRepo(t, bin)
cmd := exec.Command(bin)
cmd.Dir = root
ptmx, err := pty.Start(cmd)
if err != nil {
t.Fatalf("pty.Start: %v", err)
}
defer func() {
_ = ptmx.Close()
if cmd.ProcessState == nil || !cmd.ProcessState.Exited() {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}
}()
_ = pty.Setsize(ptmx, &pty.Winsize{Rows: 24, Cols: 100})
// Bubble Tea probes the terminal at startup with OSC 11 (background
// color) and DSR (cursor position) and waits for replies before
// the first View renders. Feed canned answers so the loop proceeds.
go func() {
time.Sleep(50 * time.Millisecond)
_, _ = ptmx.Write([]byte("\x1b]11;rgb:0000/0000/0000\x1b\\"))
_, _ = ptmx.Write([]byte("\x1b[1;1R"))
}()
first := readUntil(t, ptmx, "auto:propose", 8*time.Second)
// The workspace name `.eeco/` rides in the bar once `eeco init` has
// run; serves as the cross-render proof that the interactive surface
// actually painted (the bar no longer carries a `eeco vX` banner —
// the home block printed once at session start owns that).
if !strings.Contains(first, ".eeco/") {
t.Fatalf("digest missing workspace field:\n%q", first)
}
if !strings.Contains(first, "auto:propose") {
t.Fatalf("digest missing automation field:\n%q", first)
}
// Open the ? overlay — its content begins with "commands:".
if _, err := ptmx.Write([]byte("?")); err != nil {
t.Fatalf("write ?: %v", err)
}
overlay := readUntil(t, ptmx, "commands:", 3*time.Second)
if !strings.Contains(overlay, "commands:") {
t.Fatalf("? overlay did not render commands header:\n%q", overlay)
}
// Drain PTY output until the child exits. readUntil's goroutine
// returned when "commands:" was found; without a continuous reader,
// the PTY buffer fills and Bubble Tea's teardown writes block, which
// can stall tea.Quit. io.Copy returns on EOF when the child exits
// and ptmx.Close() runs in the deferred cleanup.
go func() { _, _ = io.Copy(io.Discard, ptmx) }()
// Dismiss overlay (any key) then quit via Ctrl-C. The slash-command
// path (`/quit` + Enter) routes through tea.Sequence(echo, tea.Quit)
// which has been observed to stall on slow Linux PTY runners even
// with the output drainer in place. Ctrl-C is the direct quit hook
// (model.onKey at internal/tui/model.go:102-104) and bypasses both
// the slash parser and tea.Sequence — exactly the contract a TUI
// must honour, and the most stable assertion for cross-platform CI.
if _, err := ptmx.Write([]byte(" ")); err != nil {
t.Fatalf("dismiss overlay: %v", err)
}
time.Sleep(80 * time.Millisecond)
if _, err := ptmx.Write([]byte{0x03}); err != nil {
t.Fatalf("write Ctrl-C: %v", err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case werr := <-done:
if werr != nil {
var ee *exec.ExitError
if errors.As(werr, &ee) && ee.ExitCode() != 0 {
t.Fatalf("eeco exited non-zero: %v", werr)
}
if !errors.As(werr, &ee) {
t.Fatalf("eeco wait: %v", werr)
}
}
case <-time.After(15 * time.Second):
_ = cmd.Process.Signal(syscall.SIGTERM)
<-done
t.Fatal("eeco did not exit within 15s of Ctrl-C")
}
}
added internal/tui/render.go
@@ -0,0 +1,144 @@
package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
// section is a styled command-output block: a horizontal rule carrying
// the title, an optional subtitle on the rule, a body of pre-formatted
// lines (callers preserve their own indent), and an optional footer
// separated from the body by a blank line. The renderer is a thin
// presentation layer over the existing styles palette; it adds no new
// colour and introduces no new write path.
type section struct {
title string
subtitle string
body []string
footer []string
}
// sectionRow is one entry in a key/value table that flows into the body.
// Callers build rows with rowsToBody before constructing a section so
// the section struct stays narrow.
type sectionRow struct {
key, value, note string
}
// defaultRuleWidth is the fill cap used when the model has not yet seen
// a WindowSizeMsg (width 0). Terminals usually wrap longer rules cleanly,
// but a hard cap keeps the visual frame bounded on first paint.
const defaultRuleWidth = 60
// renderSection formats s as ordered scrollback lines. A leading blank
// line gives the section breathing room after the echoed prompt; the
// header line is `─── title · subtitle ──…` with the trailing rule
// filling to width. Each body line is emitted verbatim; the footer
// (if any) is prefixed by a single blank line. Pure: callers print the
// result with tea.Println via the existing printLines helper.
func renderSection(width int, st styles, s section) []string {
out := make([]string, 0, len(s.body)+len(s.footer)+4)
out = append(out, "")
out = append(out, renderRule(width, st, s.title, s.subtitle))
out = append(out, s.body...)
if len(s.footer) > 0 {
out = append(out, "")
out = append(out, s.footer...)
}
return out
}
// renderRule builds the section header. The shape is
//
// ─── title · subtitle ──────────────────
//
// with the title in the `key` style and the subtitle (when set) in
// `dim`. The leading three dashes and the trailing fill use `dimmer`
// so the title is the visual anchor. Width 0 falls back to
// defaultRuleWidth.
func renderRule(width int, st styles, title, subtitle string) string {
w := width
if w <= 0 {
w = defaultRuleWidth
}
const lead = "─── "
plain := lead + title
if subtitle != "" {
plain += " · " + subtitle
}
fillN := w - lipgloss.Width(plain) - 1
if fillN < 3 {
fillN = 3
}
styled := st.dimmer.Render(lead) + st.key.Render(title)
if subtitle != "" {
styled += " " + st.dim.Render("· "+subtitle)
}
styled += st.dimmer.Render(" " + strings.Repeat("─", fillN))
return styled
}
// tableBody flows a key/value/note table into body lines with an
// optional header row + rule separator. The key column is rendered in
// `key` style; the value column in `value` style (readable primary
// foreground); the note column in `dim`. The header cells use
// `tableHeader`; the separator rule uses `dimmer`. Columns pad to the
// widest cell across the header and every row so notes stay vertically
// aligned. Leading two-space indent matches the rest of the body
// convention. An all-empty head omits the header rows entirely.
func tableBody(st styles, head [3]string, rows []sectionRow) []string {
if len(rows) == 0 {
return nil
}
maxKey := lipgloss.Width(head[0])
maxVal := lipgloss.Width(head[1])
maxNote := lipgloss.Width(head[2])
for _, r := range rows {
if n := lipgloss.Width(r.key); n > maxKey {
maxKey = n
}
if n := lipgloss.Width(r.value); n > maxVal {
maxVal = n
}
if n := lipgloss.Width(r.note); n > maxNote {
maxNote = n
}
}
hasHead := head[0] != "" || head[1] != "" || head[2] != ""
out := make([]string, 0, len(rows)+2)
if hasHead {
kPad := strings.Repeat(" ", maxKey-lipgloss.Width(head[0]))
vPad := strings.Repeat(" ", maxVal-lipgloss.Width(head[1]))
line := " " + st.tableHeader.Render(head[0]) + kPad + " " + st.tableHeader.Render(head[1]) + vPad
if head[2] != "" {
line += " " + st.tableHeader.Render(head[2])
}
out = append(out, line)
sep := " " + st.dimmer.Render(strings.Repeat("─", maxKey)) +
" " + st.dimmer.Render(strings.Repeat("─", maxVal))
if maxNote > 0 {
sep += " " + st.dimmer.Render(strings.Repeat("─", maxNote))
}
out = append(out, sep)
}
for _, r := range rows {
kPad := strings.Repeat(" ", maxKey-lipgloss.Width(r.key))
vPad := strings.Repeat(" ", maxVal-lipgloss.Width(r.value))
line := " " + st.key.Render(r.key) + kPad + " " + st.value.Render(r.value) + vPad
if r.note != "" {
line += " " + st.dim.Render(r.note)
}
out = append(out, line)
}
return out
}
// renderError produces a single styled line for short failure or
// validation messages: `<title>: <message>` with the title in `warn`
// and the message in default text. Matches the existing inline error
// shape (`"settings: " + err.Error()`) but applies a consistent colour
// across every command surface.
func renderError(st styles, title, message string) []string {
return []string{st.warn.Render(title+":") + " " + message}
}
added internal/tui/style.go
@@ -0,0 +1,74 @@
package tui
import (
"os"
"github.com/charmbracelet/lipgloss"
)
// styles holds the few rendered styles the control center uses. They are
// intentionally minimal and neutral (Constraint 4: precise, no emoji, no
// first person). When colour is disabled the styles degrade to plain
// text rather than changing layout.
type styles struct {
brand lipgloss.Style // logo accent (matches the "eco" half of the README mark)
logoMuted lipgloss.Style // logo accent for the leading "e"; adapts to terminal background (near-black on light, near-white on dark) to mirror the two PNG mark variants
prompt lipgloss.Style // » input prompt
dim lipgloss.Style // hint line, secondary text
dimmer lipgloss.Style // status footer — sits below placeholder weight
key lipgloss.Style // command names, key labels
value lipgloss.Style // primary readable body text — table values, free-text body
tableHeader lipgloss.Style // column header cells in a section table
warn lipgloss.Style // cautionary inline notes
ok lipgloss.Style // success inline notes
}
// colorEnabled reports whether coloured output is wanted. The NO_COLOR
// convention (any non-empty value disables colour) is honoured; a value
// the user cannot override (a non-terminal sink) is handled separately
// by the non-interactive path.
func colorEnabled() bool {
return os.Getenv("NO_COLOR") == ""
}
// newStyles builds the style set. With colour off every style is the
// identity style, so the same View code path renders readable plain
// text under NO_COLOR or a dumb terminal.
func newStyles(color bool) styles {
if !color {
plain := lipgloss.NewStyle()
return styles{
brand: plain, logoMuted: plain, prompt: plain,
dim: plain, dimmer: plain, key: plain,
value: plain, tableHeader: plain,
warn: plain, ok: plain,
}
}
// Atom One Dark palette. Magenta (#c678dd, the palette's magenta
// slot) is the eeco brand accent — logo "eco", prompt, primary
// affordances. Green stays a semantic success signal only (never an
// accent). Greys form a three-step hierarchy: body > hint > footer.
const (
magenta = "#c678dd" // brand accent
blue = "#61afef" // command names / keys
cyan = "#66b2ff" // table headers
yellow = "#e5c07b" // cautions
green = "#98c379" // success only
fgWhite = "#ffffff" // primary body text
fgGrey = "#abb2bf" // hints, secondary text
fgFooter = "#5c6370" // status footer, faintest
nearDark = "#282c34" // light-background foreground
)
return styles{
brand: lipgloss.NewStyle().Foreground(lipgloss.Color(magenta)).Bold(true),
logoMuted: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: nearDark, Dark: fgGrey}).Bold(true),
prompt: lipgloss.NewStyle().Foreground(lipgloss.Color(magenta)).Bold(true),
dim: lipgloss.NewStyle().Foreground(lipgloss.Color(fgGrey)),
dimmer: lipgloss.NewStyle().Foreground(lipgloss.Color(fgFooter)),
key: lipgloss.NewStyle().Foreground(lipgloss.Color(blue)).Bold(true),
value: lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: nearDark, Dark: fgWhite}),
tableHeader: lipgloss.NewStyle().Foreground(lipgloss.Color(cyan)).Bold(true),
warn: lipgloss.NewStyle().Foreground(lipgloss.Color(yellow)).Bold(true),
ok: lipgloss.NewStyle().Foreground(lipgloss.Color(green)).Bold(true),
}
}
added internal/tui/testdata/home_w120.golden
@@ -0,0 +1,13 @@
┏━━━━━━━┓ ┏━━━━━━━┓ ┏━━━━━━━━ ┏━━━━━━━┓
┃ ┃ ┃ ┃ ┃ ┃ ┃
┣━━━━━━━┛ ┣━━━━━━━┛ ┃ ┃ ┃
┃ ┃ ┃ ┃ ┃
┗━━━━━━━ ┗━━━━━━━ ┗━━━━━━━━ ┗━━━━━━━┛
v1.5.0
tip · paste multi-line requests — Alt+Enter for a newline
type /<module>
added internal/tui/testdata/home_w200.golden
@@ -0,0 +1,13 @@
┏━━━━━━━┓ ┏━━━━━━━┓ ┏━━━━━━━━ ┏━━━━━━━┓
┃ ┃ ┃ ┃ ┃ ┃ ┃
┣━━━━━━━┛ ┣━━━━━━━┛ ┃ ┃ ┃
┃ ┃ ┃ ┃ ┃
┗━━━━━━━ ┗━━━━━━━ ┗━━━━━━━━ ┗━━━━━━━┛
v1.5.0
tip · paste multi-line requests — Alt+Enter for a newline
type /<module>
added internal/tui/testdata/home_w80.golden
@@ -0,0 +1,13 @@
┏━━━━━━━┓ ┏━━━━━━━┓ ┏━━━━━━━━ ┏━━━━━━━┓
┃ ┃ ┃ ┃ ┃ ┃ ┃
┣━━━━━━━┛ ┣━━━━━━━┛ ┃ ┃ ┃
┃ ┃ ┃ ┃ ┃
┗━━━━━━━ ┗━━━━━━━ ┗━━━━━━━━ ┗━━━━━━━┛
v1.5.0
tip · paste multi-line requests — Alt+Enter for a newline
type /<module>
added internal/tui/tips.go
@@ -0,0 +1,28 @@
package tui
import "math/rand/v2"
// tips are the rotating one-line usage hints shown on the control-center
// home view. One is picked per session (pickTip) and printed below the
// logo. They stay neutral and brand-free (Constraint 4: no first person,
// no external tool names, no emoji) and short enough to fit one line at
// the golden width (80). Command discovery itself lives in the `/`
// palette and the `?` overlay, so these point at capabilities a user
// might otherwise miss rather than re-listing the command set.
var tips = []string{
"paste multi-line requests — Alt+Enter for a newline",
"type / to filter every command as you go",
"press ? for the keyboard-shortcut overlay",
"eeco go prints an AI-ready project brief",
"eeco ask \"…\" returns ranked file:line pointers",
"eeco add task \"…\" appends an item to the queue",
"eeco doctor runs diagnostic probes on the workspace",
"eeco run <name> runs one workflow",
}
// pickTip returns one tip at random. It is called once at model
// construction so the chosen tip is stable for the whole session: the
// home block is printed once into scrollback, never re-rendered.
func pickTip() string {
return tips[rand.IntN(len(tips))]
}
added internal/tui/tui.go
@@ -0,0 +1,60 @@
// Package tui is eeco's control center: a hybrid command box with a
// live status digest, rendered inline in the terminal scrollback (no
// alt-screen takeover). It accepts slash commands (with history and Tab
// completion) and free-text requests; free text is routed through the
// shared, gated AI provider so it is consented, budget-capped, and
// parked-and-queued rather than ever a silent spend or a hard failure.
//
// The control center only orchestrates engine operations that already
// exist (config, memory, garbage collection, queue, workflow run and
// scaffold). It introduces no new write path, obeys write-scope, and
// honours the same exit-code contract as every other entry point.
//
// When stdout or stdin is not a terminal (piped or CI) there is no
// interactive loop: Run prints the one-screen status digest and exits 0.
package tui
import (
"fmt"
"io"
"os"
"github.com/ajhahnde/eeco/internal/config"
tea "github.com/charmbracelet/bubbletea"
)
// interactive reports whether an interactive control center should be
// started. Both stdin and stdout must be character devices; a pipe, a
// file, or a CI sink is not, so tests and pipelines deterministically
// take the digest path. The check is stdlib-only (no extra dependency).
func interactive() bool {
return isCharDevice(os.Stdin) && isCharDevice(os.Stdout)
}
func isCharDevice(f *os.File) bool {
if f == nil {
return false
}
info, err := f.Stat()
if err != nil {
return false
}
return info.Mode()&os.ModeCharDevice != 0
}
// Run is the control-center entry point invoked by `eeco` with no
// arguments. Non-interactive: write the one-screen digest to stdout and
// return 0. Interactive: run the inline control center on the real
// terminal; return 0 on a clean quit, 1 on a terminal I/O failure.
func Run(cfg *config.Config, version string, stdout, stderr io.Writer) int {
if !interactive() {
fmt.Fprint(stdout, OneScreen(cfg, version))
return 0
}
p := tea.NewProgram(newModel(cfg, version))
if _, err := p.Run(); err != nil {
fmt.Fprintln(stderr, "eeco:", err)
return 1
}
return 0
}
added internal/tui/tui_test.go
@@ -0,0 +1,1371 @@
package tui
import (
"context"
"flag"
"fmt"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"testing"
"time"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/hooks"
"github.com/ajhahnde/eeco/internal/memory"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
)
// miniDotFrames lists the MiniDot spinner glyphs; a running footer renders
// one of them and an idle footer none.
const miniDotFrames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
// updateGolden refreshes the snapshot files under testdata/. Pass with
// `go test ./internal/tui -run TestRenderHome -update` after a deliberate
// home-view change; commit the regenerated goldens with the code.
var updateGolden = flag.Bool("update", false, "rewrite golden files under testdata/")
// --- helpers ---
// repo returns a config for a fresh temp git repo. When init is true the
// workspace is scaffolded so memory/queue/gc operations are available.
func repo(t *testing.T, doInit bool) *config.Config {
t.Helper()
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(root, config.DefaultWorkspace)
if err != nil {
t.Fatal(err)
}
if doInit {
if _, err := config.Init(cfg); err != nil {
t.Fatal(err)
}
}
return cfg
}
func asModel(t *testing.T, tm tea.Model) model {
t.Helper()
m, ok := tm.(model)
if !ok {
t.Fatalf("expected tui.model, got %T", tm)
}
return m
}
// --- non-interactive guarantee ---
func TestInteractive_TestEnvIsNonInteractive(t *testing.T) {
// Under `go test` stdio is piped, so the control center must take
// the digest path and never start an interactive loop (no hang).
if interactive() {
t.Fatal("interactive() true under test; would hang CI")
}
}
func TestRun_NonTTYPrintsDigestExitsZero(t *testing.T) {
cfg := repo(t, false)
var out, errb strings.Builder
code := Run(cfg, "9.9.9", &out, &errb)
if code != 0 {
t.Fatalf("Run exit %d, want 0", code)
}
if !strings.Contains(out.String(), "eeco 9.9.9") {
t.Errorf("digest missing version:\n%s", out.String())
}
if errb.Len() != 0 {
t.Errorf("unexpected stderr: %q", errb.String())
}
}
// --- digest ---
func TestOneScreen_Fields(t *testing.T) {
cfg := repo(t, false)
s := OneScreen(cfg, "1.2.3")
for _, want := range []string{
"eeco 1.2.3", cfg.RepoRoot, "profile", "automation",
"memory", "queue", "hooks", "missing — run `eeco init`",
} {
if !strings.Contains(s, want) {
t.Errorf("OneScreen missing %q:\n%s", want, s)
}
}
cfg = repo(t, true)
if !strings.Contains(OneScreen(cfg, "x"), "(initialised)") {
t.Error("initialised workspace not reflected")
}
}
func TestOneScreen_DoctorHintFiresOnFreshInit(t *testing.T) {
cfg := repo(t, true)
s := OneScreen(cfg, "x")
if !strings.Contains(s, "eeco doctor") {
t.Errorf("expected doctor hint on fresh init, got:\n%s", s)
}
}
func TestOneScreen_DoctorHintSuppressedOnceQueueHasItem(t *testing.T) {
cfg := repo(t, true)
// Plant a queue item to count as observable activity.
stateDir := filepath.Join(cfg.Workspace, "state")
if err := os.MkdirAll(stateDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(
filepath.Join(stateDir, "queue.md"),
[]byte("# eeco queue\n\n- [ ] **k** — t _(p, 2026-05-19)_\n"),
0o644,
); err != nil {
t.Fatal(err)
}
s := OneScreen(cfg, "x")
if strings.Contains(s, "eeco doctor") {
t.Errorf("hint should be suppressed once activity exists:\n%s", s)
}
}
func TestOneScreen_DoctorHintSuppressedOnceWorkflowScaffolded(t *testing.T) {
cfg := repo(t, true)
wfDir := filepath.Join(cfg.Workspace, "workflows", "demo")
if err := os.MkdirAll(wfDir, 0o755); err != nil {
t.Fatal(err)
}
s := OneScreen(cfg, "x")
if strings.Contains(s, "eeco doctor") {
t.Errorf("hint should be suppressed once a user workflow exists:\n%s", s)
}
}
func TestOneScreen_DoctorHintAbsentBeforeInit(t *testing.T) {
cfg := repo(t, false)
s := OneScreen(cfg, "x")
if strings.Contains(s, "eeco doctor") {
t.Errorf("hint should not fire before init:\n%s", s)
}
}
func TestBarLine_LiveCounts(t *testing.T) {
cfg := repo(t, true)
b := barLine(cfg, "0.0.0", "")
for _, want := range []string{"mem:0", "q:0", "auto:propose"} {
if !strings.Contains(b, want) {
t.Errorf("barLine missing %q: %s", want, b)
}
}
// Version is intentionally elided from the bar — the home block already
// surfaces it once on session start.
if strings.Contains(b, "eeco 0.0.0") {
t.Errorf("barLine should not duplicate the version banner: %s", b)
}
// An empty lastRun is omitted entirely (no placeholder noise).
if strings.Contains(b, "run:") {
t.Errorf("empty run should be omitted, got: %s", b)
}
// A real lastRun surfaces as a labelled field.
if b := barLine(cfg, "0.0.0", "leak-guard: ok (exit 0)"); !strings.Contains(b, "run:leak-guard") {
t.Errorf("barLine missing run field: %s", b)
}
}
// --- parsing & completion ---
func TestParseInput(t *testing.T) {
if p := parseInput(" "); p.name != "" || p.free != "" {
t.Errorf("blank should be a no-op, got %+v", p)
}
p := parseInput("/run --ai comment-hygiene")
if p.name != "run" || len(p.args) != 1 || p.args[0] != "comment-hygiene" || !p.ai {
t.Errorf("parse /run --ai: %+v", p)
}
if p := parseInput("why is the gate failing"); p.free != "why is the gate failing" {
t.Errorf("free text not captured: %+v", p)
}
if p := parseInput("/quit"); p.name != "quit" {
t.Errorf("parse /quit: %+v", p)
}
}
func TestComplete(t *testing.T) {
// Ambiguous command prefix -> longest common prefix + candidates.
got, cands := complete("/q", nil)
if got != "/qu" || len(cands) != 2 {
t.Errorf("/q completion: got %q cands %v", got, cands)
}
// Unique command prefix -> full command and a trailing space.
if got, _ := complete("/he", nil); got != "/help " {
t.Errorf("/he completion: %q", got)
}
// `/run` argument completes against the workflow names.
got, _ = complete("/run comm", []string{"comment-hygiene", "leak-guard"})
if got != "/run comment-hygiene " {
t.Errorf("/run arg completion: %q", got)
}
// Free text never completes.
if got, c := complete("explain", nil); got != "explain" || c != nil {
t.Errorf("free text should not complete: %q %v", got, c)
}
}
// --- dispatch ---
func TestDispatch_SyncCommands(t *testing.T) {
cfg := repo(t, true)
st := newStyles(false)
disp := func(input string) dispatchResult {
return dispatch(cfg, st, 80, parseInput(input))
}
join := func(r dispatchResult) string { return strings.Join(r.lines, "\n") }
if r := disp("/quit"); !r.quit {
t.Error("/quit should set quit")
}
if r := disp("/help"); len(r.lines) == 0 || !strings.Contains(join(r), "/run") {
t.Errorf("/help lines: %v", r.lines)
}
if r := disp("/hooks"); len(r.lines) == 0 || !strings.Contains(join(r), "pre-commit:") {
t.Errorf("/hooks should report live state: %v", r.lines)
}
if r := disp("/hooks pre-commit bogus"); !strings.Contains(join(r), "usage") {
t.Errorf("/hooks bad action usage: %v", r.lines)
}
if r := disp("/settings"); len(r.lines) == 0 || !strings.Contains(join(r), "automation") {
t.Errorf("/settings view: %v", r.lines)
}
if r := disp("/settings automation nonsense"); !strings.Contains(join(r), "must be manual") {
t.Errorf("/settings rejects bad automation: %v", r.lines)
}
if r := disp("/settings automation auto"); !strings.Contains(join(r), "set to") {
t.Errorf("/settings set automation: %v", r.lines)
}
if r := disp("/run"); !strings.Contains(join(r), "usage") {
t.Errorf("/run no-arg usage: %v", r.lines)
}
if r := disp("/bogus"); !strings.Contains(join(r), "unknown command") {
t.Errorf("unknown command: %v", r.lines)
}
if r := disp("/run comment-hygiene"); r.async != "run" || r.asyncS != "comment-hygiene" {
t.Errorf("/run should be async: %+v", r)
}
// Free-text chat is retired (C5): a non-slash line is handled
// synchronously with a dim hint, never an async pass.
if r := disp("ask the model"); r.async != "" || !strings.Contains(join(r), "free-text chat is retired") {
t.Errorf("free text should render the sync retirement hint, not an async pass: %+v", r)
}
}
// --- engine ops (read-only / workspace-only, no new write path) ---
func TestOpQueueAndMemory_Empty(t *testing.T) {
cfg := repo(t, true)
st := newStyles(false)
if got := opQueue(cfg, st, 80); !strings.Contains(strings.Join(got, "\n"), "empty") {
t.Errorf("opQueue empty: %v", got)
}
if got := opMemory(cfg, st, 80); !strings.Contains(strings.Join(got, "\n"), "no facts") {
t.Errorf("opMemory empty: %v", got)
}
}
func TestOpMemory_MarksPinnedAndDisabled(t *testing.T) {
cfg := repo(t, true)
store, err := memory.Open(cfg)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC)
mk := func(name, desc string, typ memory.FactType, opts ...func(*memory.Fact)) {
f := &memory.Fact{Name: name, Description: desc, Type: typ, Created: now, LastUsed: now}
for _, o := range opts {
o(f)
}
if err := store.Save(f); err != nil {
t.Fatal(err)
}
}
mk("plain-fact", "an active fact", memory.TypeProject)
mk("muted-fact", "a disabled fact", memory.TypeFeedback, func(f *memory.Fact) { f.Disabled = true })
mk("pinned-muted", "pinned and disabled", memory.TypeProject, func(f *memory.Fact) {
f.Pin = true
f.Disabled = true
})
st := newStyles(false)
lines := opMemory(cfg, st, 80)
got := strings.Join(lines, "\n")
for _, want := range []string{"fact", "description", "type"} {
if !strings.Contains(got, want) {
t.Errorf("opMemory header missing %q:\n%s", want, got)
}
}
findLine := func(needle string) string {
for _, ln := range lines {
if strings.Contains(ln, needle) {
return ln
}
}
return ""
}
cases := []struct{ slug, desc, marks string }{
{"plain-fact", "an active fact", "project"},
{"muted-fact", "a disabled fact", "[off]"},
{"pinned-muted", "pinned and disabled", "[pinned] [off]"},
}
for _, c := range cases {
ln := findLine(c.slug)
if ln == "" {
t.Errorf("opMemory missing slug %q:\n%s", c.slug, got)
continue
}
if !strings.Contains(ln, c.desc) {
t.Errorf("fact %q: description %q missing in %q", c.slug, c.desc, ln)
}
if !strings.Contains(ln, c.marks) {
t.Errorf("fact %q: marks %q missing in %q", c.slug, c.marks, ln)
}
}
}
func TestOpGC_GuardThenRun(t *testing.T) {
st := newStyles(false)
if got := opGC(repo(t, false), st, 80); !strings.Contains(strings.Join(got, "\n"), "not initialised") {
t.Errorf("opGC guard: %v", got)
}
if got := opGC(repo(t, true), st, 80); !strings.Contains(strings.Join(got, "\n"), "archived 0") {
t.Errorf("opGC run: %v", got)
}
}
func TestOpNew_GuardThenScaffold(t *testing.T) {
st := newStyles(false)
if got := opNew(repo(t, false), st, 80, "checks"); !strings.Contains(strings.Join(got, "\n"), "not initialised") {
t.Errorf("opNew guard: %v", got)
}
cfg := repo(t, true)
got := opNew(cfg, st, 80, "checks")
if !strings.Contains(strings.Join(got, "\n"), "scaffolded") {
t.Fatalf("opNew scaffold: %v", got)
}
if _, err := os.Stat(filepath.Join(cfg.Workspace, "workflows", "checks", "run")); err != nil {
t.Errorf("scaffolded entry missing: %v", err)
}
}
// --- model behaviour (headless: no Bubble Tea program loop) ---
func TestModel_HistoryNavigation(t *testing.T) {
m := newModel(repo(t, true), "v")
m.history = []string{"first", "second"}
m.histPos = len(m.history)
m.historyPrev()
if m.ta.Value() != "second" {
t.Fatalf("prev -> %q, want second", m.ta.Value())
}
m.historyPrev()
if m.ta.Value() != "first" {
t.Fatalf("prev -> %q, want first", m.ta.Value())
}
m.historyPrev() // clamps at oldest
if m.ta.Value() != "first" {
t.Fatalf("prev clamp -> %q, want first", m.ta.Value())
}
m.historyNext()
m.historyNext() // back to the (empty) live draft
if m.ta.Value() != "" {
t.Fatalf("next -> %q, want empty draft", m.ta.Value())
}
}
func TestModel_OverlayAndQuitKeys(t *testing.T) {
m := newModel(repo(t, true), "v")
// `?` on empty input opens the overlay; any key closes it.
tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("?")})
if !asModel(t, tm).overlay {
t.Fatal("? should open the overlay")
}
tm, _ = asModel(t, tm).onKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
if asModel(t, tm).overlay {
t.Fatal("any key should close the overlay")
}
// `q` on empty input quits.
tm, cmd := newModel(repo(t, true), "v").onKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
if !asModel(t, tm).quitting || cmd == nil {
t.Fatal("q on empty input should quit")
}
// Ctrl-C always quits.
tm, cmd = newModel(repo(t, true), "v").onKey(tea.KeyMsg{Type: tea.KeyCtrlC})
if !asModel(t, tm).quitting || cmd == nil {
t.Fatal("Ctrl-C should quit")
}
}
func TestModel_TabCompletesAndEscClears(t *testing.T) {
m := newModel(repo(t, true), "v")
m.ta.SetValue("/he")
tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyTab})
if v := asModel(t, tm).ta.Value(); v != "/help " {
t.Fatalf("Tab completion -> %q, want %q", v, "/help ")
}
m2 := asModel(t, tm)
tm, _ = m2.onKey(tea.KeyMsg{Type: tea.KeyEsc})
if asModel(t, tm).ta.Value() != "" {
t.Fatalf("Esc should clear input, got %q", asModel(t, tm).ta.Value())
}
}
// --- v1.3.0 home view ---
func TestRenderHome_GoldenWidths(t *testing.T) {
st := newStyles(false)
for _, w := range []int{80, 120, 200} {
t.Run("w"+strconv.Itoa(w), func(t *testing.T) {
got := renderHome(w, st, "v1.5.0", tips[0])
path := filepath.Join("testdata", "home_w"+strconv.Itoa(w)+".golden")
if *updateGolden {
if err := os.MkdirAll("testdata", 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
t.Fatal(err)
}
return
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read golden %s (run with -update to create): %v", path, err)
}
// Git for Windows can rewrite LF to CRLF on checkout when
// .gitattributes is not honoured (older clients, custom
// configs). Normalise so a CRLF golden still matches.
want := strings.ReplaceAll(string(raw), "\r\n", "\n")
if want != got {
t.Errorf("home view at width %d differs from %s — re-run with -update if intentional.\n--- want ---\n%s--- got ---\n%s", w, path, want, got)
}
})
}
}
func TestRenderHome_ShowsTipAndVersion(t *testing.T) {
st := newStyles(false)
got := renderHome(80, st, "v1.5.0", tips[0])
if !strings.Contains(got, tips[0]) {
t.Errorf("home view should contain the tip %q:\n%s", tips[0], got)
}
if !strings.Contains(got, "v1.5.0") {
t.Errorf("home view should contain the version line:\n%s", got)
}
// The home no longer lists commands; the / palette and ? overlay do.
if strings.Contains(got, "run memory garbage collection") {
t.Errorf("home view must not list commands (moved to / palette + ? overlay):\n%s", got)
}
}
func TestPickTip_ReturnsMember(t *testing.T) {
for i := 0; i < 50; i++ {
got := pickTip()
ok := false
for _, tp := range tips {
if tp == got {
ok = true
break
}
}
if !ok {
t.Fatalf("pickTip returned %q, not a member of tips", got)
}
}
}
func TestView_NeverContainsHomeBlock(t *testing.T) {
logoTop := strings.Split(eecoLogo, "\n")[0]
m := newModel(repo(t, false), "v")
if strings.Contains(m.View(), logoTop) {
t.Fatalf("View must not embed the home block (printed once via scrollback):\n%s", m.View())
}
m.ta.SetValue("/")
if strings.Contains(m.View(), logoTop) {
t.Errorf("logo must not appear when typing:\n%s", m.View())
}
m.ta.SetValue("")
if strings.Contains(m.View(), logoTop) {
t.Errorf("logo must not re-render on clear (no logo stacking):\n%s", m.View())
}
m.running = true
if strings.Contains(m.View(), logoTop) {
t.Errorf("logo must not appear while a background op is running:\n%s", m.View())
}
}
func TestUpdate_PrintsHomeOnceOnFirstWindowSize(t *testing.T) {
m := newModel(repo(t, false), "v")
if m.homePrinted {
t.Fatal("homePrinted must start false")
}
tm, cmd := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
got := asModel(t, tm)
if !got.homePrinted {
t.Fatal("first WindowSizeMsg must mark home printed")
}
if got.width != 80 {
t.Fatalf("width not stored: %d", got.width)
}
if cmd == nil {
t.Fatal("first WindowSizeMsg must emit a print command")
}
// Second WindowSizeMsg (e.g. terminal resize) must not re-print the
// home block — that would re-introduce the stacking the operator
// flagged.
tm2, cmd2 := got.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
got2 := asModel(t, tm2)
if got2.width != 100 {
t.Fatalf("resize width not stored: %d", got2.width)
}
if cmd2 != nil {
t.Fatal("second WindowSizeMsg must not re-print the home block")
}
}
func TestCommandIndex_MatchesSlashCommands(t *testing.T) {
if len(commandIndex) != len(slashCommands) {
t.Fatalf("len mismatch: commandIndex=%d slashCommands=%d", len(commandIndex), len(slashCommands))
}
for i, e := range commandIndex {
if e.name != slashCommands[i] {
t.Errorf("index %d: commandIndex=%q slashCommands=%q", i, e.name, slashCommands[i])
}
}
}
func TestModel_AsyncResultGenerationGuard(t *testing.T) {
m := newModel(repo(t, true), "v")
m.gen = 5
m.running = true
// A stale result (interrupted: gen advanced) is dropped, leaving the
// running flag untouched for the still-current operation.
tm, _ := m.Update(asyncResultMsg{gen: 4, lines: []string{"stale"}})
if !asModel(t, tm).running {
t.Fatal("stale result must not clear running")
}
// The matching result is accepted and clears running.
tm, _ = m.Update(asyncResultMsg{gen: 5, lines: []string{"ok"}, isRun: true, summary: "run x: ok"})
got := asModel(t, tm)
if got.running || got.lastRun != "run x: ok" {
t.Fatalf("current result not applied: running=%v lastRun=%q", got.running, got.lastRun)
}
}
// --- v1.3.0 slash-command palette ---
func TestPaletteOpen(t *testing.T) {
m := newModel(repo(t, true), "v")
cases := []struct {
in string
want bool
}{
{"/", true}, // bare slash opens
{"/me", true}, // command token still being typed
{"/run ", false}, // committed command + space -> argument mode
{"", false}, // empty never opens
{"hello", false}, // plain text never opens
}
for _, c := range cases {
m.ta.SetValue(c.in)
if got := m.paletteOpen(); got != c.want {
t.Errorf("paletteOpen(%q)=%v, want %v", c.in, got, c.want)
}
}
}
func TestPaletteItems_Filter(t *testing.T) {
m := newModel(repo(t, true), "v")
m.ta.SetValue("/")
if got := len(m.paletteItems()); got != len(commandIndex) {
t.Fatalf("bare slash should list all commands; got %d want %d", got, len(commandIndex))
}
m.ta.SetValue("/h")
items := m.paletteItems()
if len(items) != 2 || items[0].name != "/help" || items[1].name != "/hooks" {
t.Fatalf("/h should prefix-match /help,/hooks; got %v", items)
}
m.ta.SetValue("/zz")
if got := len(m.paletteItems()); got != 0 {
t.Fatalf("/zz should match nothing; got %d", got)
}
}
func TestPalette_CursorMoveClampAndReset(t *testing.T) {
m := newModel(repo(t, true), "v")
m.ta.SetValue("/")
m.ta.CursorEnd()
n := len(m.paletteItems())
// Down past the bottom clamps at the last row.
for i := 0; i < n+3; i++ {
tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyDown})
m = asModel(t, tm)
}
if m.pal.cursor != n-1 {
t.Fatalf("Down clamp: cursor=%d want %d", m.pal.cursor, n-1)
}
// Up past the top clamps at 0.
for i := 0; i < n+3; i++ {
tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyUp})
m = asModel(t, tm)
}
if m.pal.cursor != 0 {
t.Fatalf("Up clamp: cursor=%d want 0", m.pal.cursor)
}
// Advance, then change the filter by typing -> cursor snaps back to 0.
tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyDown})
m = asModel(t, tm)
if m.pal.cursor == 0 {
t.Fatal("Down should have advanced the cursor before the filter test")
}
tm, _ = m.onKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("h")})
m = asModel(t, tm)
if m.ta.Value() != "/h" {
t.Fatalf("typing should filter the input; ta=%q", m.ta.Value())
}
if m.pal.cursor != 0 {
t.Fatalf("a filter change must reset the cursor to 0; cursor=%d", m.pal.cursor)
}
}
func TestPalette_AcceptFillsAndCloses(t *testing.T) {
// Both Tab and Enter accept the highlighted row; neither submits.
for _, key := range []tea.KeyType{tea.KeyTab, tea.KeyEnter} {
m := newModel(repo(t, true), "v")
m.ta.SetValue("/h") // -> /help,/hooks; cursor 0 = /help
m.ta.CursorEnd()
tm, _ := m.onKey(tea.KeyMsg{Type: key})
got := asModel(t, tm)
if got.ta.Value() != "/help " {
t.Fatalf("%v accept: ta=%q want %q", key, got.ta.Value(), "/help ")
}
if got.paletteOpen() {
t.Fatalf("%v accept must close the palette", key)
}
if got.quitting {
t.Fatalf("%v accept must not submit/quit", key)
}
}
}
func TestPalette_KeyRouting(t *testing.T) {
// Palette open: Up/Down move the highlight, never the command history.
open := newModel(repo(t, true), "v")
open.history = []string{"old"}
open.histPos = len(open.history)
open.ta.SetValue("/")
open.ta.CursorEnd()
tm, _ := open.onKey(tea.KeyMsg{Type: tea.KeyDown})
if v := asModel(t, tm).ta.Value(); v != "/" {
t.Fatalf("palette-open Down must not navigate history; ta=%q", v)
}
tm, _ = open.onKey(tea.KeyMsg{Type: tea.KeyUp})
if v := asModel(t, tm).ta.Value(); v != "/" {
t.Fatalf("palette-open Up must not navigate history; ta=%q", v)
}
// Palette closed: Up still browses history.
closed := newModel(repo(t, true), "v")
closed.history = []string{"old"}
closed.histPos = len(closed.history)
tm, _ = closed.onKey(tea.KeyMsg{Type: tea.KeyUp})
if v := asModel(t, tm).ta.Value(); v != "old" {
t.Fatalf("palette-closed Up must browse history; ta=%q", v)
}
}
func TestRenderPalette_Content(t *testing.T) {
st := newStyles(false) // plain styles so assertions read raw text
items := []cmdEntry{
{"/gc", "run memory garbage collection"},
{"/help", "command and key reference"},
}
out := renderPalette(items, 0, 80, st)
for _, w := range []string{"/gc", "run memory garbage collection", "/help", "command and key reference"} {
if !strings.Contains(out, w) {
t.Errorf("renderPalette missing %q:\n%s", w, out)
}
}
if !strings.Contains(out, "›") {
t.Errorf("the selected row should carry the › marker:\n%s", out)
}
if got := renderPalette(nil, 0, 80, st); !strings.Contains(got, "no match") {
t.Errorf("an empty palette should render \"no match\"; got %q", got)
}
}
func TestRenderPalette_ScrollKeepsSelectionVisible(t *testing.T) {
st := newStyles(false)
// More items than the row cap: the highlighted row must stay visible
// (regression for the cursor-past-the-window overflow bug).
items := make([]cmdEntry, paletteMaxRows+4)
for i := range items {
items[i] = cmdEntry{name: fmt.Sprintf("/cmd%02d", i), purpose: fmt.Sprintf("does thing %d", i)}
}
last := len(items) - 1
out := renderPalette(items, last, 80, st)
sel := items[last]
if !strings.Contains(out, sel.name) || !strings.Contains(out, sel.purpose) {
t.Errorf("selected (last) row %q must be visible when scrolled:\n%s", sel.name, out)
}
// The marker must sit on the selected row's line, not a stale top row.
for _, ln := range strings.Split(out, "\n") {
if strings.Contains(ln, "›") && !strings.Contains(ln, sel.name) {
t.Errorf("marker on the wrong row %q (selected %q):\n%s", ln, sel.name, out)
}
}
if !strings.Contains(out, "more") {
t.Errorf("hidden rows should surface a \"+N more\" line:\n%s", out)
}
}
func TestView_PaletteOpenVsClosed(t *testing.T) {
m := newModel(repo(t, false), "v")
m.width = 80
m.ta.SetValue("/")
open := m.View()
if !strings.Contains(open, "/help") || !strings.Contains(open, "command and key reference") {
t.Errorf("open palette View should list commands and purposes:\n%s", open)
}
// Closed: no command rows leak into the live region.
m.ta.SetValue("")
if closed := m.View(); strings.Contains(closed, "command and key reference") {
t.Errorf("closed palette must not render rows:\n%s", closed)
}
}
// --- v1.4.0 multi-line composer + animated spinner ---
func TestModel_EnterSubmitsAltEnterNewline(t *testing.T) {
// Plain Enter on a non-empty line submits and clears the composer. Free
// text is handled synchronously now (chat retired in C5), so it prints a
// hint without starting a background op.
m := newModel(repo(t, true), "v")
m.ta.SetValue("summarise the project")
tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyEnter})
got := asModel(t, tm)
if got.running {
t.Fatal("free text must not start a background op (chat retired)")
}
if got.ta.Value() != "" {
t.Fatalf("submit should clear the composer; got %q", got.ta.Value())
}
// Alt+Enter inserts a newline rather than submitting.
m2 := newModel(repo(t, true), "v")
m2.ta.SetValue("line one")
m2.ta.CursorEnd()
tm2, _ := m2.onKey(tea.KeyMsg{Type: tea.KeyEnter, Alt: true})
got2 := asModel(t, tm2)
if got2.running || got2.quitting {
t.Fatal("Alt+Enter must not submit")
}
if !strings.Contains(got2.ta.Value(), "\n") {
t.Fatalf("Alt+Enter should insert a newline; got %q", got2.ta.Value())
}
}
func TestModel_CtrlJInsertsNewline(t *testing.T) {
// Ctrl+J is the literal-LF fallback for terminals that swallow Alt+Enter.
m := newModel(repo(t, true), "v")
m.ta.SetValue("abc")
m.ta.CursorEnd()
tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyCtrlJ})
got := asModel(t, tm)
if got.running || got.quitting {
t.Fatal("Ctrl+J must not submit or quit")
}
if !strings.Contains(got.ta.Value(), "\n") {
t.Fatalf("Ctrl+J should insert a newline; got %q", got.ta.Value())
}
}
func TestModel_UpDownLineBoundaryRouting(t *testing.T) {
m := newModel(repo(t, true), "v")
m.ta.SetWidth(80)
m.history = []string{"recalled"}
m.histPos = len(m.history)
m.ta.SetValue("top\nmiddle\nbottom") // cursor lands on the last line
m.reflowHeight()
// Move the cursor to a middle line; Up there moves the cursor, it does
// not recall history.
m.ta.CursorUp()
if m.ta.Line() != 1 {
t.Fatalf("precondition: cursor should be on the middle line; Line=%d", m.ta.Line())
}
tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyUp})
got := asModel(t, tm)
if got.ta.Value() != "top\nmiddle\nbottom" {
t.Fatalf("Up off the boundary must not recall history; value=%q", got.ta.Value())
}
if got.ta.Line() != 0 {
t.Fatalf("Up off the boundary should move the cursor up; Line=%d want 0", got.ta.Line())
}
// Up on the first line (top boundary) recalls history.
tm, _ = got.onKey(tea.KeyMsg{Type: tea.KeyUp})
if v := asModel(t, tm).ta.Value(); v != "recalled" {
t.Fatalf("Up at line 0 should recall history; got %q", v)
}
// Down on the last line (bottom boundary) restores the saved multi-line
// draft (history forward past the newest entry).
m2 := newModel(repo(t, true), "v")
m2.ta.SetWidth(80)
m2.history = []string{"recalled"}
m2.histPos = len(m2.history)
m2.ta.SetValue("a\nb")
m2.reflowHeight()
m2.ta.CursorUp() // to line 0 so the recall fires
tm2, _ := m2.onKey(tea.KeyMsg{Type: tea.KeyUp})
got2 := asModel(t, tm2)
if got2.ta.Value() != "recalled" {
t.Fatalf("setup recall failed; got %q", got2.ta.Value())
}
tm2, _ = got2.onKey(tea.KeyMsg{Type: tea.KeyDown})
if v := asModel(t, tm2).ta.Value(); v != "a\nb" {
t.Fatalf("Down at the bottom should restore the multi-line draft; got %q", v)
}
}
func TestModel_SingleLineHistoryRegression(t *testing.T) {
// A single-line draft has Line()==0==LineCount()-1, so Up and Down route
// to history exactly as before the multi-line composer.
m := newModel(repo(t, true), "v")
m.history = []string{"first", "second"}
m.histPos = len(m.history)
tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyUp})
m = asModel(t, tm)
if m.ta.Value() != "second" {
t.Fatalf("Up -> %q, want second", m.ta.Value())
}
tm, _ = m.onKey(tea.KeyMsg{Type: tea.KeyUp})
m = asModel(t, tm)
if m.ta.Value() != "first" {
t.Fatalf("Up -> %q, want first", m.ta.Value())
}
tm, _ = m.onKey(tea.KeyMsg{Type: tea.KeyDown})
m = asModel(t, tm)
if m.ta.Value() != "second" {
t.Fatalf("Down -> %q, want second", m.ta.Value())
}
}
func TestModel_ReflowHeight(t *testing.T) {
m := newModel(repo(t, true), "v")
m.ta.SetWidth(80)
m.ta.SetValue("a\nb\nc")
m.reflowHeight()
if h := m.ta.Height(); h != 3 {
t.Fatalf("a 3-line draft should reflow to 3 rows; Height=%d", h)
}
m.ta.SetValue("")
m.reflowHeight()
if h := m.ta.Height(); h != 1 {
t.Fatalf("a cleared draft should shrink to 1 row; Height=%d", h)
}
// More lines than the cap clamp at inputMaxRows (then the box scrolls).
m.ta.SetValue(strings.Repeat("x\n", inputMaxRows+5))
m.reflowHeight()
if h := m.ta.Height(); h != inputMaxRows {
t.Fatalf("a long draft should clamp at inputMaxRows=%d; Height=%d", inputMaxRows, h)
}
}
func TestModel_SpinnerTickLifecycle(t *testing.T) {
m := newModel(repo(t, true), "v")
// Running: a tick advances the spinner and re-arms the loop.
m.running = true
_, cmd := m.Update(spinner.TickMsg{})
if cmd == nil {
t.Fatal("a spinner tick while running must return a follow-up command")
}
// Idle: the tick loop dies so the spinner cannot spin forever.
m.running = false
_, cmd = m.Update(spinner.TickMsg{})
if cmd != nil {
t.Fatal("a spinner tick while idle must not re-arm the loop")
}
}
func TestModel_SpinnerInView(t *testing.T) {
m := newModel(repo(t, true), "v")
m.width = 80
idle := m.View()
if strings.Contains(idle, "working") {
t.Errorf("idle footer must not show the working label:\n%s", idle)
}
if strings.ContainsAny(idle, miniDotFrames) {
t.Errorf("idle footer must not show a spinner frame:\n%s", idle)
}
m.running = true
run := m.View()
if !strings.Contains(run, "working (Esc to interrupt)") {
t.Errorf("running footer should show the working label:\n%s", run)
}
if !strings.ContainsAny(run, miniDotFrames) {
t.Errorf("running footer should show a MiniDot spinner frame:\n%s", run)
}
}
func TestModel_AltEnterPaletteOpenInsertsNewline(t *testing.T) {
// With the palette open ("/he"), Alt+Enter inserts a newline (closing the
// palette into free text), never accepts the highlighted command —
// consistent with Ctrl+J, which already falls through.
m := newModel(repo(t, true), "v")
m.ta.SetValue("/he")
m.ta.CursorEnd()
if !m.paletteOpen() {
t.Fatal("precondition: palette should be open for /he")
}
tm, _ := m.onKey(tea.KeyMsg{Type: tea.KeyEnter, Alt: true})
got := asModel(t, tm)
if !strings.Contains(got.ta.Value(), "\n") {
t.Fatalf("Alt+Enter with palette open should insert a newline; got %q", got.ta.Value())
}
if got.ta.Value() == "/help " {
t.Fatal("Alt+Enter must not accept the palette selection")
}
if got.paletteOpen() {
t.Fatal("a newline must close the palette")
}
}
func TestPaletteClosedOnMultiline(t *testing.T) {
m := newModel(repo(t, true), "v")
// A leading slash but containing a newline is free text, not a command.
m.ta.SetValue("/foo\nbar")
if m.paletteOpen() {
t.Error("a multi-line value must close the palette even with a leading slash")
}
// A whitespace control char (tab) likewise closes it.
m.ta.SetValue("/foo\tbar")
if m.paletteOpen() {
t.Error("a tab in the value must close the palette")
}
}
// --- H1.4: dispatch / op-branch + async-guard depth (no seam, no prod code) ---
// opRun: a bad operator attribution pattern fails NewDetector before any
// workflow runs, so the run reports the compile error and never spends AI.
func TestOpRun_DetectorErrorBadAttributionPattern(t *testing.T) {
cfg := repo(t, true)
cfg.AttributionPatterns = []string{"("} // unterminated group → compile error
st := newStyles(false)
summary, lines, _ := opRun(cfg, st, 80, "comment-hygiene", false)
if !strings.Contains(summary, "run comment-hygiene:") {
t.Errorf("a detector compile error should surface in the summary: %q", summary)
}
if !strings.Contains(strings.Join(lines, "\n"), "run comment-hygiene") {
t.Errorf("the error section should carry the run title:\n%s", strings.Join(lines, "\n"))
}
}
// opRun: an unknown name misses the registry and errors through ScriptRun.
func TestOpRun_ScriptRunErrorUnknownWorkflow(t *testing.T) {
cfg := repo(t, true)
st := newStyles(false)
summary, lines, _ := opRun(cfg, st, 80, "no-such-wf", false)
if !strings.Contains(summary, "run no-such-wf:") {
t.Errorf("an unknown workflow should error through ScriptRun: %q", summary)
}
if !strings.Contains(strings.Join(lines, "\n"), "run no-such-wf") {
t.Errorf("the error section should carry the run title:\n%s", strings.Join(lines, "\n"))
}
}
// opRun: the comment-hygiene builtin runs on the temp tree (filesystem walk,
// no real git, no AI) and reports a clean, no-findings success.
func TestOpRun_CommentHygieneCleanTreeNoFindings(t *testing.T) {
cfg := repo(t, true)
st := newStyles(false)
summary, lines, _ := opRun(cfg, st, 80, "comment-hygiene", false)
got := strings.Join(lines, "\n")
if !strings.Contains(got, "no findings") {
t.Errorf("a clean tree should render the no-findings body:\n%s", got)
}
if !strings.Contains(summary, "run comment-hygiene:") {
t.Errorf("the summary should describe the run: %q", summary)
}
}
// runNames lists the builtins, tolerates a nil config, and surfaces a
// workspace-scaffolded workflow (the e.IsDir() arm over workflows/).
func TestRunNames_BuiltinsWorkspaceAndNilConfig(t *testing.T) {
cfg := repo(t, true)
base := runNames(cfg)
for _, want := range []string{"comment-hygiene", "leak-guard"} {
if !slices.Contains(base, want) {
t.Errorf("runNames should list builtin %q; got %v", want, base)
}
}
if got := runNames(nil); !slices.Contains(got, "comment-hygiene") {
t.Errorf("runNames(nil) should list builtins; got %v", got)
}
st := newStyles(false)
if out := opNew(cfg, st, 80, "demo"); !strings.Contains(strings.Join(out, "\n"), "scaffolded") {
t.Fatalf("precondition: opNew should scaffold demo: %v", out)
}
if got := runNames(cfg); !slices.Contains(got, "demo") {
t.Errorf("runNames should include the scaffolded workflow; got %v", got)
}
}
// startAsync: invoke the returned tea.Cmd for each dispatchResult branch and
// assert the asyncResultMsg it produces (the three never-invoked arms + the
// default fall-through).
func TestModel_StartAsyncBranchesInvoked(t *testing.T) {
ctx := context.Background()
const gen = 7
invoke := func(m model, res dispatchResult) asyncResultMsg {
t.Helper()
m.width = 80
cmd := m.startAsync(ctx, gen, res)
msg, ok := cmd().(asyncResultMsg)
if !ok {
t.Fatalf("startAsync cmd should return an asyncResultMsg")
}
if msg.gen != gen {
t.Errorf("result gen = %d, want %d", msg.gen, gen)
}
return msg
}
// gc: runs opGC, never a run.
if gcMsg := invoke(newModel(repo(t, true), "v"), dispatchResult{async: "gc"}); gcMsg.isRun {
t.Error("gc branch must not flag isRun")
}
// run: runs opRun, isRun with a summary.
runMsg := invoke(newModel(repo(t, true), "v"), dispatchResult{async: "run", asyncS: "comment-hygiene"})
if !runMsg.isRun || !strings.Contains(runMsg.summary, "run comment-hygiene") {
t.Errorf("run branch should set isRun + summary: %+v", runMsg)
}
// default: an unrecognised async yields a bare gen result. (Free-text
// chat was retired in C5, so there is no longer a "free" async branch.)
defMsg := invoke(newModel(repo(t, true), "v"), dispatchResult{async: ""})
if defMsg.isRun || len(defMsg.lines) != 0 {
t.Errorf("default branch should return a bare gen result: %+v", defMsg)
}
}
// onKey: Esc while an op is running cancels it, bumps the generation to
// invalidate the in-flight result, and clears the running/cancel state —
// distinct from the Esc-clears-input arm when idle.
func TestModel_EscInterruptsRunningOp(t *testing.T) {
m := newModel(repo(t, true), "v")
m.running = true
m.gen = 4
cancelled := false
m.cancel = func() { cancelled = true }
tm, cmd := m.onKey(tea.KeyMsg{Type: tea.KeyEsc})
got := asModel(t, tm)
if got.gen != 5 {
t.Errorf("Esc while running should bump gen; gen=%d want 5", got.gen)
}
if got.running {
t.Error("Esc while running should clear running")
}
if got.cancel != nil {
t.Error("Esc while running should clear the cancel func")
}
if !cancelled {
t.Error("Esc while running should invoke the cancel func")
}
if cmd == nil {
t.Error("Esc while running should emit the interrupted notice")
}
}
// opSettings: the uninitialised guard plus the per-key validation rejects.
func TestOpSettings_GuardAndValidationErrors(t *testing.T) {
st := newStyles(false)
if got := strings.Join(opSettings(repo(t, false), st, 80, []string{"automation", "auto"}), "\n"); !strings.Contains(got, "not initialised") {
t.Errorf("uninitialised settings should guard:\n%s", got)
}
cfg := repo(t, true)
if got := strings.Join(opSettings(cfg, st, 80, []string{"ai_budget", "-1"}), "\n"); !strings.Contains(got, "non-negative") {
t.Errorf("a negative ai_budget should be rejected:\n%s", got)
}
// One token → val == "" (a direct call: parseInput would drop the empty
// trailing token, so build args by hand).
if got := strings.Join(opSettings(cfg, st, 80, []string{"ai_command"}), "\n"); !strings.Contains(got, "needs an argv") {
t.Errorf("an empty ai_command should be rejected:\n%s", got)
}
if got := strings.Join(opSettings(cfg, st, 80, []string{"bogus", "x"}), "\n"); !strings.Contains(got, "unknown key") {
t.Errorf("an unknown key should render usage:\n%s", got)
}
}
// opSettings: a configured provider renders the "configured" view.
func TestOpSettings_ProviderConfiguredView(t *testing.T) {
cfg := repo(t, true)
cfg.AICommand = []string{"x"}
st := newStyles(false)
got := strings.Join(opSettings(cfg, st, 80, nil), "\n")
if !strings.Contains(got, "configured") || strings.Contains(got, "every AI pass is parked") {
t.Errorf("a configured provider should render the configured view:\n%s", got)
}
}
// opSettings: a WriteLocalKeys failure (config.local is a directory →
// ReadFile errors) wraps as a settings error. Assert the wrap text, never the
// errno (Windows-safe).
func TestOpSettings_WriteLocalKeysErrorWraps(t *testing.T) {
cfg := repo(t, true)
localPath := filepath.Join(cfg.Workspace, config.LocalFilename)
if err := os.RemoveAll(localPath); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(localPath, 0o755); err != nil {
t.Fatal(err)
}
st := newStyles(false)
// A valid key (validated before the write) so the failure is the write.
got := strings.Join(opSettings(cfg, st, 80, []string{"automation", "propose"}), "\n")
if !strings.Contains(got, "settings:") {
t.Errorf("a WriteLocalKeys failure should wrap as a settings error:\n%s", got)
}
}
// opHooks: toggling the pre-commit hook on then off reports success. Use the
// hooks.PreCommit constant (== "pre-commit"); a CamelCase literal falls
// through to usage. Assert message text only, never the file mode.
func TestOpHooks_PreCommitToggleSucceeds(t *testing.T) {
cfg := repo(t, true)
st := newStyles(false)
if on := strings.Join(opHooks(cfg, st, 80, []string{hooks.PreCommit, "on"}), "\n"); !strings.Contains(on, "hooks:") {
t.Errorf("pre-commit on should report success:\n%s", on)
}
if off := strings.Join(opHooks(cfg, st, 80, []string{hooks.PreCommit, "off"}), "\n"); !strings.Contains(off, "hooks:") {
t.Errorf("pre-commit off should report success:\n%s", off)
}
}
// opHooks: toggling session-start on then off reports success. The settings
// path must be set first, else EnableSessionStart returns
// ErrSessionNotConfigured.
func TestOpHooks_SessionStartToggleSucceeds(t *testing.T) {
cfg := repo(t, true)
cfg.SessionSettingsPath = filepath.Join(t.TempDir(), "settings.json")
st := newStyles(false)
if on := strings.Join(opHooks(cfg, st, 80, []string{hooks.SessionStart, "on"}), "\n"); !strings.Contains(on, "hooks:") {
t.Errorf("session-start on should report success:\n%s", on)
}
if off := strings.Join(opHooks(cfg, st, 80, []string{hooks.SessionStart, "off"}), "\n"); !strings.Contains(off, "hooks:") {
t.Errorf("session-start off should report success:\n%s", off)
}
}
// opHooks: wrong arity renders usage; session-start on with nothing configured
// reaches the error arm via ErrSessionNotConfigured.
func TestOpHooks_ArgArityAndConfigErrorArms(t *testing.T) {
st := newStyles(false)
usage := strings.Join(opHooks(repo(t, true), st, 80, []string{"a", "b", "c"}), "\n")
if !strings.Contains(usage, "usage") {
t.Errorf("a 3-arg hooks call should render usage:\n%s", usage)
}
errOut := strings.Join(opHooks(repo(t, true), st, 80, []string{hooks.SessionStart, "on"}), "\n")
if !strings.Contains(errOut, "session-start not configured") {
t.Errorf("session-start on (unconfigured) should hit the error arm:\n%s", errOut)
}
}
// opQueue: a non-empty queue file renders the open-count and the item body.
func TestOpQueue_NonEmptyBody(t *testing.T) {
cfg := repo(t, true)
stateDir := filepath.Join(cfg.Workspace, "state")
if err := os.MkdirAll(stateDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(
filepath.Join(stateDir, "queue.md"),
[]byte("# eeco queue\n\n- [ ] **k** — t _(p, 2026-05-19)_\n"),
0o644,
); err != nil {
t.Fatal(err)
}
st := newStyles(false)
got := strings.Join(opQueue(cfg, st, 80), "\n")
if !strings.Contains(got, "open") {
t.Errorf("a non-empty queue should report an open count:\n%s", got)
}
if !strings.Contains(got, "**k**") {
t.Errorf("a non-empty queue should render the item body:\n%s", got)
}
}
// opGC: a missing-ref reference fact is archived (clock-free: the missing-ref
// trigger fires before any staleness check); an ordinary active fact is kept
// and skipped from the action body. A disabled fact would map to "kept", so a
// missing-ref reference fact is used for the non-kept format line.
func TestOpGC_FormatsArchivedAndKeptActions(t *testing.T) {
cfg := repo(t, true)
store, err := memory.Open(cfg)
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC)
refFact := &memory.Fact{
Name: "dangling-ref", Description: "points nowhere",
Type: memory.TypeReference, Ref: "no/such/file",
Created: now, LastUsed: now,
}
if err := store.Save(refFact); err != nil {
t.Fatal(err)
}
keepFact := &memory.Fact{
Name: "keeper", Description: "still relevant",
Type: memory.TypeProject, Created: now, LastUsed: now,
}
if err := store.Save(keepFact); err != nil {
t.Fatal(err)
}
st := newStyles(false)
got := strings.Join(opGC(cfg, st, 80), "\n")
if !strings.Contains(got, "archived 1") {
t.Errorf("opGC should report one archived fact:\n%s", got)
}
if !strings.Contains(got, "dangling-ref") || !strings.Contains(got, "ref missing") {
t.Errorf("opGC should format the archived action line:\n%s", got)
}
if strings.Contains(got, "keeper") {
t.Errorf("a kept fact must be skipped from the action body:\n%s", got)
}
}
// opMemory: a file sitting where the memory directory should be makes
// memory.Open's MkdirAll fail (not-a-directory), surfacing a memory error.
// Built on repo(t,false) so Init does not pre-create memory/.
func TestOpMemory_OpenErrorOnFileAtMemoryPath(t *testing.T) {
cfg := repo(t, false)
if err := os.MkdirAll(cfg.Workspace, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(cfg.Workspace, "memory"), []byte("not a dir\n"), 0o644); err != nil {
t.Fatal(err)
}
st := newStyles(false)
if got := strings.Join(opMemory(cfg, st, 80), "\n"); !strings.Contains(got, "memory:") {
t.Errorf("a file at the memory path should surface an Open error:\n%s", got)
}
}
// opMemory: a malformed fact file aborts LoadAll, surfacing a memory error
// rather than the empty-store section.
func TestOpMemory_LoadAllErrorOnMalformedFact(t *testing.T) {
cfg := repo(t, true)
if err := os.WriteFile(filepath.Join(cfg.Workspace, "memory", "not-a-fact.md"), []byte("garbage\n"), 0o644); err != nil {
t.Fatal(err)
}
st := newStyles(false)
got := strings.Join(opMemory(cfg, st, 80), "\n")
if !strings.Contains(got, "memory:") {
t.Errorf("a malformed fact should surface a LoadAll error:\n%s", got)
}
if strings.Contains(got, "no facts") {
t.Errorf("the error path must not fall through to the empty section:\n%s", got)
}
}
// opNew: a name the scaffolder rejects surfaces a new error.
func TestOpNew_ScaffoldErrorBadName(t *testing.T) {
st := newStyles(false)
got := strings.Join(opNew(repo(t, true), st, 80, "Bad Name"), "\n")
if !strings.Contains(got, "new:") {
t.Errorf("a bad workflow name should surface a scaffold error:\n%s", got)
}
if strings.Contains(got, "scaffolded") {
t.Errorf("a rejected name must not report success:\n%s", got)
}
}
// dispatch: the genuinely-uncovered queue/memory/gc/new arms (the
// quit/clear/help/hooks/settings/run/bogus/free arms are covered elsewhere).
func TestDispatch_QueueMemoryGCNewArms(t *testing.T) {
cfg := repo(t, true)
st := newStyles(false)
disp := func(input string) dispatchResult { return dispatch(cfg, st, 80, parseInput(input)) }
join := func(r dispatchResult) string { return strings.Join(r.lines, "\n") }
if r := disp("/queue"); len(r.lines) == 0 || !strings.Contains(join(r), "queue") {
t.Errorf("/queue should render the queue section: %v", r.lines)
}
if r := disp("/memory"); len(r.lines) == 0 || !strings.Contains(join(r), "memory") {
t.Errorf("/memory should render the memory section: %v", r.lines)
}
if r := disp("/gc"); r.async != "gc" {
t.Errorf("/gc should dispatch the async gc op: %+v", r)
}
if r := disp("/new demo"); !strings.Contains(join(r), "scaffolded") {
t.Errorf("/new <name> should scaffold: %v", r.lines)
}
if r := disp("/new"); !strings.Contains(join(r), "usage") {
t.Errorf("/new no-arg should render usage: %v", r.lines)
}
}
// Init returns the textarea blink command.
func TestModel_InitReturnsBlink(t *testing.T) {
if cmd := newModel(repo(t, true), "v").Init(); cmd == nil {
t.Fatal("Init must return a non-nil (blink) command")
}
}
// truncate: the three cut arms plus the passthrough.
func TestTruncate_Table(t *testing.T) {
cases := []struct {
s string
w int
want string
}{
{"x", 0, ""}, // w <= 0
{"ab", 1, "a"}, // w <= 1, must cut
{"abcdef", 3, "ab…"}, // cut with ellipsis
{"ab", 5, "ab"}, // len(r) <= w passthrough
}
for _, c := range cases {
if got := truncate(c.s, c.w); got != c.want {
t.Errorf("truncate(%q,%d) = %q, want %q", c.s, c.w, got, c.want)
}
}
}
// View: the quitting short-circuit and the overlay branch (behavioral, not
// golden).
func TestView_QuittingAndOverlay(t *testing.T) {
q := newModel(repo(t, true), "v")
q.quitting = true
if q.View() != "" {
t.Errorf("a quitting model renders an empty view; got %q", q.View())
}
o := newModel(repo(t, true), "v")
o.overlay = true
o.width = 80
if !strings.Contains(o.View(), "press any key to close") {
t.Errorf("an open overlay should render the close hint:\n%s", o.View())
}
}
added internal/workflow/attribution.go
@@ -0,0 +1,104 @@
package workflow
import (
"bufio"
"fmt"
"regexp"
"strings"
)
// Detector finds AI-attribution fingerprints in text. It backs both the
// comment-hygiene and leak-guard workflows and enforces Constraint 3
// ("no AI attribution") — which eeco applies to its own repository.
//
// Self-clean by construction: the sensitive trigger literals are
// assembled from fragments at runtime, so this source file contains no
// contiguous attribution string for the detector to flag when it scans
// eeco's own tracked tree. The trailer rule is line-anchored so a prose
// mention of the trailer's name (for example in documentation) is not a
// false positive — only an actual trailer line is.
type Detector struct {
patterns []namedPattern
}
type namedPattern struct {
what string
re *regexp.Regexp
}
// fragment assembly: keeping these split means the full trigger token
// never appears verbatim in tracked source.
var (
coAuthored = "[Cc]o-" + "[Aa]uthored-" + "[Bb]y"
genVerb = "[Gg]enerated"
// Tool tokens are word-bounded and case-scoped on purpose: a global
// (?i) would let the bare letters "ai" inside ordinary prose (for
// example "fair") trip the gate, which would make it untrustworthy
// (Constraint 5). Generic words like "model" are excluded for the
// same reason; operators add project-specific tokens via config.
assistanten = `\b(?:[Aa]ssistant|[Aa]gent|[Cc]opilot|[Bb]ot|AI|CLI|LLM)\b`
robotEmoji = "\\x{1F916}" // U+1F916; not written as a literal glyph here.
)
// NewDetector builds the detector with the default denylist plus any
// operator-supplied extra patterns (compiled as regular expressions).
// An invalid extra pattern is an error so a typo is loud, not silent.
func NewDetector(extra []string) (*Detector, error) {
d := &Detector{patterns: []namedPattern{
// An actual trailer line: anchored to line start so a prose or
// backticked mention of the name is not flagged.
{"co-authored-by trailer", regexp.MustCompile(`(?m)^\s*` + coAuthored + `:\s*\S`)},
// "Generated with/by <AI-ish tool>" co-marketing line.
{"generated-by attribution", regexp.MustCompile(genVerb + ` (?:[Ww]ith|[Bb]y) [^\n]{0,40}?` + assistanten)},
// Robot-emoji-prefixed generated line.
{"robot-emoji attribution", regexp.MustCompile(`(?m)` + robotEmoji + `[^\n]{0,20}` + genVerb)},
}}
for i, p := range extra {
re, err := regexp.Compile(p)
if err != nil {
return nil, fmt.Errorf("attribution_pattern[%d] %q: %w", i, p, err)
}
d.patterns = append(d.patterns, namedPattern{"configured pattern", re})
}
return d, nil
}
// Scan returns one Finding per matching line. path is recorded on each
// Finding for reporting; it is not inspected. A line that trips several
// patterns is reported once (first match wins) to keep reports terse.
func (d *Detector) Scan(path, content string) []Finding {
var out []Finding
sc := bufio.NewScanner(strings.NewReader(content))
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
ln := 0
for sc.Scan() {
ln++
line := sc.Text()
for _, p := range d.patterns {
// The trailer pattern is intentionally multi-line-anchored;
// evaluating it per line keeps the anchor meaningful and the
// line number exact.
if p.re.MatchString(line) {
out = append(out, Finding{Path: path, Line: ln, Msg: p.what})
break
}
}
}
return out
}
// ScanResponse adapts the detector to ai.ResponseScanner: scans an AI response
// body, returns one description per flagged line (nil for clean). Signature
// matches ai.ResponseScanner without importing internal/ai, so the cmd / tui
// layer can wire d.ScanResponse with no import cycle.
func (d *Detector) ScanResponse(text string) []string {
findings := d.Scan("ai-response", text)
if len(findings) == 0 {
return nil
}
out := make([]string, 0, len(findings))
for _, f := range findings {
out = append(out, fmt.Sprintf("line %d: %s", f.Line, f.Msg))
}
return out
}
added internal/workflow/attribution_test.go
@@ -0,0 +1,90 @@
package workflow
import "testing"
// Fingerprint literals are assembled from fragments here for the same
// reason as in attribution.go: this test file is tracked source, and
// eeco's own comment-hygiene / leak-guard scan it. A contiguous
// attribution literal would (correctly) make eeco fail its own gate.
const (
fragCoAB = "Co-" + "Authored-" + "By"
fragGen = "Gener" + "ated"
)
func TestDetector_Clean(t *testing.T) {
d, err := NewDetector(nil)
if err != nil {
t.Fatal(err)
}
goods := []string{
"package main\n\nfunc main() {}\n",
"// guidance on authoring and code review\n",
"see the `" + fragCoAB + "` trailer in the docs\n", // backticked prose, not a trailer line
" note: " + fragCoAB + " is a git trailer key\n", // token mid-line, no colon at start
fragGen + " the quarterly report by hand on a fair day\n", // "fair" must not trip the AI token
fragGen + " with the standard build pipeline\n", // no tool token
"the model was " + fragGen + " by a deterministic script\n", // "model"/"script" excluded by design
}
for i, g := range goods {
if f := d.Scan("clean.txt", g); len(f) != 0 {
t.Errorf("good[%d] flagged %+v\n input=%q", i, f, g)
}
}
}
func TestDetector_Fingerprints(t *testing.T) {
d, _ := NewDetector(nil)
coAB := fragCoAB + ": A Person <[email protected]>"
cases := []struct{ name, in string }{
{"trailer at line start", coAB + "\n"},
{"trailer after whitespace", " " + coAB + "\n"},
{"with-tool line", fragGen + " with an AI assistant\n"},
{"by-cli line", "report " + fragGen + " by the project CLI\n"},
{"by-agent line", fragGen + " by a coding agent for us\n"},
{"robot emoji line", "\U0001F916 " + fragGen + " with a coding agent\n"},
}
for _, c := range cases {
got := d.Scan("bad.txt", c.in)
if len(got) == 0 {
t.Errorf("%s: expected a finding, input=%q", c.name, c.in)
continue
}
if got[0].Line != 1 {
t.Errorf("%s: line = %d, want 1", c.name, got[0].Line)
}
}
}
func TestDetector_MultiLineNumbers(t *testing.T) {
d, _ := NewDetector(nil)
in := "clean line\nanother clean line\n" + fragCoAB + ": X <x@y>\n"
got := d.Scan("f", in)
if len(got) != 1 || got[0].Line != 3 {
t.Fatalf("want one finding on line 3, got %+v", got)
}
}
func TestDetector_ScanResponse(t *testing.T) {
d, _ := NewDetector(nil)
got := d.ScanResponse(fragCoAB + ": X <x@y>\n")
want := []string{"line 1: co-authored-by trailer"}
if len(got) != 1 || got[0] != want[0] {
t.Errorf("ScanResponse(attribution) = %v, want %v", got, want)
}
if r := d.ScanResponse("a clean response with no fingerprint\n"); r != nil {
t.Errorf("ScanResponse(clean) = %v, want nil", r)
}
}
func TestDetector_ExtraPattern(t *testing.T) {
d, err := NewDetector([]string{`INTERNAL-CODENAME`})
if err != nil {
t.Fatal(err)
}
if f := d.Scan("x", "leaked INTERNAL-CODENAME here\n"); len(f) == 0 {
t.Error("configured extra pattern was not applied")
}
if _, err := NewDetector([]string{"("}); err == nil {
t.Error("an invalid extra regex must be a loud error")
}
}
added internal/workflow/bench_test.go
@@ -0,0 +1,168 @@
//go:build bench
package workflow
import (
"fmt"
"math/rand"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/ajhahnde/eeco/internal/config"
)
const (
benchFileCount = 50_000
// benchSeed is fixed so the fixture is byte-identical every run; only
// the wall-clock budget moves between machines.
benchSeed = 0xE6C0
benchMaxWall = 5 * time.Second
)
// benchFixturePath is <eeco-repo-root>/dist/bench-fixture, derived from
// the test source location so the bench is invariant to CWD. The
// directory is gitignored via the repo's existing `/dist/` rule and is
// removed by the `make bench` target after the run.
func benchFixturePath(tb testing.TB) string {
tb.Helper()
_, here, _, ok := runtime.Caller(0)
if !ok {
tb.Fatal("runtime.Caller failed")
}
root := filepath.Clean(filepath.Join(filepath.Dir(here), "..", ".."))
return filepath.Join(root, "dist", "bench-fixture")
}
// ensureFixture writes benchFileCount Go-like files into
// <bench-fixture>/repo, distributed across 250 packages of 200 files
// each. The marker file under .eeco-fixture-built prevents a rebuild
// on a second invocation in the same `make bench` run.
func ensureFixture(b *testing.B, dir string) {
b.Helper()
repo := filepath.Join(dir, "repo")
marker := filepath.Join(repo, ".eeco-fixture-built")
if _, err := os.Stat(marker); err == nil {
return
}
if err := os.MkdirAll(repo, 0o755); err != nil {
b.Fatal(err)
}
rng := rand.New(rand.NewSource(benchSeed))
for i := range benchFileCount {
sub := fmt.Sprintf("pkg%03d", i/200)
path := filepath.Join(repo, sub, fmt.Sprintf("file%05d.go", i))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
b.Fatal(err)
}
body := fmt.Sprintf("package %s\n\n// file %d rnd=%d\nfunc F%d() {}\n",
sub, i, rng.Int63(), i)
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
b.Fatal(err)
}
}
if err := os.WriteFile(marker, []byte("ok"), 0o644); err != nil {
b.Fatal(err)
}
}
// ensureGitAdded initialises <repo> as a git repo and `git add -A`s the
// full tree. Required by leak-guard's `gitx.TrackedFiles` probe.
// Cached by a marker so successive Run calls inside one benchmark do
// not redo the index work.
func ensureGitAdded(b *testing.B, repo string) {
b.Helper()
marker := filepath.Join(repo, ".eeco-fixture-git")
if _, err := os.Stat(marker); err == nil {
return
}
for _, argv := range [][]string{
{"git", "init", "-q"},
{"git", "config", "user.email", "bench@local"},
{"git", "config", "user.name", "bench"},
{"git", "add", "-A"},
} {
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Dir = repo
if out, err := cmd.CombinedOutput(); err != nil {
b.Fatalf("%v in %s: %v\n%s", argv, repo, err, out)
}
}
if err := os.WriteFile(marker, []byte("ok"), 0o644); err != nil {
b.Fatal(err)
}
}
func benchConfig(b *testing.B, repo string) *config.Config {
b.Helper()
cfg, err := config.Load(repo, config.DefaultWorkspace)
if err != nil {
b.Fatalf("config.Load(%s): %v", repo, err)
}
return cfg
}
func BenchmarkCommentHygiene_50k(b *testing.B) {
dir := benchFixturePath(b)
ensureFixture(b, dir)
repo := filepath.Join(dir, "repo")
if _, err := os.Stat(filepath.Join(repo, ".git")); os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil {
b.Fatal(err)
}
}
cfg := benchConfig(b, repo)
env := Env{Config: cfg}
b.ResetTimer()
start := time.Now()
for range b.N {
res, err := (commentHygiene{}).Run(env)
if err != nil {
b.Fatalf("comment-hygiene: %v", err)
}
if res.Code != CodeClean {
b.Fatalf("comment-hygiene unexpectedly flagged the fixture: %+v", res)
}
}
wall := time.Since(start)
if b.N > 0 {
wall /= time.Duration(b.N)
}
b.ReportMetric(float64(wall.Milliseconds()), "ms/scan")
if wall > benchMaxWall {
b.Fatalf("comment-hygiene wall %s exceeds %s budget on %d files", wall, benchMaxWall, benchFileCount)
}
}
func BenchmarkLeakGuard_50k(b *testing.B) {
dir := benchFixturePath(b)
ensureFixture(b, dir)
repo := filepath.Join(dir, "repo")
ensureGitAdded(b, repo)
cfg := benchConfig(b, repo)
env := Env{Config: cfg}
b.ResetTimer()
start := time.Now()
for range b.N {
res, err := (leakGuard{}).Run(env)
if err != nil {
b.Fatalf("leak-guard: %v", err)
}
if res.Code != CodeClean {
b.Fatalf("leak-guard unexpectedly flagged the fixture: %+v", res)
}
}
wall := time.Since(start)
if b.N > 0 {
wall /= time.Duration(b.N)
}
b.ReportMetric(float64(wall.Milliseconds()), "ms/scan")
if wall > benchMaxWall {
b.Fatalf("leak-guard wall %s exceeds %s budget on %d files", wall, benchMaxWall, benchFileCount)
}
}
added internal/workflow/bugsweep.go
@@ -0,0 +1,219 @@
package workflow
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/ajhahnde/eeco/internal/ai"
"github.com/ajhahnde/eeco/internal/gitx"
"github.com/ajhahnde/eeco/internal/queue"
)
// bugMarkerRE matches the conventional, uppercase bug markers. It is
// uppercase- and word-bounded on purpose: a lowercase "todo" in prose
// or a substring like "FIXMEnow" must not trip it, the same precision
// principle the attribution detector follows.
var bugMarkerRE = regexp.MustCompile(`\b(TODO|FIXME|XXX|HACK|BUG)\b`)
// bugLedgerName is the append-only ledger inside <workspace>/state/.
const bugLedgerName = "bug-ledger.md"
// bugSweep is the builtin bug finder. It does a deterministic static
// triage of the source tree into an append-only ledger, and — only with
// consent and budget — adds a gated AI reasoning pass over that triage.
// Without consent (or on provider failure) the AI prompt is parked and
// queued by the Gate; the static report still stands. It writes only
// inside the workspace and never blocks: when git is unavailable it
// falls back to a filesystem walk rather than refusing to run.
type bugSweep struct{}
func (bugSweep) Name() string { return "bug-sweep" }
func (bugSweep) Summary() string {
return "static bug-marker triage into an append-only ledger; optional gated AI pass"
}
func (bugSweep) Run(env Env) (Result, error) {
cfg := env.Config
files, err := sourceFiles(cfg.RepoRoot, cfg.WorkspaceName)
if err != nil {
return Result{}, fmt.Errorf("bug-sweep: %w", err)
}
var findings []Finding
for _, f := range files {
ln := 0
for _, line := range splitLines(f.content) {
ln++
if m := bugMarkerRE.FindString(line); m != "" {
findings = append(findings, Finding{
Path: f.rel,
Line: ln,
Msg: m + ": " + condense(line),
})
}
}
}
sort.Slice(findings, func(i, j int) bool {
if findings[i].Path != findings[j].Path {
return findings[i].Path < findings[j].Path
}
return findings[i].Line < findings[j].Line
})
stamp := time.Now().UTC()
if err := appendBugLedger(cfg.Workspace, stamp, "static", staticLedgerBody(findings)); err != nil {
return Result{}, fmt.Errorf("bug-sweep: ledger: %w", err)
}
// Gated AI reasoning pass over the static triage. The Gate enforces
// consent, budget, and prompt-parking; a Skipped outcome is normal,
// not an error.
aiSkipped := false
if env.Gate != nil {
out, gerr := env.Gate.Run(context.Background(), ai.Request{
Label: "bug-sweep",
System: "Project: " + filepath.Base(cfg.RepoRoot),
User: bugSweepUserPrompt(findings),
})
if gerr != nil {
return Result{}, fmt.Errorf("bug-sweep: ai gate: %w", gerr)
}
if out.Ran {
if err := appendBugLedger(cfg.Workspace, stamp, "ai", out.Text); err != nil {
return Result{}, fmt.Errorf("bug-sweep: ledger: %w", err)
}
_ = queue.Append(filepath.Join(cfg.Workspace, "state"), queue.Item{
Kind: "bug-sweep",
Title: "AI bug-sweep findings ready for review",
Project: filepath.Base(cfg.RepoRoot),
Detail: "appended to state/" + bugLedgerName,
Date: stamp,
})
} else {
aiSkipped = true
}
}
switch {
case len(findings) > 0:
return Result{
Code: CodeFinding,
Summary: fmt.Sprintf("%d bug marker(s) in source", len(findings)),
Findings: findings,
}, nil
case aiSkipped:
return Result{
Code: CodeAIDeferred,
Summary: "no static markers; AI pass deferred (prompt parked)",
}, nil
default:
return Result{Code: CodeClean, Summary: "no bug markers found"}, nil
}
}
// srcFile is one scanned text file, repo-relative path and content.
type srcFile struct {
rel string
content string
}
// sourceFiles returns the text files to triage. It prefers the
// git-tracked set (ignores vendored / generated trees automatically);
// when git is unavailable it falls back to a filesystem walk so the
// workflow is never blocked (binding design decision, PLAN.md).
func sourceFiles(root, workspaceName string) ([]srcFile, error) {
if gitx.Available() {
tracked, err := gitx.TrackedFiles(root)
if err == nil {
var out []srcFile
for _, rel := range tracked {
b, rerr := os.ReadFile(filepath.Join(root, rel))
if rerr != nil || !isText(b) {
continue
}
out = append(out, srcFile{rel: rel, content: string(b)})
}
return out, nil
}
// git present but listing failed (e.g. not a repo yet): walk.
}
var out []srcFile
err := walkText(root, workspaceName, func(rel, content string) error {
out = append(out, srcFile{rel: rel, content: content})
return nil
})
return out, err
}
// condense trims a source line to a short, single-line ledger excerpt.
func condense(s string) string {
s = strings.TrimSpace(s)
const max = 100
if len(s) > max {
s = s[:max] + "…"
}
return s
}
func staticLedgerBody(findings []Finding) string {
if len(findings) == 0 {
return "no bug markers found"
}
var b strings.Builder
for _, f := range findings {
fmt.Fprintf(&b, "- %s:%d %s\n", f.Path, f.Line, f.Msg)
}
return strings.TrimRight(b.String(), "\n")
}
// bugSweepUserPrompt builds the volatile User turn: the triage
// instruction and the static findings. The project handle is the cheap
// System block, threaded separately at the call site.
func bugSweepUserPrompt(findings []Finding) string {
var b strings.Builder
b.WriteString("Static bug-marker triage follows. Identify the few highest-risk " +
"items, likely root causes, and concrete next steps. Be terse.\n\n")
if len(findings) == 0 {
b.WriteString("(no static markers — reason from the codebase structure)\n")
}
for _, f := range findings {
fmt.Fprintf(&b, "%s:%d %s\n", f.Path, f.Line, f.Msg)
}
return b.String()
}
// appendBugLedger appends one dated section to the append-only ledger,
// creating it with a header on first use. The file is opened O_APPEND
// and never truncated, so prior runs are preserved verbatim.
func appendBugLedger(workspace string, stamp time.Time, phase, body string) error {
dir := filepath.Join(workspace, "state")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
path := filepath.Join(dir, bugLedgerName)
created := false
if _, err := os.Stat(path); os.IsNotExist(err) {
created = true
}
fh, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return err
}
defer fh.Close()
var b strings.Builder
if created {
b.WriteString("# bug-sweep ledger\n\nAppend-only. Each run adds a dated section; " +
"earlier sections are never rewritten.\n")
}
fmt.Fprintf(&b, "\n## %s — %s\n\n%s\n", stamp.Format(time.RFC3339), phase, body)
_, err = fh.WriteString(b.String())
return err
}
added internal/workflow/bugsweep_test.go
@@ -0,0 +1,172 @@
package workflow
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/ajhahnde/eeco/internal/ai"
"github.com/ajhahnde/eeco/internal/config"
)
// stubProvider is a deterministic ai.Provider for workflow tests.
type stubProvider struct {
calls int
text string
}
func (s *stubProvider) Name() string { return "stub" }
func (s *stubProvider) Run(context.Context, ai.Request) (ai.Response, error) {
s.calls++
return ai.Response{Text: s.text}, nil
}
func gateWith(t *testing.T, cfg *config.Config, p ai.Provider, consent bool) *ai.Gate {
t.Helper()
return &ai.Gate{
Provider: p,
Consent: consent,
Budget: 1,
StateDir: filepath.Join(cfg.Workspace, "state"),
Project: "proj",
}
}
func readLedger(t *testing.T, cfg *config.Config) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(cfg.Workspace, "state", bugLedgerName))
if err != nil {
t.Fatalf("ledger: %v", err)
}
return string(b)
}
func TestBugSweep_FindsMarkersAndAppends(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "src/a.go", "package a\n// TODO: wire this up\nfunc A() {}\n")
writeRepoFile(t, cfg.RepoRoot, "src/b.txt", "nothing here\n")
res, err := bugSweep{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("code = %d, want %d (finding)", res.Code, CodeFinding)
}
if len(res.Findings) != 1 || !strings.Contains(res.Findings[0].Msg, "TODO") {
t.Fatalf("findings = %+v", res.Findings)
}
if l := readLedger(t, cfg); !strings.Contains(l, "TODO: wire this up") {
t.Errorf("ledger missing marker:\n%s", l)
}
}
func TestBugSweep_LedgerIsAppendOnly(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "a.go", "// FIXME: one\n")
if _, err := (bugSweep{}).Run(Env{Config: cfg}); err != nil {
t.Fatal(err)
}
first := readLedger(t, cfg)
if _, err := (bugSweep{}).Run(Env{Config: cfg}); err != nil {
t.Fatal(err)
}
second := readLedger(t, cfg)
if !strings.HasPrefix(second, first) {
t.Error("second run rewrote earlier ledger content; must be append-only")
}
if strings.Count(second, "— static") != 2 {
t.Errorf("want 2 static sections, got:\n%s", second)
}
}
func TestBugSweep_CleanNoGateIsClean(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "ok.go", "package ok\nfunc Fine() {}\n")
res, err := bugSweep{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("code = %d, want %d (clean)", res.Code, CodeClean)
}
}
func TestBugSweep_NoMarkersNoConsentDefersAI(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "ok.go", "package ok\n")
sp := &stubProvider{text: "analysis"}
g := gateWith(t, cfg, sp, false)
res, err := bugSweep{}.Run(Env{Config: cfg, Gate: g})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeAIDeferred {
t.Fatalf("code = %d, want %d (AI deferred)", res.Code, CodeAIDeferred)
}
if sp.calls != 0 {
t.Errorf("provider spent without consent: calls=%d", sp.calls)
}
q, _ := os.ReadFile(filepath.Join(cfg.Workspace, "state", "queue.md"))
if !strings.Contains(string(q), "ai-parked") {
t.Errorf("parked AI pass not queued:\n%s", q)
}
}
func TestBugSweep_ConsentRunsAIAndAppends(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "ok.go", "package ok\n")
sp := &stubProvider{text: "AI: looks fine, no high-risk items"}
g := gateWith(t, cfg, sp, true)
res, err := bugSweep{}.Run(Env{Config: cfg, Gate: g})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("code = %d, want %d (clean)", res.Code, CodeClean)
}
if sp.calls != 1 {
t.Errorf("provider calls = %d, want 1", sp.calls)
}
l := readLedger(t, cfg)
if !strings.Contains(l, "— ai") || !strings.Contains(l, "looks fine") {
t.Errorf("ledger missing AI section:\n%s", l)
}
q, _ := os.ReadFile(filepath.Join(cfg.Workspace, "state", "queue.md"))
if !strings.Contains(string(q), "bug-sweep") {
t.Errorf("AI findings not queued for review:\n%s", q)
}
}
// End-to-end proof the pre-write filter closes the gap: a provider response
// carrying an attribution fingerprint is blocked at the gate, bug-sweep takes
// the same deferred branch a parked pass takes, and the attribution text never
// reaches the bug ledger (the gitignored workspace file leak-guard never scans).
func TestBugSweep_FilterBlocksAttributionResponse(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "ok.go", "package ok\n") // no static markers -> AI pass
sp := &stubProvider{text: fragCoAB + ": A Bot <b@x>\nlooks fine\n"}
g := gateWith(t, cfg, sp, true)
det, _ := NewDetector(nil)
g.Scanner = det.ScanResponse
res, err := bugSweep{}.Run(Env{Config: cfg, Gate: g})
if err != nil {
t.Fatal(err)
}
if sp.calls != 1 {
t.Errorf("provider should have run once before the block; calls=%d", sp.calls)
}
if res.Code != CodeAIDeferred {
t.Fatalf("a blocked AI pass must defer like a parked pass; code=%d want %d", res.Code, CodeAIDeferred)
}
if b, err := os.ReadFile(filepath.Join(cfg.Workspace, "state", bugLedgerName)); err == nil {
if strings.Contains(string(b), fragCoAB) {
t.Errorf("blocked attribution text leaked into the bug ledger:\n%s", b)
}
}
}
added internal/workflow/cockpitsync.go
@@ -0,0 +1,202 @@
package workflow
import (
"fmt"
"path/filepath"
"strings"
"time"
"github.com/ajhahnde/eeco/internal/cockpit"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/playbooks"
"github.com/ajhahnde/eeco/internal/queue"
)
// cockpitSync flags drift between the generated cockpit artifacts and their
// neutral playbook sources. It runs cockpit.Sync, which reports three
// staleness classes: an artifact hand-edited or left behind by an eeco
// upgrade (drifted), an active target/playbook never emitted (missing), and
// a deselected target whose file remains (orphan). It is the cockpit slice
// of the drift-detection family, sibling to doc-drift and memory-drift.
//
// It needs no git (pure render + disk + ledger), so it never blocks: only
// CodeClean or CodeFinding. cockpit.Sync's empty-ledger gate makes it a
// silent no-op on a repo where the cockpit was never generated, so it is
// safe to wire into the post-merge default.
//
// Behavior depends on the automation level (locked C4 decision #3). At
// `automation=auto` (Automation.ReconcilesCockpit) it RECONCILES: drifted and
// missing artifacts are regenerated deterministically (a render→write into the
// gitignored tree, the standing consent of `auto`); orphan and safety findings
// still queue, since removing a file is destructive and a safety violation must
// surface to the operator. At every lower level it stays detect-only: one queue
// item per finding routed to the single decision channel (AppendUnique, so a
// repeated run does not pile up duplicates), exactly as C3 shipped.
type cockpitSync struct{}
func (cockpitSync) Name() string { return "cockpit-sync" }
func (cockpitSync) Summary() string {
return "flag (or, at automation=auto, regenerate) drift between cockpit artifacts and their sources"
}
func (cockpitSync) Run(env Env) (Result, error) {
cfg := env.Config
report, err := cockpit.Sync(cfg, playbooks.All())
if err != nil {
return Result{}, fmt.Errorf("cockpit-sync: %w", err)
}
if report.Clean {
return Result{Code: CodeClean, Summary: "cockpit artifacts match their sources"}, nil
}
if cfg.Automation.ReconcilesCockpit() {
return reconcileCockpit(cfg, report)
}
return queueCockpitFindings(cfg, report.Findings)
}
// queueCockpitFindings routes one AppendUnique-deduped queue item per finding
// to the single decision channel and returns a CodeFinding result carrying the
// same findings as workflow output (the C3 detect-only behavior, also the
// auto-mode fallback for orphan/safety findings).
func queueCockpitFindings(cfg *config.Config, fs []cockpit.SyncFinding) (Result, error) {
if err := appendSyncQueue(cfg, fs); err != nil {
return Result{}, err
}
findings := make([]Finding, 0, len(fs))
for _, f := range fs {
findings = append(findings, syncFinding(f))
}
return Result{
Code: CodeFinding,
Summary: fmt.Sprintf("%d cockpit drift finding(s)", len(findings)),
Findings: findings,
}, nil
}
// reconcileCockpit regenerates the drifted and missing artifacts in report
// (the `auto` standing consent) and queues the rest (orphans are destructive to
// remove, safety violations must surface — both stay operator-in-the-loop). A
// regeneration that fails (e.g. a safety refusal) falls back to a queue item
// rather than wedging the merge.
func reconcileCockpit(cfg *config.Config, report cockpit.SyncReport) (Result, error) {
sel := cockpit.LoadSelection(cfg)
resolved := resolveSelectedPlaybooks(playbooks.All(), sel.Playbooks)
var toQueue []cockpit.SyncFinding
var findings []Finding
regenerated := 0
for _, f := range report.Findings {
if (f.Kind == "drifted" || f.Kind == "missing") && regenerateFinding(cfg, f, resolved) == nil {
regenerated++
findings = append(findings, Finding{Path: cockpitSyncLoc(f), Line: 0, Msg: "regenerated (" + f.Kind + ")"})
continue
}
toQueue = append(toQueue, f)
}
if err := appendSyncQueue(cfg, toQueue); err != nil {
return Result{}, err
}
if len(toQueue) == 0 {
return Result{Code: CodeClean, Summary: fmt.Sprintf("regenerated %d cockpit artifact(s)", regenerated)}, nil
}
for _, f := range toQueue {
findings = append(findings, syncFinding(f))
}
return Result{
Code: CodeFinding,
Summary: fmt.Sprintf("%d regenerated, %d need a decision", regenerated, len(toQueue)),
Findings: findings,
}, nil
}
// regenerateFinding regenerates the artifact a drifted/missing finding points
// at: the whole shared file for an aggregate target, or the single playbook for
// a per-playbook target. resolved is the active playbook set (aggregate re-emit
// always writes the whole set). It returns Generate's error unchanged so the
// caller can fall back to queuing on a refusal.
func regenerateFinding(cfg *config.Config, f cockpit.SyncFinding, resolved []cockpit.Playbook) error {
if cockpit.IsAggregateTarget(f.Target) {
_, err := cockpit.GenerateAll(cfg, resolved, f.Target)
return err
}
pb, err := playbooks.Get(f.Playbook)
if err != nil {
return err
}
_, err = cockpit.Generate(cfg, pb, f.Target)
return err
}
// resolveSelectedPlaybooks returns the playbook subset a selection emits: all
// when the selection does not narrow them, else the members of all named in
// narrow (Name-ordered, unknown names skipped). It mirrors the cmd layer's
// resolvePlaybooks for the aggregate re-emit set.
func resolveSelectedPlaybooks(all []cockpit.Playbook, narrow []string) []cockpit.Playbook {
if len(narrow) == 0 {
return all
}
want := make(map[string]bool, len(narrow))
for _, n := range narrow {
want[n] = true
}
out := make([]cockpit.Playbook, 0, len(narrow))
for _, pb := range all {
if want[pb.Name] {
out = append(out, pb)
}
}
return out
}
// appendSyncQueue routes one AppendUnique-deduped queue item per finding to the
// single decision channel. A no-op for an empty list.
func appendSyncQueue(cfg *config.Config, fs []cockpit.SyncFinding) error {
if len(fs) == 0 {
return nil
}
project := filepath.Base(cfg.RepoRoot)
stateDir := filepath.Join(cfg.Workspace, "state")
today := time.Now().UTC()
for _, f := range fs {
item := queue.Item{
Kind: "cockpit-sync",
Title: cockpitSyncTitle(f),
Project: project,
Detail: f.Detail,
Date: today,
}
if _, aerr := queue.AppendUnique(stateDir, item); aerr != nil {
return fmt.Errorf("cockpit-sync: queue: %w", aerr)
}
}
return nil
}
// syncFinding renders one SyncFinding as workflow output. The report line
// carries the location in Path, so the matching prefix is stripped off Detail
// to avoid "claude/handover: claude/handover: …"; the queued item keeps the
// full self-contained Detail.
func syncFinding(f cockpit.SyncFinding) Finding {
loc := cockpitSyncLoc(f)
return Finding{Path: loc, Line: 0, Msg: strings.TrimPrefix(f.Detail, loc+": ")}
}
// cockpitSyncLoc is the target[/playbook] label for a finding.
func cockpitSyncLoc(f cockpit.SyncFinding) string {
if f.Playbook != "" {
return f.Target + "/" + f.Playbook
}
return f.Target
}
// cockpitSyncTitle is the queue row title. It must be stable across runs for
// AppendUnique to dedup (the dedup key is Kind+Title), so it is derived only
// from the finding's location and kind, never from a timestamp.
func cockpitSyncTitle(f cockpit.SyncFinding) string {
action := "run `eeco cockpit generate`"
if f.Kind == "orphan" {
action = "run `eeco cockpit off --target " + f.Target + "`"
}
return fmt.Sprintf("%s %s — %s", cockpitSyncLoc(f), f.Kind, action)
}
added internal/workflow/cockpitsync_autoregen_test.go
@@ -0,0 +1,51 @@
package workflow
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/ajhahnde/eeco/internal/cockpit"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/playbooks"
)
// TestCockpitSync_AutoRegeneratesDrift: at automation=auto, a drifted artifact
// is regenerated to its clean bytes, the run reports CodeClean, and nothing is
// queued (the C4 reconcile path; orphans/safety would still queue).
func TestCockpitSync_AutoRegeneratesDrift(t *testing.T) {
cfg := newCfg(t)
cfg.UserDir = filepath.Join(cfg.RepoRoot, "tester")
cfg.Automation = config.AutomationAuto
if err := cockpit.SaveSelection(cfg, cockpit.Selection{Targets: []string{"claude"}}); err != nil {
t.Fatal(err)
}
pb, err := playbooks.Get("handover")
if err != nil {
t.Fatal(err)
}
if _, err := cockpit.Generate(cfg, pb, "claude"); err != nil {
t.Fatalf("generate: %v", err)
}
dst := filepath.Join(cfg.UserDir, ".claude", "skills", "handover", "SKILL.md")
clean, _ := os.ReadFile(dst)
if err := os.WriteFile(dst, []byte("edited\n"), 0o644); err != nil {
t.Fatal(err)
}
res, err := cockpitSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Errorf("auto-regen of pure drift = Code %d (%q), want CodeClean", res.Code, res.Summary)
}
got, _ := os.ReadFile(dst)
if string(got) != string(clean) {
t.Errorf("drifted artifact not regenerated to clean bytes:\n%s", got)
}
if q := queueBody(t, cfg); strings.Contains(q, "cockpit-sync") {
t.Errorf("auto-regen of pure drift must not queue, got:\n%s", q)
}
}
added internal/workflow/cockpitsync_test.go
@@ -0,0 +1,74 @@
package workflow
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/ajhahnde/eeco/internal/cockpit"
"github.com/ajhahnde/eeco/internal/playbooks"
)
// TestCockpitSync_EmptyLedgerCleanNoQueue: on a repo where the cockpit was
// never generated, the builtin is a silent clean no-op and writes nothing to
// the queue (the empty-ledger gate — what makes it safe in the post-merge
// default).
func TestCockpitSync_EmptyLedgerCleanNoQueue(t *testing.T) {
cfg := newCfg(t)
res, err := cockpitSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Errorf("Code = %d, want %d (%q)", res.Code, CodeClean, res.Summary)
}
if q := queueBody(t, cfg); q != "" {
t.Errorf("queue should be empty on an unused cockpit, got:\n%s", q)
}
}
// TestCockpitSync_DriftFindingIdempotent: a hand-edited artifact yields a
// CodeFinding and one queued cockpit-sync item; a repeated run does not pile
// up a duplicate (AppendUnique).
func TestCockpitSync_DriftFindingIdempotent(t *testing.T) {
cfg := newCfg(t)
// newCfg leaves UserDir empty; point it at a private tree beside the repo
// so Generate can emit an artifact the builtin then verifies.
cfg.UserDir = filepath.Join(cfg.RepoRoot, "tester")
if err := cockpit.SaveSelection(cfg, cockpit.Selection{Targets: []string{"claude"}}); err != nil {
t.Fatal(err)
}
pb, err := playbooks.Get("handover")
if err != nil {
t.Fatal(err)
}
if _, err := cockpit.Generate(cfg, pb, "claude"); err != nil {
t.Fatalf("generate: %v", err)
}
dst := filepath.Join(cfg.UserDir, ".claude", "skills", "handover", "SKILL.md")
if err := os.WriteFile(dst, []byte("edited\n"), 0o644); err != nil {
t.Fatal(err)
}
res, err := cockpitSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("Code = %d, want %d (%q)", res.Code, CodeFinding, res.Summary)
}
body := queueBody(t, cfg)
n1 := strings.Count(body, "**cockpit-sync**")
if n1 == 0 {
t.Fatalf("queue missing a cockpit-sync item:\n%s", body)
}
if _, rerr := (cockpitSync{}).Run(Env{Config: cfg}); rerr != nil {
t.Fatal(rerr)
}
n2 := strings.Count(queueBody(t, cfg), "**cockpit-sync**")
if n1 != n2 {
t.Errorf("AppendUnique not idempotent: %d cockpit-sync item(s) → %d", n1, n2)
}
}
added internal/workflow/commenthygiene.go
@@ -0,0 +1,49 @@
package workflow
import (
"fmt"
"sort"
)
// commentHygiene is a read-only gate: it scans every text file in the
// working tree (skipping .git and the gitignored workspace) for an
// AI-attribution fingerprint and fails if any shippable file carries
// one. It writes nothing and needs no external tool, so it is never
// blocked.
type commentHygiene struct{}
func (commentHygiene) Name() string { return "comment-hygiene" }
func (commentHygiene) Summary() string {
return "scan source and docs for AI-attribution fingerprints (read-only)"
}
func (commentHygiene) Run(env Env) (Result, error) {
cfg := env.Config
det, err := NewDetector(cfg.AttributionPatterns)
if err != nil {
return Result{}, err
}
var findings []Finding
err = walkText(cfg.RepoRoot, cfg.WorkspaceName, func(rel, content string) error {
findings = append(findings, det.Scan(rel, content)...)
return nil
})
if err != nil {
return Result{}, fmt.Errorf("comment-hygiene: walk: %w", err)
}
if len(findings) == 0 {
return Result{Code: CodeClean, Summary: "no attribution fingerprints found"}, nil
}
sort.Slice(findings, func(i, j int) bool {
if findings[i].Path != findings[j].Path {
return findings[i].Path < findings[j].Path
}
return findings[i].Line < findings[j].Line
})
return Result{
Code: CodeFinding,
Summary: fmt.Sprintf("%d attribution fingerprint(s) in tracked tree", len(findings)),
Findings: findings,
}, nil
}
added internal/workflow/commenthygiene_test.go
@@ -0,0 +1,63 @@
package workflow
import (
"path/filepath"
"testing"
)
func TestCommentHygiene_CleanTree(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "main.go", "package main\nfunc main(){}\n")
writeRepoFile(t, cfg.RepoRoot, "docs/readme.md", "a normal document\n")
res, err := commentHygiene{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("clean tree -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
}
func TestCommentHygiene_FlagsFingerprint(t *testing.T) {
cfg := newCfg(t)
trailer := fragCoAB + ": Someone <[email protected]>"
writeRepoFile(t, cfg.RepoRoot, "src/util.go", "package src\n// "+"\n"+trailer+"\n")
res, err := commentHygiene{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("planted fingerprint -> %d, want %d", res.Code, CodeFinding)
}
if len(res.Findings) != 1 || res.Findings[0].Path != "src/util.go" {
t.Fatalf("findings = %+v", res.Findings)
}
}
func TestCommentHygiene_SkipsWorkspace(t *testing.T) {
cfg := newCfg(t)
// A fingerprint inside the gitignored workspace must not gate the
// tracked tree (engine output is not shippable).
trailer := fragCoAB + ": Eng <e@x>"
writeRepoFile(t, cfg.RepoRoot, filepath.Join(".eeco", "state", "note.md"), trailer+"\n")
res, err := commentHygiene{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("workspace fingerprint leaked into gate: %+v", res.Findings)
}
}
func TestCommentHygiene_SkipsBinary(t *testing.T) {
cfg := newCfg(t)
trailer := fragCoAB + ": Bin <b@x>"
writeRepoFile(t, cfg.RepoRoot, "blob.bin", "\x00\x00"+trailer)
res, err := commentHygiene{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("binary file scanned: %+v", res.Findings)
}
}
added internal/workflow/commitguard.go
@@ -0,0 +1,354 @@
package workflow
import (
"os"
"os/exec"
"path/filepath"
"strings"
)
// CommitGuardResult is the outcome of inspecting a candidate shell
// command for a `git commit` that would carry AI attribution. IsCommit
// is true when a real `git commit` segment was detected — token-based,
// never substring, so `echo "git commit"` does not qualify. Findings is
// the union of attribution findings across the assembled commit message,
// the staged diff, and the raw command string; an empty Findings means
// allow.
type CommitGuardResult struct {
IsCommit bool
Findings []Finding
}
// stagedDiff returns the staged diff for the repo rooted at cwd. It is a
// package var so the test suite can stub it; the default shells out to
// git and is degrade-open — any error yields "" so the guard never
// blocks on infrastructure trouble (not a repo, git missing, …).
var stagedDiff = func(cwd string) string {
cmd := exec.Command("git", "-C", cwd, "diff", "--cached")
out, err := cmd.Output()
if err != nil {
return ""
}
return string(out)
}
// ScanCommitGuard inspects command — the full Bash command string from a
// Claude Code PreToolUse tool call — for a pending `git commit` and
// scans the text it would commit for AI attribution. cwd is the hook's
// working directory (the repo the commit targets); det is the shared
// attribution detector that also backs leak-guard and the pre-write
// scanner, so the patterns stay one source of truth.
//
// Degrade-open by contract: Findings is populated only on a positive
// detector match. Any parse/infra uncertainty — not a commit, a message
// it cannot statically resolve (heredoc / command substitution), an
// unreadable file, git unavailable — yields no findings, so a harness
// session is never wedged. Defense-in-depth keeps the git pre-commit
// hook and CI leak-guard as the hard gates; this guard covers the common
// case (an inline `-m` trailer) reliably.
func ScanCommitGuard(det *Detector, command, cwd string) CommitGuardResult {
var res CommitGuardResult
for _, words := range commandSegments(command) {
if !isGitCommit(words) {
continue
}
res.IsCommit = true
if msg := assembleMessage(words, cwd); msg != "" {
res.Findings = append(res.Findings, det.Scan("commit message", msg)...)
}
}
if !res.IsCommit {
return res
}
// The staged diff catches a non-line-anchored generated-by line added
// to a file rather than the message.
if diff := stagedDiff(cwd); diff != "" {
res.Findings = append(res.Findings, det.Scan("staged diff", diff)...)
}
// Belt: the raw command string catches a trailer or generated-by line
// embedded with a real newline inside the command.
res.Findings = append(res.Findings, det.Scan("command", command)...)
return res
}
// --- shell command lexing -------------------------------------------
// tokKind distinguishes a word from a command separator.
type tokKind int
const (
tokWord tokKind = iota
tokSep
)
type token struct {
kind tokKind
text string
}
// commandSegments splits a shell command into independent segments at
// unquoted separators (&&, ||, ;, |, &, newline) and returns each
// segment as its quote-aware word list. It is best-effort: a construct
// it cannot statically resolve (command substitution, heredoc) degrades
// to literal text, which the detector then finds nothing in — allow.
func commandSegments(command string) [][]string {
toks := lex(command)
var segs [][]string
var cur []string
for _, t := range toks {
if t.kind == tokSep {
if len(cur) > 0 {
segs = append(segs, cur)
cur = nil
}
continue
}
cur = append(cur, t.text)
}
if len(cur) > 0 {
segs = append(segs, cur)
}
return segs
}
// lex tokenizes s into words and separators, honoring single quotes,
// double quotes (with the standard backslash escapes), and backslash
// escaping. Adjacent quoted and unquoted runs concatenate into one word,
// matching shell word-splitting. Operator characters inside quotes are
// literal, so a `;` or `&&` inside an `-m "…"` value never splits.
func lex(s string) []token {
var toks []token
var buf strings.Builder
hasWord := false
flush := func() {
if hasWord {
toks = append(toks, token{tokWord, buf.String()})
buf.Reset()
hasWord = false
}
}
emitSep := func() {
flush()
toks = append(toks, token{kind: tokSep})
}
i, n := 0, len(s)
for i < n {
c := s[i]
switch c {
case '\n':
emitSep()
i++
case ' ', '\t', '\r':
flush()
i++
case ';':
emitSep()
i++
case '&':
emitSep()
if i+1 < n && s[i+1] == '&' {
i += 2
} else {
i++
}
case '|':
emitSep()
if i+1 < n && s[i+1] == '|' {
i += 2
} else {
i++
}
case '\'':
hasWord = true
i++
for i < n && s[i] != '\'' {
buf.WriteByte(s[i])
i++
}
if i < n {
i++ // closing quote
}
case '"':
hasWord = true
i++
for i < n && s[i] != '"' {
if s[i] == '\\' && i+1 < n {
switch s[i+1] {
case '"', '\\', '`', '$':
buf.WriteByte(s[i+1])
i += 2
continue
case '\n':
i += 2 // line continuation
continue
}
}
buf.WriteByte(s[i])
i++
}
if i < n {
i++ // closing quote
}
case '\\':
hasWord = true
if i+1 < n {
if s[i+1] == '\n' {
i += 2 // line continuation
continue
}
buf.WriteByte(s[i+1])
i += 2
} else {
i++
}
default:
hasWord = true
buf.WriteByte(c)
i++
}
}
flush()
return toks
}
// --- git commit detection -------------------------------------------
// isGitCommit reports whether a segment's word list invokes `git commit`.
// It skips leading NAME=VALUE env assignments, requires the program token
// to be git (bare or a path ending in /git), then walks git's global
// options to the subcommand and checks it is exactly "commit". A bare `--`
// before any subcommand, or any other subcommand, disqualifies — so
// `git status` and `git log -m commit` never fire.
func isGitCommit(words []string) bool {
i := 0
for i < len(words) && isEnvAssign(words[i]) {
i++
}
if i >= len(words) || !isGitProg(words[i]) {
return false
}
i++ // past git
// git global options that consume the following token as their value.
valueOpts := map[string]bool{
"-C": true, "-c": true, "--git-dir": true, "--work-tree": true,
"--namespace": true, "--exec-path": true, "--super-prefix": true,
"--config-env": true,
}
for i < len(words) {
w := words[i]
if w == "--" {
return false // end of options without a subcommand
}
if strings.HasPrefix(w, "-") {
if valueOpts[w] {
i += 2 // skip the option and its value
} else {
i++ // a flag or an --opt=val single token
}
continue
}
return w == "commit" // first bare token is the subcommand
}
return false
}
// isGitProg reports whether tok names the git program.
func isGitProg(tok string) bool {
return tok == "git" || strings.HasSuffix(tok, "/git")
}
// isEnvAssign reports whether w is a leading NAME=VALUE env assignment
// (NAME is a shell identifier), e.g. GIT_AUTHOR_NAME=x before `git …`.
func isEnvAssign(w string) bool {
eq := strings.IndexByte(w, '=')
if eq <= 0 {
return false
}
for i := range eq {
c := w[i]
switch {
case c == '_':
case c >= 'A' && c <= 'Z':
case c >= 'a' && c <= 'z':
case i > 0 && c >= '0' && c <= '9':
default:
return false
}
}
return true
}
// --- commit message assembly ----------------------------------------
// assembleMessage reconstructs the text a `git commit` segment would
// commit as its message. -m/--message values join with a blank line (how
// git forms paragraphs), so an attribution trailer lands at line-start
// for the line-anchored detector pattern. It falls back to -F/--file
// (read relative to cwd) and then to <cwd>/.git/COMMIT_EDITMSG. An
// unresolved or unreadable source returns "" (degrade-open).
func assembleMessage(words []string, cwd string) string {
var parts []string
var filePath string
for i := 0; i < len(words); i++ {
w := words[i]
switch {
case w == "-m" || w == "--message":
if i+1 < len(words) {
parts = append(parts, words[i+1])
i++
}
case strings.HasPrefix(w, "--message="):
parts = append(parts, w[len("--message="):])
case clusterEndsInM(w):
// a short cluster like -am / -sm: the trailing m takes the
// next token as the message.
if i+1 < len(words) {
parts = append(parts, words[i+1])
i++
}
case strings.HasPrefix(w, "-m") && !strings.HasPrefix(w, "--"):
parts = append(parts, strings.TrimPrefix(w[2:], "=")) // -mMSG / -m=MSG
case w == "-F" || w == "--file":
if i+1 < len(words) {
filePath = words[i+1]
i++
}
case strings.HasPrefix(w, "--file="):
filePath = w[len("--file="):]
case strings.HasPrefix(w, "-F") && !strings.HasPrefix(w, "--") && len(w) > 2:
filePath = strings.TrimPrefix(w[2:], "=")
}
}
if len(parts) > 0 {
return strings.Join(parts, "\n\n")
}
if filePath != "" {
if !filepath.IsAbs(filePath) {
filePath = filepath.Join(cwd, filePath)
}
if b, err := os.ReadFile(filePath); err == nil {
return string(b)
}
return ""
}
if b, err := os.ReadFile(filepath.Join(cwd, ".git", "COMMIT_EDITMSG")); err == nil {
return string(b)
}
return ""
}
// clusterEndsInM reports whether w is a combined short-flag cluster whose
// last flag is m (so it consumes the next token as the message), e.g.
// -am or -sm. Pure -m (length 2) is handled separately.
func clusterEndsInM(w string) bool {
if len(w) < 3 || w[0] != '-' || w[1] == '-' {
return false
}
for i := 1; i < len(w); i++ {
c := w[i]
if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') {
return false
}
}
return w[len(w)-1] == 'm'
}
added internal/workflow/commitguard_test.go
@@ -0,0 +1,144 @@
package workflow
import (
"os"
"path/filepath"
"testing"
)
// Trigger literals are assembled from fragments so this test source stays
// self-clean for eeco's own attribution scan (Constraint 3), matching the
// discipline in attribution.go.
func coTrailer() string {
return "Co-" + "Authored-" + "By: " + "A Real Person <[email protected]>"
}
func genLine() string {
return "Gen" + "erated " + "with our " + "Assistant"
}
func newGuardDetector(t *testing.T) *Detector {
t.Helper()
det, err := NewDetector(nil)
if err != nil {
t.Fatal(err)
}
return det
}
func TestScanCommitGuard_InlineMessageTrailer(t *testing.T) {
det := newGuardDetector(t)
cmd := `git commit -m "fix: thing" -m "` + coTrailer() + `"`
res := ScanCommitGuard(det, cmd, t.TempDir())
if !res.IsCommit {
t.Fatal("expected IsCommit true")
}
if len(res.Findings) == 0 {
t.Errorf("expected a finding for the inline trailer, got none")
}
}
func TestScanCommitGuard_CleanCommitNoFinding(t *testing.T) {
det := newGuardDetector(t)
res := ScanCommitGuard(det, `git commit -m "fix: a real change"`, t.TempDir())
if !res.IsCommit {
t.Fatal("expected IsCommit true")
}
if len(res.Findings) != 0 {
t.Errorf("clean commit produced findings: %+v", res.Findings)
}
}
func TestScanCommitGuard_FileMessageTrailer(t *testing.T) {
det := newGuardDetector(t)
dir := t.TempDir()
msgPath := filepath.Join(dir, "MSG.txt")
body := "feat: x\n\n" + coTrailer() + "\n"
if err := os.WriteFile(msgPath, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
res := ScanCommitGuard(det, `git commit -F MSG.txt`, dir)
if !res.IsCommit || len(res.Findings) == 0 {
t.Errorf("expected commit + finding from -F file, got IsCommit=%v findings=%d", res.IsCommit, len(res.Findings))
}
}
func TestScanCommitGuard_NonCommitSegments(t *testing.T) {
det := newGuardDetector(t)
for _, cmd := range []string{
`echo "git commit -m bad"`,
`git status`,
`git log --oneline`,
`ls -la && pwd`,
} {
res := ScanCommitGuard(det, cmd, t.TempDir())
if res.IsCommit {
t.Errorf("%q wrongly qualified as a commit", cmd)
}
if len(res.Findings) != 0 {
t.Errorf("%q produced findings: %+v", cmd, res.Findings)
}
}
}
func TestScanCommitGuard_ChainedCommit(t *testing.T) {
det := newGuardDetector(t)
cmd := `git add . && git commit -m "subject" -m "` + coTrailer() + `"`
res := ScanCommitGuard(det, cmd, t.TempDir())
if !res.IsCommit || len(res.Findings) == 0 {
t.Errorf("chained commit: IsCommit=%v findings=%d, want true + >0", res.IsCommit, len(res.Findings))
}
}
func TestScanCommitGuard_GlobalOptionAndEnvPrefix(t *testing.T) {
det := newGuardDetector(t)
for _, cmd := range []string{
`git -C /tmp/repo commit -m "x" -m "` + coTrailer() + `"`,
`GIT_AUTHOR_NAME=bot git commit -m "x" -m "` + coTrailer() + `"`,
`git -c user.name=x commit -am "` + coTrailer() + `"`,
} {
res := ScanCommitGuard(det, cmd, t.TempDir())
if !res.IsCommit || len(res.Findings) == 0 {
t.Errorf("%q: IsCommit=%v findings=%d, want true + >0", cmd, res.IsCommit, len(res.Findings))
}
}
}
func TestScanCommitGuard_StagedDiffFinding(t *testing.T) {
det := newGuardDetector(t)
orig := stagedDiff
defer func() { stagedDiff = orig }()
diff := "diff --git a/x b/x\n+// " + genLine() + "\n"
stagedDiff = func(string) string { return diff }
// A clean message, but the staged diff carries a generated-by line.
res := ScanCommitGuard(det, `git commit -m "fix: clean"`, t.TempDir())
if !res.IsCommit || len(res.Findings) == 0 {
t.Errorf("expected a finding from the staged diff, got IsCommit=%v findings=%d", res.IsCommit, len(res.Findings))
}
}
func TestScanCommitGuard_DegradeOpenOnCommandSubstitution(t *testing.T) {
det := newGuardDetector(t)
orig := stagedDiff
defer func() { stagedDiff = orig }()
stagedDiff = func(string) string { return "" }
// A $()-resolved message cannot be statically read: no finding (allow),
// no panic. IsCommit is still true (the segment is a commit).
res := ScanCommitGuard(det, `git commit -m "$(cat msg.txt)"`, t.TempDir())
if !res.IsCommit {
t.Fatal("expected IsCommit true")
}
if len(res.Findings) != 0 {
t.Errorf("command-substitution message must degrade open, got findings: %+v", res.Findings)
}
}
func TestScanCommitGuard_QuotedSeparatorDoesNotSplit(t *testing.T) {
det := newGuardDetector(t)
// The ';' and '&&' live inside the quoted message and must not split
// the segment, so the commit is still detected as one.
res := ScanCommitGuard(det, `git commit -m "fix; really && done"`, t.TempDir())
if !res.IsCommit {
t.Error("quoted separators wrongly split the commit segment")
}
}
added internal/workflow/disable.go
@@ -0,0 +1,94 @@
package workflow
import (
"fmt"
"os"
"path/filepath"
"github.com/ajhahnde/eeco/internal/config"
)
// DisabledMarker is the sentinel file name that marks a user-scaffolded
// workflow as disabled. Its presence inside the workflow's directory
// makes ScriptRun report the workflow as blocked rather than execute
// it. The marker is empty; its existence is the entire signal. Stored
// in-tree with the workflow it gates, so removing the workflow removes
// the marker, and no separate ledger file enters the frozen surface.
const DisabledMarker = "disabled"
// workflowDir returns the absolute path of a user-scaffolded workflow's
// directory inside the workspace.
func workflowDir(cfg *config.Config, name string) string {
return filepath.Join(cfg.Workspace, "workflows", name)
}
// WorkflowExists reports whether a user-scaffolded workflow directory
// exists for name. Used by the CLI surface to refuse a toggle that
// names a workflow eeco does not know about.
func WorkflowExists(cfg *config.Config, name string) bool {
if cfg == nil {
return false
}
info, err := os.Stat(workflowDir(cfg, name))
return err == nil && info.IsDir()
}
// IsDisabled reports whether a user-scaffolded workflow is currently
// disabled. A missing workflow is not "disabled" — it is absent; callers
// that need to distinguish use WorkflowExists first.
func IsDisabled(cfg *config.Config, name string) bool {
if cfg == nil {
return false
}
_, err := os.Stat(filepath.Join(workflowDir(cfg, name), DisabledMarker))
return err == nil
}
// Disable marks a user-scaffolded workflow as disabled by creating an
// empty sentinel marker file inside its directory. The workflow stays
// on disk; ScriptRun reports it as blocked until Enable removes the
// marker. A workflow already disabled is a clean no-op.
func Disable(cfg *config.Config, name string) error {
dir, err := toggleDir(cfg, name)
if err != nil {
return err
}
marker := filepath.Join(dir, DisabledMarker)
if _, err := os.Stat(marker); err == nil {
return nil
}
return os.WriteFile(marker, nil, 0o644)
}
// Enable removes the disabled marker from a user-scaffolded workflow's
// directory. The workflow becomes runnable again. A workflow that is
// not disabled is a clean no-op.
func Enable(cfg *config.Config, name string) error {
dir, err := toggleDir(cfg, name)
if err != nil {
return err
}
marker := filepath.Join(dir, DisabledMarker)
if err := os.Remove(marker); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// toggleDir validates inputs shared by Enable / Disable and returns the
// workflow's on-disk directory. Refuses nil config, a bad workflow
// name, and a workflow whose directory does not exist.
func toggleDir(cfg *config.Config, name string) (string, error) {
if cfg == nil {
return "", fmt.Errorf("nil config")
}
if !workflowNameRE.MatchString(name) {
return "", fmt.Errorf("workflow name %q: must be lower-kebab-case (a-z, 0-9, '-')", name)
}
dir := workflowDir(cfg, name)
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
return "", fmt.Errorf("workflow %q not found at %s", name, dir)
}
return dir, nil
}
added internal/workflow/disable_test.go
@@ -0,0 +1,84 @@
package workflow
import (
"os"
"path/filepath"
"testing"
)
func TestWorkflowExists_DetectsScaffoldedDir(t *testing.T) {
cfg := newCfg(t)
if WorkflowExists(cfg, "missing") {
t.Error("missing workflow reported as existing")
}
dir := filepath.Join(cfg.Workspace, "workflows", "present")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if !WorkflowExists(cfg, "present") {
t.Error("scaffolded workflow reported as missing")
}
}
func TestDisableEnableRoundTrip(t *testing.T) {
cfg := newCfg(t)
dir := filepath.Join(cfg.Workspace, "workflows", "wf")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if IsDisabled(cfg, "wf") {
t.Fatal("fresh workflow should not be disabled")
}
if err := Disable(cfg, "wf"); err != nil {
t.Fatalf("Disable: %v", err)
}
if !IsDisabled(cfg, "wf") {
t.Error("Disable did not flip IsDisabled")
}
if _, err := os.Stat(filepath.Join(dir, DisabledMarker)); err != nil {
t.Errorf("marker file missing after Disable: %v", err)
}
// Repeat Disable: clean no-op.
if err := Disable(cfg, "wf"); err != nil {
t.Errorf("repeat Disable returned error: %v", err)
}
if err := Enable(cfg, "wf"); err != nil {
t.Fatalf("Enable: %v", err)
}
if IsDisabled(cfg, "wf") {
t.Error("Enable did not flip IsDisabled")
}
// Repeat Enable on an already-enabled workflow: clean no-op.
if err := Enable(cfg, "wf"); err != nil {
t.Errorf("repeat Enable returned error: %v", err)
}
}
func TestDisableEnable_RejectMissingWorkflow(t *testing.T) {
cfg := newCfg(t)
if err := Disable(cfg, "ghost"); err == nil {
t.Error("Disable should reject a missing workflow")
}
if err := Enable(cfg, "ghost"); err == nil {
t.Error("Enable should reject a missing workflow")
}
}
func TestDisableEnable_RejectBadName(t *testing.T) {
cfg := newCfg(t)
if err := Disable(cfg, "Bad/Name"); err == nil {
t.Error("Disable should reject a bad name")
}
if err := Enable(cfg, "Bad/Name"); err == nil {
t.Error("Enable should reject a bad name")
}
}
func TestDisableEnable_RejectNilConfig(t *testing.T) {
if err := Disable(nil, "wf"); err == nil {
t.Error("Disable should reject nil config")
}
if err := Enable(nil, "wf"); err == nil {
t.Error("Enable should reject nil config")
}
}
added internal/workflow/docdrift.go
@@ -0,0 +1,221 @@
package workflow
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/ajhahnde/eeco/internal/gitx"
"github.com/ajhahnde/eeco/internal/queue"
)
// docDrift flags drift between the release sections documented in
// CHANGELOG.md and the project's git tags. Two drift classes:
//
// - a `vX.Y.Z` git tag with no `## [vX.Y.Z]` CHANGELOG section — the
// release happened but was never documented;
// - a `## [vX.Y.Z]` CHANGELOG section with no matching git tag — a
// documented release that was never tagged. The newest section is
// exempt: a release commit adds `## [vX.Y.Z]` before the `vX.Y.Z`
// tag is pushed, so a section strictly ahead of the latest tag is
// the expected release-in-progress state, not drift (mirrors
// version-sync's tag-anchor forward-drift allowance).
//
// Drift is reported and one review item per drift is routed to the
// queue (the single decision channel); the operator reconciles the
// CHANGELOG or the tag, eeco never edits either. This is the doc-vs-tags
// slice of the stale-state / drift detection family, sibling to
// memory-drift.
type docDrift struct{}
func (docDrift) Name() string { return "doc-drift" }
func (docDrift) Summary() string {
return "flag drift between CHANGELOG.md release sections and git tags"
}
// docDriftTagSource resolves the project's semver-shaped tags. It is
// overridable in tests; it defaults to gitx.SemverTags.
var docDriftTagSource = gitx.SemverTags
// changelogHeading matches a Keep-a-Changelog version section heading
// `## [vX.Y.Z]`; the optional `v` is tolerated and group 1 captures the
// bare X.Y.Z. `## [Unreleased]` carries no version and never matches.
var changelogHeading = regexp.MustCompile(`(?m)^##\s+\[v?(\d+\.\d+\.\d+)\]`)
// changelogSection is one `## [vX.Y.Z]` heading found in CHANGELOG.md.
type changelogSection struct {
version string // bare X.Y.Z
line int // 1-based heading line
}
// docDriftItem is one drift carried from detection to the queue-append
// loop.
type docDriftItem struct {
title string
detail string
}
func (docDrift) Run(env Env) (Result, error) {
cfg := env.Config
// The whole check compares against git tags, so a host without git
// cannot run it — report blocked (contract code 2) rather than
// passing a check that never actually ran.
if !gitx.Available() {
return Result{Code: CodeBlocked, Summary: "git not available on PATH"}, nil
}
const changelogName = "CHANGELOG.md"
abs := filepath.Join(cfg.RepoRoot, changelogName)
b, err := os.ReadFile(abs)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
// No CHANGELOG to check — a clean no-op, like memory-drift's
// "no facts" and version-sync's "no version_locations".
return Result{Code: CodeClean, Summary: "no CHANGELOG.md to check"}, nil
}
return Result{}, fmt.Errorf("doc-drift: read %s: %w", changelogName, err)
}
sections := parseChangelogSections(string(b))
tags, err := docDriftTagSource(cfg.RepoRoot)
if err != nil {
return Result{}, fmt.Errorf("doc-drift: resolve tags: %w", err)
}
if len(tags) == 0 {
// A project with a CHANGELOG but no releases yet has nothing to
// drift against — clean no-op.
return Result{Code: CodeClean, Summary: "no git tags to check against"}, nil
}
// Sort both sides ascending by semver so detection is deterministic.
sort.Slice(tags, func(i, j int) bool {
c, _ := compareSemverVal(tags[i], tags[j])
return c < 0
})
sort.Slice(sections, func(i, j int) bool {
c, _ := compareSemverVal(sections[i].version, sections[j].version)
return c < 0
})
latest := tags[len(tags)-1]
tagSet := make(map[string]bool, len(tags))
for _, tg := range tags {
tagSet[strings.TrimPrefix(tg, "v")] = true
}
sectionSet := make(map[string]bool, len(sections))
for _, s := range sections {
sectionSet[s.version] = true
}
var (
findings []Finding
drifts []docDriftItem
)
// Class 1: a git tag with no matching CHANGELOG section.
for _, tg := range tags {
if sectionSet[strings.TrimPrefix(tg, "v")] {
continue
}
findings = append(findings, Finding{
Path: changelogName,
Line: 0,
Msg: fmt.Sprintf("git tag %s has no CHANGELOG section", tg),
})
drifts = append(drifts, docDriftItem{
title: fmt.Sprintf("git tag %s is not documented in CHANGELOG.md", tg),
detail: fmt.Sprintf("tag %s exists but CHANGELOG.md has no \"## [%s]\" section — document the release",
tg, tg),
})
}
// Class 2: a CHANGELOG section with no matching git tag, excluding
// the release-in-progress section strictly ahead of the latest tag.
for _, s := range sections {
if tagSet[s.version] {
continue
}
if c, ok := compareSemverVal(s.version, latest); ok && c > 0 {
continue
}
findings = append(findings, Finding{
Path: changelogName,
Line: s.line,
Msg: fmt.Sprintf("CHANGELOG section v%s has no matching git tag", s.version),
})
drifts = append(drifts, docDriftItem{
title: fmt.Sprintf("CHANGELOG section v%s has no matching git tag", s.version),
detail: fmt.Sprintf("CHANGELOG.md:%d documents v%s but no v%s git tag exists — tag the release or correct the section",
s.line, s.version, s.version),
})
}
if len(findings) == 0 {
return Result{
Code: CodeClean,
Summary: fmt.Sprintf("CHANGELOG.md and %d git tag(s) agree", len(tags)),
}, nil
}
sort.Slice(findings, func(i, j int) bool {
if findings[i].Line != findings[j].Line {
return findings[i].Line < findings[j].Line
}
return findings[i].Msg < findings[j].Msg
})
// Route one review item per drift to the queue — eeco flags it, the
// operator reconciles the CHANGELOG against the tags.
project := filepath.Base(cfg.RepoRoot)
stateDir := filepath.Join(cfg.Workspace, "state")
today := time.Now().UTC()
for _, d := range drifts {
item := queue.Item{
Kind: "doc-drift",
Title: d.title,
Project: project,
Detail: d.detail,
Date: today,
}
// AppendUnique so a repeated run (for example the post-merge hook)
// does not pile up duplicate items for a drift still open in the
// queue; the drift itself is still real and reported below.
if _, err := queue.AppendUnique(stateDir, item); err != nil {
return Result{}, fmt.Errorf("doc-drift: queue: %w", err)
}
}
return Result{
Code: CodeFinding,
Summary: fmt.Sprintf("%d CHANGELOG/tag drift(s)", len(findings)),
Findings: findings,
}, nil
}
// parseChangelogSections returns one entry per `## [vX.Y.Z]` heading in
// content, in file order, de-duplicated on the version (a malformed
// CHANGELOG repeating a version is reported once). `## [Unreleased]`
// carries no version and is skipped.
func parseChangelogSections(content string) []changelogSection {
var out []changelogSection
seen := map[string]bool{}
for _, m := range changelogHeading.FindAllStringSubmatchIndex(content, -1) {
version := content[m[2]:m[3]]
if seen[version] {
continue
}
seen[version] = true
out = append(out, changelogSection{
version: version,
line: 1 + strings.Count(content[:m[0]], "\n"),
})
}
return out
}
added internal/workflow/docdrift_test.go
@@ -0,0 +1,199 @@
package workflow
import (
"os/exec"
"strings"
"testing"
)
// stubDocDriftTags overrides docDriftTagSource for the test so the
// tag side of the comparison is fixed without touching real git.
func stubDocDriftTags(t *testing.T, tags []string) {
t.Helper()
old := docDriftTagSource
docDriftTagSource = func(string) ([]string, error) { return tags, nil }
t.Cleanup(func() { docDriftTagSource = old })
}
func TestDocDrift_NoChangelog(t *testing.T) {
cfg := newCfg(t)
stubDocDriftTags(t, []string{"v1.0.0"})
res, err := docDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Errorf("Code = %d, want %d (%q)", res.Code, CodeClean, res.Summary)
}
if res.Summary != "no CHANGELOG.md to check" {
t.Errorf("Summary = %q", res.Summary)
}
}
func TestDocDrift_NoTags(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "# Changelog\n\n## [v1.0.0]\n")
stubDocDriftTags(t, nil)
res, err := docDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Errorf("Code = %d, want %d (%q)", res.Code, CodeClean, res.Summary)
}
if res.Summary != "no git tags to check against" {
t.Errorf("Summary = %q", res.Summary)
}
}
func TestDocDrift_AllDocumented(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md",
"# Changelog\n\n## [Unreleased]\n\n## [v1.1.0]\n\n## [v1.0.0]\n")
stubDocDriftTags(t, []string{"v1.1.0", "v1.0.0"})
res, err := docDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("Code = %d, want %d (%q)", res.Code, CodeClean, res.Summary)
}
if q := queueBody(t, cfg); q != "" {
t.Errorf("queue should be empty, got:\n%s", q)
}
}
func TestDocDrift_TagWithoutSection(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "# Changelog\n\n## [v1.0.0]\n")
stubDocDriftTags(t, []string{"v1.1.0", "v1.0.0"})
res, err := docDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("Code = %d, want %d (%q)", res.Code, CodeFinding, res.Summary)
}
if len(res.Findings) != 1 {
t.Fatalf("Findings = %d, want 1: %+v", len(res.Findings), res.Findings)
}
if !strings.Contains(res.Findings[0].Msg, "v1.1.0") {
t.Errorf("Finding.Msg = %q, want it to name v1.1.0", res.Findings[0].Msg)
}
q := queueBody(t, cfg)
if strings.Count(q, "**doc-drift**") != 1 || !strings.Contains(q, "v1.1.0") {
t.Errorf("queue missing doc-drift item for v1.1.0:\n%s", q)
}
}
func TestDocDrift_SectionWithoutTagBelowLatest(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md",
"# Changelog\n\n## [v1.2.0]\n\n## [v1.1.0]\n\n## [v1.0.0]\n")
// v1.1.0 is documented but never tagged; it sits below the latest
// tag v1.2.0, so it is a genuine gap, not a release-in-progress.
stubDocDriftTags(t, []string{"v1.2.0", "v1.0.0"})
res, err := docDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("Code = %d, want %d (%q)", res.Code, CodeFinding, res.Summary)
}
if len(res.Findings) != 1 || !strings.Contains(res.Findings[0].Msg, "v1.1.0") {
t.Fatalf("Findings = %+v, want one naming v1.1.0", res.Findings)
}
if strings.Count(queueBody(t, cfg), "**doc-drift**") != 1 {
t.Errorf("want exactly one queued doc-drift item")
}
}
func TestDocDrift_SectionAheadOfLatestTagIsClean(t *testing.T) {
cfg := newCfg(t)
// v1.1.0 is documented ahead of the latest tag v1.0.0 — the expected
// release-in-progress state, not drift. `## [Unreleased]` is ignored.
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md",
"# Changelog\n\n## [Unreleased]\n\n## [v1.1.0]\n\n## [v1.0.0]\n")
stubDocDriftTags(t, []string{"v1.0.0"})
res, err := docDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("Code = %d, want %d (%q)", res.Code, CodeClean, res.Summary)
}
if q := queueBody(t, cfg); q != "" {
t.Errorf("queue should be empty, got:\n%s", q)
}
}
func TestDocDrift_Mixed(t *testing.T) {
cfg := newCfg(t)
// Tags v1.0.0 + v1.2.0 are both undocumented (class 1); section
// v1.1.0 has no tag and sits below latest v1.2.0 (class 2); section
// v1.3.0 is ahead of latest, exempt.
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md",
"# Changelog\n\n## [v1.3.0]\n\n## [v1.1.0]\n")
stubDocDriftTags(t, []string{"v1.2.0", "v1.0.0"})
res, err := docDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("Code = %d, want %d (%q)", res.Code, CodeFinding, res.Summary)
}
if len(res.Findings) != 3 {
t.Fatalf("Findings = %d, want 3: %+v", len(res.Findings), res.Findings)
}
if n := strings.Count(queueBody(t, cfg), "**doc-drift**"); n != 3 {
t.Errorf("queued doc-drift items = %d, want 3", n)
}
}
func TestDocDrift_GitMissing(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "# Changelog\n\n## [v1.0.0]\n")
// Emptying PATH hides the git binary; the workflow must report
// blocked rather than pass a check it never ran.
t.Setenv("PATH", "")
res, err := docDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeBlocked {
t.Errorf("Code = %d, want %d (%q)", res.Code, CodeBlocked, res.Summary)
}
}
// TestDocDrift_RealGit exercises the real gitx.SemverTags wiring (no
// stub) against an actual tagged repo, covering the integration end to
// end alongside the stubbed table cases above.
func TestDocDrift_RealGit(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "# Changelog\n\n## [Unreleased]\n")
gitInit(t, cfg.RepoRoot)
runGit(t, cfg.RepoRoot, "commit", "-q", "-m", "init")
runGit(t, cfg.RepoRoot, "tag", "v0.1.0")
res, err := docDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("Code = %d, want %d (%q)", res.Code, CodeFinding, res.Summary)
}
if len(res.Findings) != 1 || !strings.Contains(res.Findings[0].Msg, "v0.1.0") {
t.Errorf("Findings = %+v, want one naming v0.1.0", res.Findings)
}
}
added internal/workflow/evolve.go
@@ -0,0 +1,313 @@
package workflow
import (
"context"
"fmt"
"path/filepath"
"strings"
"time"
"github.com/ajhahnde/eeco/internal/ai"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/gitx"
"github.com/ajhahnde/eeco/internal/queue"
)
// evolveCandidatePrefix is the line prefix the AI pass uses to name a
// proposed workflow, parsed back out for the scaffold/auto levels.
const evolveCandidatePrefix = "WORKFLOW:"
// maxEvolveLogLines caps how much commit history feeds the digest: a
// repetition signal does not need the entire history, and the digest
// must stay terse and deterministic.
const maxEvolveLogLines = 40
// evolve detects repeated manual activity and turns it into proposed
// workflows. Its aggressiveness scales with the configured automation
// level, never past the floor invariants (PLAN.md §Automation level):
//
// - manual: does nothing (new workflows only via `eeco new`);
// - propose: a gated AI pass proposes; the proposal is queued;
// - scaffold/auto: as propose, and each proposed workflow is written
// inactive into the workspace and queued "ready to activate".
//
// It never activates, runs, or commits a workflow, and every AI pass is
// consent-gated, budget-capped, and parked on skip by the shared Gate —
// exactly the bug-sweep / handover-refresh discipline.
type evolve struct{}
func (evolve) Name() string { return "evolve" }
func (evolve) Summary() string {
return "propose workflows from repeated activity; scaffold per the automation level"
}
func (evolve) Run(env Env) (Result, error) {
cfg := env.Config
if cfg.Automation == config.AutomationManual {
return Result{
Code: CodeClean,
Summary: "evolve disabled at automation=manual (scaffold workflows with `eeco new`)",
}, nil
}
stamp := time.Now().UTC()
stateDir := filepath.Join(cfg.Workspace, "state")
project := filepath.Base(cfg.RepoRoot)
// 0. Load + reconcile the repetition ledger. Reconciliation is a
// one-way flip (unresolved → resolved when the queue row is now
// ticked); a corrupt ledger degrades to empty so a broken file
// never wedges evolve. Save when any resolution flipped.
history, herr := LoadHistory(stateDir)
if herr != nil {
return Result{}, fmt.Errorf("evolve: load history: %w", herr)
}
if reconciled, changed := ReconcileHistory(stateDir, history, stamp); changed {
history = reconciled
if serr := SaveHistory(stateDir, history); serr != nil {
return Result{}, fmt.Errorf("evolve: save reconciled history: %w", serr)
}
}
// 1. Deterministic signal extraction — no AI spend, no Gate touch.
// Each surfaced candidate becomes its own queue item the operator
// can resolve independently; the AI pass below is optional
// enrichment, not the only path to output. A candidate whose
// (SignalKind, SignalKey) is already in the repetition ledger is
// suppressed — once proposed, never re-proposed; the
// re-propose-on-signal-recurrence knob is a follow-on slice.
detCandidates := computeDeterministicCandidates(cfg)
survivors := make([]Candidate, 0, len(detCandidates))
for _, c := range detCandidates {
if len(c.Signals) > 0 && history.HasProposed(c.Signals[0].Kind, c.Signals[0].Key) {
continue
}
survivors = append(survivors, c)
}
detCandidates = survivors
findings := make([]Finding, 0, len(detCandidates)+2)
ledgerDirty := false
for _, c := range detCandidates {
title := "Workflow candidate: " + c.Title
if _, qerr := queue.AppendUnique(stateDir, queue.Item{
Kind: "evolve",
Title: title,
Project: project,
Detail: c.Reason,
Date: stamp,
}); qerr != nil {
return Result{}, fmt.Errorf("evolve: queue deterministic candidate: %w", qerr)
}
findings = append(findings, Finding{Path: c.Title, Msg: c.Reason})
if len(c.Signals) > 0 {
history.Records = append(history.Records, HistoryRecord{
SignalKind: c.Signals[0].Kind,
SignalKey: c.Signals[0].Key,
CountAtProposal: c.Signals[0].Count,
QueueKind: "evolve",
QueueTitle: title,
ProposedAt: stamp.Format(time.RFC3339),
})
ledgerDirty = true
}
}
if ledgerDirty {
if serr := SaveHistory(stateDir, history); serr != nil {
return Result{}, fmt.Errorf("evolve: save history: %w", serr)
}
}
// 2. AI gate pass. A nil Gate or a Skipped outcome is the
// no-consent / over-budget / provider-error path: the Gate has
// already parked the prompt and queued an item. With zero
// deterministic candidates the workflow defers (the exit-3 contract
// is preserved — there is genuinely nothing to report); with at
// least one deterministic candidate the workflow exits clean
// surfacing the deterministic list, the AI enrichment simply did
// not run.
if env.Gate == nil {
if len(detCandidates) == 0 {
return Result{
Code: CodeAIDeferred,
Summary: "evolve deferred: no AI gate available and no deterministic candidates",
}, nil
}
return Result{
Code: CodeClean,
Summary: fmt.Sprintf("evolve surfaced %d deterministic candidate(s) (no AI gate)", len(detCandidates)),
Findings: findings,
}, nil
}
out, gerr := env.Gate.Run(context.Background(), ai.Request{
Label: "evolve",
System: ai.ProjectDigest(cfg),
User: evolveUserPrompt(cfg),
Cache: true,
})
if gerr != nil {
return Result{}, fmt.Errorf("evolve: ai gate: %w", gerr)
}
if !out.Ran {
if len(detCandidates) == 0 {
return Result{
Code: CodeAIDeferred,
Summary: "evolve deferred: AI pass not run (prompt parked, queued)",
}, nil
}
return Result{
Code: CodeClean,
Summary: fmt.Sprintf("evolve surfaced %d deterministic candidate(s); AI pass not run", len(detCandidates)),
Findings: findings,
}, nil
}
// 3. AI ran — queue the proposal summary and scaffold AI candidates
// per the automation level. Deterministic candidates above are
// advisory only; scaffolding stays on the AI path so the existing
// consent + budget contract still gates any workspace write here.
if _, err := queue.AppendUnique(stateDir, queue.Item{
Kind: "evolve",
Title: "Workflow proposal ready for review",
Project: project,
Detail: "automation=" + string(cfg.Automation) + "\n" + condense(firstLine(out.Text)),
Date: stamp,
}); err != nil {
return Result{}, fmt.Errorf("evolve: queue proposal: %w", err)
}
findings = append(findings, Finding{Path: "evolve", Msg: "proposal queued for review"})
if cfg.Automation.ScaffoldsWorkflows() {
for _, name := range parseCandidates(out.Text) {
dir, serr := Scaffold(cfg, name)
if serr != nil {
// A name collision or invalid name is not fatal: record
// it for the maintainer and keep going.
findings = append(findings, Finding{
Path: name, Msg: "not scaffolded: " + serr.Error(),
})
_, _ = queue.AppendUnique(stateDir, queue.Item{
Kind: "evolve",
Title: "Proposed workflow could not be scaffolded: " + name,
Project: project,
Detail: serr.Error(),
Date: stamp,
})
continue
}
rel := dir
if r, rerr := filepath.Rel(cfg.RepoRoot, dir); rerr == nil {
rel = filepath.ToSlash(r)
}
findings = append(findings, Finding{
Path: name, Msg: "scaffolded inactive at " + rel,
})
_, _ = queue.AppendUnique(stateDir, queue.Item{
Kind: "evolve",
Title: "Scaffolded workflow ready to activate: " + name,
Project: project,
Detail: "wrote " + rel + " (inactive) — review, then `eeco run " + name + "` to use it",
Date: stamp,
})
}
}
return Result{
Code: CodeClean,
Summary: fmt.Sprintf("evolve proposed and queued (automation=%s)", cfg.Automation),
Findings: findings,
}, nil
}
// computeDeterministicCandidates reads the same git log evolvePrompt
// already feeds to the AI pass, extracts repeated commit-type signals,
// and turns them into proposed workflow candidates. Returns nil when
// git is unavailable or the log is empty — the caller treats that the
// same as zero candidates.
func computeDeterministicCandidates(cfg *config.Config) []Candidate {
if !gitx.Available() {
return nil
}
log, _, err := gitx.ChangesSince(cfg.RepoRoot, "")
if err != nil || log == "" {
return nil
}
lines := splitLines(log)
if len(lines) > maxEvolveLogLines {
lines = lines[:maxEvolveLogLines]
}
return ProposeCandidates(ComputeSignals(lines))
}
// evolveUserPrompt builds the volatile User turn the gated pass reasons
// over: the instruction, recent commit subjects (a manual-repetition
// signal), and the workspace decision backlog. The deterministic project
// shape is the cacheable System block (ai.ProjectDigest), threaded
// separately at the call site. Reading these is not an AI spend; only
// the gated Gate.Run is.
func evolveUserPrompt(cfg *config.Config) string {
var b strings.Builder
b.WriteString("Identify gaps in the project's cockpit playbooks and propose small " +
"playbook/cockpit improvements that absorb maintenance the maintainer keeps " +
"doing by hand. Be terse and concrete.\n")
b.WriteString("After any prose, list at most three proposals, one per line, as:\n")
b.WriteString(evolveCandidatePrefix + " <lower-kebab-name> — <one-line purpose>\n")
if gitx.Available() {
if log, _, err := gitx.ChangesSince(cfg.RepoRoot, ""); err == nil && log != "" {
b.WriteString("\nRecent commits:\n")
lines := splitLines(log)
if len(lines) > maxEvolveLogLines {
lines = lines[:maxEvolveLogLines]
}
for _, ln := range lines {
b.WriteString(ln + "\n")
}
}
}
if n, err := queue.Count(filepath.Join(cfg.Workspace, "state")); err == nil {
fmt.Fprintf(&b, "\nOpen queue items: %d\n", n)
}
return b.String()
}
// parseCandidates extracts the proposed workflow names from the AI
// text. Only well-formed lower-kebab names are kept (Scaffold enforces
// this too); duplicates collapse so a name is scaffolded at most once.
func parseCandidates(text string) []string {
seen := map[string]struct{}{}
var names []string
for _, raw := range splitLines(text) {
line := strings.TrimSpace(raw)
rest, ok := strings.CutPrefix(line, evolveCandidatePrefix)
if !ok {
continue
}
rest = strings.TrimSpace(rest)
// Take the token up to the first space / em-dash / hyphen-spacer.
name := rest
for _, sep := range []string{" ", "—", "\t"} {
if i := strings.Index(name, sep); i >= 0 {
name = name[:i]
}
}
name = strings.TrimSpace(name)
if !workflowNameRE.MatchString(name) {
continue
}
if _, dup := seen[name]; dup {
continue
}
seen[name] = struct{}{}
names = append(names, name)
}
return names
}
func firstLine(s string) string {
first, _, _ := strings.Cut(strings.TrimSpace(s), "\n")
return first
}
added internal/workflow/evolve_test.go
@@ -0,0 +1,352 @@
package workflow
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/queue"
)
func queueBody(t *testing.T, cfg *config.Config) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(cfg.Workspace, "state", queue.Filename))
if err != nil {
if os.IsNotExist(err) {
return ""
}
t.Fatalf("queue: %v", err)
}
return string(b)
}
func TestEvolve_ManualIsDisabledNoOp(t *testing.T) {
cfg := newCfg(t)
cfg.Automation = config.AutomationManual
res, err := evolve{}.Run(Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, true)})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Errorf("code = %d, want clean", res.Code)
}
if !strings.Contains(res.Summary, "manual") {
t.Errorf("summary = %q, want it to mention manual", res.Summary)
}
if q := queueBody(t, cfg); q != "" {
t.Errorf("manual evolve must not queue anything, got:\n%s", q)
}
}
func TestEvolve_ProposeDefersWithoutConsent(t *testing.T) {
cfg := newCfg(t)
cfg.Automation = config.AutomationPropose
// Consent false: the Gate parks the prompt and queues ai-parked;
// evolve reports AI-deferred (contract code 3), like bug-sweep.
res, err := evolve{}.Run(Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, false)})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeAIDeferred {
t.Fatalf("code = %d, want %d (AI deferred)", res.Code, CodeAIDeferred)
}
if !strings.Contains(queueBody(t, cfg), "ai-parked") {
t.Error("expected the Gate to have queued an ai-parked item")
}
}
func TestEvolve_ProposeQueuesProposalNoScaffold(t *testing.T) {
cfg := newCfg(t)
cfg.Automation = config.AutomationPropose
p := &stubProvider{text: "Repeated release bumps.\nWORKFLOW: release-bump — automate it\n"}
res, err := evolve{}.Run(Env{Config: cfg, Gate: gateWith(t, cfg, p, true)})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("code = %d, want clean", res.Code)
}
q := queueBody(t, cfg)
if !strings.Contains(q, "**evolve**") || !strings.Contains(q, "proposal ready") {
t.Errorf("queue missing evolve proposal:\n%s", q)
}
// propose level must NOT write a workflow.
if _, err := os.Stat(filepath.Join(cfg.Workspace, "workflows", "release-bump")); !os.IsNotExist(err) {
t.Errorf("propose level scaffolded a workflow (err=%v)", err)
}
}
func TestEvolve_ScaffoldWritesInactiveAndQueues(t *testing.T) {
cfg := newCfg(t)
cfg.Automation = config.AutomationScaffold
p := &stubProvider{text: "prose\nWORKFLOW: dep-audit — audit deps\nWORKFLOW: dep-audit — duplicate\nWORKFLOW: BAD NAME — skip\n"}
res, err := evolve{}.Run(Env{Config: cfg, Gate: gateWith(t, cfg, p, true)})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("code = %d, want clean", res.Code)
}
dir := filepath.Join(cfg.Workspace, "workflows", "dep-audit")
if _, err := os.Stat(filepath.Join(dir, "run")); err != nil {
t.Fatalf("scaffolded entry missing: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "README.md")); err != nil {
t.Fatalf("scaffolded README missing: %v", err)
}
q := queueBody(t, cfg)
if !strings.Contains(q, "ready to activate") || !strings.Contains(q, "dep-audit") {
t.Errorf("queue missing 'ready to activate' for dep-audit:\n%s", q)
}
// The duplicate name must be scaffolded only once (idempotent set).
if strings.Count(q, "ready to activate") != 1 {
t.Errorf("dep-audit scaffolded/queued more than once:\n%s", q)
}
}
func TestEvolve_ScaffoldCollisionIsNotFatal(t *testing.T) {
cfg := newCfg(t)
cfg.Automation = config.AutomationScaffold
// Pre-existing workflow of the same name: Scaffold refuses to
// overwrite; evolve must record the collision and keep going.
clash := filepath.Join(cfg.Workspace, "workflows", "taken")
if err := os.MkdirAll(clash, 0o755); err != nil {
t.Fatal(err)
}
p := &stubProvider{text: "WORKFLOW: taken — collides\nWORKFLOW: fresh-one — ok\n"}
res, err := evolve{}.Run(Env{Config: cfg, Gate: gateWith(t, cfg, p, true)})
if err != nil {
t.Fatalf("collision must not be fatal: %v", err)
}
if res.Code != CodeClean {
t.Fatalf("code = %d, want clean", res.Code)
}
if _, err := os.Stat(filepath.Join(cfg.Workspace, "workflows", "fresh-one", "run")); err != nil {
t.Errorf("the non-colliding candidate was not scaffolded: %v", err)
}
q := queueBody(t, cfg)
if !strings.Contains(q, "could not be scaffolded: taken") {
t.Errorf("collision not surfaced in queue:\n%s", q)
}
}
func TestEvolve_NilGateDefers(t *testing.T) {
cfg := newCfg(t)
cfg.Automation = config.AutomationPropose
res, err := evolve{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeAIDeferred {
t.Errorf("code = %d, want %d (AI deferred) with nil gate", res.Code, CodeAIDeferred)
}
}
func TestEvolve_NoConsent_DetCandidates_ReturnsClean(t *testing.T) {
cfg := newCfg(t)
cfg.Automation = config.AutomationPropose
root := cfg.RepoRoot
writeRepoFile(t, root, "a.txt", "1")
gitInit(t, root)
runGit(t, root, "commit", "-q", "-m", "feat: one")
runGit(t, root, "commit", "-q", "--allow-empty", "-m", "feat: two")
runGit(t, root, "commit", "-q", "--allow-empty", "-m", "feat: three")
res, err := evolve{}.Run(Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, false)})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("code = %d, want %d (clean) — no-consent + det≥1 must exit 0", res.Code, CodeClean)
}
if !strings.Contains(res.Summary, "deterministic candidate") {
t.Errorf("summary = %q, want it to mention deterministic candidate(s)", res.Summary)
}
q := queueBody(t, cfg)
if !strings.Contains(q, "Workflow candidate: feat-workflow") {
t.Errorf("queue missing feat-workflow candidate:\n%s", q)
}
if !strings.Contains(q, "ai-parked") {
t.Errorf("queue missing ai-parked item (gate did not park on no-consent):\n%s", q)
}
}
func TestEvolve_NoConsent_NoDetCandidates_DefersExit3(t *testing.T) {
cfg := newCfg(t)
cfg.Automation = config.AutomationPropose
root := cfg.RepoRoot
writeRepoFile(t, root, "a.txt", "1")
gitInit(t, root)
runGit(t, root, "commit", "-q", "-m", "plain message one")
runGit(t, root, "commit", "-q", "--allow-empty", "-m", "plain message two")
runGit(t, root, "commit", "-q", "--allow-empty", "-m", "plain message three")
res, err := evolve{}.Run(Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, false)})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeAIDeferred {
t.Fatalf("code = %d, want %d (AI deferred) — no-consent + det=0 must preserve exit 3", res.Code, CodeAIDeferred)
}
}
func TestEvolve_LedgerWrittenOnFirstRun(t *testing.T) {
cfg := newCfg(t)
cfg.Automation = config.AutomationPropose
root := cfg.RepoRoot
writeRepoFile(t, root, "a.txt", "1")
gitInit(t, root)
runGit(t, root, "commit", "-q", "-m", "fix: one")
runGit(t, root, "commit", "-q", "--allow-empty", "-m", "fix: two")
runGit(t, root, "commit", "-q", "--allow-empty", "-m", "fix: three")
env := Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, false)}
if _, err := (evolve{}).Run(env); err != nil {
t.Fatal(err)
}
stateDir := filepath.Join(cfg.Workspace, "state")
h, err := LoadHistory(stateDir)
if err != nil {
t.Fatal(err)
}
if len(h.Records) != 1 {
t.Fatalf("ledger records: got %d, want 1", len(h.Records))
}
r := h.Records[0]
if r.SignalKind != SignalCommitType || r.SignalKey != "fix" {
t.Errorf("ledger signal: got %s/%s, want %s/fix", r.SignalKind, r.SignalKey, SignalCommitType)
}
if r.CountAtProposal != 3 {
t.Errorf("CountAtProposal: got %d, want 3", r.CountAtProposal)
}
if r.QueueTitle != "Workflow candidate: fix-workflow" {
t.Errorf("QueueTitle: got %q", r.QueueTitle)
}
if r.Resolved {
t.Errorf("fresh record must not be resolved")
}
}
func TestEvolve_LedgerSuppressesRepeatRun(t *testing.T) {
cfg := newCfg(t)
cfg.Automation = config.AutomationPropose
root := cfg.RepoRoot
writeRepoFile(t, root, "a.txt", "1")
gitInit(t, root)
runGit(t, root, "commit", "-q", "-m", "fix: one")
runGit(t, root, "commit", "-q", "--allow-empty", "-m", "fix: two")
runGit(t, root, "commit", "-q", "--allow-empty", "-m", "fix: three")
// First run files the candidate + ledger record.
env := Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, false)}
if _, err := (evolve{}).Run(env); err != nil {
t.Fatal(err)
}
q1 := queueBody(t, cfg)
count1 := strings.Count(q1, "Workflow candidate: fix-workflow")
if count1 != 1 {
t.Fatalf("first run candidate count: got %d, want 1", count1)
}
// Second run with identical git log: ledger must suppress the
// candidate; the queue row count stays at 1 (no duplicate).
env = Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, false)}
if _, err := (evolve{}).Run(env); err != nil {
t.Fatal(err)
}
q2 := queueBody(t, cfg)
count2 := strings.Count(q2, "Workflow candidate: fix-workflow")
if count2 != 1 {
t.Errorf("ledger suppression failed: candidate appeared %d times, want 1", count2)
}
// Ledger must still hold exactly one record (no append on repeat).
h, err := LoadHistory(filepath.Join(cfg.Workspace, "state"))
if err != nil {
t.Fatal(err)
}
if len(h.Records) != 1 {
t.Errorf("ledger records after repeat run: got %d, want 1", len(h.Records))
}
}
func TestEvolve_LedgerResolvedRecordStillSuppresses(t *testing.T) {
cfg := newCfg(t)
cfg.Automation = config.AutomationPropose
root := cfg.RepoRoot
stateDir := filepath.Join(cfg.Workspace, "state")
writeRepoFile(t, root, "a.txt", "1")
gitInit(t, root)
runGit(t, root, "commit", "-q", "-m", "fix: one")
runGit(t, root, "commit", "-q", "--allow-empty", "-m", "fix: two")
runGit(t, root, "commit", "-q", "--allow-empty", "-m", "fix: three")
// First run.
env := Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, false)}
if _, err := (evolve{}).Run(env); err != nil {
t.Fatal(err)
}
// Operator resolves the queue item by ticking the checkbox.
body, err := os.ReadFile(filepath.Join(stateDir, queue.Filename))
if err != nil {
t.Fatal(err)
}
rewritten := strings.Replace(string(body), "- [ ] **evolve**", "- [x] **evolve**", 1)
if err := os.WriteFile(filepath.Join(stateDir, queue.Filename), []byte(rewritten), 0o644); err != nil {
t.Fatal(err)
}
// Second run: reconciliation flips Resolved; suppression still holds
// (resolved records suppress in v2.2.0 — re-propose-on-recurrence is
// a follow-on slice).
env = Env{Config: cfg, Gate: gateWith(t, cfg, &stubProvider{text: "x"}, false)}
if _, err := (evolve{}).Run(env); err != nil {
t.Fatal(err)
}
h, err := LoadHistory(stateDir)
if err != nil {
t.Fatal(err)
}
if len(h.Records) != 1 {
t.Fatalf("records after run-resolve-run: got %d, want 1", len(h.Records))
}
if !h.Records[0].Resolved {
t.Errorf("reconciliation must flip Resolved → true")
}
}
func TestParseCandidates(t *testing.T) {
in := strings.Join([]string{
"some prose first",
"WORKFLOW: good-name — does a thing",
"WORKFLOW: spaced-name\twith tab",
"WORKFLOW: Bad_Name — rejected",
"WORKFLOW: good-name — duplicate dropped",
"not a candidate line",
}, "\n")
got := parseCandidates(in)
want := []string{"good-name", "spaced-name"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Errorf("parseCandidates = %v, want %v", got, want)
}
}
func TestEvolve_RegisteredInDefaultRegistry(t *testing.T) {
if _, ok := DefaultRegistry().Get("evolve"); !ok {
t.Fatal("evolve not registered in DefaultRegistry")
}
}
added internal/workflow/gate.go
@@ -0,0 +1,97 @@
package workflow
import (
"errors"
"fmt"
"os/exec"
"strings"
)
// buildGate runs the project's declared parse/build gate — the ordered
// command chain in cfg.Gate — step by step, with the repository root as
// the working directory, stopping at the first failure. The chain is the
// profile default (a single step) or the operator's repeatable `gate`
// key in config.local; a project with no gate (the generic profile, or
// a lone `gate=`) is a clean no-op, so the gate is opt-in per project.
//
// Exit-code contract:
// - every step exits 0 -> CodeClean
// - a step exits non-zero -> CodeFinding (chain stops there)
// - a step's command is not on PATH, or -> CodeBlocked
// a step that pre-flighted cannot run
// - no gate declared -> CodeClean
//
// Every step's command is checked on PATH before the first step runs, so
// a chain that cannot complete is reported blocked rather than running
// partway and reporting a finding (a missing tool outranks a finding).
type buildGate struct{}
func (buildGate) Name() string { return "gate" }
func (buildGate) Summary() string {
return "run the project's declared parse/build gate chain"
}
func (buildGate) Run(env Env) (Result, error) {
steps := env.Config.Gate
if len(steps) == 0 {
return Result{Code: CodeClean, Summary: "no gate declared"}, nil
}
// Pre-flight: a chain is runnable only if every step's command is on
// PATH. Report the first missing tool as blocked before running any
// step, so a partly-run chain never masquerades as a finding.
for _, step := range steps {
if len(step) == 0 {
continue
}
if _, err := exec.LookPath(step[0]); err != nil {
return Result{
Code: CodeBlocked,
Summary: fmt.Sprintf("gate step %q is not on PATH", step[0]),
}, nil
}
}
for i, step := range steps {
if len(step) == 0 {
continue
}
label := strings.Join(step, " ")
if env.Out != nil {
fmt.Fprintf(env.Out, "gate step %d/%d: %s\n", i+1, len(steps), label)
}
cmd := exec.Command(step[0], step[1:]...)
cmd.Dir = env.Config.RepoRoot
cmd.Stdout = env.Out
cmd.Stderr = env.Out
runErr := cmd.Run()
if runErr == nil {
continue
}
var ee *exec.ExitError
if errors.As(runErr, &ee) {
return Result{
Code: CodeFinding,
Summary: fmt.Sprintf("gate step %d/%d failed: %s", i+1, len(steps), label),
Findings: []Finding{{
Path: label,
Line: 0,
Msg: fmt.Sprintf("exited %d", ee.ExitCode()),
}},
}, nil
}
// The tool was on PATH at pre-flight but still could not run
// (removed mid-run, lost the executable bit): the chain cannot
// complete, so this is blocked rather than a finding.
return Result{
Code: CodeBlocked,
Summary: fmt.Sprintf("gate step %d/%d could not run: %s", i+1, len(steps), label),
}, nil
}
return Result{
Code: CodeClean,
Summary: fmt.Sprintf("%d gate step(s) passed", len(steps)),
}, nil
}
added internal/workflow/gate_test.go
@@ -0,0 +1,158 @@
package workflow
import (
"os/exec"
"strings"
"testing"
)
// requireGo skips a test when the Go toolchain is not on PATH. The gate
// workflow tests drive real subprocesses; `go` is the one command
// guaranteed present while `go test` runs and behaves identically on
// every platform — `go version` exits 0, `go help <bogus>` exits 2.
func requireGo(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("go"); err != nil {
t.Skip("go toolchain not on PATH")
}
}
func TestGate_NameAndSummary(t *testing.T) {
if got := (buildGate{}).Name(); got != "gate" {
t.Errorf("Name() = %q, want gate", got)
}
if (buildGate{}).Summary() == "" {
t.Error("Summary() is empty")
}
}
func TestGate_NoGateDeclaredIsClean(t *testing.T) {
cfg := newCfg(t)
cfg.Gate = nil
res, err := buildGate{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("no gate -> %d (%s)", res.Code, res.Summary)
}
if res.Summary != "no gate declared" {
t.Errorf("summary = %q", res.Summary)
}
}
func TestGate_SingleStepPasses(t *testing.T) {
requireGo(t)
cfg := newCfg(t)
cfg.Gate = [][]string{{"go", "version"}}
res, err := buildGate{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("single passing step -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if res.Summary != "1 gate step(s) passed" {
t.Errorf("summary = %q", res.Summary)
}
}
func TestGate_SingleStepFailsIsFinding(t *testing.T) {
requireGo(t)
cfg := newCfg(t)
cfg.Gate = [][]string{{"go", "help", "eeco-bogus-topic-xyz"}}
res, err := buildGate{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("failing step -> %d (%s), want CodeFinding", res.Code, res.Summary)
}
if len(res.Findings) != 1 {
t.Fatalf("findings = %+v, want one", res.Findings)
}
if res.Findings[0].Path != "go help eeco-bogus-topic-xyz" {
t.Errorf("finding path = %q", res.Findings[0].Path)
}
}
func TestGate_MultiStepAllPass(t *testing.T) {
requireGo(t)
cfg := newCfg(t)
cfg.Gate = [][]string{{"go", "version"}, {"go", "version"}}
res, err := buildGate{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("two passing steps -> %d (%s)", res.Code, res.Summary)
}
if res.Summary != "2 gate step(s) passed" {
t.Errorf("summary = %q", res.Summary)
}
}
func TestGate_StopsAtFirstFailure(t *testing.T) {
requireGo(t)
cfg := newCfg(t)
// Step 2 fails; step 3 must never run.
cfg.Gate = [][]string{
{"go", "version"},
{"go", "help", "eeco-bogus-topic-xyz"},
{"go", "version"},
}
var out strings.Builder
res, err := buildGate{}.Run(Env{Config: cfg, Out: &out})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("chain with a failing step -> %d (%s)", res.Code, res.Summary)
}
if !strings.Contains(res.Summary, "step 2/3") {
t.Errorf("summary = %q, want it to name step 2/3", res.Summary)
}
// The progress log announces steps 1 and 2 but never step 3 — the
// chain stopped at the first failure.
if strings.Contains(out.String(), "gate step 3/3") {
t.Errorf("step 3 ran after the chain should have stopped:\n%s", out.String())
}
}
func TestGate_MissingToolBlocks(t *testing.T) {
cfg := newCfg(t)
cfg.Gate = [][]string{{"eeco-definitely-absent-cmd-zzz"}}
res, err := buildGate{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeBlocked {
t.Fatalf("missing tool -> %d (%s), want CodeBlocked", res.Code, res.Summary)
}
if !strings.Contains(res.Summary, "eeco-definitely-absent-cmd-zzz") {
t.Errorf("summary = %q, want it to name the missing tool", res.Summary)
}
}
func TestGate_MissingToolInChainBlocksBeforeAnyStepRuns(t *testing.T) {
requireGo(t)
cfg := newCfg(t)
// Step 1 is runnable, step 2 is not. Pre-flight must block the whole
// chain before step 1 runs — a chain that cannot complete is blocked,
// not a finding (a missing tool outranks a finding).
cfg.Gate = [][]string{
{"go", "version"},
{"eeco-definitely-absent-cmd-zzz"},
}
var out strings.Builder
res, err := buildGate{}.Run(Env{Config: cfg, Out: &out})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeBlocked {
t.Fatalf("missing tool in chain -> %d (%s), want CodeBlocked", res.Code, res.Summary)
}
if out.Len() != 0 {
t.Errorf("a step ran before the pre-flight block:\n%s", out.String())
}
}
added internal/workflow/gitwriteguard.go
@@ -0,0 +1,364 @@
package workflow
import (
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
)
// GitWriteGuardResult is the outcome of the git-write guard over a
// candidate Bash command. Decision is decisionDeny or decisionAllow. On a
// deny, Reason is the operator-facing explanation carried back as the
// PreToolUse permission-decision reason. On an allow, Consumed lists the
// one-shot sentinel kinds ("commit" / "tag") the caller must remove — the
// guard leaves consumption to the caller so a deny (an unauthorized op, or
// a gate finding on an authorized commit) never burns the authorization.
// A command with no commit / tag mutation is an allow with empty Consumed.
type GitWriteGuardResult struct {
Decision string
Reason string
Consumed []string
}
const (
decisionAllow = "allow"
decisionDeny = "deny"
)
// sentinelTTL is how long an authorization sentinel stays valid after the
// operator sets it via `eeco authorize`. A stale sentinel is cleared and
// treated as unauthorized, so a forgotten authorization cannot linger.
const sentinelTTL = 15 * time.Minute
// gitGlobalValueOpts are the git global options that consume the following
// token as their value, used when walking past global options to the
// subcommand. It mirrors the set in isGitCommit (commitguard.go); a second
// copy here keeps ScanCommitGuard untouched while classifyGitWrite reuses
// the same walk for any subcommand.
var gitGlobalValueOpts = map[string]bool{
"-C": true, "-c": true, "--git-dir": true, "--work-tree": true,
"--namespace": true, "--exec-path": true, "--super-prefix": true,
"--config-env": true,
}
// tagMutationFlags are the `git tag` flags that turn a tag op into a
// mutation (create / annotate / sign / delete / move). A bare `git tag`,
// `git tag -l`, or `git tag -n` is a read-only listing and passes.
var tagMutationFlags = map[string]bool{
"-a": true, "-s": true, "-d": true, "-f": true,
"--annotate": true, "--sign": true, "--delete": true,
"--force": true, "--message": true, "--file": true,
"--create-reflog": true,
}
// shellWrappers are the command prefixes that hide a git op inside a
// quoted argument the tokenizer cannot see into; their presence triggers
// the raw-string backstop (mirrors pre-commit-guard.sh:128-133).
var shellWrappers = []string{"bash -c", "sh -c", "zsh -c", " -lc ", "eval "}
var (
reWrappedGitCommit = regexp.MustCompile(`(^|[^[:alnum:]_])git[[:space:]]+commit([[:space:]]|$)`)
reWrappedGitTag = regexp.MustCompile(`(^|[^[:alnum:]_])git[[:space:]]+tag([[:space:]]|$)`)
)
// ScanGitWriteGuard generalizes the attribution-only commit-guard into the
// full git-write guard the cockpit machinery installs as a PreToolUse hook.
// It blocks a pending `git commit` and a `git tag` MUTATION unless a
// one-shot authorization sentinel (set by `eeco authorize`, 15-min TTL)
// exists, and on an authorized commit folds in eeco's CI-parity gates
// (attribution + workspace-path leak) so an authorized write still cannot
// carry a leak into history. command is the PreToolUse Bash command, cwd
// the repo it targets, stateDir the sentinel directory (<workspace>/state),
// and workspaceName the engine dir name used to build the leak pattern.
//
// Posture (locked decision #2): the write-verb classifier fails CLOSED — a
// command that cannot be tokenized cleanly but whose raw text shows a
// commit / tag is denied. Everything downstream (the leak / attribution
// fold-in) degrades OPEN, so the git pre-commit hook and CI stay the hard
// gates and a session is never wedged. A deny is carried in Decision; the
// runner translates it to the JSON permission-decision body and always
// exits 0.
func ScanGitWriteGuard(det *Detector, command, cwd, stateDir, workspaceName string) GitWriteGuardResult {
commit, tagMut := classifyCommand(command)
if !commit && !tagMut {
return GitWriteGuardResult{Decision: decisionAllow}
}
var consumed []string
// (1a) git tag mutation: user-only, gated first. A combined
// `git tag v1 && git commit` falls through to the commit gate after the
// tag sentinel is queued for consumption.
if tagMut {
if !sentinelAuthorized(stateDir, "tag") {
return GitWriteGuardResult{Decision: decisionDeny, Reason: tagDenyReason}
}
consumed = append(consumed, "tag")
}
// (1b) git commit: require authorization.
if commit {
if !sentinelAuthorized(stateDir, "commit") {
return GitWriteGuardResult{Decision: decisionDeny, Reason: commitDenyReason}
}
// (2) authorized commit: fold in the CI-parity gates (degrade-open).
// A finding denies but PRESERVES the sentinel (do not queue "commit"
// for consumption) so a re-commit after the fix works.
if problems := commitGateFindings(det, command, cwd, workspaceName); len(problems) > 0 {
return GitWriteGuardResult{Decision: decisionDeny, Reason: gateDenyReason(problems)}
}
consumed = append(consumed, "commit")
}
return GitWriteGuardResult{Decision: decisionAllow, Consumed: consumed}
}
const (
commitDenyReason = "eeco git-write-guard: git commit is user-driven — the user commits himself. " +
"After explicit authorization, run `eeco authorize commit` to allow one commit " +
"(15-min, one-shot), then re-run."
tagDenyReason = "eeco git-write-guard: git tag mutation is user-only. " +
"To allow one tag op, run `eeco authorize tag` (15-min, one-shot). " +
"Read-only tag ops (git tag, git tag -l, …) are never blocked."
)
// gateDenyReason renders the deny message for an authorized commit that
// tripped the leak / attribution gates. The authorization sentinel is
// preserved, so the operator fixes the listed problems and re-commits.
func gateDenyReason(problems []string) string {
return "eeco git-write-guard: commit blocked — " + strings.Join(problems, "; ") +
". Fix these, then re-commit (authorization preserved)."
}
// classifyCommand reports whether the command invokes `git commit` and
// whether it invokes a `git tag` MUTATION, across every segment of a
// compound command. When the command cannot be tokenized cleanly it fails
// CLOSED, trusting a raw substring match (locked decision #2). A shell
// wrapper (bash -c / eval / …) triggers a raw backstop regardless, since
// the tokenizer cannot see a git op hidden inside the wrapper's quoted arg.
func classifyCommand(command string) (commit, tagMut bool) {
if commandParseOK(command) {
for _, words := range commandSegments(command) {
verb, mut := classifyGitWrite(words)
switch verb {
case "commit":
commit = true
case "tag":
if mut {
tagMut = true
}
}
}
} else {
// Fail CLOSED: an unbalanced-quote command we cannot tokenize is
// denied if its raw text shows a commit / tag write.
if strings.Contains(command, "git commit") {
commit = true
}
if strings.Contains(command, "git tag") {
tagMut = true
}
}
if hasShellWrapper(command) {
if reWrappedGitCommit.MatchString(command) {
commit = true
}
if reWrappedGitTag.MatchString(command) {
tagMut = true
}
}
return commit, tagMut
}
// classifyGitWrite inspects one segment's word list and returns the git
// subcommand ("" when the segment is not a git invocation) and, for a
// `git tag`, whether it is a mutation. It reuses isEnvAssign / isGitProg
// and the global-option walk from isGitCommit, generalized to any
// subcommand.
func classifyGitWrite(words []string) (verb string, tagMutation bool) {
i := 0
for i < len(words) && isEnvAssign(words[i]) {
i++
}
if i >= len(words) || !isGitProg(words[i]) {
return "", false
}
i++ // past git
for i < len(words) {
w := words[i]
if w == "--" {
return "", false // end of options without a subcommand
}
if strings.HasPrefix(w, "-") {
if gitGlobalValueOpts[w] {
i += 2
} else {
i++
}
continue
}
if w != "tag" {
return w, false
}
return "tag", tagIsMutation(words[i+1:])
}
return "", false
}
// tagIsMutation reports whether the args after `git tag` denote a mutation:
// a name argument (create) or any mutation flag (annotate / sign / delete /
// force / message / file). A bare listing (`git tag`, `-l`, `-n`) is not.
func tagIsMutation(rest []string) bool {
for _, a := range rest {
if !strings.HasPrefix(a, "-") {
return true // a name arg ⇒ create
}
if tagMutationFlags[a] ||
strings.HasPrefix(a, "-m") ||
strings.HasPrefix(a, "--message") ||
strings.HasPrefix(a, "--file") {
return true
}
}
return false
}
// hasShellWrapper reports whether the command contains a known shell
// wrapper that could hide a git op inside a quoted argument.
func hasShellWrapper(command string) bool {
for _, w := range shellWrappers {
if strings.Contains(command, w) {
return true
}
}
return false
}
// commandParseOK reports whether command tokenizes cleanly — every quote is
// closed. The guard fails CLOSED when this is false (locked decision #2). It
// mirrors lex's single-quote, double-quote, and backslash handling so its
// verdict matches the tokenizer the classifier relies on.
func commandParseOK(command string) bool {
i, n := 0, len(command)
for i < n {
switch command[i] {
case '\'':
i++
for i < n && command[i] != '\'' {
i++
}
if i >= n {
return false // unterminated single quote
}
i++
case '"':
i++
for i < n && command[i] != '"' {
if command[i] == '\\' && i+1 < n {
i += 2
continue
}
i++
}
if i >= n {
return false // unterminated double quote
}
i++
case '\\':
if i+1 < n {
i += 2
} else {
i++
}
default:
i++
}
}
return true
}
// sentinelAuthorized reports whether a one-shot authorization sentinel for
// kind ("commit"/"tag") exists and is within its TTL. A stale sentinel is
// removed and reported unauthorized, so a forgotten authorization never
// lingers (it is also cleared at session start in C4b).
func sentinelAuthorized(stateDir, kind string) bool {
path := filepath.Join(stateDir, "git-"+kind+"-authorized")
info, err := os.Stat(path)
if err != nil {
return false
}
if time.Since(info.ModTime()) > sentinelTTL {
_ = os.Remove(path) // stale ⇒ clear, treat as unauthorized
return false
}
return true
}
// commitGateFindings runs eeco's CI-parity gates over an authorized commit
// and returns the operator-facing problems (empty = clean). Every check
// degrades open: an unreadable diff or a message it cannot statically
// resolve yields no finding, so the git pre-commit hook + CI stay the hard
// gates (locked decision #2). It folds three families: AI-attribution
// (det, eeco's comment-hygiene equivalent) over the assembled message, the
// staged diff, and the raw command; plus a workspace-path leak over staged
// additions (leak-guard's pattern).
func commitGateFindings(det *Detector, command, cwd, workspaceName string) []string {
var problems []string
add := func(p string) {
if !slices.Contains(problems, p) {
problems = append(problems, p)
}
}
scanAttr := func(where, text string) {
for _, f := range det.Scan(where, text) {
add(f.Msg + " in " + where)
}
}
// Attribution in the assembled message of each commit segment.
for _, words := range commandSegments(command) {
if verb, _ := classifyGitWrite(words); verb != "commit" {
continue
}
if msg := assembleMessage(words, cwd); msg != "" {
scanAttr("commit message", msg)
}
}
// Attribution + workspace-path leak in the staged additions.
if diff := stagedDiff(cwd); diff != "" {
scanAttr("staged diff", diff)
for _, line := range scanDiffWorkspaceLeak(diff, workspaceName) {
add("workspace path in staged content: " + strings.TrimSpace(line))
}
}
// Attribution embedded with a real newline in the raw command (a trailer
// or generated-by line inside -m).
scanAttr("command", command)
return problems
}
// scanDiffWorkspaceLeak returns the added diff lines that reference an
// engine subdirectory under the workspace (the state/memory/… dirs) — the
// workspace-path leak leak-guard catches in tracked files, applied here to
// the prospective staged content. An empty workspaceName disables the scan
// (no pattern to build). Only added lines (`+`, excluding the `+++` header)
// are scanned.
func scanDiffWorkspaceLeak(diff, workspaceName string) []string {
if workspaceName == "" {
return nil
}
re := regexp.MustCompile(regexp.QuoteMeta(workspaceName) + `/(?:` + reAlt(engineSubdirs) + `)/`)
var out []string
for _, line := range splitLines(diff) {
if !strings.HasPrefix(line, "+") || strings.HasPrefix(line, "+++") {
continue
}
if re.MatchString(line) {
out = append(out, strings.TrimPrefix(line, "+"))
}
}
return out
}
added internal/workflow/gitwriteguard_test.go
@@ -0,0 +1,224 @@
package workflow
import (
"os"
"path/filepath"
"testing"
"time"
)
// writeSentinel creates a fresh authorization sentinel for kind under dir.
func writeSentinel(t *testing.T, dir, kind string) string {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
p := filepath.Join(dir, "git-"+kind+"-authorized")
if err := os.WriteFile(p, nil, 0o600); err != nil {
t.Fatal(err)
}
return p
}
func TestScanGitWriteGuard_UnauthorizedCommitDenies(t *testing.T) {
det := newGuardDetector(t)
res := ScanGitWriteGuard(det, `git commit -m "fix: x"`, t.TempDir(), t.TempDir(), ".eeco")
if res.Decision != decisionDeny {
t.Fatalf("unauthorized commit: Decision=%q, want deny", res.Decision)
}
if len(res.Consumed) != 0 {
t.Errorf("a deny must not consume a sentinel, got %v", res.Consumed)
}
}
func TestScanGitWriteGuard_AuthorizedCommitAllowsAndConsumes(t *testing.T) {
det := newGuardDetector(t)
orig := stagedDiff
defer func() { stagedDiff = orig }()
stagedDiff = func(string) string { return "" }
state := t.TempDir()
writeSentinel(t, state, "commit")
res := ScanGitWriteGuard(det, `git commit -m "fix: a real change"`, t.TempDir(), state, ".eeco")
if res.Decision != decisionAllow {
t.Fatalf("authorized clean commit: Decision=%q reason=%q, want allow", res.Decision, res.Reason)
}
if len(res.Consumed) != 1 || res.Consumed[0] != "commit" {
t.Errorf("Consumed=%v, want [commit]", res.Consumed)
}
}
func TestScanGitWriteGuard_StaleSentinelDeniesAndClears(t *testing.T) {
det := newGuardDetector(t)
state := t.TempDir()
p := writeSentinel(t, state, "commit")
old := time.Now().Add(-30 * time.Minute)
if err := os.Chtimes(p, old, old); err != nil {
t.Fatal(err)
}
res := ScanGitWriteGuard(det, `git commit -m x`, t.TempDir(), state, ".eeco")
if res.Decision != decisionDeny {
t.Fatalf("stale sentinel: Decision=%q, want deny", res.Decision)
}
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Errorf("stale sentinel should have been cleared, stat err=%v", err)
}
}
func TestScanGitWriteGuard_AuthorizedCommitWithAttributionDeniesPreserved(t *testing.T) {
det := newGuardDetector(t)
orig := stagedDiff
defer func() { stagedDiff = orig }()
stagedDiff = func(string) string { return "" }
state := t.TempDir()
p := writeSentinel(t, state, "commit")
cmd := `git commit -m "fix: x" -m "` + coTrailer() + `"`
res := ScanGitWriteGuard(det, cmd, t.TempDir(), state, ".eeco")
if res.Decision != decisionDeny {
t.Fatalf("authorized commit carrying a trailer: Decision=%q, want deny", res.Decision)
}
if len(res.Consumed) != 0 {
t.Errorf("gate-deny must preserve the sentinel, Consumed=%v", res.Consumed)
}
if _, err := os.Stat(p); err != nil {
t.Errorf("sentinel must survive a gate-deny, stat err=%v", err)
}
}
func TestScanGitWriteGuard_AuthorizedCommitWorkspaceLeakDenies(t *testing.T) {
det := newGuardDetector(t)
orig := stagedDiff
defer func() { stagedDiff = orig }()
// Use a neutral workspace name in the fixture (not the repo's real ".eeco")
// so the leak literal in this test source does not trip the repo's own
// leak-guard, mirroring the other gate tests.
stagedDiff = func(string) string {
return "diff --git a/x b/x\n+see ws/state/queue.md for details\n"
}
state := t.TempDir()
writeSentinel(t, state, "commit")
res := ScanGitWriteGuard(det, `git commit -m "fix"`, t.TempDir(), state, "ws")
if res.Decision != decisionDeny {
t.Fatalf("staged workspace-path leak: Decision=%q, want deny", res.Decision)
}
}
func TestScanGitWriteGuard_TagMutationGated(t *testing.T) {
det := newGuardDetector(t)
state := t.TempDir()
// Unauthorized mutation denies.
if res := ScanGitWriteGuard(det, `git tag -a v1 -m x`, t.TempDir(), state, ".eeco"); res.Decision != decisionDeny {
t.Errorf("unauthorized tag mutation: Decision=%q, want deny", res.Decision)
}
// Authorized mutation allows + consumes.
writeSentinel(t, state, "tag")
res := ScanGitWriteGuard(det, `git tag v1`, t.TempDir(), state, ".eeco")
if res.Decision != decisionAllow {
t.Fatalf("authorized tag create: Decision=%q, want allow", res.Decision)
}
if len(res.Consumed) != 1 || res.Consumed[0] != "tag" {
t.Errorf("Consumed=%v, want [tag]", res.Consumed)
}
}
func TestScanGitWriteGuard_ReadOnlyTagAndGitPass(t *testing.T) {
det := newGuardDetector(t)
for _, cmd := range []string{
`git tag`,
`git tag -l`,
`git tag -n5`,
`git status`,
`git log --oneline`,
`echo "git commit -m bad"`,
`ls -la && pwd`,
} {
res := ScanGitWriteGuard(det, cmd, t.TempDir(), t.TempDir(), ".eeco")
if res.Decision != decisionAllow {
t.Errorf("%q: Decision=%q, want allow", cmd, res.Decision)
}
}
}
func TestScanGitWriteGuard_FailClosedOnParseError(t *testing.T) {
det := newGuardDetector(t)
// An unterminated single quote cannot be tokenized cleanly; the raw text
// shows `git commit`, so the guard fails CLOSED and denies.
cmd := `git commit -m 'unterminated`
res := ScanGitWriteGuard(det, cmd, t.TempDir(), t.TempDir(), ".eeco")
if res.Decision != decisionDeny {
t.Fatalf("parse-error with git commit substring: Decision=%q, want deny (fail-closed)", res.Decision)
}
}
func TestScanGitWriteGuard_WrapperBackstopDenies(t *testing.T) {
det := newGuardDetector(t)
for _, cmd := range []string{
`bash -c "git commit -m x"`,
`sh -c 'git tag v9'`,
`eval "git commit -m y"`,
} {
res := ScanGitWriteGuard(det, cmd, t.TempDir(), t.TempDir(), ".eeco")
if res.Decision != decisionDeny {
t.Errorf("wrapped write %q: Decision=%q, want deny", cmd, res.Decision)
}
}
}
func TestScanGitWriteGuard_ChainedUnauthorizedCommitDenies(t *testing.T) {
det := newGuardDetector(t)
res := ScanGitWriteGuard(det, `git add . && git commit -m subject`, t.TempDir(), t.TempDir(), ".eeco")
if res.Decision != decisionDeny {
t.Errorf("chained unauthorized commit: Decision=%q, want deny", res.Decision)
}
}
func TestClassifyGitWrite(t *testing.T) {
cases := []struct {
words []string
verb string
mut bool
}{
{[]string{"git", "commit", "-m", "x"}, "commit", false},
{[]string{"git", "-C", "/r", "commit"}, "commit", false},
{[]string{"GIT_AUTHOR_NAME=bot", "git", "commit"}, "commit", false},
{[]string{"git", "tag"}, "tag", false},
{[]string{"git", "tag", "-l"}, "tag", false},
{[]string{"git", "tag", "v1"}, "tag", true},
{[]string{"git", "tag", "-a", "v1", "-m", "x"}, "tag", true},
{[]string{"git", "tag", "-d", "v1"}, "tag", true},
{[]string{"git", "status"}, "status", false},
{[]string{"git", "--", "commit"}, "", false},
{[]string{"echo", "git", "commit"}, "", false},
}
for _, c := range cases {
verb, mut := classifyGitWrite(c.words)
if verb != c.verb || mut != c.mut {
t.Errorf("classifyGitWrite(%v) = (%q,%v), want (%q,%v)", c.words, verb, mut, c.verb, c.mut)
}
}
}
func TestCommandParseOK(t *testing.T) {
ok := []string{
`git commit -m "fix"`,
`git commit -m 'fix'`,
`git commit -m "a \"quoted\" word"`,
`git status`,
}
for _, c := range ok {
if !commandParseOK(c) {
t.Errorf("commandParseOK(%q) = false, want true", c)
}
}
bad := []string{
`git commit -m 'unterminated`,
`git commit -m "open`,
}
for _, c := range bad {
if commandParseOK(c) {
t.Errorf("commandParseOK(%q) = true, want false", c)
}
}
}
added internal/workflow/handover.go
@@ -0,0 +1,167 @@
package workflow
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/ajhahnde/eeco/internal/gitx"
"github.com/ajhahnde/eeco/internal/queue"
)
// handoverDir is the per-note directory inside <workspace>/docs/.
const handoverDir = "handover"
// handoverRefresh writes a dated session-handover note plus a "what
// changed since the last one" summary, then queues it for the
// maintainer to review. It never overwrites: every note is a fresh
// uniquely-stamped file, so prior handovers stay intact. The note lives
// in the gitignored workspace (write-scope floor invariant). git is
// used read-only for the change summary; when git is unavailable the
// note is still written (without the summary) rather than blocking.
type handoverRefresh struct{}
func (handoverRefresh) Name() string { return "handover-refresh" }
func (handoverRefresh) Summary() string {
return "dated handover note + change-since-last summary; queued, never overwrites"
}
func (handoverRefresh) Run(env Env) (Result, error) {
cfg := env.Config
dir := filepath.Join(cfg.Workspace, "docs", handoverDir)
if err := os.MkdirAll(dir, 0o755); err != nil {
return Result{}, fmt.Errorf("handover-refresh: %w", err)
}
prevBase := lastHandoverHead(dir)
head := ""
changeSummary := "(git unavailable — no change summary)"
if sha, err := gitx.HeadSHA(cfg.RepoRoot); err == nil {
head = sha
log, stat, cerr := gitx.ChangesSince(cfg.RepoRoot, prevBase)
switch {
case cerr != nil:
changeSummary = "(change summary unavailable: " + cerr.Error() + ")"
case prevBase == "":
changeSummary = "first handover — no prior baseline.\n\n" + nonEmpty(log, "(no commits)")
default:
changeSummary = nonEmpty(log, "(no new commits)")
if stat != "" {
changeSummary += "\n\n" + stat
}
}
} else if !errors.Is(err, gitx.ErrUnavailable) {
changeSummary = "(change summary unavailable: " + err.Error() + ")"
}
stamp := time.Now().UTC()
path := uniquePath(dir, "handover-"+stamp.Format("20060102T150405.000000000Z")+".md")
note := buildHandoverNote(stamp, prevBase, head, changeSummary)
if err := os.WriteFile(path, []byte(note), 0o644); err != nil {
return Result{}, fmt.Errorf("handover-refresh: write note: %w", err)
}
rel := path
if r, err := filepath.Rel(cfg.RepoRoot, path); err == nil {
rel = filepath.ToSlash(r)
}
if err := queue.Append(filepath.Join(cfg.Workspace, "state"), queue.Item{
Kind: "handover",
Title: "Handover note ready for review",
Project: filepath.Base(cfg.RepoRoot),
Detail: "wrote " + rel + " — review and carry forward what is still relevant",
Date: stamp,
}); err != nil {
return Result{}, fmt.Errorf("handover-refresh: queue: %w", err)
}
return Result{
Code: CodeClean,
Summary: "handover note written and queued for review (" + rel + ")",
}, nil
}
// lastHandoverHead returns the head SHA recorded in the most recent
// existing note, or "" when there is none. The timestamped filenames
// sort lexically in chronological order.
func lastHandoverHead(dir string) string {
ents, err := os.ReadDir(dir)
if err != nil {
return ""
}
var names []string
for _, e := range ents {
if !e.IsDir() && strings.HasPrefix(e.Name(), "handover-") && strings.HasSuffix(e.Name(), ".md") {
names = append(names, e.Name())
}
}
if len(names) == 0 {
return ""
}
sort.Strings(names)
b, err := os.ReadFile(filepath.Join(dir, names[len(names)-1]))
if err != nil {
return ""
}
for _, line := range strings.Split(string(b), "\n") {
if v, ok := strings.CutPrefix(strings.TrimSpace(line), "head:"); ok {
return strings.TrimSpace(v)
}
}
return ""
}
// uniquePath returns base inside dir, or base with a numeric suffix if
// it already exists, so a note is never overwritten.
func uniquePath(dir, base string) string {
path := filepath.Join(dir, base)
if _, err := os.Stat(path); os.IsNotExist(err) {
return path
}
ext := filepath.Ext(base)
stem := strings.TrimSuffix(base, ext)
for i := 2; ; i++ {
cand := filepath.Join(dir, fmt.Sprintf("%s-%d%s", stem, i, ext))
if _, err := os.Stat(cand); os.IsNotExist(err) {
return cand
}
}
}
func nonEmpty(s, fallback string) string {
if strings.TrimSpace(s) == "" {
return fallback
}
return s
}
func buildHandoverNote(stamp time.Time, base, head, changes string) string {
if base == "" {
base = "none"
}
if head == "" {
head = "unknown"
}
return fmt.Sprintf(`# Handover — %s
Written by eeco run handover-refresh. This note is a draft for the
maintainer; nothing here is committed.
base: %s
head: %s
## Changes since last handover
%s
## Open threads
(carry forward what is still relevant; delete the rest)
`, stamp.Format(time.RFC3339), base, head, changes)
}
added internal/workflow/handover_test.go
@@ -0,0 +1,125 @@
package workflow
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestHandover_NoGitStillWritesAndQueues(t *testing.T) {
cfg := newCfg(t) // temp dir, not a git repo
res, err := handoverRefresh{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("code = %d, want %d (clean)", res.Code, CodeClean)
}
dir := filepath.Join(cfg.Workspace, "docs", handoverDir)
ents, err := os.ReadDir(dir)
if err != nil || len(ents) != 1 {
t.Fatalf("want 1 handover note, got %v (err %v)", ents, err)
}
b, _ := os.ReadFile(filepath.Join(dir, ents[0].Name()))
note := string(b)
if !strings.Contains(note, "## Changes since last handover") ||
!strings.Contains(note, "head: unknown") {
t.Errorf("note without git should still be well-formed:\n%s", note)
}
q, _ := os.ReadFile(filepath.Join(cfg.Workspace, "state", "queue.md"))
if !strings.Contains(string(q), "handover") {
t.Errorf("handover not queued:\n%s", q)
}
}
func TestHandover_NeverOverwrites(t *testing.T) {
cfg := newCfg(t)
for range 2 {
if _, err := (handoverRefresh{}).Run(Env{Config: cfg}); err != nil {
t.Fatal(err)
}
}
dir := filepath.Join(cfg.Workspace, "docs", handoverDir)
ents, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
if len(ents) != 2 {
t.Fatalf("two runs must yield two distinct notes, got %d: %v", len(ents), names(ents))
}
}
func TestHandover_GitChangeSummary(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
cfg := newCfg(t)
root := cfg.RepoRoot
gitRun(t, root, "init", "-q")
gitRun(t, root, "config", "user.email", "[email protected]")
gitRun(t, root, "config", "user.name", "t")
writeRepoFile(t, root, "f.txt", "one\n")
gitRun(t, root, "add", "-A")
gitRun(t, root, "commit", "-q", "-m", "first commit")
if _, err := (handoverRefresh{}).Run(Env{Config: cfg}); err != nil {
t.Fatal(err)
}
dir := filepath.Join(cfg.Workspace, "docs", handoverDir)
first := latestNote(t, dir)
if !strings.Contains(first, "first handover") {
t.Errorf("first note should mark the baseline:\n%s", first)
}
writeRepoFile(t, root, "f.txt", "one\ntwo\n")
gitRun(t, root, "add", "-A")
gitRun(t, root, "commit", "-q", "-m", "second commit changes things")
if _, err := (handoverRefresh{}).Run(Env{Config: cfg}); err != nil {
t.Fatal(err)
}
second := latestNote(t, dir)
if !strings.Contains(second, "second commit changes things") {
t.Errorf("second note should summarise the new commit:\n%s", second)
}
}
// --- helpers ---
func gitRun(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
func names(ents []os.DirEntry) []string {
var out []string
for _, e := range ents {
out = append(out, e.Name())
}
return out
}
func latestNote(t *testing.T, dir string) string {
t.Helper()
ents, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
var latest string
for _, e := range ents {
if e.Name() > latest {
latest = e.Name()
}
}
b, err := os.ReadFile(filepath.Join(dir, latest))
if err != nil {
t.Fatal(err)
}
return string(b)
}
added internal/workflow/helpers_test.go
@@ -0,0 +1,69 @@
package workflow
import (
"os"
"os/exec"
"path/filepath"
"testing"
"github.com/ajhahnde/eeco/internal/config"
)
// newCfg returns a config rooted at a fresh temp repo with an
// initialised-looking workspace (the directories the workflows touch).
func newCfg(t *testing.T) *config.Config {
return newCfgWithProfile(t, config.ProfileGeneric)
}
// newCfgWithProfile is the profile-parameterised form of newCfg, used by
// tests that need to exercise per-profile behaviour (e.g. scaffold
// template selection).
func newCfgWithProfile(t *testing.T, profile config.Profile) *config.Config {
t.Helper()
root := t.TempDir()
ws := filepath.Join(root, ".eeco")
for _, d := range []string{"workflows", "state", "memory"} {
if err := os.MkdirAll(filepath.Join(ws, d), 0o755); err != nil {
t.Fatal(err)
}
}
return &config.Config{
RepoRoot: root,
WorkspaceName: ".eeco",
Workspace: ws,
Profile: profile,
StaleDays: config.DefaultStaleDays,
}
}
func writeRepoFile(t *testing.T, root, rel, content string) {
t.Helper()
full := filepath.Join(root, rel)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// gitInit makes root a real git repo and stages everything currently in
// it. Tests that need tracked files call this after writing them.
func gitInit(t *testing.T, root string) {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
for _, args := range [][]string{
{"init", "-q"},
{"config", "user.email", "[email protected]"},
{"config", "user.name", "t"},
{"add", "-A"},
} {
cmd := exec.Command("git", args...)
cmd.Dir = root
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
}
added internal/workflow/history.go
@@ -0,0 +1,125 @@
package workflow
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/ajhahnde/eeco/internal/queue"
)
// HistoryFilename is the evolve-history ledger filename inside
// <workspace>/state/. Frozen surface; renaming or removing it is a
// breaking change.
const HistoryFilename = "evolve-history.json"
// HistoryRecord is one entry the evolve workflow has surfaced.
//
// SignalKind and SignalKey identify the deterministic signal that
// produced the proposal (e.g. SignalCommitType + "fix") and form the
// suppression key: a recurring signal already in the ledger does not
// re-trigger a proposal.
//
// QueueKind and QueueTitle pin the proposal back to the queue row the
// workflow filed, so reconciliation can ask the queue whether the
// operator has resolved the item.
//
// Resolved and ResolvedAt are additive: omitted from the wire when
// false/empty so older ledgers without the fields still round-trip.
// More fields may be added in later slices following the same
// additive discipline (e.g. accepted-vs-rejected disambiguation).
type HistoryRecord struct {
SignalKind string `json:"signal_kind"`
SignalKey string `json:"signal_key"`
CountAtProposal int `json:"count_at_proposal"`
QueueKind string `json:"queue_kind"`
QueueTitle string `json:"queue_title"`
ProposedAt string `json:"proposed_at"`
Resolved bool `json:"resolved,omitempty"`
ResolvedAt string `json:"resolved_at,omitempty"`
}
// History is the on-disk shape of the evolve repetition ledger.
type History struct {
Records []HistoryRecord `json:"records"`
}
// LoadHistory reads <stateDir>/evolve-history.json. A missing file is
// the empty ledger; a corrupt file degrades to the empty ledger so
// evolve is never wedged by a broken ledger — the next save rewrites
// it from scratch.
func LoadHistory(stateDir string) (History, error) {
var h History
b, err := os.ReadFile(filepath.Join(stateDir, HistoryFilename))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return h, nil
}
return h, fmt.Errorf("evolve history: read: %w", err)
}
if len(b) == 0 {
return h, nil
}
if jerr := json.Unmarshal(b, &h); jerr != nil {
return History{}, nil
}
return h, nil
}
// SaveHistory writes h to disk, creating the state dir if missing.
// Marshalled with indentation and a trailing newline so the file is
// human-inspectable (mirrors hooks.json).
func SaveHistory(stateDir string, h History) error {
if err := os.MkdirAll(stateDir, 0o755); err != nil {
return fmt.Errorf("evolve history: state dir: %w", err)
}
b, err := json.MarshalIndent(h, "", " ")
if err != nil {
return fmt.Errorf("evolve history: encode: %w", err)
}
return os.WriteFile(filepath.Join(stateDir, HistoryFilename), append(b, '\n'), 0o644)
}
// HasProposed reports whether a candidate of the given (signalKind,
// signalKey) has already been proposed. Suppression is unconditional:
// once proposed, never re-proposed, regardless of the
// record's resolved state. A re-propose-on-signal-recurrence knob is
// reserved for a follow-on slice.
func (h History) HasProposed(signalKind, signalKey string) bool {
for _, r := range h.Records {
if r.SignalKind == signalKind && r.SignalKey == signalKey {
return true
}
}
return false
}
// ReconcileHistory walks h's unresolved records and, for each, asks
// the queue whether the recorded (QueueKind, QueueTitle) row has been
// ticked. A ticked row flips the record to Resolved=true with
// ResolvedAt=now. Resolution is one-way: a record that is already
// Resolved is left untouched. Returns the updated ledger and a
// changed flag the caller uses to decide whether to write.
//
// Latency: reconciliation runs once per evolve invocation; there is
// no live event stream. An operator who ticks a queue item between
// runs sees the ledger update on the next `eeco run evolve`.
func ReconcileHistory(stateDir string, h History, now time.Time) (History, bool) {
changed := false
for i := range h.Records {
if h.Records[i].Resolved {
continue
}
ok, err := queue.Resolved(stateDir, h.Records[i].QueueKind, h.Records[i].QueueTitle)
if err != nil || !ok {
continue
}
h.Records[i].Resolved = true
h.Records[i].ResolvedAt = now.UTC().Format(time.RFC3339)
changed = true
}
return h, changed
}
added internal/workflow/history_test.go
@@ -0,0 +1,214 @@
package workflow
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/ajhahnde/eeco/internal/queue"
)
func TestLoadHistory_MissingFileIsEmpty(t *testing.T) {
dir := t.TempDir()
h, err := LoadHistory(dir)
if err != nil {
t.Fatalf("missing file must not error, got %v", err)
}
if len(h.Records) != 0 {
t.Errorf("missing file: got %d records, want 0", len(h.Records))
}
}
func TestLoadHistory_CorruptFileDegradesToEmpty(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, HistoryFilename), []byte("{not json"), 0o644); err != nil {
t.Fatal(err)
}
h, err := LoadHistory(dir)
if err != nil {
t.Fatalf("corrupt file must degrade silently, got %v", err)
}
if len(h.Records) != 0 {
t.Errorf("corrupt file: got %d records, want 0", len(h.Records))
}
}
func TestHistory_SaveLoadRoundTrip(t *testing.T) {
dir := t.TempDir()
in := History{Records: []HistoryRecord{
{
SignalKind: SignalCommitType,
SignalKey: "fix",
CountAtProposal: 5,
QueueKind: "evolve",
QueueTitle: "Workflow candidate: fix-workflow",
ProposedAt: "2026-05-24T10:00:00Z",
},
{
SignalKind: SignalCommitType,
SignalKey: "docs",
CountAtProposal: 4,
QueueKind: "evolve",
QueueTitle: "Workflow candidate: docs-workflow",
ProposedAt: "2026-05-24T10:00:00Z",
Resolved: true,
ResolvedAt: "2026-05-25T09:00:00Z",
},
}}
if err := SaveHistory(dir, in); err != nil {
t.Fatal(err)
}
out, err := LoadHistory(dir)
if err != nil {
t.Fatal(err)
}
if len(out.Records) != len(in.Records) {
t.Fatalf("len mismatch: got %d, want %d", len(out.Records), len(in.Records))
}
for i, r := range in.Records {
if out.Records[i] != r {
t.Errorf("record %d round-trip: got %+v, want %+v", i, out.Records[i], r)
}
}
}
func TestHistory_OmitemptyForResolvedDefaults(t *testing.T) {
dir := t.TempDir()
in := History{Records: []HistoryRecord{{
SignalKind: SignalCommitType,
SignalKey: "fix",
CountAtProposal: 3,
QueueKind: "evolve",
QueueTitle: "Workflow candidate: fix-workflow",
ProposedAt: "2026-05-24T10:00:00Z",
}}}
if err := SaveHistory(dir, in); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(dir, HistoryFilename))
if err != nil {
t.Fatal(err)
}
s := string(b)
if strings.Contains(s, "\"resolved\"") || strings.Contains(s, "\"resolved_at\"") {
t.Errorf("default record must omit resolved fields on wire:\n%s", s)
}
}
func TestHasProposed(t *testing.T) {
h := History{Records: []HistoryRecord{
{SignalKind: SignalCommitType, SignalKey: "fix"},
{SignalKind: SignalCommitType, SignalKey: "docs", Resolved: true},
}}
cases := []struct {
kind, key string
want bool
}{
{SignalCommitType, "fix", true},
{SignalCommitType, "docs", true},
{SignalCommitType, "feat", false},
{"other-kind", "fix", false},
}
for _, tc := range cases {
if got := h.HasProposed(tc.kind, tc.key); got != tc.want {
t.Errorf("HasProposed(%q,%q) = %v, want %v", tc.kind, tc.key, got, tc.want)
}
}
}
func TestReconcileHistory_TicksOpenItemToResolved(t *testing.T) {
dir := t.TempDir()
// Pre-fill queue with an open item, then mark it resolved by
// rewriting the queue file with `- [x]`.
if err := queue.Append(dir, queue.Item{
Kind: "evolve",
Title: "Workflow candidate: fix-workflow",
Project: "proj",
Date: time.Now(),
}); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(filepath.Join(dir, queue.Filename))
if err != nil {
t.Fatal(err)
}
rewritten := strings.Replace(string(body), "- [ ] **evolve**", "- [x] **evolve**", 1)
if err := os.WriteFile(filepath.Join(dir, queue.Filename), []byte(rewritten), 0o644); err != nil {
t.Fatal(err)
}
h := History{Records: []HistoryRecord{{
SignalKind: SignalCommitType,
SignalKey: "fix",
QueueKind: "evolve",
QueueTitle: "Workflow candidate: fix-workflow",
}}}
now := time.Date(2026, 5, 25, 12, 0, 0, 0, time.UTC)
out, changed := ReconcileHistory(dir, h, now)
if !changed {
t.Fatalf("changed must be true when an open item flipped")
}
if !out.Records[0].Resolved {
t.Errorf("record must flip to resolved")
}
if out.Records[0].ResolvedAt == "" {
t.Errorf("ResolvedAt must be set on flip")
}
}
func TestReconcileHistory_AlreadyResolvedStays(t *testing.T) {
dir := t.TempDir()
h := History{Records: []HistoryRecord{{
SignalKind: SignalCommitType,
SignalKey: "fix",
QueueKind: "evolve",
QueueTitle: "Workflow candidate: fix-workflow",
Resolved: true,
ResolvedAt: "2026-05-20T10:00:00Z",
}}}
out, changed := ReconcileHistory(dir, h, time.Now())
if changed {
t.Errorf("an already-resolved record must not trigger change")
}
if out.Records[0].ResolvedAt != "2026-05-20T10:00:00Z" {
t.Errorf("ResolvedAt must not be overwritten: got %q", out.Records[0].ResolvedAt)
}
}
func TestReconcileHistory_OpenItemStaysUnresolved(t *testing.T) {
dir := t.TempDir()
if err := queue.Append(dir, queue.Item{
Kind: "evolve",
Title: "Workflow candidate: fix-workflow",
Project: "proj",
Date: time.Now(),
}); err != nil {
t.Fatal(err)
}
h := History{Records: []HistoryRecord{{
SignalKind: SignalCommitType,
SignalKey: "fix",
QueueKind: "evolve",
QueueTitle: "Workflow candidate: fix-workflow",
}}}
out, changed := ReconcileHistory(dir, h, time.Now())
if changed {
t.Errorf("an open queue item must not flip the record")
}
if out.Records[0].Resolved {
t.Errorf("record must stay unresolved while queue row is open")
}
}
func TestQueueResolved_MissingFile(t *testing.T) {
dir := t.TempDir()
ok, err := queue.Resolved(dir, "evolve", "missing")
if err != nil {
t.Fatalf("missing queue must not error, got %v", err)
}
if ok {
t.Errorf("missing queue: got ok=true, want false")
}
}
added internal/workflow/leakguard.go
@@ -0,0 +1,112 @@
package workflow
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/ajhahnde/eeco/internal/gitx"
)
// engineSubdirs are the workspace's internal directories. A tracked
// file that references one of these as a path is leaking engine output
// into history; the bare workspace name in prose, source constants, or
// the .gitignore ignore line is not (and is explicitly allowed by
// Constraint 2).
var engineSubdirs = []string{"state", "memory", "engine", "workflows", "docs", "attic"}
// leakGuard blocks a commit that would carry an AI-attribution string,
// a Co-Authored-By trailer, or a workspace engine-path into tracked
// files. It inspects the git-tracked set plus the prospective commit
// message (.git/COMMIT_EDITMSG). It writes nothing and requires git; if
// git is unavailable it returns blocked (contract code 2) rather than
// passing silently.
type leakGuard struct{}
func (leakGuard) Name() string { return "leak-guard" }
func (leakGuard) Summary() string {
return "block attribution / workspace-path leakage into tracked files (read-only)"
}
func (leakGuard) Run(env Env) (Result, error) {
cfg := env.Config
if !gitx.Available() {
return Result{Code: CodeBlocked, Summary: "git not available — cannot inspect tracked tree"}, nil
}
det, err := NewDetector(cfg.AttributionPatterns)
if err != nil {
return Result{}, err
}
wsPathRE := regexp.MustCompile(
regexp.QuoteMeta(cfg.WorkspaceName) + `/(?:` + reAlt(engineSubdirs) + `)/`)
tracked, err := gitx.TrackedFiles(cfg.RepoRoot)
if err != nil {
if errors.Is(err, gitx.ErrUnavailable) {
return Result{Code: CodeBlocked, Summary: "git not available — cannot inspect tracked tree"}, nil
}
return Result{}, fmt.Errorf("leak-guard: %w", err)
}
var findings []Finding
for _, rel := range tracked {
b, rerr := os.ReadFile(filepath.Join(cfg.RepoRoot, rel))
if rerr != nil || !isText(b) {
continue
}
content := string(b)
findings = append(findings, det.Scan(rel, content)...)
// The .gitignore workspace entry is the documented, intended
// modification (Constraint 2); never treat it as a path leak.
if rel != ".gitignore" {
findings = append(findings, scanLines(rel, content, wsPathRE, "workspace path in tracked file")...)
}
}
// The prospective commit message, when a commit is in progress.
msgPath := filepath.Join(cfg.RepoRoot, ".git", "COMMIT_EDITMSG")
if b, rerr := os.ReadFile(msgPath); rerr == nil {
findings = append(findings, det.Scan("COMMIT_EDITMSG", string(b))...)
}
if len(findings) == 0 {
return Result{Code: CodeClean, Summary: "no leak in tracked tree or commit message"}, nil
}
sort.Slice(findings, func(i, j int) bool {
if findings[i].Path != findings[j].Path {
return findings[i].Path < findings[j].Path
}
return findings[i].Line < findings[j].Line
})
return Result{
Code: CodeFinding,
Summary: fmt.Sprintf("%d leak(s) would enter tracked history", len(findings)),
Findings: findings,
}, nil
}
// scanLines reports each line of content matching re.
func scanLines(path, content string, re *regexp.Regexp, what string) []Finding {
var out []Finding
ln := 0
for _, line := range splitLines(content) {
ln++
if re.MatchString(line) {
out = append(out, Finding{Path: path, Line: ln, Msg: what})
}
}
return out
}
func reAlt(xs []string) string {
q := make([]string, len(xs))
for i, x := range xs {
q[i] = regexp.QuoteMeta(x)
}
return strings.Join(q, "|")
}
added internal/workflow/leakguard_test.go
@@ -0,0 +1,110 @@
package workflow
import (
"os"
"path/filepath"
"testing"
)
func TestLeakGuard_CleanTrackedTree(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "main.go", "package main\nfunc main(){}\n")
writeRepoFile(t, cfg.RepoRoot, ".gitignore", "/.eeco/\n")
gitInit(t, cfg.RepoRoot)
res, err := leakGuard{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("clean repo -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
}
func TestLeakGuard_FlagsTrailerInTrackedFile(t *testing.T) {
cfg := newCfg(t)
trailer := fragCoAB + ": A <a@b>"
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "release notes\n"+trailer+"\n")
gitInit(t, cfg.RepoRoot)
res, err := leakGuard{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("tracked trailer -> %d, want %d (%+v)", res.Code, CodeFinding, res.Findings)
}
}
func TestLeakGuard_FlagsWorkspacePathLeak(t *testing.T) {
cfg := newCfg(t)
// Build the engine path from fragments so this tracked test source
// never carries a contiguous workspace-engine path for the gate to
// (correctly) flag against eeco itself.
eng := cfg.WorkspaceName + "/state/"
writeRepoFile(t, cfg.RepoRoot, "notes.txt", "debug dump from "+eng+"queue.md here\n")
gitInit(t, cfg.RepoRoot)
res, err := leakGuard{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("workspace path leak -> %d, want %d", res.Code, CodeFinding)
}
}
func TestLeakGuard_GitignoreEntryExempt(t *testing.T) {
cfg := newCfg(t)
// The .gitignore workspace line is the documented intended change;
// it must never be reported as a leak — even an engine-path-shaped
// line here is exempt. Fragment-built for the reason above.
ws := cfg.WorkspaceName
writeRepoFile(t, cfg.RepoRoot, ".gitignore", "/"+ws+"/\n"+ws+"/state/\n")
gitInit(t, cfg.RepoRoot)
res, err := leakGuard{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf(".gitignore flagged: %+v", res.Findings)
}
}
func TestLeakGuard_IgnoresUntrackedFile(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "tracked.txt", "fine\n")
gitInit(t, cfg.RepoRoot)
// Written after `git add`: untracked, so it cannot enter history.
writeRepoFile(t, cfg.RepoRoot, "untracked.txt", fragCoAB+": X <x@y>\n")
res, err := leakGuard{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("untracked file gated the commit: %+v", res.Findings)
}
}
func TestLeakGuard_ScansCommitMessage(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "f.txt", "ok\n")
gitInit(t, cfg.RepoRoot)
msg := "feat: a change\n\n" + fragCoAB + ": Tool <t@x>\n"
if err := os.WriteFile(filepath.Join(cfg.RepoRoot, ".git", "COMMIT_EDITMSG"), []byte(msg), 0o644); err != nil {
t.Fatal(err)
}
res, err := leakGuard{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("commit-message trailer -> %d, want %d", res.Code, CodeFinding)
}
var sawMsg bool
for _, f := range res.Findings {
if f.Path == "COMMIT_EDITMSG" {
sawMsg = true
}
}
if !sawMsg {
t.Errorf("commit message not attributed in findings: %+v", res.Findings)
}
}
added internal/workflow/manifestrefresh.go
@@ -0,0 +1,48 @@
package workflow
import (
"fmt"
"github.com/ajhahnde/eeco/internal/manifest"
)
// manifestRefresh rebuilds the per-directory .ai.json manifests across the
// knowledge dirs (the project-type-aware directories scaffolded under
// <repo>/<username>/ as siblings of the engine workspace) and all of their
// nested subdirectories. Each manifest is a deterministic skeleton — paths and
// kinds only; the opt-in AI enrichment pass fills descriptions separately, so
// this builtin needs no AI and never blocks.
//
// It is the post-merge member of the family: a `git pull` / `git merge` is when
// another author's files land, so it is the moment to re-enumerate the
// knowledge tree. The writes land in the gitignored per-user area (never the
// tracked tree) and are idempotent — the .ai.json itself is excluded from the
// walk, so a re-run over unchanged dirs reproduces byte-identical files.
type manifestRefresh struct{}
func (manifestRefresh) Name() string { return "manifest-refresh" }
func (manifestRefresh) Summary() string {
return "rebuild the per-directory .ai.json manifests in the knowledge dirs"
}
func (manifestRefresh) Run(env Env) (Result, error) {
cfg := env.Config
dirs, err := manifest.KnowledgeDirs(cfg.UserDir, cfg.WorkspaceName)
if err != nil {
return Result{}, fmt.Errorf("manifest-refresh: list knowledge dirs: %w", err)
}
if len(dirs) == 0 {
return Result{Code: CodeClean, Summary: "no knowledge dirs to refresh"}, nil
}
for _, d := range dirs {
m, err := manifest.Build(cfg.UserDir, d)
if err != nil {
return Result{}, fmt.Errorf("manifest-refresh: build %s: %w", d, err)
}
if err := manifest.Write(cfg.UserDir, d, m); err != nil {
return Result{}, fmt.Errorf("manifest-refresh: write %s: %w", d, err)
}
}
return Result{Code: CodeClean, Summary: fmt.Sprintf("refreshed %d manifest(s)", len(dirs))}, nil
}
added internal/workflow/manifestrefresh_test.go
@@ -0,0 +1,69 @@
package workflow
import (
"os"
"path/filepath"
"testing"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/manifest"
)
func TestManifestRefresh_WritesSkeletonsForKnowledgeDirs(t *testing.T) {
root := t.TempDir()
userDir := filepath.Join(root, "alice")
// Engine workspace (must be skipped) + two knowledge dirs, one nested.
mustMkdir(t, filepath.Join(userDir, ".eeco", "state"))
mustMkdir(t, filepath.Join(userDir, "backend"))
mustMkdir(t, filepath.Join(userDir, "frontend", "routes"))
mustWrite(t, filepath.Join(userDir, "backend", "main.go"), "package main")
cfg := &config.Config{UserDir: userDir, WorkspaceName: ".eeco"}
res, err := manifestRefresh{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("code = %d, want %d", res.Code, CodeClean)
}
// Top-level dirs AND the nested subdirectory each get a manifest.
for _, d := range []string{"backend", "frontend", filepath.Join("frontend", "routes")} {
if _, err := os.Stat(filepath.Join(userDir, d, manifest.FileName)); err != nil {
t.Fatalf("manifest missing for %s: %v", d, err)
}
}
// The engine workspace must NOT get a manifest.
if _, err := os.Stat(filepath.Join(userDir, ".eeco", manifest.FileName)); !os.IsNotExist(err) {
t.Fatalf("engine workspace should be skipped, stat err = %v", err)
}
}
func TestManifestRefresh_NoKnowledgeDirsIsCleanNoop(t *testing.T) {
root := t.TempDir()
userDir := filepath.Join(root, "alice")
mustMkdir(t, filepath.Join(userDir, ".eeco"))
cfg := &config.Config{UserDir: userDir, WorkspaceName: ".eeco"}
res, err := manifestRefresh{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("code = %d, want %d", res.Code, CodeClean)
}
}
func mustMkdir(t *testing.T, p string) {
t.Helper()
if err := os.MkdirAll(p, 0o755); err != nil {
t.Fatal(err)
}
}
func mustWrite(t *testing.T, p, body string) {
t.Helper()
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
added internal/workflow/memorydrift.go
@@ -0,0 +1,158 @@
package workflow
import (
"fmt"
"os"
"path/filepath"
"sort"
"time"
"github.com/ajhahnde/eeco/internal/gitx"
"github.com/ajhahnde/eeco/internal/memory"
"github.com/ajhahnde/eeco/internal/queue"
)
// memoryDrift flags memory facts whose `ref:` file has changed since the
// fact was written. A fact may carry a `ref:` to the file it documents
// and a `created:` date; when that file has been committed on a later
// calendar day than the fact was authored, the fact may now describe
// stale code. `eeco gc` already catches a `ref:` that no longer exists
// on disk — this workflow catches the complementary case: the file is
// still there but has moved on. Drift is reported and one review item
// per stale fact is routed to the queue (the single decision channel);
// the operator reconciles the fact, eeco never edits it.
type memoryDrift struct{}
func (memoryDrift) Name() string { return "memory-drift" }
func (memoryDrift) Summary() string {
return "flag memory facts whose ref file changed since the fact was written"
}
// memoryDriftCommitDate resolves the last-commit date of a repo-relative
// path. It is overridable in tests; it defaults to gitx.LastCommitDate.
var memoryDriftCommitDate = gitx.LastCommitDate
// staleFact is one memory fact whose ref file outran it, carried from
// the detection loop to the queue-append loop.
type staleFact struct {
name string
ref string
created time.Time
changed time.Time
}
func (memoryDrift) Run(env Env) (Result, error) {
cfg := env.Config
// The whole check is a comparison against git commit history, so a
// host without git cannot run it — report blocked (contract code 2)
// rather than passing a check that never actually ran.
if !gitx.Available() {
return Result{Code: CodeBlocked, Summary: "git not available on PATH"}, nil
}
store, err := memory.Open(cfg)
if err != nil {
return Result{}, fmt.Errorf("memory-drift: %w", err)
}
facts, err := store.LoadAll()
if err != nil {
return Result{}, fmt.Errorf("memory-drift: %w", err)
}
var (
findings []Finding
stale []staleFact
checked int
)
for _, f := range facts {
if f.Ref == "" {
continue
}
abs := filepath.Join(cfg.RepoRoot, filepath.FromSlash(f.Ref))
if _, serr := os.Stat(abs); serr != nil {
// A `ref:` that is missing on disk is `eeco gc`'s job, not
// this workflow's — skip it rather than double-report.
continue
}
commit, ok, derr := memoryDriftCommitDate(cfg.RepoRoot, f.Ref)
if derr != nil {
return Result{}, fmt.Errorf("memory-drift: %s: %w", f.Ref, derr)
}
if !ok {
// The ref file exists but has no commit history (untracked,
// or never committed) — there is no commit date to age the
// fact against, so skip it.
continue
}
checked++
createdDay := utcDay(f.Created)
changedDay := utcDay(commit)
if changedDay.After(createdDay) {
findings = append(findings, Finding{
Path: f.Ref,
Line: 0,
Msg: fmt.Sprintf("fact %q written %s; %s last changed %s",
f.Name, createdDay.Format(memory.DateLayout),
f.Ref, changedDay.Format(memory.DateLayout)),
})
stale = append(stale, staleFact{
name: f.Name, ref: f.Ref, created: createdDay, changed: changedDay,
})
}
}
if len(findings) == 0 {
if checked == 0 {
return Result{Code: CodeClean, Summary: "no memory facts carry a ref to check"}, nil
}
return Result{
Code: CodeClean,
Summary: fmt.Sprintf("%d memory fact(s) with a ref are current", checked),
}, nil
}
sort.Slice(findings, func(i, j int) bool {
if findings[i].Path != findings[j].Path {
return findings[i].Path < findings[j].Path
}
return findings[i].Msg < findings[j].Msg
})
// Route one review item per stale fact to the queue — eeco flags the
// drift, the operator reconciles the fact against the current file.
project := filepath.Base(cfg.RepoRoot)
stateDir := filepath.Join(cfg.Workspace, "state")
today := time.Now().UTC()
for _, s := range stale {
item := queue.Item{
Kind: "memory-drift",
Title: fmt.Sprintf("memory %q may be stale: %s changed since the fact was written", s.name, s.ref),
Project: project,
Detail: fmt.Sprintf("fact written %s; %s last changed %s — review the fact against the current file",
s.created.Format(memory.DateLayout), s.ref, s.changed.Format(memory.DateLayout)),
Date: today,
}
// AppendUnique so a repeated run (for example the post-merge hook)
// does not pile up duplicate items for a finding still open in the
// queue; the finding itself is still real and reported below.
if _, err := queue.AppendUnique(stateDir, item); err != nil {
return Result{}, fmt.Errorf("memory-drift: queue: %w", err)
}
}
return Result{
Code: CodeFinding,
Summary: fmt.Sprintf("%d memory fact(s) may be stale (ref changed since the fact was written)", len(findings)),
Findings: findings,
}, nil
}
// utcDay truncates t to its UTC calendar day, so a fact's `created:`
// date and a commit's timestamp compare on the same footing regardless
// of the commit's original time zone.
func utcDay(t time.Time) time.Time {
u := t.UTC()
return time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC)
}
added internal/workflow/memorydrift_test.go
@@ -0,0 +1,255 @@
package workflow
import (
"os/exec"
"strings"
"testing"
"time"
"github.com/ajhahnde/eeco/internal/config"
"github.com/ajhahnde/eeco/internal/memory"
)
// ymd parses a YYYY-MM-DD date into UTC midnight, panicking on a bad
// literal (test inputs are constants).
func ymd(s string) time.Time {
t, err := time.Parse("2006-01-02", s)
if err != nil {
panic(err)
}
return t
}
// seedFact writes one reference fact carrying ref and created into the
// workspace memory store.
func seedFact(t *testing.T, cfg *config.Config, name, ref string, created time.Time) {
t.Helper()
store, err := memory.Open(cfg)
if err != nil {
t.Fatal(err)
}
f := &memory.Fact{
Name: name,
Description: "desc " + name,
Type: memory.TypeReference,
Created: created,
LastUsed: created,
Ref: ref,
Body: "body",
}
if err := store.Save(f); err != nil {
t.Fatalf("seed fact %s: %v", name, err)
}
}
// stubCommitDates overrides memoryDriftCommitDate for the test: a path
// present in m resolves to its date (ok=true); a path absent from m
// resolves to ok=false (no commit history).
func stubCommitDates(t *testing.T, m map[string]time.Time) {
t.Helper()
old := memoryDriftCommitDate
memoryDriftCommitDate = func(_ string, path string) (time.Time, bool, error) {
d, ok := m[path]
return d, ok, nil
}
t.Cleanup(func() { memoryDriftCommitDate = old })
}
func TestMemoryDrift_NoFacts(t *testing.T) {
cfg := newCfg(t)
stubCommitDates(t, nil)
res, err := memoryDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Errorf("Code = %d, want %d", res.Code, CodeClean)
}
if res.Summary != "no memory facts carry a ref to check" {
t.Errorf("Summary = %q", res.Summary)
}
}
func TestMemoryDrift_RefCurrent(t *testing.T) {
cfg := newCfg(t)
seedFact(t, cfg, "alpha", "internal/a.go", ymd("2026-05-20"))
writeRepoFile(t, cfg.RepoRoot, "internal/a.go", "package a\n")
// Same calendar day as the fact's created date — not drift.
stubCommitDates(t, map[string]time.Time{"internal/a.go": ymd("2026-05-20")})
res, err := memoryDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Errorf("Code = %d, want %d (%q)", res.Code, CodeClean, res.Summary)
}
if res.Summary != "1 memory fact(s) with a ref are current" {
t.Errorf("Summary = %q", res.Summary)
}
if q := queueBody(t, cfg); q != "" {
t.Errorf("queue should be empty, got:\n%s", q)
}
}
func TestMemoryDrift_RefChangedAfter(t *testing.T) {
cfg := newCfg(t)
seedFact(t, cfg, "alpha", "internal/a.go", ymd("2026-05-10"))
writeRepoFile(t, cfg.RepoRoot, "internal/a.go", "package a\n")
stubCommitDates(t, map[string]time.Time{"internal/a.go": ymd("2026-05-20")})
res, err := memoryDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("Code = %d, want %d (%q)", res.Code, CodeFinding, res.Summary)
}
if len(res.Findings) != 1 {
t.Fatalf("Findings = %d, want 1", len(res.Findings))
}
if res.Findings[0].Path != "internal/a.go" {
t.Errorf("Finding.Path = %q", res.Findings[0].Path)
}
q := queueBody(t, cfg)
if !strings.Contains(q, "**memory-drift**") || !strings.Contains(q, `"alpha"`) {
t.Errorf("queue missing memory-drift item for alpha:\n%s", q)
}
}
func TestMemoryDrift_RepeatedRunDedupsQueue(t *testing.T) {
cfg := newCfg(t)
seedFact(t, cfg, "alpha", "internal/a.go", ymd("2026-05-10"))
writeRepoFile(t, cfg.RepoRoot, "internal/a.go", "package a\n")
stubCommitDates(t, map[string]time.Time{"internal/a.go": ymd("2026-05-20")})
for i := range 2 {
res, err := memoryDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
// The finding is real on every run — only the queue write dedups.
if res.Code != CodeFinding {
t.Fatalf("run %d: Code = %d, want %d (%q)", i, res.Code, CodeFinding, res.Summary)
}
}
if got := queueBody(t, cfg); strings.Count(got, "**memory-drift**") != 1 {
t.Errorf("repeated runs should queue exactly one open item:\n%s", got)
}
}
func TestMemoryDrift_RefMissingOnDisk(t *testing.T) {
cfg := newCfg(t)
// The ref file is never written — a missing ref is eeco gc's job.
seedFact(t, cfg, "alpha", "internal/gone.go", ymd("2026-05-10"))
stubCommitDates(t, map[string]time.Time{"internal/gone.go": ymd("2026-05-20")})
res, err := memoryDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Errorf("Code = %d, want %d (%q)", res.Code, CodeClean, res.Summary)
}
if res.Summary != "no memory facts carry a ref to check" {
t.Errorf("Summary = %q", res.Summary)
}
}
func TestMemoryDrift_RefUntracked(t *testing.T) {
cfg := newCfg(t)
seedFact(t, cfg, "alpha", "internal/a.go", ymd("2026-05-10"))
writeRepoFile(t, cfg.RepoRoot, "internal/a.go", "package a\n")
// No entry in the map → ok=false → no commit history to compare.
stubCommitDates(t, map[string]time.Time{})
res, err := memoryDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Errorf("Code = %d, want %d (%q)", res.Code, CodeClean, res.Summary)
}
if res.Summary != "no memory facts carry a ref to check" {
t.Errorf("Summary = %q", res.Summary)
}
}
func TestMemoryDrift_FactWithoutRef(t *testing.T) {
cfg := newCfg(t)
seedFact(t, cfg, "alpha", "", ymd("2026-05-10"))
stubCommitDates(t, nil)
res, err := memoryDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean || res.Summary != "no memory facts carry a ref to check" {
t.Errorf("Code = %d, Summary = %q", res.Code, res.Summary)
}
}
func TestMemoryDrift_MixedFacts(t *testing.T) {
cfg := newCfg(t)
seedFact(t, cfg, "alpha", "internal/a.go", ymd("2026-05-10")) // stale
seedFact(t, cfg, "beta", "internal/b.go", ymd("2026-05-20")) // current
seedFact(t, cfg, "gamma", "", ymd("2026-05-01")) // no ref
writeRepoFile(t, cfg.RepoRoot, "internal/a.go", "package a\n")
writeRepoFile(t, cfg.RepoRoot, "internal/b.go", "package b\n")
stubCommitDates(t, map[string]time.Time{
"internal/a.go": ymd("2026-05-20"),
"internal/b.go": ymd("2026-05-20"),
})
res, err := memoryDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("Code = %d, want %d (%q)", res.Code, CodeFinding, res.Summary)
}
if len(res.Findings) != 1 {
t.Fatalf("Findings = %d, want 1: %+v", len(res.Findings), res.Findings)
}
if res.Findings[0].Path != "internal/a.go" {
t.Errorf("stale fact = %q, want internal/a.go", res.Findings[0].Path)
}
if got := queueBody(t, cfg); strings.Count(got, "**memory-drift**") != 1 {
t.Errorf("want exactly one queued item:\n%s", got)
}
}
// TestMemoryDrift_RealGit exercises the real gitx.LastCommitDate wiring
// (no stub) against an actual commit, so the integration is covered end
// to end alongside the table-driven stub cases above.
func TestMemoryDrift_RealGit(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "internal/a.go", "package a\n")
gitInit(t, cfg.RepoRoot)
runGit(t, cfg.RepoRoot, "commit", "-q", "-m", "add a.go")
// The commit lands now; the fact claims to predate it by years.
seedFact(t, cfg, "alpha", "internal/a.go", ymd("2020-01-01"))
res, err := memoryDrift{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("Code = %d, want %d (%q)", res.Code, CodeFinding, res.Summary)
}
if len(res.Findings) != 1 || res.Findings[0].Path != "internal/a.go" {
t.Errorf("Findings = %+v", res.Findings)
}
}
func runGit(t *testing.T, root string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = root
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
added internal/workflow/registry.go
@@ -0,0 +1,36 @@
package workflow
import "sort"
// Registry maps a workflow name to its native implementation. The
// builtins are read-only and ship in the binary; user workflows live in
// the workspace and are run by the script runner, not registered here.
type Registry struct {
builtins map[string]Workflow
}
// DefaultRegistry returns the registry of builtin workflows available in
// this milestone.
func DefaultRegistry() *Registry {
r := &Registry{builtins: map[string]Workflow{}}
for _, w := range []Workflow{commentHygiene{}, leakGuard{}, bugSweep{}, handoverRefresh{}, evolve{}, versionSync{}, buildGate{}, memoryDrift{}, docDrift{}, manifestRefresh{}, cockpitSync{}} {
r.builtins[w.Name()] = w
}
return r
}
// Get returns the named builtin and whether it exists.
func (r *Registry) Get(name string) (Workflow, bool) {
w, ok := r.builtins[name]
return w, ok
}
// Names returns the builtin workflow names, sorted.
func (r *Registry) Names() []string {
names := make([]string, 0, len(r.builtins))
for n := range r.builtins {
names = append(names, n)
}
sort.Strings(names)
return names
}
added internal/workflow/registry_test.go
@@ -0,0 +1,29 @@
package workflow
import (
"reflect"
"testing"
)
func TestDefaultRegistry(t *testing.T) {
r := DefaultRegistry()
want := []string{"bug-sweep", "cockpit-sync", "comment-hygiene", "doc-drift", "evolve", "gate", "handover-refresh", "leak-guard", "manifest-refresh", "memory-drift", "version-sync"}
if got := r.Names(); !reflect.DeepEqual(got, want) {
t.Fatalf("Names() = %v, want %v", got, want)
}
for _, n := range want {
w, ok := r.Get(n)
if !ok {
t.Fatalf("%s not registered", n)
}
if w.Name() != n {
t.Errorf("Name() = %q, want %q", w.Name(), n)
}
if w.Summary() == "" {
t.Errorf("%s: empty Summary()", n)
}
}
if _, ok := r.Get("nope"); ok {
t.Error("unknown workflow reported as registered")
}
}
added internal/workflow/run.go
@@ -0,0 +1,90 @@
package workflow
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
)
// EntryName is the runnable entry a workflow directory must contain.
const EntryName = "run"
// Run executes a native builtin and normalises its exit code to the
// contract. A workflow that returns an out-of-contract code is treated
// as a failure (1) so a bug cannot report a false "clean".
func Run(w Workflow, env Env) (Result, error) {
if env.Config == nil {
return Result{}, errors.New("workflow.Run: nil config")
}
res, err := w.Run(env)
if err != nil {
return res, err
}
res.Code = normalizeCode(res.Code)
return res, nil
}
// ScriptRun executes a scaffolded workflow living at
// <workspace>/workflows/<name>/. The entry runs with the repository
// root as its working directory and the resolved config exported into
// the environment (the workflow contract). The entry's own exit code is
// returned verbatim after normalisation; it owns the contract.
//
// Blocked (2) is returned when the workflow directory or its runnable
// entry is missing, rather than failing as if it had run.
func ScriptRun(name string, env Env) (Result, error) {
cfg := env.Config
if cfg == nil {
return Result{}, errors.New("workflow.ScriptRun: nil config")
}
dir := filepath.Join(cfg.Workspace, "workflows", name)
entry := filepath.Join(dir, EntryName)
info, err := os.Stat(entry)
if err != nil || info.IsDir() {
return Result{
Code: CodeBlocked,
Summary: fmt.Sprintf("workflow %q has no runnable %s entry", name, EntryName),
}, nil
}
// A sentinel marker file flips the workflow off without removing it
// from disk (`eeco workflows <name> off`). Treated as blocked, not a
// finding: the workflow could not run, exactly like a missing tool.
if _, derr := os.Stat(filepath.Join(dir, DisabledMarker)); derr == nil {
return Result{
Code: CodeBlocked,
Summary: fmt.Sprintf("workflow %q is disabled (eeco workflows %s on)", name, name),
}, nil
}
cmd := exec.Command(entry)
cmd.Dir = cfg.RepoRoot
cmd.Env = append(os.Environ(),
"EECO_REPO_ROOT="+cfg.RepoRoot,
"EECO_WORKSPACE="+cfg.Workspace,
"EECO_PROFILE="+string(cfg.Profile),
"EECO_AI="+strconv.FormatBool(env.AI),
)
cmd.Stdout = env.Out
cmd.Stderr = env.Out
runErr := cmd.Run()
if runErr == nil {
return Result{Code: CodeClean, Summary: name + " passed"}, nil
}
var ee *exec.ExitError
if errors.As(runErr, &ee) {
code := normalizeCode(ee.ExitCode())
return Result{
Code: code,
Summary: fmt.Sprintf("%s exited %d", name, ee.ExitCode()),
}, nil
}
// Could not execute at all (not executable, bad interpreter): the
// required entry is effectively unusable -> blocked, not a finding.
return Result{
Code: CodeBlocked,
Summary: fmt.Sprintf("cannot execute %s: %v", entry, runErr),
}, nil
}
added internal/workflow/run_test.go
@@ -0,0 +1,129 @@
package workflow
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
type fakeWF struct {
code int
err error
}
func (fakeWF) Name() string { return "fake" }
func (fakeWF) Summary() string { return "fake" }
func (f fakeWF) Run(Env) (Result, error) { return Result{Code: f.code}, f.err }
func TestRun_NormalizesOutOfContractCode(t *testing.T) {
cfg := newCfg(t)
res, err := Run(fakeWF{code: 99}, Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Errorf("out-of-contract 99 -> %d, want %d", res.Code, CodeFinding)
}
}
func TestRun_NilConfig(t *testing.T) {
if _, err := Run(fakeWF{}, Env{}); err == nil {
t.Error("nil config must error")
}
}
func TestScriptRun_MissingEntryIsBlocked(t *testing.T) {
cfg := newCfg(t)
res, err := ScriptRun("ghost", Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeBlocked {
t.Errorf("missing entry -> %d, want %d (blocked)", res.Code, CodeBlocked)
}
}
func TestScriptRun_HonoursExitContract(t *testing.T) {
if _, err := os.Stat("/bin/sh"); err != nil {
t.Skip("no /bin/sh")
}
cfg := newCfg(t)
for _, code := range []int{0, 1, 2, 3} {
name := "wf"
dir := filepath.Join(cfg.Workspace, "workflows", name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
script := "#!/bin/sh\nexit " + strconv.Itoa(code) + "\n"
if err := os.WriteFile(filepath.Join(dir, EntryName), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
res, err := ScriptRun(name, Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != code {
t.Errorf("script exit %d -> Result.Code %d", code, res.Code)
}
_ = os.RemoveAll(dir)
}
}
func TestScriptRun_DisabledMarkerIsBlocked(t *testing.T) {
if _, err := os.Stat("/bin/sh"); err != nil {
t.Skip("no /bin/sh")
}
cfg := newCfg(t)
name := "wf"
dir := filepath.Join(cfg.Workspace, "workflows", name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
script := "#!/bin/sh\nexit 0\n"
if err := os.WriteFile(filepath.Join(dir, EntryName), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
// Enabled: runs clean.
res, err := ScriptRun(name, Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("enabled workflow -> %d, want %d", res.Code, CodeClean)
}
// Plant the disabled marker and re-run.
if err := os.WriteFile(filepath.Join(dir, DisabledMarker), nil, 0o644); err != nil {
t.Fatal(err)
}
res, err = ScriptRun(name, Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeBlocked {
t.Fatalf("disabled workflow -> %d, want %d (blocked)", res.Code, CodeBlocked)
}
if !strings.Contains(res.Summary, "disabled") {
t.Errorf("summary should mention 'disabled', got %q", res.Summary)
}
}
func TestScriptRun_NonExecutableIsBlocked(t *testing.T) {
cfg := newCfg(t)
dir := filepath.Join(cfg.Workspace, "workflows", "nox")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
// Not executable and no interpreter: cannot be run -> blocked.
if err := os.WriteFile(filepath.Join(dir, EntryName), []byte("nonsense"), 0o644); err != nil {
t.Fatal(err)
}
res, err := ScriptRun("nox", Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeBlocked {
t.Errorf("non-executable entry -> %d, want %d (blocked)", res.Code, CodeBlocked)
}
}
added internal/workflow/scaffold.go
@@ -0,0 +1,97 @@
package workflow
import (
"embed"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"github.com/ajhahnde/eeco/internal/config"
)
//go:embed template
var templateFS embed.FS
var workflowNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// namePlaceholder is replaced with the workflow name in every templated
// file as it is written.
const namePlaceholder = "__NAME__"
// Scaffold creates a new user workflow directory at
// <workspace>/workflows/<name>/ from the embedded template and returns
// its absolute path. It writes only inside the workspace (Constraint 1)
// and refuses to overwrite an existing workflow.
func Scaffold(cfg *config.Config, name string) (string, error) {
if cfg == nil {
return "", fmt.Errorf("scaffold: nil config")
}
if !workflowNameRE.MatchString(name) {
return "", fmt.Errorf("workflow name %q: must be lower-kebab-case (a-z, 0-9, '-')", name)
}
workflowsDir := filepath.Join(cfg.Workspace, "workflows")
dst := filepath.Join(workflowsDir, name)
// Defence in depth: the regex already forbids separators and dots,
// but verify the cleaned target stays inside the workspace before
// any write.
rel, err := filepath.Rel(cfg.Workspace, dst)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("workflow path %q escapes the workspace", name)
}
if _, err := os.Stat(dst); err == nil {
return "", fmt.Errorf("workflow %q already exists at %s", name, dst)
} else if !os.IsNotExist(err) {
return "", err
}
if err := os.MkdirAll(dst, 0o755); err != nil {
return "", fmt.Errorf("scaffold: create dir: %w", err)
}
walkRoot := path.Join("template", profileSubdir(cfg.Profile))
err = fs.WalkDir(templateFS, walkRoot, func(p string, de fs.DirEntry, err error) error {
if err != nil {
return err
}
if de.IsDir() {
return nil
}
data, rerr := templateFS.ReadFile(p)
if rerr != nil {
return rerr
}
body := strings.ReplaceAll(string(data), namePlaceholder, name)
base := filepath.Base(p)
// The runnable entry must be executable; embed cannot carry the
// mode bit, so it is set explicitly here.
mode := fs.FileMode(0o644)
if base == EntryName {
mode = 0o755
}
return os.WriteFile(filepath.Join(dst, base), []byte(body), mode)
})
if err != nil {
return "", fmt.Errorf("scaffold: write template: %w", err)
}
return dst, nil
}
// profileSubdir maps a config.Profile to the template subdirectory its
// scaffold uses. Profiles without a dedicated template fall back to
// "generic" — identical to the pre-per-profile-templates behaviour,
// where every project got the same stub.
func profileSubdir(p config.Profile) string {
switch p {
case config.ProfileGo, config.ProfilePython, config.ProfileGeneric:
return string(p)
default:
return string(config.ProfileGeneric)
}
}
added internal/workflow/scaffold_test.go
@@ -0,0 +1,137 @@
package workflow
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/ajhahnde/eeco/internal/config"
)
func TestScaffold_CreatesRunnableWorkflow(t *testing.T) {
cfg := newCfg(t)
dir, err := Scaffold(cfg, "my-check")
if err != nil {
t.Fatal(err)
}
want := filepath.Join(cfg.Workspace, "workflows", "my-check")
if dir != want {
t.Errorf("dir = %q, want %q", dir, want)
}
entry := filepath.Join(dir, EntryName)
info, err := os.Stat(entry)
if err != nil {
t.Fatalf("entry missing: %v", err)
}
if runtime.GOOS != "windows" && info.Mode().Perm()&0o100 == 0 {
t.Errorf("entry not executable: mode %v", info.Mode())
}
body, _ := os.ReadFile(entry)
if strings.Contains(string(body), namePlaceholder) {
t.Error("placeholder not substituted in run")
}
if !strings.Contains(string(body), "my-check") {
t.Error("workflow name not substituted into run")
}
readme, err := os.ReadFile(filepath.Join(dir, "README.md"))
if err != nil || !strings.Contains(string(readme), "my-check") {
t.Errorf("README missing or not substituted: %v", err)
}
}
func TestScaffold_RefusesDuplicate(t *testing.T) {
cfg := newCfg(t)
if _, err := Scaffold(cfg, "dup"); err != nil {
t.Fatal(err)
}
if _, err := Scaffold(cfg, "dup"); err == nil {
t.Error("second scaffold of same name must fail")
}
}
func TestScaffold_RejectsBadNames(t *testing.T) {
cfg := newCfg(t)
for _, bad := range []string{"", ".", "..", "a/b", "../escape", "Bad_Name", "-leading", "white space"} {
if _, err := Scaffold(cfg, bad); err == nil {
t.Errorf("name %q should be rejected", bad)
}
}
// Nothing leaked outside the workflows dir.
entries, _ := os.ReadDir(filepath.Join(cfg.Workspace, "workflows"))
if len(entries) != 0 {
t.Errorf("rejected names created entries: %v", entries)
}
}
func TestScaffold_StaysInWorkspace(t *testing.T) {
cfg := newCfg(t)
if _, err := Scaffold(cfg, "ok-name"); err != nil {
t.Fatal(err)
}
// The only thing created must be under <workspace>/workflows.
if _, err := os.Stat(filepath.Join(cfg.RepoRoot, "ok-name")); !os.IsNotExist(err) {
t.Error("scaffold wrote outside the workspace")
}
}
func TestScaffold_ProfileGo_PicksGoTemplate(t *testing.T) {
cfg := newCfgWithProfile(t, config.ProfileGo)
dir, err := Scaffold(cfg, "go-check")
if err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(filepath.Join(dir, EntryName))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), "go vet ./...") {
t.Errorf("go-profile run missing go-specific marker; got:\n%s", body)
}
if !strings.Contains(string(body), "go-check") {
t.Error("workflow name not substituted into go-profile run")
}
}
func TestScaffold_ProfilePython_PicksPythonTemplate(t *testing.T) {
cfg := newCfgWithProfile(t, config.ProfilePython)
dir, err := Scaffold(cfg, "py-check")
if err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(filepath.Join(dir, EntryName))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), "python3 -m compileall") {
t.Errorf("python-profile run missing python-specific marker; got:\n%s", body)
}
if !strings.Contains(string(body), "py-check") {
t.Error("workflow name not substituted into python-profile run")
}
}
func TestScaffold_DeferredProfile_FallsBackToGeneric(t *testing.T) {
for _, p := range []config.Profile{config.ProfileZig, config.ProfileRust, config.ProfileNode} {
t.Run(string(p), func(t *testing.T) {
cfg := newCfgWithProfile(t, p)
dir, err := Scaffold(cfg, "fallback-check")
if err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(filepath.Join(dir, EntryName))
if err != nil {
t.Fatal(err)
}
s := string(body)
if strings.Contains(s, "go vet ./...") || strings.Contains(s, "python3 -m compileall") {
t.Errorf("deferred profile %q got a per-language template; want generic stub. body:\n%s", p, s)
}
if !strings.Contains(s, "passing clean") {
t.Errorf("deferred profile %q did not get the generic stub; body:\n%s", p, s)
}
})
}
}
added internal/workflow/scan.go
@@ -0,0 +1,70 @@
package workflow
import (
"bytes"
"io/fs"
"os"
"path/filepath"
"strings"
)
// maxScanBytes caps the size of a file the text scanners will read. A
// file larger than this is treated as non-text and skipped: attribution
// fingerprints live in source and docs, not in large generated blobs.
const maxScanBytes = 4 << 20 // 4 MiB
// walkText walks root and calls fn(relPath, content) for every regular
// text file, skipping the .git directory and the gitignored workspace
// (engine output must never gate the tracked tree). Skipped: binary
// files, oversized files, and unreadable entries. relPath is
// slash-separated and repo-relative.
func walkText(root, workspaceName string, fn func(rel, content string) error) error {
return filepath.WalkDir(root, func(path string, de fs.DirEntry, err error) error {
if err != nil {
return err
}
name := de.Name()
if de.IsDir() {
if path == root {
return nil
}
if name == ".git" || name == workspaceName {
return filepath.SkipDir
}
return nil
}
if !de.Type().IsRegular() {
return nil
}
info, ierr := de.Info()
if ierr != nil || info.Size() > maxScanBytes {
return nil
}
b, rerr := os.ReadFile(path)
if rerr != nil || !isText(b) {
return nil
}
rel, rerr := filepath.Rel(root, path)
if rerr != nil {
return nil
}
return fn(filepath.ToSlash(rel), string(b))
})
}
// isText reports whether b looks like text: a NUL byte in the first
// chunk marks it binary. Cheap and good enough for source/doc trees.
func isText(b []byte) bool {
n := min(len(b), 8000)
return !bytes.ContainsRune(b[:n], 0)
}
// splitLines splits content into lines, dropping a trailing CR so a
// CRLF file reports the same line content as an LF one.
func splitLines(content string) []string {
lines := strings.Split(content, "\n")
for i, l := range lines {
lines[i] = strings.TrimSuffix(l, "\r")
}
return lines
}
added internal/workflow/signals.go
@@ -0,0 +1,132 @@
package workflow
import (
"fmt"
"regexp"
"sort"
"strings"
)
// Signal kinds emitted by ComputeSignals. Only commit-type ships
// today; future signal kinds (e.g. recurring file touches) need a new
// gitx read surface and are deferred to a later slice.
const (
SignalCommitType = "commit-type"
)
// Thresholds for the deterministic evolve pass. minCommitTypeOccurrences
// is the floor on how often a commit-type must appear in the recent
// history before it counts as a repetition signal; the value is
// deliberately small (3) so a one-off backport or release run does not
// trip it. maxDeterministicCandidates caps the surfaced list so a noisy
// history cannot flood the queue.
const (
minCommitTypeOccurrences = 3
maxDeterministicCandidates = 5
)
// conventionalCommitRE matches the leading `<type>(<scope>)?!?:` of a
// conventional-commit subject. Group 1 captures the type — lower-case
// letters only, per the spec's recommended shape — and is the only
// portion eeco's deterministic pass tallies.
var conventionalCommitRE = regexp.MustCompile(`^([a-z]+)(?:\([^)]*\))?!?:\s`)
// Signal is one observation about the recent history: a repeated
// commit-type, a repeated file touch (future), etc. Kind names the
// signal class, Key the specific value (e.g. "fix"), Count the number
// of occurrences in the inspected window.
type Signal struct {
Kind string
Key string
Count int
}
// Candidate is one proposed workflow the deterministic pass surfaces.
// Title is the suggested workflow name (always satisfies the workflow
// name regex Scaffold enforces). Reason is a one-line human-readable
// explanation that becomes the queue item's detail line. Signals are
// the underlying observations that justified the candidate.
type Candidate struct {
Title string
Reason string
Signals []Signal
}
// ComputeSignals scans `git log --oneline` lines for repeated
// conventional-commit types. Each input line is the bare
// `<short-sha> <subject>` shape gitx.ChangesSince returns; lines that
// do not parse as conventional-commit subjects are ignored. The result
// is sorted descending by Count, then ascending by Key, so the output
// is stable across runs over the same input.
func ComputeSignals(logLines []string) []Signal {
counts := map[string]int{}
for _, line := range logLines {
subject := extractSubject(line)
if subject == "" {
continue
}
m := conventionalCommitRE.FindStringSubmatch(subject)
if m == nil {
continue
}
counts[m[1]]++
}
signals := make([]Signal, 0, len(counts))
for k, n := range counts {
if n < minCommitTypeOccurrences {
continue
}
signals = append(signals, Signal{Kind: SignalCommitType, Key: k, Count: n})
}
sort.Slice(signals, func(i, j int) bool {
if signals[i].Count != signals[j].Count {
return signals[i].Count > signals[j].Count
}
return signals[i].Key < signals[j].Key
})
return signals
}
// ProposeCandidates turns commit-type signals into workflow proposals,
// one candidate per signal. Order follows ComputeSignals (count desc,
// key asc); the output is capped at maxDeterministicCandidates.
// Candidate titles are constructed so they always satisfy the workflow
// name regex Scaffold enforces — a malformed type (which the regex on
// ComputeSignals already filters out) would be dropped here too.
func ProposeCandidates(signals []Signal) []Candidate {
out := make([]Candidate, 0, len(signals))
for _, s := range signals {
if len(out) >= maxDeterministicCandidates {
break
}
if s.Kind != SignalCommitType {
continue
}
name := s.Key + "-workflow"
if !workflowNameRE.MatchString(name) {
continue
}
out = append(out, Candidate{
Title: name,
Reason: fmt.Sprintf("repeated commit-type %q (%d occurrences in recent history)", s.Key, s.Count),
Signals: []Signal{s},
})
}
return out
}
// extractSubject splits a `git log --oneline` line into its subject
// portion. The format is `<short-sha> <subject>`; an empty or
// SHA-only line returns the empty string.
func extractSubject(line string) string {
line = strings.TrimSpace(line)
if line == "" {
return ""
}
_, rest, ok := strings.Cut(line, " ")
if !ok {
return ""
}
return strings.TrimSpace(rest)
}
added internal/workflow/signals_test.go
@@ -0,0 +1,161 @@
package workflow
import (
"strings"
"testing"
)
func TestComputeSignals_EmptyInput(t *testing.T) {
if got := ComputeSignals(nil); len(got) != 0 {
t.Errorf("nil input: got %v, want empty", got)
}
if got := ComputeSignals([]string{}); len(got) != 0 {
t.Errorf("empty slice: got %v, want empty", got)
}
if got := ComputeSignals([]string{"", " "}); len(got) != 0 {
t.Errorf("blank lines: got %v, want empty", got)
}
}
func TestComputeSignals_BelowThresholdIsDropped(t *testing.T) {
lines := []string{
"abc1234 fix: one",
"abc1235 fix: two",
// only two "fix:" — below threshold of 3
"abc1236 docs: unrelated",
}
got := ComputeSignals(lines)
if len(got) != 0 {
t.Errorf("below-threshold counts must drop, got %v", got)
}
}
func TestComputeSignals_AtAndAboveThreshold(t *testing.T) {
lines := []string{
"abc1234 fix: one",
"abc1235 fix: two",
"abc1236 fix: three", // hits threshold
}
got := ComputeSignals(lines)
if len(got) != 1 || got[0].Key != "fix" || got[0].Count != 3 || got[0].Kind != SignalCommitType {
t.Errorf("threshold hit: got %v, want one fix=3", got)
}
}
func TestComputeSignals_OrderingCountDescThenKeyAsc(t *testing.T) {
lines := []string{
// 4 fix
"a1 fix: a", "a2 fix: b", "a3 fix: c", "a4 fix: d",
// 3 chore
"b1 chore: a", "b2 chore: b", "b3 chore: c",
// 3 docs
"c1 docs: a", "c2 docs: b", "c3 docs: c",
}
got := ComputeSignals(lines)
if len(got) != 3 {
t.Fatalf("expected 3 signals, got %v", got)
}
if got[0].Key != "fix" || got[0].Count != 4 {
t.Errorf("rank 1: got %v, want fix=4", got[0])
}
// Tie at count=3: alphabetical → chore before docs.
if got[1].Key != "chore" || got[2].Key != "docs" {
t.Errorf("tie-break order: got %v, want chore then docs", got)
}
}
func TestComputeSignals_ConventionalShapes(t *testing.T) {
lines := []string{
"a1 feat: one",
"a2 feat(ui): two", // scope → still counts as feat
"a3 feat(api)!: three", // breaking → still counts as feat
"a4 not a commit subject",
"a5 RANDOM CAPITALS: ignored",
}
got := ComputeSignals(lines)
if len(got) != 1 || got[0].Key != "feat" || got[0].Count != 3 {
t.Errorf("conventional shapes: got %v, want feat=3", got)
}
}
func TestComputeSignals_NonConventionalIgnored(t *testing.T) {
lines := []string{
"a1 fix something",
"a2 fix something else",
"a3 fix one more thing", // no colon — not a conventional subject
}
got := ComputeSignals(lines)
if len(got) != 0 {
t.Errorf("non-conventional must be ignored, got %v", got)
}
}
func TestProposeCandidates_TitleAndReason(t *testing.T) {
signals := []Signal{
{Kind: SignalCommitType, Key: "fix", Count: 5},
}
got := ProposeCandidates(signals)
if len(got) != 1 {
t.Fatalf("expected 1 candidate, got %v", got)
}
if got[0].Title != "fix-workflow" {
t.Errorf("title: got %q, want fix-workflow", got[0].Title)
}
if !workflowNameRE.MatchString(got[0].Title) {
t.Errorf("title %q must satisfy workflowNameRE", got[0].Title)
}
if !strings.Contains(got[0].Reason, "fix") || !strings.Contains(got[0].Reason, "5") {
t.Errorf("reason should mention type+count: got %q", got[0].Reason)
}
if len(got[0].Signals) != 1 || got[0].Signals[0].Key != "fix" {
t.Errorf("signals should carry source signal, got %v", got[0].Signals)
}
}
func TestProposeCandidates_CapAtFive(t *testing.T) {
signals := []Signal{
{Kind: SignalCommitType, Key: "a", Count: 10},
{Kind: SignalCommitType, Key: "b", Count: 9},
{Kind: SignalCommitType, Key: "c", Count: 8},
{Kind: SignalCommitType, Key: "d", Count: 7},
{Kind: SignalCommitType, Key: "e", Count: 6},
{Kind: SignalCommitType, Key: "f", Count: 5}, // 6th — must drop
}
got := ProposeCandidates(signals)
if len(got) != 5 {
t.Fatalf("cap not enforced, got %d candidates", len(got))
}
// The 6th signal ("f") must NOT appear.
for _, c := range got {
if c.Title == "f-workflow" {
t.Errorf("6th candidate leaked past the cap")
}
}
}
func TestProposeCandidates_UnknownKindIgnored(t *testing.T) {
signals := []Signal{
{Kind: "future-file-touch", Key: "x", Count: 99},
{Kind: SignalCommitType, Key: "fix", Count: 3},
}
got := ProposeCandidates(signals)
if len(got) != 1 || got[0].Title != "fix-workflow" {
t.Errorf("unknown signal kind must be ignored, got %v", got)
}
}
func TestComputeSignals_HandlesLogOneline(t *testing.T) {
// Realistic git log --oneline shape: "<short-sha> <subject>"
log := strings.Join([]string{
"a0cf4fb docs: formal versioning policy",
"2a29a6b chore: gitignore the local roadmap file",
"4bfd48a docs: cross-repo nav design",
"0fcfcae docs: cross-project fingerprint",
"ef1f084 feat: built, installable, validated",
}, "\n")
got := ComputeSignals(splitLines(log))
// 3 docs (≥ threshold), 1 chore, 1 feat → only docs survives.
if len(got) != 1 || got[0].Key != "docs" || got[0].Count != 3 {
t.Errorf("realistic log: got %v, want docs=3", got)
}
}
added internal/workflow/template/generic/README.md
@@ -0,0 +1,6 @@
__NAME__ — one-line description of what this workflow checks.
Edit run to implement the check. It executes with the repository root
as the working directory and the EECO_* variables in the environment.
Exit codes: 0 clean, 1 finding, 2 blocked (a required tool is missing),
3 AI pass deferred (no --ai).
added internal/workflow/template/generic/run
@@ -0,0 +1,19 @@
#!/bin/sh
# Workflow: __NAME__
#
# Runs with the repository root as the working directory. Available
# environment: EECO_REPO_ROOT, EECO_WORKSPACE, EECO_PROFILE, EECO_AI.
#
# Contract — exit with one of:
# 0 clean
# 1 finding / failure
# 2 blocked (a required tool is missing)
# 3 AI pass deferred (EECO_AI=false and an AI pass was wanted)
#
# Write nothing outside "$EECO_WORKSPACE". Use the queue for decisions.
set -eu
# TODO: implement the check. Placeholder passes clean.
echo "__NAME__: not yet implemented — passing clean"
exit 0
added internal/workflow/template/go/README.md
@@ -0,0 +1,9 @@
__NAME__ — one-line description of what this workflow checks.
Go-profile scaffold: run PATH-checks `go` (exit 2 if missing) and then
runs `go vet ./...` as an inert default. Replace the vet call with the
actual check this workflow needs. It executes with the repository root
as the working directory and the EECO_* variables in the environment.
Exit codes: 0 clean, 1 finding, 2 blocked (a required tool is missing),
3 AI pass deferred (no --ai).
added internal/workflow/template/go/run
@@ -0,0 +1,24 @@
#!/bin/sh
# Workflow: __NAME__
#
# Runs with the repository root as the working directory. Available
# environment: EECO_REPO_ROOT, EECO_WORKSPACE, EECO_PROFILE, EECO_AI.
#
# Contract — exit with one of:
# 0 clean
# 1 finding / failure
# 2 blocked (a required tool is missing)
# 3 AI pass deferred (EECO_AI=false and an AI pass was wanted)
#
# Write nothing outside "$EECO_WORKSPACE". Use the queue for decisions.
set -eu
if ! command -v go >/dev/null 2>&1; then
echo "__NAME__: blocked — go not on PATH" >&2
exit 2
fi
# Go-profile default: a vet pass over every package. Replace with the
# real check this workflow is meant to perform.
go vet ./...
added internal/workflow/template/python/README.md
@@ -0,0 +1,10 @@
__NAME__ — one-line description of what this workflow checks.
Python-profile scaffold: run PATH-checks `python3` (exit 2 if missing)
and then byte-compiles every .py file via `python3 -m compileall -q .`
as an inert default. Replace the compileall call with the actual check
this workflow needs. It executes with the repository root as the working
directory and the EECO_* variables in the environment.
Exit codes: 0 clean, 1 finding, 2 blocked (a required tool is missing),
3 AI pass deferred (no --ai).
added internal/workflow/template/python/run
@@ -0,0 +1,25 @@
#!/bin/sh
# Workflow: __NAME__
#
# Runs with the repository root as the working directory. Available
# environment: EECO_REPO_ROOT, EECO_WORKSPACE, EECO_PROFILE, EECO_AI.
#
# Contract — exit with one of:
# 0 clean
# 1 finding / failure
# 2 blocked (a required tool is missing)
# 3 AI pass deferred (EECO_AI=false and an AI pass was wanted)
#
# Write nothing outside "$EECO_WORKSPACE". Use the queue for decisions.
set -eu
if ! command -v python3 >/dev/null 2>&1; then
echo "__NAME__: blocked — python3 not on PATH" >&2
exit 2
fi
# Python-profile default: byte-compile every .py file under the repo
# (read-only, no network, no writes outside __pycache__). Replace with
# the real check this workflow is meant to perform.
python3 -m compileall -q .
added internal/workflow/versionsync.go
@@ -0,0 +1,448 @@
package workflow
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"github.com/ajhahnde/eeco/internal/gitx"
)
// versionSync is a read-only gate that reports drift between the
// version strings declared in `config.local`'s `version_locations`
// list. Every entry is a `path:regex` pair (split on the first colon);
// the regex must declare at least one capture group, and group 1 holds
// the version string. The reserved value `version_locations=auto`
// switches the gate to auto-detect: it scans a fixed set of common
// version files (see versionDetectTargets) instead of an explicit list.
//
// Anchor modes:
// - cfg.VersionAnchor == "" (default): consistency-only — the first
// declared location is the anchor; the rest must match it.
// - cfg.VersionAnchor == "tag": the latest semver-shaped reachable git
// tag is the source of truth. Declared locations must be semver
// greater-or-equal to it so a release commit can bump declared
// locations ahead of the not-yet-pushed tag; backward-drift fails.
// No reachable tag yet → fall back to consistency-only.
// - cfg.VersionAnchor == "<path>:<regex>": designated-file mode. The
// pair is parsed like a `version_locations` entry; the captured
// value is the source of truth. Declared locations must strict-equal
// it. A missing path exits 2 (blocked).
type versionSync struct{}
func (versionSync) Name() string { return "version-sync" }
func (versionSync) Summary() string {
return "verify version strings agree across declared locations (read-only)"
}
// versionSyncTagSource is the function that resolves the tag-anchor
// expected version. Overridable in tests; defaults to
// gitx.LatestSemverTag.
var versionSyncTagSource = func(root string) (string, error) {
tag, err := gitx.LatestSemverTag(root)
if errors.Is(err, gitx.ErrUnavailable) {
// Treat missing git as "no tag available" — fall back to
// consistency-only rather than blocking on a host without git.
return "", nil
}
return tag, err
}
type vsCapture struct {
path string
line int
value string
}
func (versionSync) Run(env Env) (Result, error) {
cfg := env.Config
if len(cfg.VersionLocations) == 0 {
return Result{Code: CodeClean, Summary: "no version_locations declared"}, nil
}
// version_locations=auto switches from an explicit declared list to
// auto-detection over a fixed set of common version files. The config
// parser guarantees `auto` stands alone, so a one-element list holding
// exactly "auto" is the whole signal.
autoMode := len(cfg.VersionLocations) == 1 && cfg.VersionLocations[0] == "auto"
var captures []vsCapture
if autoMode {
detected, err := detectVersionLocations(cfg.RepoRoot)
if err != nil {
return Result{}, err
}
if len(detected) == 0 {
return Result{Code: CodeClean, Summary: "auto-detect: no version locations found"}, nil
}
captures = detected
} else {
declared, missing, err := readDeclaredLocations(cfg.RepoRoot, cfg.VersionLocations)
if err != nil {
return Result{}, err
}
if len(missing) > 0 {
sort.Strings(missing)
return Result{
Code: CodeBlocked,
Summary: fmt.Sprintf("%d declared location(s) missing on disk: %s", len(missing), strings.Join(missing, ", ")),
}, nil
}
captures = declared
}
var findings []Finding
for _, c := range captures {
if c.value == "" {
findings = append(findings, Finding{
Path: c.path,
Line: 0,
Msg: "regex matched no version string",
})
}
}
if len(findings) > 0 {
sort.Slice(findings, func(i, j int) bool { return findings[i].Path < findings[j].Path })
return Result{
Code: CodeFinding,
Summary: fmt.Sprintf("%d declared location(s) carry no version string", len(findings)),
Findings: findings,
}, nil
}
var (
res Result
err error
)
switch cfg.VersionAnchor {
case "":
res = runConsistencyOnly(captures)
case "tag":
res, err = runTagAnchor(cfg.RepoRoot, captures)
default:
res, err = runFileAnchor(cfg.RepoRoot, cfg.VersionAnchor, captures)
}
if err != nil {
return Result{}, err
}
if autoMode {
res.Summary = "auto-detect: " + res.Summary
}
return res, nil
}
// readDeclaredLocations parses every `path:regex` entry, reads the file,
// and captures the version string per entry. A missing path is reported
// via the missing slice (caller maps it to CodeBlocked); a regex
// matching nothing produces an empty-value capture (caller maps it to a
// finding). Other parse errors short-circuit as a workflow error.
func readDeclaredLocations(repoRoot string, entries []string) ([]vsCapture, []string, error) {
captures := make([]vsCapture, 0, len(entries))
var missing []string
for _, entry := range entries {
rel, pattern, ok := strings.Cut(entry, ":")
if !ok || rel == "" || pattern == "" {
return nil, nil, fmt.Errorf("version-sync: invalid version_locations entry %q (expected \"path:regex\")", entry)
}
cap, miss, err := readVersionAt(repoRoot, rel, pattern)
if err != nil {
return nil, nil, err
}
if miss {
missing = append(missing, rel)
continue
}
captures = append(captures, cap)
}
return captures, missing, nil
}
// readVersionAt reads one path:regex pair. miss=true means the path is
// absent on disk (caller decides exit code).
func readVersionAt(repoRoot, rel, pattern string) (cap vsCapture, miss bool, err error) {
re, err := regexp.Compile(pattern)
if err != nil {
return vsCapture{}, false, fmt.Errorf("version-sync: compile regex %q: %w", pattern, err)
}
if re.NumSubexp() < 1 {
return vsCapture{}, false, fmt.Errorf("version-sync: regex %q needs at least one capture group", pattern)
}
abs := filepath.Join(repoRoot, filepath.FromSlash(rel))
b, err := os.ReadFile(abs)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return vsCapture{}, true, nil
}
return vsCapture{}, false, fmt.Errorf("version-sync: read %s: %w", rel, err)
}
content := string(b)
idx := re.FindStringSubmatchIndex(content)
if idx == nil {
return vsCapture{path: rel}, false, nil
}
value := content[idx[2]:idx[3]]
line := 1 + strings.Count(content[:idx[2]], "\n")
return vsCapture{path: rel, line: line, value: value}, false, nil
}
// versionDetectTargets is the fixed, high-precision set of files
// `version_locations=auto` scans for a project version string. Each
// entry is a path:regex pair in the same shape as a declared
// version_locations entry; the regex declares one capture group holding
// a semver-shaped version. The set is deliberately small — only files
// whose version field is unambiguous — so auto-detect does not flag a
// version-shaped string that is not the project version. The slice
// order is the detection order and so the consistency-only anchor order.
var versionDetectTargets = []struct {
path string
regex string
}{
{"VERSION", `\bv?(\d+\.\d+\.\d+)\b`},
{"CHANGELOG.md", `(?m)^##\s+\[v?(\d+\.\d+\.\d+)\]`},
{"package.json", `"version"\s*:\s*"v?(\d+\.\d+\.\d+)"`},
{"pyproject.toml", `(?m)^\s*version\s*=\s*"v?(\d+\.\d+\.\d+)"`},
{"Cargo.toml", `(?m)^\s*version\s*=\s*"v?(\d+\.\d+\.\d+)"`},
}
// detectVersionLocations scans versionDetectTargets relative to repoRoot
// and returns one capture per file that exists and carries a
// version-shaped string. A target whose file is absent — or present but
// matching no version — is skipped, so auto-detect reports drift only
// across files that actually declare a version. Captures come back in
// versionDetectTargets order, so the first detected file is the
// deterministic consistency-only anchor.
func detectVersionLocations(repoRoot string) ([]vsCapture, error) {
var captures []vsCapture
for _, t := range versionDetectTargets {
cap, miss, err := readVersionAt(repoRoot, t.path, t.regex)
if err != nil {
return nil, err
}
if miss || cap.value == "" {
continue
}
captures = append(captures, cap)
}
return captures, nil
}
// runConsistencyOnly is the slice-1 behaviour: first capture is the
// anchor; the rest must match it.
func runConsistencyOnly(captures []vsCapture) Result {
anchor := captures[0]
var findings []Finding
for _, c := range captures[1:] {
if c.value != anchor.value {
findings = append(findings, Finding{
Path: c.path,
Line: c.line,
Msg: fmt.Sprintf("%s differs from %s:%d (%s)", c.value, anchor.path, anchor.line, anchor.value),
})
}
}
if len(findings) == 0 {
return Result{
Code: CodeClean,
Summary: fmt.Sprintf("%d declared location(s) agree on %s", len(captures), anchor.value),
}
}
sortFindings(findings)
return Result{
Code: CodeFinding,
Summary: fmt.Sprintf("%d version drift(s) from %s (%s)", len(findings), anchor.path, anchor.value),
Findings: findings,
}
}
// runTagAnchor compares declared locations against the latest
// semver-shaped reachable git tag. Mutual disagreement still fails;
// strictly-less-than-tag (backward-drift) fails; greater-or-equal is
// clean. When no semver-shaped tag is reachable yet, falls back to
// consistency-only with a note in the summary so the operator knows the
// tag-anchor mode is configured but not yet active.
func runTagAnchor(repoRoot string, captures []vsCapture) (Result, error) {
tag, err := versionSyncTagSource(repoRoot)
if err != nil {
return Result{}, fmt.Errorf("version-sync: resolve tag-anchor: %w", err)
}
if tag == "" {
res := runConsistencyOnly(captures)
res.Summary = "tag-anchor: no semver tag reachable yet; " + res.Summary
return res, nil
}
// Backward-drift check against the tag. Forward-drift is allowed so
// a release commit (CHANGELOG bumped to vN.M+1.0 before the tag
// vN.M+1.0 exists) passes the gate.
var findings []Finding
for _, c := range captures {
cmp, ok := compareSemverVal(c.value, tag)
if !ok {
findings = append(findings, Finding{
Path: c.path,
Line: c.line,
Msg: fmt.Sprintf("%s is not semver-shaped (tag-anchor compares against %s)", c.value, tag),
})
continue
}
if cmp < 0 {
findings = append(findings, Finding{
Path: c.path,
Line: c.line,
Msg: fmt.Sprintf("%s is behind tag-anchor %s", c.value, tag),
})
}
}
if len(findings) > 0 {
sortFindings(findings)
return Result{
Code: CodeFinding,
Summary: fmt.Sprintf("%d location(s) behind tag-anchor %s", len(findings), tag),
Findings: findings,
}, nil
}
// Then enforce mutual consistency among the declared locations: a
// release commit moves every declared location together, so any
// disagreement is still a bug class slice 1 catches.
anchor := captures[0]
for _, c := range captures[1:] {
if c.value != anchor.value {
findings = append(findings, Finding{
Path: c.path,
Line: c.line,
Msg: fmt.Sprintf("%s differs from %s:%d (%s)", c.value, anchor.path, anchor.line, anchor.value),
})
}
}
if len(findings) > 0 {
sortFindings(findings)
return Result{
Code: CodeFinding,
Summary: fmt.Sprintf("%d version drift(s) from %s (%s); tag-anchor %s", len(findings), anchor.path, anchor.value, tag),
Findings: findings,
}, nil
}
summary := fmt.Sprintf("%d declared location(s) agree on %s; tag-anchor %s", len(captures), anchor.value, tag)
if compareSemverFatal(anchor.value, tag) > 0 {
summary = fmt.Sprintf("%d declared location(s) agree on %s (ahead of tag-anchor %s)", len(captures), anchor.value, tag)
}
return Result{Code: CodeClean, Summary: summary}, nil
}
// runFileAnchor uses a `path:regex` source of truth file. Strict
// equality across every declared location. Missing source-of-truth path
// exits 2 (blocked) so the operator notices a typo rather than silently
// going to consistency-only.
func runFileAnchor(repoRoot, anchor string, captures []vsCapture) (Result, error) {
rel, pattern, ok := strings.Cut(anchor, ":")
if !ok || rel == "" || pattern == "" {
return Result{}, fmt.Errorf("version-sync: invalid version_anchor %q (expected \"tag\" or \"path:regex\")", anchor)
}
cap, miss, err := readVersionAt(repoRoot, rel, pattern)
if err != nil {
return Result{}, err
}
if miss {
return Result{
Code: CodeBlocked,
Summary: fmt.Sprintf("version_anchor file missing on disk: %s", rel),
}, nil
}
if cap.value == "" {
return Result{
Code: CodeFinding,
Summary: "version_anchor regex matched no version string",
Findings: []Finding{{Path: rel, Line: 0, Msg: "regex matched no version string"}},
}, nil
}
var findings []Finding
for _, c := range captures {
if c.value != cap.value {
findings = append(findings, Finding{
Path: c.path,
Line: c.line,
Msg: fmt.Sprintf("%s differs from version_anchor %s:%d (%s)", c.value, cap.path, cap.line, cap.value),
})
}
}
if len(findings) > 0 {
sortFindings(findings)
return Result{
Code: CodeFinding,
Summary: fmt.Sprintf("%d version drift(s) from version_anchor %s (%s)", len(findings), cap.path, cap.value),
Findings: findings,
}, nil
}
return Result{
Code: CodeClean,
Summary: fmt.Sprintf("%d declared location(s) agree with version_anchor %s on %s", len(captures), cap.path, cap.value),
}, nil
}
func sortFindings(findings []Finding) {
sort.Slice(findings, func(i, j int) bool {
if findings[i].Path != findings[j].Path {
return findings[i].Path < findings[j].Path
}
return findings[i].Line < findings[j].Line
})
}
// compareSemverVal returns a stdlib-style cmp (negative / 0 / positive)
// for two `vX.Y.Z` or `X.Y.Z` strings, with an ok flag reporting whether
// both inputs parsed as strict three-component semver. A malformed input
// makes ok=false; the caller treats that as a finding so the operator
// notices a non-semver-shaped value the tag-anchor cannot compare.
func compareSemverVal(a, b string) (int, bool) {
ap, aOk := splitSemver(a)
bp, bOk := splitSemver(b)
if !aOk || !bOk {
return 0, false
}
for i := range 3 {
if ap[i] != bp[i] {
if ap[i] < bp[i] {
return -1, true
}
return 1, true
}
}
return 0, true
}
// compareSemverFatal is the panic-free cmp used inside the
// already-validated post-comparison block; it falls back to 0 on parse
// failure (anchor already proved valid at that point).
func compareSemverFatal(a, b string) int {
cmp, ok := compareSemverVal(a, b)
if !ok {
return 0
}
return cmp
}
// splitSemver parses `vX.Y.Z` / `X.Y.Z` into three non-negative ints.
func splitSemver(v string) ([3]int, bool) {
var out [3]int
v = strings.TrimPrefix(v, "v")
parts := strings.Split(v, ".")
if len(parts) != 3 {
return out, false
}
for i, p := range parts {
if p == "" {
return out, false
}
n, err := strconv.Atoi(p)
if err != nil || n < 0 {
return out, false
}
out[i] = n
}
return out, true
}
added internal/workflow/versionsync_test.go
@@ -0,0 +1,508 @@
package workflow
import (
"strings"
"testing"
)
// stubTagSource replaces the versionSyncTagSource resolver for the
// duration of a test. The returned cleanup restores the previous
// resolver; defer the call. An empty value simulates "no semver tag
// reachable" (a fresh repo or one carrying only foreign tags).
func stubTagSource(tag string) func() {
prev := versionSyncTagSource
versionSyncTagSource = func(string) (string, error) { return tag, nil }
return func() { versionSyncTagSource = prev }
}
func TestVersionSync_NoLocationsDeclaredIsClean(t *testing.T) {
cfg := newCfg(t)
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("no version_locations -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
}
func TestVersionSync_SingleLocationAgreesWithItself(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.11.0] - 2026-05-22\n")
cfg.VersionLocations = []string{`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`}
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("single location -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
}
func TestVersionSync_MultipleLocationsAgreeIsClean(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.11.0] - 2026-05-22\n")
writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.11.0\n")
writeRepoFile(t, cfg.RepoRoot, "README.md", "badge: v1.11.0 here\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`VERSION:^v(\d+\.\d+\.\d+)`,
`README.md:badge: v(\d+\.\d+\.\d+)`,
}
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("agreeing locations -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if !strings.Contains(res.Summary, "1.11.0") {
t.Errorf("summary missing anchor version: %s", res.Summary)
}
}
func TestVersionSync_DriftReportsFinding(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.11.0] - 2026-05-22\n")
writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.10.0\n")
writeRepoFile(t, cfg.RepoRoot, "README.md", "badge: v1.11.0 here\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`VERSION:^v(\d+\.\d+\.\d+)`,
`README.md:badge: v(\d+\.\d+\.\d+)`,
}
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("drift -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if len(res.Findings) != 1 {
t.Fatalf("findings = %d, want 1: %+v", len(res.Findings), res.Findings)
}
if res.Findings[0].Path != "VERSION" {
t.Errorf("finding path = %q, want VERSION", res.Findings[0].Path)
}
}
func TestVersionSync_MultipleDriftsReported(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.11.0]\n")
writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.10.0\n")
writeRepoFile(t, cfg.RepoRoot, "README.md", "badge: v1.9.5 here\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`VERSION:^v(\d+\.\d+\.\d+)`,
`README.md:badge: v(\d+\.\d+\.\d+)`,
}
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("multi-drift -> %d (%s)", res.Code, res.Summary)
}
if len(res.Findings) != 2 {
t.Fatalf("findings = %d, want 2: %+v", len(res.Findings), res.Findings)
}
if res.Findings[0].Path != "README.md" || res.Findings[1].Path != "VERSION" {
t.Errorf("findings unsorted or wrong paths: %+v", res.Findings)
}
}
func TestVersionSync_MissingLocationBlocks(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.11.0]\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`MISSING.md:v(\d+\.\d+\.\d+)`,
}
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeBlocked {
t.Fatalf("missing location -> %d (%s)", res.Code, res.Summary)
}
if !strings.Contains(res.Summary, "MISSING.md") {
t.Errorf("summary missing the absent path: %s", res.Summary)
}
}
func TestVersionSync_RegexMatchesNothingReportsFinding(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.11.0]\n")
writeRepoFile(t, cfg.RepoRoot, "VERSION", "no version here\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`VERSION:^v(\d+\.\d+\.\d+)`,
}
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("no match -> %d (%s)", res.Code, res.Summary)
}
if len(res.Findings) != 1 || res.Findings[0].Path != "VERSION" {
t.Errorf("unexpected findings: %+v", res.Findings)
}
}
func TestVersionSync_InvalidEntryErrors(t *testing.T) {
cfg := newCfg(t)
cfg.VersionLocations = []string{"no-colon-here"}
_, err := versionSync{}.Run(Env{Config: cfg})
if err == nil {
t.Fatal("expected error for malformed entry")
}
}
func TestVersionSync_RegexWithoutCaptureGroupErrors(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "v1.11.0\n")
cfg.VersionLocations = []string{`CHANGELOG.md:v\d+\.\d+\.\d+`}
_, err := versionSync{}.Run(Env{Config: cfg})
if err == nil {
t.Fatal("expected error for regex without capture group")
}
}
func TestVersionSync_BadRegexErrors(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "v1.11.0\n")
cfg.VersionLocations = []string{"CHANGELOG.md:[unclosed"}
_, err := versionSync{}.Run(Env{Config: cfg})
if err == nil {
t.Fatal("expected error for malformed regex")
}
}
func TestVersionSync_TagAnchorCleanWhenLocationsAgreeWithTag(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.12.0]\n")
writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.12.0\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`VERSION:^v(\d+\.\d+\.\d+)`,
}
cfg.VersionAnchor = "tag"
defer stubTagSource("v1.12.0")()
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("tag-anchor clean -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if !strings.Contains(res.Summary, "tag-anchor v1.12.0") {
t.Errorf("summary missing tag-anchor mention: %s", res.Summary)
}
}
func TestVersionSync_TagAnchorAllowsForwardDrift(t *testing.T) {
// Release-commit case: CHANGELOG bumped to v1.13.0 ahead of the
// not-yet-pushed tag. The gate must NOT block this.
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.13.0]\n")
writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.13.0\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`VERSION:^v(\d+\.\d+\.\d+)`,
}
cfg.VersionAnchor = "tag"
defer stubTagSource("v1.12.0")()
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("tag-anchor forward-drift -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if !strings.Contains(res.Summary, "ahead of tag-anchor v1.12.0") {
t.Errorf("summary missing forward-drift note: %s", res.Summary)
}
}
func TestVersionSync_TagAnchorBackwardDriftFails(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.11.0]\n")
writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.11.0\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`VERSION:^v(\d+\.\d+\.\d+)`,
}
cfg.VersionAnchor = "tag"
defer stubTagSource("v1.12.0")()
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("backward-drift -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if len(res.Findings) != 2 {
t.Fatalf("findings = %d, want 2: %+v", len(res.Findings), res.Findings)
}
if !strings.Contains(res.Findings[0].Msg, "behind tag-anchor v1.12.0") {
t.Errorf("finding msg = %q, want backward-drift mention", res.Findings[0].Msg)
}
}
func TestVersionSync_TagAnchorMutualDisagreementCaught(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.13.0]\n")
writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.13.1\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`VERSION:^v(\d+\.\d+\.\d+)`,
}
cfg.VersionAnchor = "tag"
defer stubTagSource("v1.12.0")()
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("mutual disagreement under tag-anchor -> %d (%s)", res.Code, res.Summary)
}
// Both ahead of the tag (≥), so the tag pre-check passes; the
// consistency check then catches the mutual disagreement.
if !strings.Contains(res.Summary, "tag-anchor v1.12.0") {
t.Errorf("summary missing tag-anchor mention: %s", res.Summary)
}
}
func TestVersionSync_TagAnchorNoTagsFallsBackToConsistency(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.12.0]\n")
writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.12.0\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`VERSION:^v(\d+\.\d+\.\d+)`,
}
cfg.VersionAnchor = "tag"
defer stubTagSource("")()
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("tag-anchor no-tags -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if !strings.Contains(res.Summary, "no semver tag reachable yet") {
t.Errorf("summary missing fallback note: %s", res.Summary)
}
}
func TestVersionSync_TagAnchorNonSemverFails(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.12.0]\n")
writeRepoFile(t, cfg.RepoRoot, "PROJECT", "v2024-05-22\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`PROJECT:^v(.+)`,
}
cfg.VersionAnchor = "tag"
defer stubTagSource("v1.12.0")()
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("non-semver under tag-anchor -> %d (%s)", res.Code, res.Summary)
}
if len(res.Findings) != 1 || res.Findings[0].Path != "PROJECT" {
t.Errorf("findings unexpected: %+v", res.Findings)
}
if !strings.Contains(res.Findings[0].Msg, "not semver-shaped") {
t.Errorf("finding msg = %q, want non-semver mention", res.Findings[0].Msg)
}
}
func TestVersionSync_FileAnchorClean(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.13.0\n")
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.13.0]\n")
writeRepoFile(t, cfg.RepoRoot, "README.md", "badge: v1.13.0 here\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`README.md:badge: v(\d+\.\d+\.\d+)`,
}
cfg.VersionAnchor = `VERSION:^v(\d+\.\d+\.\d+)`
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("file-anchor clean -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if !strings.Contains(res.Summary, "version_anchor VERSION") {
t.Errorf("summary missing version_anchor mention: %s", res.Summary)
}
}
func TestVersionSync_FileAnchorDriftFails(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "VERSION", "v1.13.0\n")
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.12.0]\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
}
cfg.VersionAnchor = `VERSION:^v(\d+\.\d+\.\d+)`
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("file-anchor drift -> %d (%s)", res.Code, res.Summary)
}
if len(res.Findings) != 1 || res.Findings[0].Path != "CHANGELOG.md" {
t.Errorf("findings = %+v", res.Findings)
}
}
func TestVersionSync_FileAnchorMissingPathBlocks(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.12.0]\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
}
cfg.VersionAnchor = `MISSING:^v(\d+\.\d+\.\d+)`
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeBlocked {
t.Fatalf("file-anchor missing -> %d (%s)", res.Code, res.Summary)
}
if !strings.Contains(res.Summary, "MISSING") {
t.Errorf("summary missing the absent path: %s", res.Summary)
}
}
func TestVersionSync_FindingLineNumberMatchesContent(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.11.0]\n")
// Drift sits on line 3; the finding must point there. `(?m)` flips
// `^` from start-of-string to start-of-line — the documented spelling
// for matching a version string further down a file.
writeRepoFile(t, cfg.RepoRoot, "VERSION", "header\n\nv1.10.0\n")
cfg.VersionLocations = []string{
`CHANGELOG.md:^## \[v(\d+\.\d+\.\d+)\]`,
`VERSION:(?m)^v(\d+\.\d+\.\d+)`,
}
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding || len(res.Findings) != 1 {
t.Fatalf("unexpected result: %+v", res)
}
if res.Findings[0].Line != 3 {
t.Errorf("finding line = %d, want 3", res.Findings[0].Line)
}
}
func TestVersionSync_AutoDetectNoVersionFilesIsClean(t *testing.T) {
cfg := newCfg(t)
cfg.VersionLocations = []string{"auto"}
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("auto, no files -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if !strings.Contains(res.Summary, "no version locations found") {
t.Errorf("summary missing the no-locations note: %s", res.Summary)
}
}
func TestVersionSync_AutoDetectSingleFileIsClean(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.14.0] - 2026-05-22\n")
cfg.VersionLocations = []string{"auto"}
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("auto, single file -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if !strings.HasPrefix(res.Summary, "auto-detect: ") {
t.Errorf("summary missing the auto-detect prefix: %s", res.Summary)
}
}
func TestVersionSync_AutoDetectAgreeingFilesIsClean(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "VERSION", "1.14.0\n")
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.14.0]\n")
writeRepoFile(t, cfg.RepoRoot, "package.json", "{\n \"name\": \"demo\",\n \"version\": \"1.14.0\"\n}\n")
cfg.VersionLocations = []string{"auto"}
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("auto, agreeing files -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if !strings.Contains(res.Summary, "1.14.0") {
t.Errorf("summary missing the detected version: %s", res.Summary)
}
}
func TestVersionSync_AutoDetectDriftReportsFinding(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.14.0]\n")
writeRepoFile(t, cfg.RepoRoot, "package.json", "{\n \"version\": \"1.13.0\"\n}\n")
cfg.VersionLocations = []string{"auto"}
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeFinding {
t.Fatalf("auto, drift -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if len(res.Findings) != 1 || res.Findings[0].Path != "package.json" {
t.Fatalf("findings = %+v, want one on package.json", res.Findings)
}
}
func TestVersionSync_AutoDetectSkipsFileWithoutVersion(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.14.0]\n")
// A package.json with no `version` field carries no version-shaped
// string — auto-detect must skip it, not flag it as a drift.
writeRepoFile(t, cfg.RepoRoot, "package.json", "{\n \"name\": \"demo\"\n}\n")
cfg.VersionLocations = []string{"auto"}
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("auto, version-less file -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
}
func TestVersionSync_AutoDetectComposesWithTagAnchor(t *testing.T) {
cfg := newCfg(t)
writeRepoFile(t, cfg.RepoRoot, "VERSION", "1.13.0\n")
writeRepoFile(t, cfg.RepoRoot, "CHANGELOG.md", "## [v1.13.0]\n")
cfg.VersionLocations = []string{"auto"}
cfg.VersionAnchor = "tag"
defer stubTagSource("v1.12.0")()
res, err := versionSync{}.Run(Env{Config: cfg})
if err != nil {
t.Fatal(err)
}
if res.Code != CodeClean {
t.Fatalf("auto + tag-anchor -> %d (%s) %+v", res.Code, res.Summary, res.Findings)
}
if !strings.HasPrefix(res.Summary, "auto-detect: ") || !strings.Contains(res.Summary, "tag-anchor v1.12.0") {
t.Errorf("summary missing auto-detect prefix or tag-anchor note: %s", res.Summary)
}
}
added internal/workflow/workflow.go
@@ -0,0 +1,91 @@
// Package workflow is eeco's workflow registry, runner, and scaffolder.
//
// A workflow inspects a repository and either passes cleanly, reports a
// finding, blocks (a required tool is missing), or defers an AI pass.
// The exit-code contract is fixed and shared with the CLI:
//
// 0 clean
// 1 finding / failure
// 2 blocked (a required tool is missing)
// 3 AI pass deferred (no --ai)
//
// Builtin workflows are implemented natively in Go and registered in the
// default registry. User workflows are scaffolded into the gitignored
// workspace as a directory with a runnable entry plus a one-line README
// and executed by the script runner, which honours the same contract.
//
// Every workflow is read-only with respect to the tracked tree: it
// writes only inside the workspace and uses the queue for any decision.
package workflow
import (
"io"
"github.com/ajhahnde/eeco/internal/ai"
"github.com/ajhahnde/eeco/internal/config"
)
// Exit codes shared by every workflow and surfaced as the process exit
// code by `eeco run`.
const (
CodeClean = 0
CodeFinding = 1
CodeBlocked = 2
CodeAIDeferred = 3
)
// Finding is one located issue. Line is 1-based; 0 means the finding is
// file-scoped rather than tied to a specific line.
type Finding struct {
Path string
Line int
Msg string
}
// Result is the outcome of a single workflow run. Code is one of the
// Code* constants. Summary is a one-line headline for the report;
// Findings carries the detail lines.
type Result struct {
Code int
Summary string
Findings []Finding
}
// Env is the execution context handed to a workflow. The repository
// root is cfg.RepoRoot; a workflow treats it as its working directory
// and must never write outside cfg.Workspace.
type Env struct {
Config *config.Config
// AI reports whether the operator opted this run into a gated,
// budget-capped AI pass (`--ai`). It mirrors Gate.Consent and is kept
// for read-only builtins that only need the boolean.
AI bool
// Gate is the shared, single-invocation AI gate (consent + budget +
// prompt-parking). A workflow that wants an AI pass calls Gate.Run
// and falls back to its non-AI path when the Outcome is Skipped. Nil
// is tolerated: a workflow then behaves as if no pass was consented.
Gate *ai.Gate
// Out is an optional sink for progress lines. Nil is fine.
Out io.Writer
}
// Workflow is a named, runnable check. Run must be side-effect-free on
// the tracked tree and must return a Code from the contract.
type Workflow interface {
Name() string
// Summary is the one-line description shown in listings.
Summary() string
Run(env Env) (Result, error)
}
// normalizeCode clamps an arbitrary integer to the contract. Anything
// outside {0,1,2,3} is treated as a failure (1) so a misbehaving
// workflow can never masquerade as clean.
func normalizeCode(c int) int {
switch c {
case CodeClean, CodeFinding, CodeBlocked, CodeAIDeferred:
return c
default:
return CodeFinding
}
}
added scripts/build.sh
@@ -0,0 +1,81 @@
#!/bin/sh
# Cross-builds eeco for the six release targets.
# Writes archives + SHA256SUMS into dist/.
#
# Inputs (env, with defaults):
# VERSION git describe --tags --dirty --always, or "dev"
# COMMIT git short SHA, or "unknown"
# BUILD_DATE ISO-8601 UTC, or current date
#
# Single static binary: CGO_ENABLED=0 across the matrix.
set -eu
VERSION="${VERSION:-$(git describe --tags --dirty --always 2>/dev/null || echo dev)}"
COMMIT="${COMMIT:-$(git rev-parse --short HEAD 2>/dev/null || echo unknown)}"
BUILD_DATE="${BUILD_DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}"
LDFLAGS="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.buildDate=${BUILD_DATE}"
# Resolve repo root from this script's location so the build works from any cwd.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${ROOT}"
DIST="${ROOT}/dist"
STAGE="${DIST}/_stage"
rm -rf "${STAGE}"
mkdir -p "${DIST}" "${STAGE}"
build_one() {
GOOS="$1"
GOARCH="$2"
EXT=""
ARCHIVE_EXT="tar.gz"
if [ "${GOOS}" = "windows" ]; then
EXT=".exe"
ARCHIVE_EXT="zip"
fi
OUT_DIR="${STAGE}/${GOOS}_${GOARCH}"
mkdir -p "${OUT_DIR}"
printf 'build %s/%s\n' "${GOOS}" "${GOARCH}"
CGO_ENABLED=0 GOOS="${GOOS}" GOARCH="${GOARCH}" \
go build -trimpath -ldflags "${LDFLAGS}" \
-o "${OUT_DIR}/eeco${EXT}" ./cmd/eeco
cp README.md LICENSE "${OUT_DIR}/"
# Bundle the Unix man page into non-windows archives so brew's
# `man1.install "eeco.1"` (scripts/gen-packaging.sh) can pick it up
# from the extracted tarball. Generated upstream by gen-manpage.sh;
# `make release` depends on `manpage` so the file always exists.
if [ "${GOOS}" != "windows" ] && [ -f "${DIST}/eeco.1" ]; then
cp "${DIST}/eeco.1" "${OUT_DIR}/"
fi
ARCHIVE="${DIST}/eeco_${VERSION}_${GOOS}_${GOARCH}.${ARCHIVE_EXT}"
rm -f "${ARCHIVE}"
if [ "${ARCHIVE_EXT}" = "zip" ]; then
( cd "${STAGE}" && zip -q -r "${ARCHIVE}" "${GOOS}_${GOARCH}" )
else
( cd "${STAGE}" && tar -czf "${ARCHIVE}" "${GOOS}_${GOARCH}" )
fi
}
build_one darwin amd64
build_one darwin arm64
build_one linux amd64
build_one linux arm64
build_one windows amd64
build_one windows arm64
rm -rf "${STAGE}"
# Write SHA256SUMS over every archive in dist/ plus the standalone
# eeco.1 man page (shipped as its own release asset so direct
# downloaders can verify it).
( cd "${DIST}" && shasum -a 256 eeco_${VERSION}_*.tar.gz eeco_${VERSION}_*.zip eeco.1 > SHA256SUMS )
printf '\nartifacts:\n'
ls -1 "${DIST}"
added scripts/check-coverage.sh
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Enforce the coverage ratchet: total statement coverage must not regress
# below a floor.
#
# This is a regression GUARD, not a vanity target -- deleting a covered test
# or adding uncovered code below the floor fails CI. The floor sits just below
# the measured current total (with a small noise margin) and is raised as the
# foundation test net grows; never chase the number for its own sake.
#
# Reads coverage.out (produced by `make cover`) and compares its total against
# FLOOR below. awk does the float compare so there is no `bc` dependency.
#
# Run locally: make cover-check (or: make cover && bash scripts/check-coverage.sh)
# CI: the `coverage` step in .github/workflows/ci.yml runs `make cover-check`.
set -euo pipefail
# Ratchet floor, not a target. Raised 80 -> 83 at H1 close (measured total
# 84.8%); raise again as the net grows -- keep it below total with a margin.
FLOOR=83
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PROFILE="$ROOT/coverage.out"
if [ ! -f "$PROFILE" ]; then
echo "check-coverage: $PROFILE not found -- run \`make cover\` first" >&2
exit 1
fi
total="$(go tool cover -func="$PROFILE" | awk '/^total:/ {print $NF}' | tr -d '%')"
if [ -z "$total" ]; then
echo "check-coverage: could not parse total coverage from $PROFILE" >&2
exit 1
fi
if ! awk -v t="$total" -v f="$FLOOR" 'BEGIN { exit !(t+0 >= f+0) }'; then
echo "check-coverage: total ${total}% is below floor ${FLOOR}% -- add tests or justify" >&2
exit 1
fi
echo "check-coverage: total ${total}% >= floor ${FLOOR}% -- ratchet holds"
added scripts/check-version-badge.sh
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Verify the README version badge stays in lockstep with the latest release tag.
#
# The badge is a static shields badge on purpose: the dynamic github/v/release
# badge fails intermittently with shields' "Unable to select next GitHub token
# from pool" error (its shared GitHub-API token pool runs dry). A static badge
# never calls the API, so it can never show that error -- but it must be bumped
# at release time. This gate fails if it ever drifts behind the latest tag.
#
# Semantics mirror eeco's own version_anchor=tag: badge >= latest tag is OK (a
# release commit may bump the badge ahead of the not-yet-pushed tag); a badge
# behind the latest tag fails.
#
# Run locally: bash scripts/check-version-badge.sh
# CI: a step in the verify job (.github/workflows/ci.yml), after make gates.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
README="$ROOT/README.md"
badge="$(grep -oE 'badge/version-v[0-9]+\.[0-9]+\.[0-9]+-' "$README" | head -1 | sed -E 's#badge/version-v([0-9.]+)-#\1#')"
if [ -z "$badge" ]; then
echo "check-version-badge: no static version badge found in README.md" >&2
exit 1
fi
tag="$(git -C "$ROOT" describe --tags --abbrev=0 2>/dev/null | sed 's/^v//')"
if [ -z "$tag" ]; then
echo "check-version-badge: no git tag found; skipping lockstep check" >&2
exit 0
fi
newest="$(printf '%s\n%s\n' "$tag" "$badge" | sort -V | tail -1)"
if [ "$newest" != "$badge" ]; then
echo "check-version-badge: README badge v$badge is behind latest tag v$tag -- bump the badge" >&2
exit 1
fi
echo "check-version-badge: README badge v$badge >= latest tag v$tag -- in lockstep"
added scripts/gen-manpage.sh
@@ -0,0 +1,71 @@
#!/bin/sh
# Generates the eeco.1 Unix man page from docs/USAGE.md.
#
# Strips the cross-repo-fingerprint HTML logo header and the Prev/Next
# nav footer (both render badly in roff), then pipes the cleaned
# Markdown through go-md2man. The two strip patterns mirror the Go
# implementation at internal/guide/render.go (stripTopHTMLBlock +
# stripTrailingNavFooter) but stay independent so the slice does not
# add a frozen-surface dependency.
#
# Run from any cwd; resolves the repo root from this script's location.
# Output: dist/eeco.1. Pinned go-md2man version keeps CI byte-stable.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
DIST="${ROOT}/dist"
SRC="${ROOT}/docs/USAGE.md"
if [ ! -f "${SRC}" ]; then
echo "error: ${SRC} not found" >&2
exit 1
fi
mkdir -p "${DIST}"
# Title metadata for the `.TH` roff header. Without these inputs
# go-md2man emits an empty header so `man eeco` prints `()` for
# name(section). Mirrors gen-packaging.sh's VERSION resolution.
VERSION="${VERSION:-$(git -C "${ROOT}" describe --tags --dirty --always 2>/dev/null || echo dev)}"
MAN_DATE="${MAN_DATE:-$(date -u +%Y-%m-%d)}"
TMP_MD="$(mktemp "${DIST}/eeco.usage.XXXXXX.md")"
trap 'rm -f "${TMP_MD}"' EXIT
# Strip:
# 1. Top HTML block: from `<div align="center">` through the first
# standalone `---` that follows it.
# 2. Trailing nav footer: a `---` followed (after optional blanks) by
# a line matching `[← Prev: …]` or `[Next: …]`, sitting at EOF.
awk -v ver="${VERSION}" -v mdate="${MAN_DATE}" '
BEGIN {
# Prepend a proper H1 so go-md2man emits a .TH header with
# name, section, date, source, and manual fields.
printf "# eeco 1 \"%s\" \"%s\" \"User Commands\"\n\n", mdate, ver
}
/^<div align="center">$/ { in_top = 1; next }
in_top && /^---$/ { in_top = 0; next }
in_top { next }
{ buf[++n] = $0 }
END {
last = n
while (last > 0 && buf[last] ~ /^[[:space:]]*$/) last--
if (last > 0 && (buf[last] ~ /Prev:/ || buf[last] ~ /Next:/)) {
cut = last - 1
while (cut > 0 && buf[cut] ~ /^[[:space:]]*$/) cut--
if (cut > 0 && buf[cut] == "---") cut--
last = cut
}
for (i = 1; i <= last; i++) print buf[i]
}
' "${SRC}" > "${TMP_MD}"
# Pin go-md2man so CI is reproducible (golangci-lint precedent at
# Makefile:60-61). Run via `go run` so no separate install step is
# needed — actions/setup-go already provides the toolchain.
MD2MAN_VERSION="${MD2MAN_VERSION:-v2.0.5}"
go run "github.com/cpuguy83/go-md2man/v2@${MD2MAN_VERSION}" \
-in "${TMP_MD}" -out "${DIST}/eeco.1"
printf 'wrote:\n %s\n' "${DIST}/eeco.1"
added scripts/gen-packaging.sh
@@ -0,0 +1,137 @@
#!/bin/sh
# Generates the package-manager manifests for a release from the
# already-built archives:
# dist/eeco.rb formula for a personal tap
# dist/eeco.json Windows package manifest
#
# Deterministic and offline: reads dist/SHA256SUMS (written by
# scripts/build.sh) and substitutes the version + per-archive hashes.
# Run after `make release`. Signing/attestation stay in CI only; this
# script performs no network and no git writes.
#
# Inputs (env, with defaults):
# VERSION git describe --tags --dirty --always, or "dev"
set -eu
VERSION="${VERSION:-$(git describe --tags --dirty --always 2>/dev/null || echo dev)}"
# Tag carries a leading "v"; the manifest version field does not.
TAG="${VERSION}"
VER="${VERSION#v}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
DIST="${ROOT}/dist"
SUMS="${DIST}/SHA256SUMS"
if [ ! -f "${SUMS}" ]; then
echo "error: ${SUMS} not found; run 'make release' first" >&2
exit 1
fi
DESC="Self-maintaining workflow ecosystem for a coding project"
HOMEPAGE="https://github.com/ajhahnde/eeco"
BASE="https://github.com/ajhahnde/eeco/releases/download/${TAG}"
# sha256 for one archive basename, read from SHA256SUMS ($NF == name
# tolerates the two-space shasum separator).
sum_for() {
awk -v f="$1" '$NF == f { print $1 }' "${SUMS}"
}
archive() { echo "eeco_${TAG}_$1.tar.gz"; }
zipname() { echo "eeco_${TAG}_$1.zip"; }
DARWIN_AMD64="$(sum_for "$(archive darwin_amd64)")"
DARWIN_ARM64="$(sum_for "$(archive darwin_arm64)")"
LINUX_AMD64="$(sum_for "$(archive linux_amd64)")"
LINUX_ARM64="$(sum_for "$(archive linux_arm64)")"
WIN_AMD64="$(sum_for "$(zipname windows_amd64)")"
WIN_ARM64="$(sum_for "$(zipname windows_arm64)")"
for pair in \
"darwin_amd64:${DARWIN_AMD64}" "darwin_arm64:${DARWIN_ARM64}" \
"linux_amd64:${LINUX_AMD64}" "linux_arm64:${LINUX_ARM64}" \
"windows_amd64:${WIN_AMD64}" "windows_arm64:${WIN_ARM64}"; do
if [ -z "${pair#*:}" ]; then
echo "error: no SHA256 for ${pair%%:*} in ${SUMS}" >&2
exit 1
fi
done
# Formula for a personal tap. Archives stage the binary under a
# <goos>_<goarch>/ directory (see scripts/build.sh), but Homebrew strips
# a single top-level wrapper directory on extract, so by install time the
# binary sits at the staging root — install the bare basename.
cat > "${DIST}/eeco.rb" <<EOF
class Eeco < Formula
desc "${DESC}"
homepage "${HOMEPAGE}"
version "${VER}"
license "Apache-2.0"
on_macos do
if Hardware::CPU.arm?
url "${BASE}/$(archive darwin_arm64)"
sha256 "${DARWIN_ARM64}"
else
url "${BASE}/$(archive darwin_amd64)"
sha256 "${DARWIN_AMD64}"
end
end
on_linux do
if Hardware::CPU.arm?
url "${BASE}/$(archive linux_arm64)"
sha256 "${LINUX_ARM64}"
else
url "${BASE}/$(archive linux_amd64)"
sha256 "${LINUX_AMD64}"
end
end
def install
bin.install "eeco"
man1.install "eeco.1"
end
test do
assert_match version.to_s, shell_output("#{bin}/eeco version")
end
end
EOF
# Windows package manifest. "bin" points at the nested staged path.
cat > "${DIST}/eeco.json" <<EOF
{
"version": "${VER}",
"description": "${DESC}",
"homepage": "${HOMEPAGE}",
"license": "Apache-2.0",
"architecture": {
"64bit": {
"url": "${BASE}/$(zipname windows_amd64)",
"hash": "${WIN_AMD64}",
"bin": "windows_amd64\\\\eeco.exe"
},
"arm64": {
"url": "${BASE}/$(zipname windows_arm64)",
"hash": "${WIN_ARM64}",
"bin": "windows_arm64\\\\eeco.exe"
}
},
"checkver": "github",
"autoupdate": {
"architecture": {
"64bit": {
"url": "${HOMEPAGE}/releases/download/v\$version/eeco_v\$version_windows_amd64.zip"
},
"arm64": {
"url": "${HOMEPAGE}/releases/download/v\$version/eeco_v\$version_windows_arm64.zip"
}
}
}
}
EOF
printf 'wrote:\n %s\n %s\n' "${DIST}/eeco.rb" "${DIST}/eeco.json"
added scripts/regen-demo.sh
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Regenerate assets/demo.gif from assets/demo.tape.
# Requires vhs (https://github.com/charmbracelet/vhs) installed locally.
#
# brew install vhs
# scripts/regen-demo.sh
#
# The script builds the local binary first and puts it on PATH so the
# tape's `eeco` invocations resolve to this checkout.
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
if ! command -v vhs >/dev/null 2>&1; then
echo "vhs not on PATH. Install with: brew install vhs" >&2
exit 1
fi
make build
# Render the canonical (dark) tape, then derive + render the light variant
# by swapping only the theme and output. Both feed the README's
# prefers-color-scheme <picture> block (dark = OneDark, light = AtomOneLight).
PATH="$ROOT:$PATH" vhs assets/demo.tape
light_tape="$(mktemp)"
sed -e 's/^Set Theme .*/Set Theme "AtomOneLight"/' \
-e 's#^Output .*#Output assets/demo-light.gif#' \
assets/demo.tape > "$light_tape"
PATH="$ROOT:$PATH" vhs "$light_tape"
rm -f "$light_tape"
ls -lh assets/demo-dark.gif assets/demo-light.gif
added scripts/release.sh
@@ -0,0 +1,36 @@
#!/bin/sh
# Convenience wrapper: cross-build the matrix, print checksums, and
# remind the operator of the remaining manual steps. Performs no git
# writes and never uploads anything.
#
# Usage:
# scripts/release.sh # version derived from `git describe`
# scripts/release.sh v0.5.0 # pin VERSION explicitly
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
if [ "$#" -ge 1 ]; then
VERSION="$1"
export VERSION
fi
"${SCRIPT_DIR}/build.sh"
"${SCRIPT_DIR}/gen-packaging.sh"
echo
echo "SHA256SUMS:"
cat "${ROOT}/dist/SHA256SUMS"
echo
echo "next manual steps:"
echo " 1. git add (excluding PLAN.md, dist/)"
echo " 2. git commit -m \"feat: release engineering\""
echo " 3. git tag -a <operator-named-tag> -m \"<tag-name>\""
echo " 4. git push origin main --tags"
echo
echo "On the tag push, CI cross-builds, generates eeco.rb / eeco.json,"
echo "keyless-signs SHA256SUMS, attests build provenance, and uploads"
echo "every asset to the GitHub Release. The dist/ output here is the"
echo "local-repro and inspection copy; no manual upload is needed."
added scripts/render_logo.swift
@@ -0,0 +1,141 @@
#!/usr/bin/env swift
// Render the eeco wordmark to a transparent PNG using the Orbitron font.
//
// Variants:
// 1: Regular "eeco" (whole word, color1)
// 2: Bold "eeco" (whole word, color1)
// 3: SemiBold "eeco" (whole word, color1)
// 4: split — "e" SemiBold color1 + "eco" Regular color2 (the mark)
//
// Requires Orbitron-Regular.ttf, Orbitron-Bold.ttf, Orbitron-SemiBold.ttf
// in /Library/Fonts or ~/Library/Fonts.
//
// Reproduce the committed assets (neutral "e" + magenta "eco" accent):
// swift scripts/render_logo.swift 4 assets/eeco_logo_light.png \
// --color1 282c34 --color2 c678dd
// swift scripts/render_logo.swift 4 assets/eeco_logo_dark.png \
// --color1 abb2bf --color2 c678dd
//
// For variant 4: --color1 styles "e", --color2 styles "eco".
// For variants 1-3: --color1 styles the whole word; --color2 ignored.
// Default colors: black (000000).
import AppKit
import Foundation
func parseHexColor(_ hex: String) -> NSColor? {
var h = hex
if h.hasPrefix("#") { h.removeFirst() }
guard h.count == 6, let v = UInt32(h, radix: 16) else { return nil }
let r = CGFloat((v >> 16) & 0xff) / 255.0
let g = CGFloat((v >> 8) & 0xff) / 255.0
let b = CGFloat( v & 0xff) / 255.0
return NSColor(srgbRed: r, green: g, blue: b, alpha: 1.0)
}
let args = CommandLine.arguments
guard args.count >= 3, let variant = Int(args[1]) else {
FileHandle.standardError.write(
("Usage: \(args[0]) <variant 1-4> <output.png> " +
"[--font-size N] [--color1 RRGGBB] [--color2 RRGGBB]\n")
.data(using: .utf8)!)
exit(2)
}
let outputPath = args[2]
var fontSize: CGFloat = 400
var color1: NSColor = .black
var color2: NSColor = .black
var i = 3
while i < args.count {
switch args[i] {
case "--font-size":
if i + 1 < args.count, let s = Double(args[i + 1]) {
fontSize = CGFloat(s)
i += 2
} else { exit(2) }
case "--color1":
if i + 1 < args.count, let c = parseHexColor(args[i + 1]) {
color1 = c
i += 2
} else { exit(2) }
case "--color2":
if i + 1 < args.count, let c = parseHexColor(args[i + 1]) {
color2 = c
i += 2
} else { exit(2) }
default:
FileHandle.standardError.write("unknown arg: \(args[i])\n".data(using: .utf8)!)
exit(2)
}
}
func font(_ name: String, size: CGFloat) -> NSFont {
guard let f = NSFont(name: name, size: size) else {
FileHandle.standardError.write("Font \(name) not found\n".data(using: .utf8)!)
exit(3)
}
return f
}
let regular = font("Orbitron-Regular", size: fontSize)
let bold = font("Orbitron-Bold", size: fontSize)
let semibold = font("Orbitron-SemiBold", size: fontSize)
let attrString: NSAttributedString
switch variant {
case 1:
attrString = NSAttributedString(
string: "eeco",
attributes: [.font: regular, .foregroundColor: color1])
case 2:
attrString = NSAttributedString(
string: "eeco",
attributes: [.font: bold, .foregroundColor: color1])
case 3:
attrString = NSAttributedString(
string: "eeco",
attributes: [.font: semibold, .foregroundColor: color1])
case 4:
let s = NSMutableAttributedString()
s.append(NSAttributedString(
string: "e",
attributes: [.font: bold, .foregroundColor: color1]))
s.append(NSAttributedString(
string: "eco",
attributes: [.font: regular, .foregroundColor: color2]))
attrString = s
default:
FileHandle.standardError.write("Variant must be 1-4\n".data(using: .utf8)!)
exit(2)
}
let padding: CGFloat = fontSize * 0.15
let textSize = attrString.size()
let imageW = ceil(textSize.width + 2 * padding)
let imageH = ceil(textSize.height + 2 * padding)
let image = NSImage(size: NSSize(width: imageW, height: imageH))
image.lockFocus()
NSColor.clear.setFill()
NSRect(x: 0, y: 0, width: imageW, height: imageH).fill()
let drawY = (imageH - textSize.height) / 2
attrString.draw(at: NSPoint(x: padding, y: drawY))
image.unlockFocus()
guard let tiff = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiff),
let png = bitmap.representation(using: .png, properties: [:])
else {
FileHandle.standardError.write("Failed to encode PNG\n".data(using: .utf8)!)
exit(4)
}
do {
try png.write(to: URL(fileURLWithPath: outputPath))
print("wrote \(outputPath) (\(Int(imageW))×\(Int(imageH)) px, variant \(variant))")
} catch {
FileHandle.standardError.write("write failed: \(error)\n".data(using: .utf8)!)
exit(5)
}
added scripts/sync-taps.sh
@@ -0,0 +1,88 @@
#!/bin/sh
# Pushes the generated package-manager manifests into their tap repos so
# `brew install ajhahnde/eeco/eeco` and `scoop install eeco` resolve to
# the current release:
# dist/eeco.rb -> ajhahnde/homebrew-eeco : Formula/eeco.rb
# dist/eeco.json -> ajhahnde/scoop-eeco : bucket/eeco.json
#
# Run after `make packaging` (which writes the manifests offline from
# dist/SHA256SUMS). This script does the one network step the release
# needs that gen-packaging.sh deliberately omits: the upsert into the
# external tap repos, via the GitHub Contents API (`gh api`). No clone.
#
# Auth: the workflow's GITHUB_TOKEN cannot push to other repos, so a PAT
# with `contents:write` on both tap repos is required in TAP_PUSH_TOKEN.
#
# Inputs (env, with defaults):
# VERSION release tag (e.g. v2.0.0); default: git describe
# TAP_PUSH_TOKEN PAT with push to the tap repos (required unless --dry-run)
# BREW_TAP_REPO default ajhahnde/homebrew-eeco
# SCOOP_TAP_REPO default ajhahnde/scoop-eeco
#
# Flags:
# --dry-run print what would be pushed; perform no network call
set -eu
VERSION="${VERSION:-$(git describe --tags --dirty --always 2>/dev/null || echo dev)}"
BREW_TAP_REPO="${BREW_TAP_REPO:-ajhahnde/homebrew-eeco}"
SCOOP_TAP_REPO="${SCOOP_TAP_REPO:-ajhahnde/scoop-eeco}"
DRY_RUN=0
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=1 ;;
*) echo "error: unknown argument: $arg" >&2; exit 2 ;;
esac
done
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
DIST="${ROOT}/dist"
BREW_SRC="${DIST}/eeco.rb"
SCOOP_SRC="${DIST}/eeco.json"
for f in "${BREW_SRC}" "${SCOOP_SRC}"; do
if [ ! -f "$f" ]; then
echo "error: ${f} not found; run 'make packaging' first" >&2
exit 1
fi
done
if [ "${DRY_RUN}" -eq 0 ] && [ -z "${TAP_PUSH_TOKEN:-}" ]; then
echo "error: TAP_PUSH_TOKEN is required (or pass --dry-run)" >&2
exit 1
fi
# Upsert one file into a tap repo at a fixed path. Creates the file or
# updates it in place, carrying the prior blob sha when one exists.
# $1 repo (owner/name) $2 dest path in repo $3 local source file
sync_one() {
repo="$1"; dest="$2"; src="$3"
if [ "${DRY_RUN}" -eq 1 ]; then
printf 'dry-run: would push %s -> %s:%s (%s)\n' \
"$src" "$repo" "$dest" "${VERSION}"
return 0
fi
# base64, no newlines — the Contents API wants a single string.
content="$(base64 < "${src}" | tr -d '\n')"
# Existing blob sha (empty when the file does not yet exist → create).
sha="$(GH_TOKEN="${TAP_PUSH_TOKEN}" gh api \
"repos/${repo}/contents/${dest}" --jq '.sha' 2>/dev/null || true)"
set -- --method PUT "repos/${repo}/contents/${dest}" \
-f "message=eeco ${VERSION}: sync ${dest##*/}" \
-f "content=${content}"
if [ -n "${sha}" ]; then
set -- "$@" -f "sha=${sha}"
fi
GH_TOKEN="${TAP_PUSH_TOKEN}" gh api "$@" >/dev/null
printf 'synced %s:%s -> %s\n' "$repo" "$dest" "${VERSION}"
}
sync_one "${BREW_TAP_REPO}" "Formula/eeco.rb" "${BREW_SRC}"
sync_one "${SCOOP_TAP_REPO}" "bucket/eeco.json" "${SCOOP_SRC}"
added workflows/README.md
@@ -0,0 +1 @@
Builtin workflow definitions are embedded from this directory.