@salaros/ai-harness 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,14 +1,21 @@
1
+ MIT License
2
+
1
3
  Copyright (c) 2019 CODECAVE
2
4
 
3
- All rights reserved.
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
4
11
 
5
- Redistribution and use in source and binary forms, with or without
6
- modification, are strictly prohibited without prior written permission
7
- from the copyright holder.
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
8
14
 
9
- THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
10
- "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
11
- LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
12
- A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
13
- HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
14
- SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES.
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,186 +1,102 @@
1
1
  # AI harness
2
2
 
3
- A starting point that does not assume a language or framework: `.gitignore`, `.gitattributes`, Git hooks, a folder layout with a README in every folder, and an AI-agnostic **agent harness** (skills, hook scripts, four agents) that works the same in Claude Code, Codex, Cursor, GitHub Copilot, Gemini CLI, and any other tool that reads `AGENTS.md` and `.agents/skills`.
3
+ A starting point for projects built with AI coding agents. It gives every agent the same instructions, skills, safety hooks and documentation workflow, whether you use Claude Code, Codex, Cursor, GitHub Copilot, Gemini CLI or OpenCode.
4
4
 
5
- Requires **Node 22 or newer** (the only runtime the harness needs) and Git; nothing else. The [harness CI workflow](.github/workflows/harness.yml) runs the suite on Ubuntu and Windows. A second workflow, [skills-update](.github/workflows/skills-update.yml), runs `npx skills update` weekly, relinks, regenerates the third-party notice, and commits the result to `development` only if the suite and the chain check still pass; it then opens a pull request promoting `development` to `master`, because a skill is a prompt an agent runs with full permissions and its diff wants a reader. A third, [release](.github/workflows/release.yml), publishes this package to npm when a bare version tag such as `0.2.4` is pushed, over npm trusted publishing rather than a stored token: it runs the suite and the chain check, refuses a tag that is lightweight, disagrees with `package.json` or names a commit that is not on `master`, refuses a tarball missing the installer, and cuts a GitHub Release from a second job that holds no npm credential.
5
+ It assumes no language or framework. What you put in `src/` decides the stack.
6
6
 
7
- ## Adding the harness to a repository you already have
7
+ ## What you get
8
8
 
