@salaros/ai-harness 0.1.0

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 ADDED
@@ -0,0 +1,14 @@
1
+ Copyright (c) 2019 CODECAVE
2
+
3
+ All rights reserved.
4
+
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.
8
+
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.
package/README.md ADDED
@@ -0,0 +1,176 @@
1
+ # AI harness
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`.
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.
6
+
7
+ ## Adding the harness to a repository you already have
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:
10
+
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
+ 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.
18
+
19
+ 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.
20
+
21
+ 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.
22
+
23
+ `scripts/harness-files.tsv` is the table behind all of this, one row per path.
24
+
25
+ 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.
26
+
27
+ Skills are added and updated, never removed: one you vendored yourself survives every update, and `skills-lock.json` is merged as a union.
28
+
29
+ ## Getting started
30
+
31
+ 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.
32
+ 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.
33
+ 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.
34
+ 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.
35
+ 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).
36
+
37
+ ## Layout
38
+
39
+ | Path | What it is |
40
+ | --- | --- |
41
+ | `src/`, `tests/`, `scripts/`, `tools/`, `docs/` | Product code, tests, repo automation, dev utilities, documents. Each has a README describing what belongs there. |
42
+ | `AGENTS.md` | The one file every agent reads: layout, where skills are, domain-language pointers. Kept short on purpose. |
43
+ | `CLAUDE.md` | One line, `@AGENTS.md`, because Claude Code reads `CLAUDE.md` rather than `AGENTS.md`. |
44
+ | `MEMORY.md` | The project facts (name, purpose, 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. |
45
+ | `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. |
46
+ | `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. |
47
+ | `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. |
48
+ | `.agents/skills/<name>/` | The canonical, vendored copy of each skill (`SKILL.md` plus its reference files). |
49
+ | `.agents/hooks/` | Harness-neutral hook scripts (see [Hooks](#hooks)). |
50
+ | `.agents/agents/` | Agent definitions (see [Agents](#agents)). |
51
+ | `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. The first two are the upstream’s own and are never installed; the receipt is written into the repo being updated. |
52
+ | `.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. |
53
+ | `.claude/` | Claude Code wiring: `skills/*` and `agents` are symlinks into `.agents/`, `settings.json` wires the hooks. |
54
+ | `.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. |
55
+ | `docs/agents/` | Per-repo configuration the skills read: issue tracker, triage labels, domain-doc rules. |
56
+ | `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. |
57
+ | `.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. |
58
+ | `.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. |
59
+ | `workflows/` | Workflow specs written by `loop-me`. |
60
+ | `.scratch/` | Committed working files: feature specs, ticket drafts, prototypes not yet on a branch. |
61
+ | `.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. |
62
+
63
+ ## Skills
64
+
65
+ 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` links.
66
+
67
+ ```bash
68
+ npx skills add mattpocock/skills -s wait-what -a claude-code codex -y # add a skill
69
+ npx skills remove wait-what -y # remove one
70
+ npx skills update # newer versions of everything
71
+ node scripts/skills.js relink # after any of the above, and after writing a local skill
72
+ ```
73
+
74
+ - Relink does two things. It creates a `.claude/skills` link for every skill under `.agents/skills` that has none, which is how a local skill written by hand becomes visible to the harness at all: `npx skills` links only what it vendored. And on Windows it rewrites the links the CLI recreates as absolute junctions, 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 any installed skill is unlinked.
75
+ - `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.
76
+ - 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.
77
+ - 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`.
78
+ - 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`.
79
+
80
+ `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.
81
+
82
+ ## Hooks
83
+
84
+ 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.
85
+
86
+ | Script | Event | Does |
87
+ | --- | --- | --- |
88
+ | `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. |
89
+ | `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. |
90
+ | `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. |
91
+
92
+ 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.
93
+
94
+ `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.
95
+
96
+ ## Agents
97
+
98
+ 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.
99
+
100
+ | Agent | For |
101
+ | --- | --- |
102
+ | `engineer` | Engineering tasks bigger than a one-line edit, and the ADR → SPEC → TDD → IPLAN → Code half of the documentation chain |
103
+ | `business-analyst` | Requirements, process design, interface contracts, agent-ready briefs, the BRD → PRD → EARS → BDD half of the documentation chain |
104
+ | `devops` | Containers, CI/CD, Kubernetes, infrastructure as code, rollouts and incidents |
105
+ | `assistant` | Non-technical colleagues |
106
+
107
+ 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.
108
+
109
+ ## Files per AI tool
110
+
111
+ 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.
112
+
113
+ ### Claude Code
114
+
115
+ | Need | File | In repo |
116
+ | --- | --- | --- |
117
+ | Instructions | `CLAUDE.md` containing `@AGENTS.md` (Claude Code does not read `AGENTS.md` itself) | yes |
118
+ | Skills | `.claude/skills/<name>` → symlink to `../../.agents/skills/<name>` | yes |
119
+ | 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 |
120
+ | Agents | `.claude/agents` → symlink to `../.agents/agents`; frontmatter `name`, `description` (+ optional `tools`, `model`, `skills`) | yes |
121
+ | Per-developer overrides | `.claude/settings.local.json` (git-ignored) | no |
122
+
123
+ 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/`.
124
+
125
+ ### Other tools
126
+
127
+ | Tool | Instructions | Skills | Hooks | Agents | MCP | Notes |
128
+ | --- | --- | --- | --- | --- | --- | --- |
129
+ | 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) |
130
+ | 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) |
131
+ | 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) |
132
+ | 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/) |
133
+ | 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/) |
134
+ | 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 | |
135
+
136
+ 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`.
137
+
138
+ ## Documentation chain
139
+
140
+ 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.
141
+
142
+ ## What each skill expects from the repo
143
+
144
+ 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.
145
+
146
+ ### Shared setup: `docs/agents/`
147
+
148
+ 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**:
149
+
150
+ - `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.
151
+ - `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.
152
+ - `docs/agents/domain.md`: tells skills to read `CONTEXT.md` and `docs/adr/` before exploring, and to stay silent when they are absent.
153
+ - `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.
154
+ - The `## Agent skills` section in `AGENTS.md` points at the three files.
155
+
156
+ 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.
157
+
158
+ ### Per skill
159
+
160
+ | Skill | Reads | Writes |
161
+ | --- | --- | --- |
162
+ | `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 |
163
+ | `to-tickets` | `docs/agents/issue-tracker.md` | drafts under `.scratch/<feature-slug>/`, then Jira issues in dependency order |
164
+ | `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 |
165
+ | `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 |
166
+ | `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 |
167
+ | `docs-check` | the `AGENTS.md` table, `docs/` | repairs in place |
168
+ | `loose-ends` | `TODO.md` | `TODO.md` at the repo root, created on the first entry |
169
+ | `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 |
170
+ | `teach` | the working directory as a workspace | `MISSION.md`, `RESOURCES.md`, `NOTES.md`, `reference/`, `lessons/`, `learning-records/`, `assets/` |
171
+ | `loop-me` | `NOTES.md`, shared with `teach` | `workflows/<name>.md`, `NOTES.md` |
172
+ | `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 |
173
+ | `retro` | the session logs of the agent that ran | proposed edits to `AGENTS.md`, `CODING_STANDARDS.md`, the docs and the skills |
174
+ | `agent-browser` | the `agent-browser` CLI (`npx agent-browser` fetches it) | nothing in the repo |
175
+
176
+ `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`.
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@salaros/ai-harness",
3
+ "version": "0.1.0",
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
+ "bin": {
6
+ "ai-harness": "scripts/update-harness.js"
7
+ },
8
+ "files": [
9
+ "scripts/update-harness.js",
10
+ "scripts/lib.js",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "engines": {
15
+ "node": ">=22"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/salaros/ai-harness.git"
20
+ },
21
+ "license": "MIT",
22
+ "keywords": [
23
+ "agent",
24
+ "skills",
25
+ "claude-code",
26
+ "git-hooks",
27
+ "harness",
28
+ "template"
29
+ ]
30
+ }
@@ -0,0 +1,29 @@
1
+ ## Scripts
2
+
3
+ Automation scripts for working with this repository. They are run by
4
+ developers and CI, not by the application itself, and are never shipped as
5
+ part of the product.
6
+
7
+ ### What belongs here
8
+
9
+ - **Setup** — bootstrap a fresh clone (install dependencies, git hooks,
10
+ local configuration).
11
+ - **Build and release** — compile, package, version, publish.
12
+ - **Maintenance** — clean caches, regenerate code or docs, migrate data.
13
+ - **CI helpers** — steps invoked from pipeline definitions, kept here so
14
+ they can also be run locally.
15
+
16
+ ### Conventions
17
+
18
+ - One task per script, named after what it does (`githooks-init.js`,
19
+ `build.js`, `release.js`); Node, so they run the same on every OS.
20
+ - Scripts are safe to run from any working directory — each resolves the
21
+ repository root from its own location.
22
+ - Every script starts with a short comment describing what it does and how
23
+ to call it; scripts that take arguments print usage when run with `-h`.
24
+ - 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.
27
+ - Scripts must be idempotent where possible — running them twice should not
28
+ break anything.
29
+
package/scripts/lib.js ADDED
@@ -0,0 +1,42 @@
1
+ // scripts/lib.js
2
+ // Small process and table-reading helpers shared by scripts and, through .agents/hooks/lib.js, by
3
+ // the hooks. Nothing here knows about a harness's hook payload; that lives in .agents/hooks/lib.js,
4
+ // which requires this file rather than the other way around, so scripts/ never reaches into the
5
+ // harness-specific folder.
6
+ // const lib = require("./lib");
7
+ // const root = lib.chdirRoot(); // cd to the repo root (this file is one level under it), return it
8
+ // lib.stdin() // everything on stdin, or "" if there is none
9
+ // lib.node(["scripts/skills.js", "missing"]) // run a script with this node; { status, output }
10
+ // lib.shell("npm install") // run a command through the OS shell
11
+ // lib.readTsv("scripts/stacks.tsv") // rows as arrays of cells; blank and # lines skipped
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+ const { spawnSync } = require("child_process");
15
+
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.
19
+ function chdirRoot() {
20
+ const root = path.resolve(__dirname, "..");
21
+ process.chdir(root);
22
+ return root;
23
+ }
24
+
25
+ function stdin() { try { return fs.readFileSync(0, "utf8"); } catch { return ""; } }
26
+
27
+ // Runs a command; output is stdout and stderr combined, status is the exit code (-1 if it could not start).
28
+ function run(cmd, args, opts = {}) {
29
+ const r = spawnSync(cmd, args, { encoding: "utf8", ...opts });
30
+ const output = ((r.stdout || "") + (r.stderr || "")).replace(/\s+$/, "");
31
+ return { status: r.status === null ? -1 : r.status, output: r.error ? `${r.error.message}${output ? "\n" + output : ""}` : output };
32
+ }
33
+ const node = (args, opts) => run(process.execPath, args, opts);
34
+ const shell = (cmd, opts) => run(cmd, [], { shell: true, ...opts });
35
+
36
+ function readTsv(file) {
37
+ return fs.readFileSync(file, "utf8").split(/\r?\n/)
38
+ .filter(l => l.trim() && !l.startsWith("#"))
39
+ .map(l => l.split("\t"));
40
+ }
41
+
42
+ module.exports = { chdirRoot, stdin, run, node, shell, readTsv };
@@ -0,0 +1,441 @@
1
+ #!/usr/bin/env node
2
+ // scripts/update-harness.js
3
+ // Installs this repository's harness into another one, and updates it there afterwards. The
4
+ // upstream keeps moving; a repo made from it fills the same tree with work of its own. This script
5
+ // is the line between the two, and scripts/harness-files.tsv is where that line is written down.
6
+ //
7
+ // Updating is a three-way merge, not a copy. harness-lock.json at the target root records the
8
+ // upstream commit the harness was last taken from, so an update has a base: the recorded commit's
9
+ // version of a file, the target's version, and the new one. A file nobody edited takes the new
10
+ // version outright. An edited one keeps its edits and gains the changes around them. Only a real
11
+ // collision is written with conflict markers, and every one of those is reported and exits 1, so a
12
+ // half-merged harness is never mistaken for a clean update.
13
+ //
14
+ // First run into a repo with no harness-lock.json is an install: there is no base, so every managed
15
+ // file is written, and nothing that already exists is touched.
16
+ //
17
+ // Usage:
18
+ // node scripts/update-harness.js update this repo from the upstream it records
19
+ // node scripts/update-harness.js --dry-run say what would change, write nothing
20
+ // node scripts/update-harness.js --ref v2 update from a tag or branch instead
21
+ // node scripts/update-harness.js --target ../other install into or update another checkout
22
+ // node scripts/update-harness.js --from ../ai-harness use a checkout you already have, no clone
23
+ // node scripts/update-harness.js --astro-docs add tools/docs-site, the Astro renderer for the chain
24
+ // node scripts/update-harness.js --no-check install without proving it afterwards
25
+ // Installing into a repo that has no harness yet, from anywhere:
26
+ // git clone https://github.com/salaros/ai-harness .harness && \
27
+ // node .harness/scripts/update-harness.js --from .harness --target . && rm -rf .harness
28
+ const fs = require("fs");
29
+ const os = require("os");
30
+ const path = require("path");
31
+ const lib = require("./lib");
32
+
33
+ const TEMPLATE = "https://github.com/salaros/ai-harness.git";
34
+ const LOCK = "harness-lock.json";
35
+ const MANIFEST = "scripts/harness-files.tsv";
36
+ const DEFAULT_REF = "master";
37
+
38
+ const argv = process.argv.slice(2);
39
+ const flag = name => argv.includes(name);
40
+ const value = (name, fallback) => { const i = argv.indexOf(name); return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback; };
41
+ const dryRun = flag("--dry-run");
42
+
43
+ // ---------------------------------------------------------------- the target
44
+
45
+ // Where the harness is going. Run from inside a project, that is the project: this script sits in
46
+ // its scripts/ folder. Run through npx, the package is an extracted tarball with no .git of its own,
47
+ // so the answer is the directory the user is standing in. One rule covers both, and --target covers
48
+ // installing into a checkout from somewhere else entirely.
49
+ function targetRoot() {
50
+ const given = value("--target", null);
51
+ if (given) return path.resolve(given);
52
+ const beside = path.resolve(__dirname, "..");
53
+ return fs.existsSync(path.join(beside, ".git")) ? beside : process.cwd();
54
+ }
55
+
56
+ // ---------------------------------------------------------------- the upstream
57
+
58
+ // A clone deep enough to read the recorded commit: an update needs that commit's version of a file
59
+ // as the merge base, and --depth 1 would not have it. Removed again unless the caller supplied one.
60
+ function templateCheckout(ref) {
61
+ // The manifest is what makes a checkout usable here, so both routes are held to it: a --from
62
+ // that points somewhere else, and a --ref naming a branch or tag from before the table existed,
63
+ // fail the same way. Without this the run reaches readTsv and dies in a stack trace naming a
64
+ // temporary directory the reader has never heard of.
65
+ const usable = dir => fs.existsSync(path.join(dir, MANIFEST));
66
+ const given = value("--from", null);
67
+ if (given) {
68
+ const dir = path.resolve(given);
69
+ if (!usable(dir)) fail(`${dir} does not look like the upstream harness: no ${MANIFEST}`);
70
+ return { dir, temporary: false };
71
+ }
72
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "harness-"));
73
+ say(`cloning ${TEMPLATE} at ${ref}`);
74
+ const r = lib.run("git", ["clone", "--quiet", "--branch", ref, TEMPLATE, dir]);
75
+ if (r.status !== 0) fail(`could not clone the upstream at ${ref}\n${r.output}`);
76
+ if (!usable(dir)) {
77
+ fs.rmSync(dir, { recursive: true, force: true });
78
+ fail(`${TEMPLATE} at ${ref} carries no ${MANIFEST}, so there is nothing to install from; try another --ref`);
79
+ }
80
+ return { dir, temporary: true };
81
+ }
82
+
83
+ const at = (dir, args) => lib.run("git", ["-C", dir, ...args]);
84
+
85
+ // The upstream's version of a path at a commit, or null when the file did not exist there. Also how
86
+ // a missing base is detected: a rewritten history no longer holds the recorded commit.
87
+ function blob(dir, commit, file) {
88
+ const r = at(dir, ["show", `${commit}:${file}`]);
89
+ return r.status === 0 ? r.output : null;
90
+ }
91
+
92
+ // ---------------------------------------------------------------- the manifest
93
+
94
+ function policies(templateDir) {
95
+ return lib.readTsv(path.join(templateDir, MANIFEST)).map(([p, policy]) => ({ path: p, policy }));
96
+ }
97
+
98
+ // First match wins, so the table's order is its precedence. A row ending in / covers everything under it.
99
+ // `optional:<flag>` is seeded only when the run asked for it, and is otherwise not installed at all:
100
+ // the docs site is the case, useful to some projects and dead weight in the rest.
101
+ function policyFor(rows, file) {
102
+ const row = rows.find(r => r.path.endsWith("/") ? file.startsWith(r.path) : file === r.path);
103
+ if (!row) return "merge"; // anything the upstream ships and nobody classified is harness
104
+ if (!row.policy.startsWith("optional:")) return row.policy;
105
+ return flag(`--${row.policy.slice("optional:".length)}`) ? "seed" : "template";
106
+ }
107
+
108
+ // Every path the upstream tracks, with the mode Git recorded. Mode 120000 is a symlink, and the
109
+ // harness has two kinds: .claude/agents pointing at .agents/agents, and one per skill under
110
+ // .claude/skills. Written as ordinary files they become text files holding a path, which is how a
111
+ // harness ends up looking installed while the agent sees no skills and no agents at all.
112
+ function templateFiles(dir) {
113
+ const r = at(dir, ["ls-files", "-s"]);
114
+ if (r.status !== 0) fail(`could not list the upstream's files\n${r.output}`);
115
+ return r.output.split(/\r?\n/).filter(Boolean).map(line => {
116
+ const [meta, file] = line.split("\t");
117
+ return { file, link: meta.startsWith("120000"), exec: meta.startsWith("100755") };
118
+ });
119
+ }
120
+
121
+ // Git runs a hook only if it is executable, and says nothing when it is not: an installed harness
122
+ // whose hooks are mode 644 looks installed and gates nothing. The upstream records them 100755, so
123
+ // that mode has to travel, and only Git can carry it. `chmod` alone is not enough -- on Windows
124
+ // core.fileMode is false and the call does nothing, so the file would be staged 100644 later and the
125
+ // hooks would run for whoever installed them and silently never run for anyone else. `git add
126
+ // --chmod=+x` writes the mode into the index whether or not the file was tracked, which is why the
127
+ // install stages these few files rather than leaving them for the project's own `git add`.
128
+ function carryMode(target, file) {
129
+ try { fs.chmodSync(path.join(target, file), 0o755); } catch { /* the filesystem does not do modes */ }
130
+ const r = lib.run("git", ["-C", target, "add", "--chmod=+x", "--", file]);
131
+ if (r.status !== 0) say(`could not mark ${file} executable: ${r.output}`);
132
+ }
133
+
134
+ // A symlink recorded in Git is a blob holding its target. Windows needs Developer Mode and
135
+ // core.symlinks=true for this to work at all, so a refusal is reported rather than thrown: the
136
+ // harness still functions with the links missing, it is just invisible to the harnesses that read
137
+ // them, and README says how to turn them on.
138
+ function link(target, file, to) {
139
+ const full = path.join(target, file);
140
+ if (dryRun) return "written";
141
+ fs.mkdirSync(path.dirname(full), { recursive: true });
142
+ let existing = null;
143
+ try { existing = fs.lstatSync(full); } catch { /* absent */ }
144
+ if (existing) {
145
+ if (!existing.isSymbolicLink()) return "kept"; // something of the project's is in the way
146
+ if (fs.readlinkSync(full).split(path.sep).join("/") === to) return null;
147
+ fs.unlinkSync(full);
148
+ }
149
+ try {
150
+ fs.symlinkSync(to.split("/").join(path.sep), full, "dir");
151
+ return existing ? "merged" : "written";
152
+ } catch (e) {
153
+ say(`could not create the symlink ${file} -> ${to}: ${e.code || e.message}`);
154
+ return "kept";
155
+ }
156
+ }
157
+
158
+ // ---------------------------------------------------------------- skeletons
159
+
160
+ // Three files the upstream does not ship, because there they would be lies: MEMORY.md
161
+ // describes a project this repo is not, and CONTEXT.md and TODO.md are written by the skills that
162
+ // own them, when there is something real to put in them. A repo that just took the harness has
163
+ // neither the files nor any sign the harness expects them, so an empty one is laid down: it names
164
+ // the file, says which skill fills it, and is valid to every check that reads it. Written only when
165
+ // absent, and never touched again.
166
+ const SKELETONS = {
167
+ "MEMORY.md": [
168
+ "# Project memory",
169
+ "",
170
+ "The facts no other file derives. Read this first. The `project-init` skill writes it, and the",
171
+ "`pre-commit` and `pre-push` hooks refuse to let work leave a clone while any value is still a",
172
+ "`<placeholder>`.",
173
+ "",
174
+ "- **Name:** <name>",
175
+ "- **Purpose:** <purpose>",
176
+ "- **Requirements:** <requirements>",
177
+ "- **Unit type:** <unit type>",
178
+ "- **Language:** <language>",
179
+ "- **Runtime / package manager:** <runtime>",
180
+ "",
181
+ ],
182
+ "CONTEXT.md": [
183
+ "# Context",
184
+ "",
185
+ "The project's glossary: one entry per term the code and the documents both use, in the words",
186
+ "the business uses. The `domain-modeling` skill writes an entry the moment a term is settled,",
187
+ "and the decisions those terms come out of live in `docs/adr/`.",
188
+ "",
189
+ ],
190
+ "TODO.md": [
191
+ "# TODO",
192
+ "",
193
+ "Loose ends, in the [todo-md](https://github.com/todo-md/todo-md) format the `loose-ends` skill",
194
+ "writes: `- [ ] <text> #question|#assumption|#deferred (<source>)`. An entry is deleted in the",
195
+ "commit that settles it rather than ticked, so the length of this file means something.",
196
+ "",
197
+ ],
198
+ };
199
+
200
+ function skeletons(target) {
201
+ for (const [file, lines] of Object.entries(SKELETONS)) {
202
+ if (fs.existsSync(path.join(target, file))) continue;
203
+ write(target, file, lines.join("\n"));
204
+ notes.seeded.push(file);
205
+ }
206
+ }
207
+
208
+ // ---------------------------------------------------------------- merging
209
+
210
+ // git merge-file writes the merged result and reports the number of conflicts, or a negative status
211
+ // for trouble. Used rather than a hand-rolled diff3 because the target already needs Git.
212
+ function threeWay(base, ours, theirs) {
213
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "merge-"));
214
+ const f = n => path.join(dir, n);
215
+ try {
216
+ fs.writeFileSync(f("base"), base);
217
+ fs.writeFileSync(f("ours"), ours);
218
+ fs.writeFileSync(f("theirs"), theirs);
219
+ const r = lib.run("git", ["merge-file", "-L", "yours", "-L", "upstream (base)", "-L", "upstream (new)",
220
+ f("ours"), f("base"), f("theirs")]);
221
+ return { text: fs.readFileSync(f("ours"), "utf8"), conflicts: r.status > 0, failed: r.status < 0 };
222
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
223
+ }
224
+
225
+ // ---------------------------------------------------------------- reporting
226
+
227
+ const notes = { written: [], merged: [], conflicted: [], seeded: [], kept: [], skipped: [], template: [], check: null };
228
+ const say = m => console.log(m);
229
+ function fail(m) { console.error(`update-harness: ${m}`); process.exit(1); }
230
+
231
+ function write(target, file, text, exec) {
232
+ const full = path.join(target, file);
233
+ if (dryRun) return;
234
+ fs.mkdirSync(path.dirname(full), { recursive: true });
235
+ fs.writeFileSync(full, text);
236
+ if (exec) carryMode(target, file);
237
+ }
238
+
239
+ // ---------------------------------------------------------------- the self check
240
+
241
+ // Merging is not checking. The installer knows it wrote a file; it cannot know whether the result
242
+ // still works -- an AGENTS.md whose chain table no longer parses, routing sections naming an agent
243
+ // this repo does not have, a skill nothing links to, an upstream with no licence row. The suite
244
+ // answers all of that, and it is the upstream's own: fixtures that prove a harness are no use to a
245
+ // project carrying one, so they do not travel.
246
+ //
247
+ // So they are borrowed. Written in, run, and taken away again, leaving the repo as the install left
248
+ // it. Running them from the upstream checkout instead would be tidier and does not work: both
249
+ // lib.js files resolve the repo root from __dirname with no override, so the suite only ever tests
250
+ // the checkout it sits in.
251
+ const SUITE = [".agents/hooks/test.js", ".agents/hooks/tests/"];
252
+ const wantedBySuite = file => SUITE.some(p => p.endsWith("/") ? file.startsWith(p) : file === p);
253
+
254
+ function selfCheck(target, templateDir, head, files) {
255
+ const borrowed = [];
256
+ for (const { file } of files) {
257
+ if (!wantedBySuite(file)) continue;
258
+ // Anything already at one of these paths is the project's own: it is not overwritten here,
259
+ // and the cleanup below must not remove it either, so it is left out of the borrowed list.
260
+ if (fs.existsSync(path.join(target, file))) continue;
261
+ const text = blob(templateDir, head, file);
262
+ if (text === null) continue;
263
+ write(target, file, text);
264
+ borrowed.push(file);
265
+ }
266
+ if (!borrowed.length) return { skipped: "the suite is already in this repo; run it yourself with node .agents/hooks/test.js" };
267
+
268
+ try {
269
+ const r = lib.node([path.join(target, ".agents/hooks/test.js")], { cwd: target });
270
+ const lines = r.output.split(/\r?\n/).filter(l => l.trim());
271
+ return { failed: r.status !== 0, summary: lines[lines.length - 1] || "no output", output: r.output };
272
+ } finally {
273
+ // Only what this function wrote, and only the directories that writing it created.
274
+ for (const file of borrowed) { try { fs.rmSync(path.join(target, file)); } catch { /* already gone */ } }
275
+ const dirs = [...new Set(borrowed.map(f => path.dirname(f)))].sort((a, b) => b.length - a.length);
276
+ for (const dir of dirs) { try { fs.rmdirSync(path.join(target, dir)); } catch { /* the project's, or not empty */ } }
277
+ }
278
+ }
279
+
280
+ // ---------------------------------------------------------------- the run
281
+
282
+ function main() {
283
+ const target = targetRoot();
284
+ if (!fs.existsSync(path.join(target, ".git"))) fail(`${target} is not a git checkout`);
285
+
286
+ const lockPath = path.join(target, LOCK);
287
+ const previous = fs.existsSync(lockPath) ? JSON.parse(fs.readFileSync(lockPath, "utf8")) : null;
288
+ const ref = value("--ref", previous ? previous.ref : DEFAULT_REF);
289
+ const { dir: templateDir, temporary } = templateCheckout(ref);
290
+
291
+ try {
292
+ const head = at(templateDir, ["rev-parse", "HEAD"]).output.trim();
293
+ // A base is what makes this an update rather than an overwrite. Without one -- a first
294
+ // install, or an upstream whose history was rewritten -- an existing file is left alone
295
+ // instead of being guessed at, and the run says so.
296
+ let base = previous ? previous.commit : null;
297
+ if (base && at(templateDir, ["cat-file", "-e", `${base}^{commit}`]).status !== 0) {
298
+ say(`the recorded upstream commit ${base.slice(0, 8)} is not in ${TEMPLATE} any more, so this run has no merge base: existing files are left alone`);
299
+ base = null;
300
+ }
301
+ if (previous && base === head) { say(`harness is already at ${head.slice(0, 8)} (${ref}); nothing to update`); return; }
302
+
303
+ const rows = policies(templateDir);
304
+ const files = templateFiles(templateDir);
305
+ const skills = [];
306
+
307
+ for (const { file, link: isLink, exec } of files) {
308
+ const policy = policyFor(rows, file);
309
+ const theirs = blob(templateDir, head, file);
310
+ if (theirs === null) continue;
311
+ const full = path.join(target, file);
312
+ const exists = fs.existsSync(full);
313
+
314
+ if (policy === "template") { notes.template.push(file); continue; }
315
+ if (isLink) {
316
+ // A skill link is relink's to make, once the directory it lives in exists: it knows
317
+ // which skills this project actually has, where the upstream only knows its own.
318
+ if (policy === "skills") {
319
+ if (!dryRun) fs.mkdirSync(path.dirname(full), { recursive: true });
320
+ continue;
321
+ }
322
+ const how = link(target, file, theirs.trim());
323
+ if (how) notes[how].push(file);
324
+ continue;
325
+ }
326
+ if (policy === "skills") { skills.push(file); continue; }
327
+ // Reported only when the target actually has it: "left alone, yours" about a file the
328
+ // repo does not have names something that was never there.
329
+ if (policy === "skip") { if (exists) notes.skipped.push(file); continue; }
330
+
331
+ if (policy === "seed") {
332
+ if (exists) { notes.kept.push(file); continue; }
333
+ write(target, file, theirs, exec);
334
+ notes.seeded.push(file);
335
+ continue;
336
+ }
337
+ // merge
338
+ if (!exists) { write(target, file, theirs, exec); notes.written.push(file); continue; }
339
+ const ours = fs.readFileSync(full, "utf8");
340
+ if (ours === theirs) continue; // already current
341
+ if (base === null) { notes.kept.push(file); continue; }
342
+ const from = blob(templateDir, base, file);
343
+ if (from === null) { notes.kept.push(file); continue; } // new to the upstream, and the target has its own
344
+ if (ours === from) { write(target, file, theirs, exec); notes.written.push(file); continue; }
345
+ const merged = threeWay(from, ours, theirs);
346
+ if (merged.failed) { notes.kept.push(file); continue; }
347
+ write(target, file, merged.text, exec);
348
+ (merged.conflicts ? notes.conflicted : notes.merged).push(file);
349
+ }
350
+
351
+ skeletons(target);
352
+ mergeSkills(target, templateDir, head, skills);
353
+
354
+ if (!dryRun) {
355
+ fs.writeFileSync(lockPath, JSON.stringify({
356
+ template: TEMPLATE, ref, commit: head, updated: new Date().toISOString().slice(0, 10),
357
+ }, null, 2) + "\n");
358
+ finish(target);
359
+ // After finish(), because the suite checks the links relink has just written.
360
+ if (!flag("--no-check")) notes.check = selfCheck(target, templateDir, head, files);
361
+ }
362
+ report(target, head, ref, base);
363
+ } finally {
364
+ if (temporary) fs.rmSync(templateDir, { recursive: true, force: true });
365
+ }
366
+ }
367
+
368
+ // Skills merge by name, not by content: the upstream's are added and updated, and a skill the
369
+ // project vendored itself is never removed. skills-lock.json is the union, the project's entry
370
+ // winning where both name the same skill, so a project that pinned a different source keeps it.
371
+ function mergeSkills(target, templateDir, head, files) {
372
+ const SKILLS = ".agents/skills/";
373
+ const ours = path.join(target, "skills-lock.json");
374
+ const theirLock = JSON.parse(blob(templateDir, head, "skills-lock.json") || '{"skills":{}}');
375
+ const ourLock = fs.existsSync(ours) ? JSON.parse(fs.readFileSync(ours, "utf8")) : { skills: {} };
376
+ ourLock.skills = ourLock.skills || {};
377
+
378
+ const mine = new Set(Object.keys(ourLock.skills));
379
+ for (const file of files) {
380
+ if (!file.startsWith(SKILLS)) continue; // .claude/skills links are rebuilt, not copied
381
+ const name = file.slice(SKILLS.length).split("/")[0];
382
+ // A skill the project installed under a name the upstream also uses stays the project's.
383
+ if (mine.has(name) && !theirLock.skills[name]) continue;
384
+ const text = blob(templateDir, head, file);
385
+ if (text === null) continue;
386
+ const full = path.join(target, file);
387
+ const exists = fs.existsSync(full);
388
+ if (exists && fs.readFileSync(full, "utf8") === text) continue;
389
+ write(target, file, text);
390
+ (exists ? notes.merged : notes.written).push(file);
391
+ }
392
+ for (const [name, entry] of Object.entries(theirLock.skills)) {
393
+ if (!ourLock.skills[name]) ourLock.skills[name] = entry;
394
+ }
395
+ if (!dryRun) fs.writeFileSync(ours, JSON.stringify(ourLock, null, 2) + "\n");
396
+ }
397
+
398
+ // Two files nothing copied: the per-harness skill links, which depend on which skills this project
399
+ // has rather than which the upstream ships, and the third-party notice, which must describe this
400
+ // project's lock file. Both are generated, so the install leaves a harness that works rather than a
401
+ // list of commands to remember.
402
+ function finish(target) {
403
+ for (const [label, args] of [["links", ["relink"]], ["notices", ["notices"]]]) {
404
+ const r = lib.node([path.join(target, "scripts/skills.js"), ...args], { cwd: target });
405
+ say(r.status === 0 ? r.output : `${label}: ${r.output}`);
406
+ }
407
+ }
408
+
409
+ function report(target, head, ref, base) {
410
+ const list = (label, arr) => { if (arr.length) say(`\n${label} (${arr.length}):\n ${arr.sort().join("\n ")}`); };
411
+ say("");
412
+ say(dryRun ? `dry run against ${ref} at ${head.slice(0, 8)}` : `harness updated to ${ref} at ${head.slice(0, 8)}`);
413
+ if (!base) say("no merge base: this was an install, so nothing that already existed was changed");
414
+ list("added", notes.written);
415
+ list("merged", notes.merged);
416
+ list("created for the first time", notes.seeded);
417
+ list("left alone, yours", notes.kept.concat(notes.skipped));
418
+ // Named rather than listed: the fixtures alone are sixty files, and the point is the rule, not
419
+ // the inventory. Nothing a project runs reaches any of it.
420
+ if (notes.template.length) {
421
+ const named = notes.template.filter(f => !f.startsWith(".agents/hooks/tests/"));
422
+ say(`\nnot installed, the upstream's own (${notes.template.length}): ${named.join(", ")}, and the suite's fixtures`);
423
+ }
424
+ if (notes.conflicted.length) {
425
+ list("CONFLICTED, resolve the markers by hand", notes.conflicted);
426
+ say(`\nEach one holds <<<<<<< yours / ======= / >>>>>>> upstream (new). Resolve them, then run the suite:\n node .agents/hooks/test.js`);
427
+ }
428
+ const check = notes.check;
429
+ if (check && check.skipped) say(`\nself check skipped: ${check.skipped}`);
430
+ else if (check && check.failed) say(`\nSELF CHECK FAILED, so this install does not work yet:\n${check.output}`);
431
+ else if (check) say(`\nself check: ${check.summary}, run from the upstream suite and removed again`);
432
+
433
+ if (!dryRun) {
434
+ const suite = fs.existsSync(path.join(target, ".agents/hooks/test.js"));
435
+ say(`\nIn ${target}, point Git at the hooks once per clone:`);
436
+ say(` node scripts/githooks-init.js${suite ? " && node .agents/hooks/test.js" : " && node scripts/docs-check.js"}`);
437
+ }
438
+ if (notes.conflicted.length || (check && check.failed)) process.exit(1);
439
+ }
440
+
441
+ main();