9
- Starting a project from scratch is a clone of this repository. Adding the harness to a repository that already exists, and keeping it current afterwards, is one command run from that repository's root:
9
+ - **`AGENTS.md`**: one instruction file that every agent reads.
10
+ - **Skills**: 60 vendored [agent skills](https://skills.sh) for requirements, design, testing, code review and more.
11
+ - **Agents**: `engineer`, `business-analyst`, `devops` and `assistant`, each routing work to the right skills.
12
+ - **Hooks**: agent hooks block dangerous shell commands and check every edit. Git hooks check commit messages, the documentation chain and formatting.
13
+ - **A documentation chain**: BRD → PRD → EARS → BDD → ADR → SPEC → TDD → plan → code, with a checker that keeps every document traceable to the one before it.
10
14
 
11
- ```bash
12
- npx @salaros/ai-harness
13
- ```
14
-
15
- It installs what the harness needs and nothing else: `AGENTS.md`, the agents and their routing, the skills, the Git hooks, and the checks those hooks call, the documentation-chain validator among them. What maintains the upstream stays behind — the installer itself, the harness test suite and its fixtures, the CI workflows, `package.json`, and the .NET and OpenCode config a project may have no use for.
16
-
17
- The published package is the installer, not the harness. npm carries four files -- `scripts/update-harness.js`, `scripts/lib.js`, `README.md` and `LICENSE` -- and everything they install comes from a clone of this repository, made at run time from the `master` branch. So the version on npm numbers the installer, not what it installs, and two runs of the same version a month apart give you different skills. That is the trade the harness makes: `master` is the released line, reached only through a reviewed pull request, and tracking it lets a skill fix reach every repo the week it lands instead of waiting for a release. What it costs is reproducibility across time. Pass `--ref <tag>` when you need a fixed harness, and read `harness-lock.json` to see the exact commit a past run took.
18
-
19
- It never writes `README.md` or `LICENSE`. Under `docs/`, `src/`, `tests/`, `tools/`, `workflows/` and `.scratch/` it adds the folder README where that file is absent and touches nothing else, so a `src/` with code in it gains a README and keeps the code. A first run leaves three skeletons behind when the repo has none: `MEMORY.md` with its six facts still `<placeholder>`, so the initialisation gate says what to run next; an empty `CONTEXT.md` glossary; and a `TODO.md` holding its header and nothing else. Later runs leave all three alone.
20
-
21
- Pass `--astro-docs` to also install `tools/docs-site/`, the Astro site that renders the chain in a browser. Nothing in the harness reaches it, so it stays out unless asked for.
22
-
23
- Every run proves itself before it finishes. Merging is not checking: the installer knows it wrote a file, not whether the result still works. So it borrows the upstream's own test suite — writes it in, runs it, and takes it away again, leaving the repo as the install left it — and reports what it found. A failure exits non-zero, the same way a conflict does. That is what catches an `AGENTS.md` whose chain table no longer parses, routing sections naming an agent this repo does not have, a skill nothing links to, or a vendored upstream with no licence row. Pass `--no-check` to skip it.
15
+ ## Requirements
24
16
 
25
- `scripts/harness-files.tsv` is the table behind all of this, one row per path.
17
+ - [Node.js](https://nodejs.org) 22 or newer
18
+ - Git
19
+ - On Windows: Developer Mode on, and `git config --global core.symlinks true` set **before** you clone
26
20
 
27
- Running it again updates. The first run writes `harness-lock.json` naming the upstream commit it took, which gives a later run a merge base: a harness file nobody edited takes the new version, an edited one keeps its edits and gains the changes around them, and only a real collision is written with conflict markers, reported, and exits non-zero. That is how `AGENTS.md` gains a new section while keeping your own rules, and how `docs/agents/issue-tracker.md` keeps your project key.
21
+ ## Installation
28
22
 
29
- Two files are merged even on a first install, where there is no receipt to take a base from: `AGENTS.md`, the map every agent reads and the table `docs-check` parses, and `docs/README.md`, which says what the chain puts where. Keeping a stale copy of either leaves a repo that looks installed and behaves like the version it came from, so the installer finds the base instead — the upstream version yours is closest to is where you forked from, whatever a receipt would have said. A copy nobody edited merges cleanly; an edited one keeps its edits and gains the rest. A file written from scratch matches nothing in the upstream's history and is written with conflict markers, so both versions are there to read.
30
-
31
- Skills are added and updated, never removed: one you vendored yourself survives every update, and `skills-lock.json` is merged as a union.
32
-
33
- A repo whose harness predates `harness-lock.json` is the one case where "never touch what is already there" works against you: with no receipt there is no merge base, so the old harness stays and its checks then run against the new skills and fail, naming rules this version dropped. The run says so and names `--adopt`, which replaces every harness file with the upstream's — hooks, agents, scripts, `AGENTS.md`, and a `.claude/agents` that checked out as a text file rather than a symlink. It stays opt-in because it discards your edits to those files. `--dry-run --quiet` lists them first.
34
-
35
- That first install writes a receipt even though it kept the old files, so `--adopt` has to keep working afterwards — it is normally the self check that tells you the harness is stale, and by then the receipt exists. A run at the recorded commit says so and takes every harness file again, rather than answering the one command that fixes it with "nothing to update".
36
-
37
- The run reports each path as it works on it: the policy, the mode Git records, what happened, and the path, with the skills tree collapsed to one line per skill. `--quiet` gives the summary alone.
38
-
39
- ## Getting started
40
-
41
- 1. **Windows only, before cloning:** enable Developer Mode (Settings → System → For developers) and run `git config --global core.symlinks true`. The `.claude/` folder is tracked as symlinks; without this, Git checks them out as text files and Claude Code sees no skills.
42
- 2. Clone, then install the Git hooks once: `node scripts/githooks-init.js`. Node is the only runtime the harness needs; on Windows nothing else (no `sh`) is required.
43
- 3. Run the `project-init` skill (`/project-init` in Claude Code). It asks, through the tool's own question prompt, what the project is, where the requirements live, the stack and, if the project has one, the issue tracker, and writes the answers to `MEMORY.md`, this README and `docs/agents/issue-tracker.md`. It hands you the scaffold commands for the stack; running them is up to you.
44
- 4. Skills are vendored in `.agents/skills`, so there is nothing to install. To add one: `npx skills add <owner/repo> -s <skill> -a claude-code codex -y`, then `node scripts/skills.js relink`, then commit.
45
- 5. Open the repo in your AI tool and check the section for your tool under [Files per AI tool](#files-per-ai-tool). Authorise the Atlassian MCP server if the project tracks work in Jira, and the Figma one if it has designs; both are optional, and a project that plans in `docs/` and has no designs needs neither. See [MCP servers](#files-per-ai-tool).
46
-
47
- ## Layout
48
-
49
- | Path | What it is |
50
- | --- | --- |
51
- | `src/`, `tests/`, `scripts/`, `tools/`, `docs/` | Product code, tests, repo automation, dev utilities, documents. Each has a README describing what belongs there. |
52
- | `AGENTS.md` | The one file every agent reads: layout, where skills are, domain-language pointers. Kept short on purpose. |
53
- | `CLAUDE.md` | One line, `@AGENTS.md`, because Claude Code reads `CLAUDE.md` rather than `AGENTS.md`. |
54
- | `MEMORY.md` | The project facts (name, purpose, the language its prose is written in, requirements location, stack, the frontend framework when the project has browser code, and the issue tracker when there is one), one per line. Written by the `project-init` skill; absent until it runs. |
55
- | `TODO.md` | The loose-ends ledger: questions nobody has answered, assumptions taken on trust, and work knowingly left undone. Follows the [todo-md standard](https://github.com/todo-md/todo-md) -- a `# TODO` header, one entry per line as `- [ ] <text> #<tag> (<source>)`, `@user` and indented subtasks optional -- so its tooling reads this file. Written by the `loose-ends` skill and absent until the first entry, since an empty ledger says less than no ledger. It departs from the standard in one place: a settled entry is deleted in the commit that settles it rather than kept as `[x]` or `[-]`, because entries nobody will act on are context every agent re-reads. Needs no issue tracker. |
56
- | `THIRD-PARTY-NOTICES.md`, `scripts/skill-licences.tsv` | Vendoring a skill copies someone else’s work here, and MIT and Apache-2.0 both ask that the copyright and permission notice travel with the copy; `npx skills` carries only what sits inside the skill folder, so an upstream keeping its licence at the repo root sends none. The TSV holds one row per upstream (SPDX id, copyright line, licence URL, and any restriction); `node scripts/skills.js notices` regenerates the notice from it and `skills-lock.json`. A new upstream with no row fails the harness suite. |
57
- | `skills-lock.json` | Written by `npx skills`: source, path and hash of every installed skill. The single record of what is installed; `node scripts/skills.js install` restores from it. |
58
- | `.agents/skills/<name>/` | The canonical, vendored copy of each skill (`SKILL.md` plus its reference files). |
59
- | `.agents/hooks/` | Harness-neutral hook scripts (see [Hooks](#hooks)). |
60
- | `.agents/agents/` | Agent definitions (see [Agents](#agents)). |
61
- | `scripts/update-harness.js`, `scripts/harness-files.tsv`, `harness-lock.json` | The installer, the table of what travels and what does not, and the receipt naming the upstream commit a repo last took and the released installer that wrote it. The first two are the upstream’s own and are never installed; the receipt is written into the repo being updated. |
62
- | `.agents/routing.md` | Route rows shared by some agents but not all, in a section per audience, plus the rule for what earns a row. What every agent needs is in `AGENTS.md` ("Working here") instead, since every session loads that. Above `agents/` because `.claude/agents` is a symlink to that folder and a harness reads everything in it as an agent. |
63
- | `.claude/` | Claude Code wiring: `skills/*` and `agents` are symlinks into `.agents/`, `settings.json` wires the hooks. |
64
- | `.githooks/`, `scripts/githooks-init.js` | Git hooks, run through `core.hooksPath` after `githooks-init.js` is run once per clone. They are the only shell scripts left, because Git runs them through its own `sh` on every OS; each is a two-line wrapper piping changed paths into a Node script. `post-merge` feeds `scripts/on-manifest-change.js`, which restores what `scripts/stacks.tsv` says (skills, npm, pnpm, yarn, NuGet, uv). `pre-commit` and `pre-push` both start with `scripts/check-initialised.js`, which refuses an unconfigured clone: `MEMORY.md` must record the six facts that describe the project itself (name, purpose, requirements, unit type, language, runtime), none of them left as a `<placeholder>`. The `Jira` line is not among them: a tracker is a choice, not a property of the code. `pre-commit` then pipes the staged `TODO.md` into `scripts/check-todo.js`, which checks the shape of the loose-ends ledger against the todo-md standard: the `# TODO` header, and an entry carrying a known tag (`#question`, `#assumption`, `#deferred`) and a source the chain would accept. Only checkbox lines are entries, every other line is prose, and an indented subtask inherits its parent's tag and source. It never asks for entries to exist, and it rejects `[x]` and `[-]`, because a settled entry is deleted rather than kept. `pre-commit` then feeds `scripts/check-staged-docs.js`, which blocks a commit that would break the documentation chain. `commit-msg` feeds `scripts/check-commit-msg.js`, which blocks a commit whose message is not a conventional commit (`<type>(<scope>)?!?: <description>`, subject at most 72 characters, a description of at least four words, and a body of real prose after a blank line — trailers such as `Refs:` are metadata and do not count as one); messages Git writes itself for a merge, revert, fixup or squash are left alone. A message citing no Jira issue key is warned about rather than rejected, to encourage the habit without blocking a change nobody has raised a ticket for; the key comes from `docs/agents/issue-tracker.md`, and any `PROJ-123` shape counts until `project-init` records the real one. A project whose `MEMORY.md` records `Issue tracker: none` is not warned: it has no key to cite. A tracker whose references are not `KEY-123` sets `Key format:` in `docs/agents/issue-tracker.md`, so a GitHub-Issues project is warned about `#42` instead. `pre-push` feeds `scripts/format-changed.js`, which checks that the files a push publishes are formatted, using the stack's own formatter from `scripts/stacks.tsv` (`prettier`, `dotnet format` or a project's Husky.NET task runner, `ruff`). It never rewrites files: it reports and blocks, and skips silently when the stack's formatter is not installed. Formatting is checked on push rather than on commit because some formatters load the whole project and cost seconds. The restore, chain and format scripts take `--dry-run`; `check-commit-msg.js` takes the message file, or reads stdin. `git commit --no-verify` and `git push --no-verify` skip them. |
65
- | `docs/agents/` | Per-repo configuration the skills read: issue tracker, triage labels, domain-doc rules. |
66
- | `CODING_STANDARDS.md` | Rules the `code-review` skill applies to a diff. A stub until the stack lands; anything a tool enforces stays out of it. |
67
- | `.editorconfig`, `.gitattributes`, `.gitignore`, `stylecop.json` | Encoding, indentation, line endings, ignored output and analyzer settings. Stack-specific entries are kept when they are inert on other stacks, so no stack is forced. |
68
- | `.skip-project-init` | Untracked, ignored, created by hand: it tells the initialisation gate that this clone has no project to configure, such as the upstream's own repo. Never committed, so it stays with whoever made it. |
69
- | `workflows/` | Workflow specs written by `loop-me`. |
70
- | `.scratch/` | Committed working files: feature specs, ticket drafts, prototypes not yet on a branch. |
71
- | `.mcp.json`, `opencode.json` | MCP server registrations for Claude Code and OpenCode: Atlassian for Jira, Figma for designs. Both are optional; a project with no tracker and no designs authorises neither. |
72
-
73
- ## Skills
74
-
75
- Skills follow the Agent Skills format: a folder with a `SKILL.md` whose frontmatter carries a `name` and a `description`, plus optional reference files. They are managed with the [`skills` CLI](https://skills.sh); `skills-lock.json` records what is installed and `.agents/skills` holds the files. Commit both, plus the `.claude/skills` link.
23
+ ### Start a new project
76
24
 
77
25
  ```bash
78
- npx skills add mattpocock/skills -s wait-what -a claude-code codex -y # add a skill
79
- npx skills remove wait-what -y # remove one
80
- npx skills update # newer versions of everything
81
- node scripts/skills.js relink # after any of the above, and after writing a local skill
26
+ git clone https://github.com/salaros/ai-harness.git my-project
27
+ cd my-project
28
+ node scripts/githooks-init.js
82
29
  ```
83
30
 
84
- - Relink maintains the harness folders. `.claude/skills` is one link to `.agents/skills`, so every skill there is already visible, a local one written by hand included; relink says so and leaves it alone. Any other harness folder holding a `skills/` directory gets a link per skill instead, which is what makes a hand-written skill visible where `npx skills` links only what it vendored, and on Windows relink rewrites the absolute junctions the CLI creates, which Git cannot store, into relative symlinks. It is idempotent, it reports a link pointing at a skill that is no longer installed, and the test suite fails if a per-skill link is missing or the folder link points anywhere but `.agents/skills`.
85
- - `node scripts/skills.js install` restores `.agents/skills` from the lock file. A normal clone never needs it; the post-merge Git hook runs it when the lock changes.
86
- - Do not edit a vendored skill in place; the next update overwrites it. Fork it under another name outside `.agents/skills`, or change it upstream.
87
- - Two kinds of skill: **model-invoked** ones carry a description the agent matches on its own; **user-invoked** ones (`disable-model-invocation: true`) only fire when you type `/name`.
88
- - The lock file records only a hash per skill, so an upstream repo going private or rewriting history is otherwise invisible. Run `npx skills update` every so often (monthly, or before a stretch of work that leans on skills), then `node .agents/hooks/test.js` to catch a skill an update broke, and review the diff before committing. The current upstreams are `addyosmani/agent-skills`, `alirezarezvani/claude-skills`, `anthropics/skills`, `codewithmukesh/dotnet-claude-kit`, `dietrichgebert/ponytail`, `diskd-ai/design-doc`, `github/awesome-copilot`, `jeffallan/claude-skills`, `mattpocock/skills`, `mindrally/skills`, `openai/skills`, `softaworks/agent-toolkit`, `sprngr/rubber-duck`, `thebushidocollective/han`, `vercel-labs/agent-browser`, `vercel-labs/agent-skills`, `vercel-labs/skills`, `vuejs-ai/skills`, `wondelai/skills` and `wshobson/agents`.
89
-
90
- `node scripts/skills.js list` prints what is installed: name, how it is invoked, which agents route it, and where it came from. It is a report, not a rule; nothing requires a skill to be routed or listed anywhere.
91
-
92
- ## Hooks
93
-
94
- Three Node scripts in `.agents/hooks/`, run with the `node` on your PATH (Windows has no `sh` on its PATH by default, and only Claude Code and Git bring their own). Each reads the harness's JSON payload from stdin, prints a message, and uses the exit code every harness understands the same way: `0` = fine, `2` = block or send the message back to the agent.
31
+ Then open the folder in your AI tool and run the `project-init` skill (`/project-init` in Claude Code). It asks what the project is, where its requirements live, its stack and its issue tracker, and records the answers in `MEMORY.md`. The Git hooks refuse commits until it has run.
95
32
 
96
- | Script | Event | Does |
97
- | --- | --- | --- |
98
- | `session-start.js` | session start | Prints a brief into the agent's context: branch, whether Git hooks are installed, skills recorded in `skills-lock.json` but missing from disk, whether `CONTEXT.md`, `docs/adr/` and the issue-tracker config exist. |
99
- | `guard-command.js` | before a shell command | Blocks force pushes, `git reset --hard`, `git clean -f`, `git branch -D`, and recursive deletes of `/`, `~`, `.git` or `*`. Checks the harness's own command field (falling back to the raw payload for an unrecognised shape) and only where the phrase could be a real invocation, so a dangerous phrase quoted as data in another command's arguments does not trip it. The agent is told to ask you instead. |
100
- | `check-edit.js` | after a file write/edit | Syntax-checks `*.js`, validates `*.json`, runs `scripts/docs-check.js` for Markdown under `docs/` and for `AGENTS.md`, runs the hook suite when the harness itself changed, and refuses in-place edits of vendored skills. |
33
+ ### Add the harness to an existing repository
101
34
 
102
- The scripts are harness-neutral; the wiring is one small config file per tool, listed under [Files per AI tool](#files-per-ai-tool). Only the Claude Code wiring ships in the repo, because it is the one that has been run.
35
+ From the repository's root:
103
36
 
104
- `lib.js` is the one place that knows how a harness hands over its payload: it finds the repo root (`CLAUDE_PROJECT_DIR`, `CURSOR_PROJECT_DIR` or `GEMINI_PROJECT_DIR`, else the checkout the hooks live in) and turns the edited path into a repo-relative one whether the harness sent `tool_input.file_path` (Claude Code, Gemini CLI), a top-level `file_path` (Cursor) or a `toolArgs` string with a `path` (Copilot). Paths outside the repo and unreadable payloads are skipped with a note on stderr, never blocked. `node .agents/hooks/test.js` pipes every fixture in `.agents/hooks/tests/` through its script (a hook, or `scripts/on-manifest-change.js --dry-run`) and compares exit code and output; `check-edit.js` runs that suite itself whenever a hook, a script the hooks call, the Git hook or the stack table changes.
37
+ ```bash
38
+ npx @salaros/ai-harness
39
+ ```
105
40
 
106
- ## Agents
41
+ The installer adds the harness files and leaves your own work alone: it never writes `README.md` or `LICENSE`, and it adds a folder README only where one is missing. It records the upstream commit it installed in `harness-lock.json`.
107
42
 
108
- Agent definitions live in `.agents/agents/*.md`: YAML frontmatter with `name` and `description`, then the system prompt. The format is the one Claude Code, Cursor and Gemini CLI read directly; Codex and Copilot need a copy in their own shape (see their sections). Each agent routes a request to the skills it owns and runs them to their own definition of done. A row that two or three agents read lives in `.agents/routing.md` instead, in a section per audience that each agent names in its Route step; where an agent's own table and a section cover one trigger, the agent's row wins. A row every agent reads is not routing at all: it goes in `AGENTS.md` ("Working here"), which every session loads.
43
+ Run the same command again to update. Files you haven't edited take the new version, files you have edited keep your changes and gain the new ones, and a real conflict is written with conflict markers and reported.
109
44
 
110
- | Agent | For |
45
+ | Option | Effect |
111
46
  | --- | --- |
112
- | `engineer` | Engineering tasks bigger than a one-line edit, and the ADR → SPEC → TDD → IPLAN → Code half of the documentation chain |
113
- | `business-analyst` | Requirements, process design, interface contracts, agent-ready briefs, the BRD → PRD → EARS → BDD half of the documentation chain |
114
- | `devops` | Containers, CI/CD, Kubernetes, infrastructure as code, rollouts and incidents |
115
- | `assistant` | Non-technical colleagues |
116
-
117
- Which skills an agent routes to is the route table in its own file; `node scripts/skills.js list` prints the same mapping from the other direction, one row per skill.
47
+ | `--dry-run` | Show what would change, write nothing |
48
+ | `--quiet` | Print only the summary |
49
+ | `--ref <tag>` | Install a fixed version instead of the latest `master` |
50
+ | `--adopt` | Replace every harness file with the upstream's, for repos installed before `harness-lock.json` existed. Discards your edits to those files. |
51
+ | `--astro-docs` | Also install `tools/docs-site/`, a website that renders your documentation |
52
+ | `--no-check` | Skip the check the installer runs after writing |
53
+ | `--help` | Print the options and exit. Any argument not listed here stops the installer before it writes. |
118
54
 
119
- ## Files per AI tool
55
+ The npm package holds only the installer. It fetches the harness from `master` at run time, so two runs a month apart may install different skills. Use `--ref` when you need the same result every time.
120
56
 
121
- What each tool reads, what is already in the repo, and what you must create for that tool. Paths are relative to the repo root. Only the Claude Code wiring has been run; the other rows come from each tool's own documentation.
57
+ ## Usage
122
58
 
123
- ### Claude Code
59
+ Talk to your agent as usual. `AGENTS.md` tells it which skill fits the task. You can also call a skill or an agent by name:
124
60
 
125
- | Need | File | In repo |
126
- | --- | --- | --- |
127
- | Instructions | `CLAUDE.md` containing `@AGENTS.md` (Claude Code does not read `AGENTS.md` itself) | yes |
128
- | Skills | `.claude/skills` → symlink to `../.agents/skills` | yes |
129
- | Hooks | `.claude/settings.json` → `hooks.SessionStart`, `PreToolUse` (matcher `Bash`), `PostToolUse` (matcher `Edit\|Write\|MultiEdit`), each `{"type":"command","command":"node \"$CLAUDE_PROJECT_DIR/.agents/hooks/<hook>.js\""}` | yes |
130
- | Agents | `.claude/agents` → symlink to `../.agents/agents`; frontmatter `name`, `description` (+ optional `tools`, `model`, `skills`) | yes |
131
- | Per-developer overrides | `.claude/settings.local.json` (git-ignored) | no |
132
-
133
- Invoke an agent with "use the engineer agent to …", or a skill with `/tdd`, `/grilling`, and so on. On Windows the symlinks need Developer Mode and `core.symlinks=true` (see Getting started); if you cannot use symlinks, run `npx skills add … --copy` and copy `.agents/agents/*.md` into `.claude/agents/`.
134
-
135
- ### Other tools
61
+ ```text
62
+ /grilling stress-test an idea before writing requirements
63
+ /brd write a business requirements document
64
+ /tdd build a feature test-first
65
+ use the engineer agent to implement docs/spec/0001-billing.md
66
+ ```
136
67
 
137
- | Tool | Instructions | Skills | Hooks | Agents | MCP | Notes |
138
- | --- | --- | --- | --- | --- | --- | --- |
139
- | OpenAI Codex | `AGENTS.md`, read natively | `.agents/skills/`, read natively | create `.codex/hooks.json`, same shape as `.claude/settings.json` | create `.codex/agents/<name>.toml`, one per agent | create `.codex/config.toml` with `[mcp_servers.atlassian]`, then `codex mcp login atlassian` | Edits arrive as `apply_patch` commands with the paths inside the patch; `check-edit.js` skips them until `lib.js` learns that shape. [Docs](https://developers.openai.com/codex/hooks) |
140
- | Cursor | `AGENTS.md`, read natively | `.agents/skills/`, read natively | create `.cursor/hooks.json` (`sessionStart`, `beforeShellExecution`, `afterFileEdit`) | create `.cursor/agents/<name>.md`: copy or symlink | create `.cursor/mcp.json` (`mcpServers`, no `type`) | Exit 2 blocks; `file_path` and `command` are top-level payload fields, which `lib.js` reads. [Docs](https://cursor.com/docs/agent/hooks) |
141
- | GitHub Copilot | `AGENTS.md`, read natively | `.agents/skills/`, read natively | create `.github/hooks/*.json` (`sessionStart`, `preToolUse`, `postToolUse`) | create `.github/agents/<name>.agent.md`: copy with the suffix | create `.vscode/mcp.json` (`servers`) | `toolArgs` is a JSON string inside the payload, which `lib.js` parses; any non-zero exit denies. [Docs](https://docs.github.com/en/copilot/reference/hooks-reference) |
142
- | Gemini CLI | `GEMINI.md` by default; point it at `AGENTS.md` through `.gemini/settings.json` | `.agents/skills/`, read natively | create `.gemini/settings.json` with `hooks` (`SessionStart`, `BeforeTool`, `AfterTool`) | create `.gemini/agents/<name>.md`: copy or symlink | create `.gemini/settings.json` with `mcpServers` (`httpUrl`) | Same payload shape as Claude Code; timeouts in milliseconds and a `name` per hook. [Docs](https://geminicli.com/docs/hooks/) |
143
- | OpenCode | `AGENTS.md`, read natively | `.agents/skills/`, read natively | a JS plugin under `.opencode/plugins/` that shells out to the three scripts; there are no command hooks | none | `opencode.json`, in repo | [Docs](https://opencode.ai/docs/plugins/) |
144
- | Anything else | `AGENTS.md` | read each `SKILL.md` description and load what matches, as `AGENTS.md` says | wire the three scripts to the tool's events: payload on stdin, exit 2 blocks | an agent file pasted as the system prompt | its own file | |
68
+ Everyday commands:
145
69
 
146
- Two servers are registered, and neither is required: Atlassian, whose URL and Jira conventions the skills follow are in `docs/agents/issue-tracker.md`, and Figma at `https://mcp.figma.com/mcp`, used by the `figma` skills. Authorise the one you use. A project that plans its work in `docs/` needs no tracker, and one with no designs yet needs no Figma. Each is authorised in the tool's own way, and the `figma` skill's `references/figma-mcp-config.md` covers a bearer token for a harness that cannot do the interactive flow. The question tool per harness is in `docs/agents/questions.md`.
70
+ ```bash
71
+ node scripts/docs-check.js # check the documentation chain
72
+ node scripts/check-harness.js # check the harness itself
73
+ npx skills add <owner/repo> -s <skill> -a claude-code codex -y
74
+ node scripts/skills.js relink # run after adding or updating skills
75
+ ```
147
76
 
148
- ## Documentation chain
77
+ Claude Code works as soon as you clone. Other tools need a small config file for hooks and agents; [.agents/README.md](.agents/README.md#files-per-ai-tool) lists the files for each tool.
149
78
 
150
- The chain BRD → PRD → EARS → BDD → ADR → SPEC → TDD → IPLAN → Code, its folders and the skill for each stage are the table in `AGENTS.md` ("Documentation"); `docs/README.md` says what each document must contain. Documents are `docs/<stage>/NNNN-<slug>.md` and cite what they derive from; `scripts/docs-check.js` reads the stages from that table and verifies the IDs and citations, the edit hook runs it after every change under `docs/` or to `AGENTS.md`, and the `docs-check` skill repairs its findings.
79
+ ## Documentation
151
80
 
152
- ## What each skill expects from the repo
81
+ - [AGENTS.md](AGENTS.md): the rules every agent follows, including the documentation chain.
82
+ - [docs/README.md](docs/README.md): what each document in the chain must contain.
83
+ - [.agents/README.md](.agents/README.md): reference for the layout, skills, hooks, agents, per-tool setup and what each skill expects.
84
+ - `src/`, `tests/`, `scripts/`, `tools/` and `docs/` each have a README saying what belongs in them.
153
85
 
154
- Most skills need nothing beyond `AGENTS.md` and their own folder. The files under `docs/agents/` are configuration the harness ships; everything else in the table below is created by the skill itself when first needed.
86
+ ## Releasing
155
87
 
156
- ### Shared setup: `docs/agents/`
88
+ Maintainers publish the installer to npm by pushing an annotated version tag on `master`:
157
89
 
158
- A tracker is optional, and it does not have to be Jira: a project can plan entirely in `docs/`, recording `Issue tracker: none`. `code-review`, `to-tickets` and `triage` are the skills that read the issue-tracker configuration, and each asks for one when it is reached for without it. When there is a tracker the harness ships configured for **Jira through the Atlassian MCP server**:
90
+ ```bash
91
+ git tag -a 0.2.8 -m "0.2.8" && git push origin 0.2.8
92
+ ```
159
93
 
160
- - `docs/agents/issue-tracker.md`: which MCP tools create, read, label, link and close issues. **Replace `TODO-PROJECT-KEY` with your Jira project key** before first use.
161
- - `docs/agents/triage-labels.md`: maps the five triage roles (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`) to plain Jira labels of the same names.
162
- - `docs/agents/domain.md`: tells skills to read `CONTEXT.md` and `docs/adr/` before exploring, and to stay silent when they are absent.
163
- - `docs/agents/questions.md`: the question tool each harness offers (Claude Code, OpenCode, Copilot and so on) and the plain-text fallback, so interviews and confirmations always go through the tool. `AGENTS.md` ("Working here") makes this a rule for every skill.
164
- - The `## Agent skills` section in `AGENTS.md` points at the three files.
94
+ The [release workflow](.github/workflows/release.yml) runs the tests, writes the tag's version into `package.json` and publishes. Leave the version in `package.json` at `0.0.0`.
165
95
 
166
- To switch trackers (GitHub via `gh`, GitLab via `glab`, or local Markdown under `.scratch/`), run `/setup-matt-pocock-skills` in your agent; the skill is vendored and rewrites the three files and the `AGENTS.md` block from its templates.
96
+ ## License
167
97
 
168
- ### Per skill
98
+ MIT; see [LICENSE](LICENSE). Vendored skills keep their own licences, listed in [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md).
169
99
 
170
- | Skill | Reads | Writes |
171
- | --- | --- | --- |
172
- | `code-review` | `docs/agents/issue-tracker.md`; `CODING_STANDARDS.md`; the spec, from an issue in the commit messages, a path you pass, or a file under `docs/`, `specs/` or `.scratch/` matching the branch | nothing |
173
- | `to-tickets` | `docs/agents/issue-tracker.md` | drafts under `.scratch/<feature-slug>/`, then Jira issues in dependency order |
174
- | `triage` | `docs/agents/issue-tracker.md`, `docs/agents/triage-labels.md`, `.out-of-scope/` | `.out-of-scope/<concept>.md` per rejected request; Jira labels and comments |
175
- | `domain-modeling`, also through `grill-with-docs`, `improve-codebase-architecture` and `triage` | `CONTEXT.md`, `docs/adr/` | `CONTEXT.md`, `docs/adr/NNNN-<slug>.md`; `CONTEXT-MAP.md` only in a multi-context repo |
176
- | `brd`, `prd`, `feature-forge`, `bdd-scenarios`, `design-doc`, `create-implementation-plan` | the document one stage upstream; `CONTEXT.md` when present | `docs/<stage>/NNNN-<slug>.md` per the `AGENTS.md` table (`.scratch/<feature>/` for the plan), whatever path the skill's own instructions name |
177
- | `docs-check` | the `AGENTS.md` table, `docs/` | repairs in place |
178
- | `loose-ends` | `TODO.md` | `TODO.md` at the repo root, created on the first entry |
179
- | `project-init` | answers from the harness's question tool | `MEMORY.md`, the Project section of this README, and the key and site in `docs/agents/issue-tracker.md` when the project has a tracker |
180
- | `teach` | the working directory as a workspace | `MISSION.md`, `RESOURCES.md`, `NOTES.md`, `reference/`, `lessons/`, `learning-records/`, `assets/` |
181
- | `loop-me` | `NOTES.md`, shared with `teach` | `workflows/<name>.md`, `NOTES.md` |
182
- | `implement`, `tdd`, `prototype` | a spec or tickets; the stack's test runner, type checker and task runner | code under `src/` and `tests/`; prototypes on a throwaway branch |
183
- | `retro` | the session logs of the agent that ran | proposed edits to `AGENTS.md`, `CODING_STANDARDS.md`, the docs and the skills |
184
- | `agent-browser` | the `agent-browser` CLI (`npx agent-browser` fetches it) | nothing in the repo |
100
+ ## Contact
185
101
 
186
- `handoff` and `improve-codebase-architecture` write to the OS temp directory. `setup-matt-pocock-skills` rewrites `docs/agents/` and the `Agent skills` block of `AGENTS.md` when the tracker changes. Every other skill reads nothing but `AGENTS.md`.
102
+ Maintained by CODECAVE. Report problems and suggestions in [GitHub issues](https://github.com/salaros/ai-harness/issues).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salaros/ai-harness",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Installs and updates the agent harness from its upstream repository: hooks, skills, agents and the documentation chain, merged into an existing repository without touching its own work.",
5
5
  "bin": {
6
6
  "ai-harness": "scripts/update-harness.js"
package/scripts/README.md CHANGED
@@ -18,12 +18,24 @@ part of the product.
18
18
  - One task per script, named after what it does (`githooks-init.js`,
19
19
  `build.js`, `release.js`); Node, so they run the same on every OS.
20
20
  - Scripts are safe to run from any working directory — each resolves the
21
- repository root from its own location.
21
+ repository root from its own location, or from `--root=<dir>` when given
22
+ (`lib.root()`; `lib.args()` is the arguments without that flag). The
23
+ suite uses the flag to run a script against a temporary repo, and a script
24
+ that calls another passes its own root along.
25
+ - A script run as a command may `chdir` there (`lib.chdirRoot()`). A script
26
+ another script requires takes the root as its first argument and resolves
27
+ against it (`lib.root()` at the entry point, `path.resolve(root, …)`
28
+ inside), because `chdir` moves the working directory for the caller too.
29
+ `docs-check.js` does both: it chdirs nothing, and its command line resolves
30
+ the root itself.
22
31
  - Every script starts with a short comment describing what it does and how
23
32
  to call it; scripts that take arguments print usage when run with `-h`.
24
33
  - Node, so scripts run the same on Linux, macOS and Windows without a shell
25
- adapter; `.githooks/post-merge` is the one exception, since Git always
26
- runs it through its own bundled shell.
34
+ adapter. The files in `.githooks/` are the exception, since Git runs a hook
35
+ through its own bundled shell, so each is two lines that find the repo root
36
+ and hand over to `githook.js <hook>`. Nothing else belongs in them: a
37
+ decision made in the shell is one no test can reach, and the suite asserts
38
+ the shape.
27
39
  - Scripts must be idempotent where possible — running them twice should not
28
40
  break anything.
29
41
 
package/scripts/lib.js CHANGED
@@ -4,7 +4,9 @@
4
4
  // which requires this file rather than the other way around, so scripts/ never reaches into the
5
5
  // harness-specific folder.
6
6
  // const lib = require("./lib");
7
- // const root = lib.chdirRoot(); // cd to the repo root (this file is one level under it), return it
7
+ // const root = lib.root(); // the repo root: --root=<dir> if given, else one level above this file
8
+ // const root = lib.chdirRoot(); // the same, and cd there
9
+ // const [cmd, ...rest] = lib.args(); // the command line, without --root=<dir>
8
10
  // lib.stdin() // everything on stdin, or "" if there is none
9
11
  // lib.node(["scripts/skills.js", "missing"]) // run a script with this node; { status, output }
10
12
  // lib.shell("npm install") // run a command through the OS shell
@@ -13,13 +15,27 @@ const fs = require("fs");
13
15
  const path = require("path");
14
16
  const { spawnSync } = require("child_process");
15
17
 
16
- // Every script under scripts/ resolves the repo root from its own location (scripts/README.md,
17
- // "Conventions") and chdirs there so its relative paths (SKILL.md files, README.md, stacks.tsv)
18
- // work no matter where it was invoked from.
18
+ // Every script under scripts/ resolves the repo root once, on its command line, and a script run as
19
+ // a command chdirs there so its relative paths (SKILL.md files, README.md, stacks.tsv) work no matter
20
+ // where it was invoked from. A script another script requires takes the root as an argument and
21
+ // resolves against it: chdir is a process-wide effect, so a library that moves the working directory
22
+ // moves it for its caller too.
23
+ // The root is the repo this file sits in, unless the command was given --root=<dir>. That flag is
24
+ // how a check reaches a repo other than its own checkout: the suite points a case at a throwaway
25
+ // directory rather than planting files in the tree it runs in, and the installer checks a target
26
+ // with the upstream's copy of a script. One token, so an argument parser that skips "--" flags skips
27
+ // it too.
28
+ const ROOT_FLAG = "--root=";
29
+ const root = () => {
30
+ const given = process.argv.slice(2).find(a => a.startsWith(ROOT_FLAG));
31
+ return path.resolve(given ? given.slice(ROOT_FLAG.length) : path.join(__dirname, ".."));
32
+ };
33
+ const args = () => process.argv.slice(2).filter(a => !a.startsWith(ROOT_FLAG));
34
+
19
35
  function chdirRoot() {
20
- const root = path.resolve(__dirname, "..");
21
- process.chdir(root);
22
- return root;
36
+ const dir = root();
37
+ process.chdir(dir);
38
+ return dir;
23
39
  }
24
40
 
25
41
  function stdin() { try { return fs.readFileSync(0, "utf8"); } catch { return ""; } }
@@ -39,4 +55,4 @@ function readTsv(file) {
39
55
  .map(l => l.split("\t"));
40
56
  }
41
57
 
42
- module.exports = { chdirRoot, stdin, run, node, shell, readTsv };
58
+ module.exports = { root, args, ROOT_FLAG, chdirRoot, stdin, run, node, shell, readTsv };
@@ -24,6 +24,7 @@
24
24
  // node scripts/update-harness.js --no-check install without proving it afterwards
25
25
  // node scripts/update-harness.js --quiet the summary alone, no line per path
26
26
  // node scripts/update-harness.js --adopt take every harness file from the upstream, losing local edits
27
+ // node scripts/update-harness.js --help print this usage and exit
27
28
  // Installing into a repo that has no harness yet, from anywhere:
28
29
  // git clone https://github.com/salaros/ai-harness .harness && \
29
30
  // node .harness/scripts/update-harness.js --from .harness --target . && rm -rf .harness
@@ -41,6 +42,50 @@ const DEFAULT_REF = "master";
41
42
  const argv = process.argv.slice(2);
42
43
  const flag = name => argv.includes(name);
43
44
  const value = (name, fallback) => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback; };
45
+ // Every argument the installer knows. An optional part of the harness adds its own flag through the
46
+ // manifest (optional:<flag>), so those are checked once the upstream checkout is read. Anything else
47
+ // stops the run before a file is written: this script installs when it is run, so a mistyped
48
+ // --dry-rn, or a --help it did not understand, used to install for real.
49
+ const FLAGS = ["--dry-run", "--adopt", "--quiet", "--no-check", "--help", "-h"];
50
+ const VALUES = ["--ref", "--target", "--from"];
51
+ function unknownArgs(args, optional) {
52
+ const known = [...FLAGS, ...optional.map(name => `--${name}`)];
53
+ const unknown = [];
54
+ for (let i = 0; i < args.length; i++) {
55
+ if (VALUES.includes(args[i])) i++;
56
+ else if (!known.includes(args[i])) unknown.push(args[i]);
57
+ }
58
+ return unknown;
59
+ }
60
+ // The part of that check that needs no manifest, so a typo fails before the clone rather than after
61
+ // it: anything not shaped like a flag, and anything a letter or two from a flag this script already
62
+ // knows, which no optional part would be named.
63
+ function mistypedArgs(args) {
64
+ const named = [...FLAGS, ...VALUES];
65
+ const near = (a, b) => {
66
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
67
+ for (let j = 1; j <= b.length; j++) d[0][j] = j;
68
+ for (let i = 1; i <= a.length; i++)
69
+ for (let j = 1; j <= b.length; j++)
70
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
71
+ return d[a.length][b.length] <= 2;
72
+ };
73
+ const mistyped = [];
74
+ for (let i = 0; i < args.length; i++) {
75
+ if (VALUES.includes(args[i])) { i++; continue; }
76
+ if (named.includes(args[i])) continue;
77
+ if (!/^--[a-z0-9]+(-[a-z0-9]+)*$/.test(args[i]) || named.some(n => near(args[i], n))) mistyped.push(args[i]);
78
+ }
79
+ return mistyped;
80
+ }
81
+ // Read from the comment block at the top of this file, so the usage has one copy.
82
+ function usage() {
83
+ const lines = fs.readFileSync(__filename, "utf8").split(/\r?\n/);
84
+ const start = lines.findIndex(l => l.startsWith("// Usage:"));
85
+ const out = [];
86
+ for (let i = start + 1; i < lines.length && lines[i].startsWith("// "); i++) out.push(lines[i].slice(3).replace("node scripts/update-harness.js", "npx @salaros/ai-harness"));
87
+ return ["Installs or updates the agent harness in the current git repository.", "", "Usage:", ...out].join("\n");
88
+ }
44
89
  const dryRun = flag("--dry-run");
45
90
  const adopt = flag("--adopt");
46
91
 
@@ -188,11 +233,13 @@ function policies(templateDir) {
188
233
  // First match wins, so the table's order is its precedence. A row ending in / covers everything under it.
189
234
  // `optional:<flag>` is seeded only when the run asked for it, and is otherwise not installed at all:
190
235
  // the docs site is the case, useful to some projects and dead weight in the rest.
191
- function policyFor(rows, file) {
236
+ // `wants` answers whether the run asked for an optional part, so the table's meaning does not depend
237
+ // on the process's own argv and a test can ask what a repo would get either way.
238
+ function policyFor(rows, file, wants) {
192
239
  const row = rows.find(r => r.path.endsWith("/") ? file.startsWith(r.path) : file === r.path);
193
240
  if (!row) return "merge"; // anything the upstream ships and nobody classified is harness
194
241
  if (!row.policy.startsWith("optional:")) return row.policy;
195
- return flag(`--${row.policy.slice("optional:".length)}`) ? "seed" : "template";
242
+ return wants(row.policy.slice("optional:".length)) ? "seed" : "template";
196
243
  }
197
244
 
198
245
  // Every path the upstream tracks, with the mode Git recorded. Mode 120000 is a symlink, and the
@@ -291,10 +338,19 @@ const SKELETONS = {
291
338
  ],
292
339
  };
293
340
 
341
+ // A repo that already has an INTENT.md names the product and its purpose there, so the MEMORY.md
342
+ // laid down beside it leaves those two out rather than asking for them a second time.
343
+ function skeletonLines(target, file, lines) {
344
+ if (file !== "MEMORY.md" || !fs.existsSync(path.join(target, "INTENT.md"))) return lines;
345
+ const facts = lines.filter(l => !/^- \*\*(?:Name|Purpose):\*\*/.test(l));
346
+ const first = facts.findIndex(l => l.startsWith("- **"));
347
+ return [...facts.slice(0, first), "The name and purpose are in `INTENT.md`, under `## Product`.", "", ...facts.slice(first)];
348
+ }
349
+
294
350
  function skeletons(target) {
295
351
  for (const [file, lines] of Object.entries(SKELETONS)) {
296
352
  if (fs.existsSync(path.join(target, file))) { step("seed", "100644", "yours", file); continue; }
297
- write(target, file, lines.join("\n"));
353
+ write(target, file, skeletonLines(target, file, lines).join("\n"));
298
354
  step("seed", "100644", "created", file, "seeded");
299
355
  }
300
356
  }
@@ -349,6 +405,69 @@ const isCrlf = text => (text.match(CRLF) || []).length * 2 > (text.match(LF) ||
349
405
  const toLf = text => text.replace(CRLF, "\n");
350
406
  const asFound = (text, crlf) => crlf ? text.replace(LF, "\r\n") : text;
351
407
 
408
+ // ---------------------------------------------------------------- the decision
409
+ //
410
+ // What happens to one file the target already has, decided apart from doing it. Everything these two
411
+ // read is an argument, including the three things they cannot compute -- the base's text, the search
412
+ // for a base when the receipt has none, and the three-way merge itself -- so the caller passes them
413
+ // in and the loop below is left reading, writing and reporting.
414
+ //
415
+ // Both answer the same shape: `outcome` is the word the run prints, `bucket` the summary list it
416
+ // belongs in (null for a file nothing happened to), and `text` what to write, or null to write
417
+ // nothing. Splitting them this way is what makes the table of cases testable: an install rewrites
418
+ // someone else's repository, and every branch below used to need a git checkout and a temp tree to
419
+ // reach even once.
420
+
421
+ // A file with no lines to merge: it is the upstream's copy or it is the project's, and the base
422
+ // decides which. A logo the project replaced stays replaced.
423
+ function decideBinary({ held, theirs, hasBase, adopt }, { baseBytes }) {
424
+ if (same(held, theirs)) return { outcome: "unchanged", bucket: null, text: null };
425
+ const was = hasBase ? baseBytes() : null;
426
+ if (adopt || same(held, was)) return { outcome: adopt ? "adopted" : "written", bucket: "written", text: theirs };
427
+ return { outcome: hasBase ? "yours, binary" : "yours, no base", bucket: "kept", text: null };
428
+ }
429
+
430
+ // A text file. `raw` is what is on disk, in whatever line endings it has; `theirs` is the upstream's,
431
+ // always LF. The comparison and the merge happen in LF and the result is written back in the endings
432
+ // the file already had, so a Windows checkout does not read as edited from top to bottom.
433
+ function decideText({ policy, raw, theirs, hasBase, adopt }, { baseText, recoverBase, merge }) {
434
+ const crlf = isCrlf(raw);
435
+ const ours = toLf(raw);
436
+ const keep = { outcome: hasBase ? "yours, new here" : "yours, no base", bucket: "kept", text: null };
437
+
438
+ if (ours === theirs) return { outcome: "unchanged", bucket: null, text: null };
439
+ // Before the base logic, not inside it: a repo that needs adopting usually has a receipt
440
+ // already, written by the install that kept the stale files in the first place.
441
+ if (adopt) return { outcome: "adopted", bucket: "written", text: asFound(theirs, crlf) };
442
+ // Markers an earlier run wrote and nobody resolved. Left to the merge, the marked-up file is now
443
+ // its own nearest base, so the merge takes it whole, the run says "unchanged" and a half-merged
444
+ // harness passes as settled. Named instead, and the run exits 1 until someone resolves it or
445
+ // --adopt above throws it away.
446
+ if (MARKED.test(ours)) return { outcome: "STILL OPEN", bucket: "conflicted", text: null };
447
+
448
+ // A reconcile file is one the harness cannot work around: AGENTS.md is the map every agent reads
449
+ // and holds the table docs-check parses, and docs/README.md says what the chain puts where.
450
+ // Keeping a stale one leaves a repo that looks installed and behaves like the version it came
451
+ // from, so these are merged even when the receipt is missing. Nothing in the upstream's history
452
+ // matching means this copy was written by hand, and an empty base makes the whole file one
453
+ // conflict -- the honest answer: both versions are there to read, and the run exits 1.
454
+ let from = hasBase ? baseText() : null;
455
+ if (from === null && policy === "reconcile") from = recoverBase(ours);
456
+ if (from === null && policy === "reconcile") from = "";
457
+ if (from === null) return keep;
458
+
459
+ if (ours === from) return { outcome: "written", bucket: "written", text: asFound(theirs, crlf) };
460
+ const merged = merge(from, ours, theirs);
461
+ if (merged.failed) return { outcome: "yours, merge failed", bucket: "kept", text: null };
462
+ const result = asFound(merged.text, crlf);
463
+ if (merged.conflicts) return { outcome: "CONFLICT", bucket: "conflicted", text: result };
464
+ // A file that keeps a local edit merges cleanly on every later run and comes out the same every
465
+ // time. Reported as merged each run it reads as churn, and the reader goes looking for a change
466
+ // nobody made, so what the run did is decided by the result, not the route.
467
+ if (result === raw) return { outcome: "unchanged", bucket: null, text: null };
468
+ return { outcome: "merged", bucket: "merged", text: result };
469
+ }
470
+
352
471
  function write(target, file, text, exec) {
353
472
  const full = path.join(target, file);
354
473
  if (dryRun) return;
@@ -361,47 +480,28 @@ function write(target, file, text, exec) {
361
480
 
362
481
  // Merging is not checking. The installer knows it wrote a file; it cannot know whether the result
363
482
  // still works -- an AGENTS.md whose chain table no longer parses, routing sections naming an agent
364
- // this repo does not have, a skill nothing links to, an upstream with no licence row. The suite
365
- // answers all of that, and it is the upstream's own: fixtures that prove a harness are no use to a
366
- // project carrying one, so they do not travel.
483
+ // this repo does not have, a skill nothing links to, an upstream with no licence row. Those are the
484
+ // harness invariants, and scripts/check-harness.js holds them as functions of a root.
367
485
  //
368
- // So they are borrowed. Written in, run, and taken away again, leaving the repo as the install left
369
- // it. Running them from the upstream checkout instead would be tidier and does not work: both
370
- // lib.js files resolve the repo root from __dirname with no override, so the suite only ever tests
371
- // the checkout it sits in.
372
- const SUITE = [".agents/hooks/test.js", ".agents/hooks/tests/"];
373
- const wantedBySuite = file => SUITE.some(p => p.endsWith("/") ? file.startsWith(p) : file === p);
374
-
375
- function selfCheck(target, templateDir, head, files) {
376
- const borrowed = [];
377
- for (const { file } of files) {
378
- if (!wantedBySuite(file)) continue;
379
- // Anything already at one of these paths is the project's own: it is not overwritten here,
380
- // and the cleanup below must not remove it either, so it is left out of the borrowed list.
381
- if (fs.existsSync(path.join(target, file))) continue;
382
- const text = blob(templateDir, head, file);
383
- if (text === null) continue;
384
- write(target, file, text);
385
- borrowed.push(file);
386
- }
387
- phase(`self check: ${borrowed.length} file(s) borrowed from the upstream suite`);
388
- if (!borrowed.length) return { skipped: "the suite is already in this repo; run it yourself with node .agents/hooks/test.js" };
389
-
390
- try {
391
- const r = lib.node([path.join(target, ".agents/hooks/test.js")], { cwd: target });
392
- const lines = r.output.split(/\r?\n/).filter(l => l.trim());
393
- return { failed: r.status !== 0, summary: lines[lines.length - 1] || "no output", output: r.output };
394
- } finally {
395
- // Only what this function wrote, and only the directories that writing it created.
396
- for (const file of borrowed) { try { fs.rmSync(path.join(target, file)); } catch { /* already gone */ } }
397
- const dirs = [...new Set(borrowed.map(f => path.dirname(f)))].sort((a, b) => b.length - a.length);
398
- for (const dir of dirs) { try { fs.rmdirSync(path.join(target, dir)); } catch { /* the project's, or not empty */ } }
399
- }
486
+ // So they run from the upstream checkout against the target, and nothing is written into the target
487
+ // to run them. The upstream's copy rather than the one just installed, so the check is the one that
488
+ // matches the files this run wrote. The suite's fixtures stay upstream: they prove the harness
489
+ // scripts, which the upstream's own CI has already done.
490
+ function selfCheck(target, templateDir) {
491
+ const script = path.join(templateDir, "scripts", "check-harness.js");
492
+ if (!fs.existsSync(script)) return { skipped: "this upstream ref has no scripts/check-harness.js" };
493
+ phase("self check: the harness invariants, run from the upstream against this repo");
494
+ const harness = require(script);
495
+ const r = harness.check(target);
496
+ return { failed: r.failed.length > 0, summary: r.summary, output: harness.format(r) };
400
497
  }
401
498
 
402
499
  // ---------------------------------------------------------------- the run
403
500
 
404
501
  function main() {
502
+ if (flag("--help") || flag("-h")) { console.log(usage()); return; }
503
+ const mistyped = mistypedArgs(argv);
504
+ if (mistyped.length) fail(`unknown argument(s): ${mistyped.join(" ")}. Nothing was written; run with --help for the options.`);
405
505
  const target = targetRoot();
406
506
  if (!fs.existsSync(path.join(target, ".git"))) fail(`${target} is not a git checkout`);
407
507
 
@@ -411,6 +511,14 @@ function main() {
411
511
  const { dir: templateDir, temporary } = templateCheckout(ref);
412
512
 
413
513
  try {
514
+ // Checked before anything is said about the target, so a bad argument is the only message.
515
+ const rows = policies(templateDir);
516
+ const optional = rows.filter(r => r.policy.startsWith("optional:")).map(r => r.policy.slice("optional:".length));
517
+ const unknown = unknownArgs(argv, optional);
518
+ if (unknown.length) {
519
+ if (temporary) fs.rmSync(templateDir, { recursive: true, force: true });
520
+ fail(`unknown argument(s): ${unknown.join(" ")}. Nothing was written; run with --help for the options.`);
521
+ }
414
522
  const head = at(templateDir, ["rev-parse", "HEAD"]).output.trim();
415
523
  // A base is what makes this an update rather than an overwrite. Without one -- a first
416
524
  // install, or an upstream whose history was rewritten -- an existing file is left alone
@@ -439,14 +547,14 @@ function main() {
439
547
  else say(`this repo has a harness (${stale.join(", ")}) but no ${LOCK}, so it predates the receipt and there is no merge base.\nEvery harness file already here is kept, which leaves old checks running against new skills. Re-run with --adopt to replace them, or --dry-run --quiet to list them first.`);
440
548
  }
441
549
 
442
- const rows = policies(templateDir);
550
+ const wants = name => flag(`--${name}`);
443
551
  const files = templateFiles(templateDir);
444
552
  const skills = [];
445
553
 
446
554
  phase(`${files.length} path(s) in ${ref} at ${head.slice(0, 8)}`);
447
555
  for (const entry of files) {
448
556
  const { file, link: isLink, exec } = entry;
449
- const policy = policyFor(rows, file);
557
+ const policy = policyFor(rows, file, wants);
450
558
  const m = mode(entry);
451
559
  const theirs = blob(templateDir, head, file);
452
560
  // Git listed the path a moment ago, so failing to read it is the checkout being unhappy
@@ -491,54 +599,20 @@ function main() {
491
599
  }
492
600
  // merge
493
601
  if (!exists) { write(target, file, theirs, exec); step(policy, m, "written", file, "written"); continue; }
494
- // Binary: there are no lines to merge, so it is the upstream's copy or it is the
495
- // project's, and the base decides which. A logo the project replaced stays replaced.
496
- if (Buffer.isBuffer(theirs)) {
497
- const held = fs.readFileSync(full);
498
- if (same(held, theirs)) { step(policy, m, "unchanged", file); continue; }
499
- const was = base === null ? null : blob(templateDir, base, file);
500
- if (adopt || same(held, was)) {
501
- write(target, file, theirs, exec);
502
- step(policy, m, adopt ? "adopted" : "written", file, "written");
503
- } else step(policy, m, base === null ? "yours, no base" : "yours, binary", file, "kept");
504
- continue;
505
- }
506
- const raw = fs.readFileSync(full, "utf8");
507
- const crlf = isCrlf(raw);
508
- const ours = toLf(raw);
509
- if (ours === theirs) { step(policy, m, "unchanged", file); continue; }
510
- // Before the base logic, not inside it: a repo that needs adopting usually has a
511
- // receipt already, written by the install that kept the stale files in the first place.
512
- if (adopt) { write(target, file, asFound(theirs, crlf), exec); step(policy, m, "adopted", file, "written"); continue; }
513
- // Markers an earlier run wrote and nobody resolved. Left to the merge, the marked-up file
514
- // is now its own nearest base, so the merge takes it whole, the run says "unchanged" and
515
- // a half-merged harness passes as settled. Named instead, and the run exits 1 until
516
- // someone resolves it or --adopt above throws it away.
517
- if (MARKED.test(ours)) { step(policy, m, "STILL OPEN", file, "conflicted"); continue; }
518
- // A reconcile file is one the harness cannot work around: AGENTS.md is the map every
519
- // agent reads and holds the table docs-check parses, and docs/README.md says what the
520
- // chain puts where. Keeping a stale one leaves a repo that looks installed and behaves
521
- // like the version it came from, so these are merged even when the receipt is missing.
522
- let from = base === null ? null : blob(templateDir, base, file);
523
- if (from === null && policy === "reconcile") from = recoverBase(templateDir, file, ours);
524
- if (from === null && policy === "reconcile") {
525
- // Nothing in the upstream's history matches, so this copy was written by hand. An
526
- // empty base makes the whole file one conflict, which is the honest answer: both
527
- // versions are there to read, and the run exits 1 rather than pretending.
528
- from = "";
529
- }
530
- if (from === null) { step(policy, m, base === null ? "yours, no base" : "yours, new here", file, "kept"); continue; }
531
- if (ours === from) { write(target, file, asFound(theirs, crlf), exec); step(policy, m, "written", file, "written"); continue; }
532
- const merged = threeWay(from, ours, theirs);
533
- if (merged.failed) { step(policy, m, "yours, merge failed", file, "kept"); continue; }
534
- const result = asFound(merged.text, crlf);
535
- if (merged.conflicts) { write(target, file, result, exec); step(policy, m, "CONFLICT", file, "conflicted"); continue; }
536
- // A file that keeps a local edit merges cleanly on every later run and comes out the same
537
- // every time. Reported as merged each run it reads as churn, and the reader goes looking
538
- // for a change nobody made, so what the run did is decided by the result, not the route.
539
- if (result === raw) { step(policy, m, "unchanged", file); continue; }
540
- write(target, file, result, exec);
541
- step(policy, m, "merged", file, "merged");
602
+
603
+ // Everything the decision needs, read here; what to do with its answer, done here. The
604
+ // decision itself is decideBinary/decideText, which touch neither git nor the disk.
605
+ const hasBase = base !== null;
606
+ const held = Buffer.isBuffer(theirs) ? fs.readFileSync(full) : fs.readFileSync(full, "utf8");
607
+ const { outcome, bucket, text } = Buffer.isBuffer(theirs)
608
+ ? decideBinary({ held, theirs, hasBase, adopt }, { baseBytes: () => blob(templateDir, base, file) })
609
+ : decideText({ policy, raw: held, theirs, hasBase, adopt }, {
610
+ baseText: () => blob(templateDir, base, file),
611
+ recoverBase: ours => recoverBase(templateDir, file, ours),
612
+ merge: threeWay,
613
+ });
614
+ if (text !== null) write(target, file, text, exec);
615
+ step(policy, m, outcome, file, bucket);
542
616
  }
543
617
 
544
618
  phase("skeletons a project starts with");
@@ -552,8 +626,8 @@ function main() {
552
626
  updated: new Date().toISOString().slice(0, 10),
553
627
  }, null, 2) + "\n");
554
628
  finish(target);
555
- // After finish(), because the suite checks the links relink has just written.
556
- if (!flag("--no-check")) notes.check = selfCheck(target, templateDir, head, files);
629
+ // After finish(), because the invariants check the links relink has just written.
630
+ if (!flag("--no-check")) notes.check = selfCheck(target, templateDir);
557
631
  }
558
632
  report(target, head, ref, base);
559
633
  } finally {
@@ -653,19 +727,23 @@ function report(target, head, ref, base) {
653
727
  }
654
728
  if (notes.conflicted.length) {
655
729
  list("CONFLICTED, resolve the markers by hand", notes.conflicted, true);
656
- say(`\nEach one holds <<<<<<< yours / ======= / >>>>>>> upstream (new). Resolve them, then run the suite:\n node .agents/hooks/test.js`);
730
+ say(`\nEach one holds <<<<<<< yours / ======= / >>>>>>> upstream (new). Resolve them, then check the harness:\n node scripts/check-harness.js`);
657
731
  }
658
732
  const check = notes.check;
659
733
  if (check && check.skipped) say(`\nself check skipped: ${check.skipped}`);
660
734
  else if (check && check.failed) say(`\nSELF CHECK FAILED, so this install does not work yet:\n${check.output}`);
661
- else if (check) say(`\nself check: ${check.summary}, run from the upstream suite and removed again`);
735
+ else if (check) say(`\nself check: ${check.summary}`);
662
736
 
663
737
  if (!dryRun) {
664
- const suite = fs.existsSync(path.join(target, ".agents/hooks/test.js"));
665
- say(`\nIn ${target}, point Git at the hooks once per clone:`);
666
- say(` node scripts/githooks-init.js${suite ? " && node .agents/hooks/test.js" : " && node scripts/docs-check.js"}`);
738
+ say(`\nIn ${target}, point Git at the hooks once per clone, then check the harness:`);
739
+ say(` node scripts/githooks-init.js && node scripts/check-harness.js`);
667
740
  }
668
741
  if (notes.conflicted.length || notes.unreadable.length || (check && check.failed)) process.exit(1);
669
742
  }
670
743
 
671
- main();
744
+ // The decision, and the two pure helpers under it, so the suite can put a case in and read the
745
+ // answer out rather than building a git checkout to reach one branch. Everything else here writes to
746
+ // somebody's repository and stays behind main().
747
+ module.exports = { unknownArgs, mistypedArgs, usage, policyFor, decideText, decideBinary, lineCounts, overlap, NEAREST, skeletonLines };
748
+
749
+ if (require.main === module) main();