@salaros/ai-harness 0.2.3 → 0.2.7
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/README.md +12 -10
- package/package.json +1 -1
- package/scripts/README.md +15 -3
- package/scripts/lib.js +24 -8
- package/scripts/update-harness.js +108 -94
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
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
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
|
|
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.
|
|
6
6
|
|
|
7
7
|
## Adding the harness to a repository you already have
|
|
8
8
|
|
|
@@ -14,11 +14,13 @@ npx @salaros/ai-harness
|
|
|
14
14
|
|
|
15
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
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
|
+
|
|
17
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.
|
|
18
20
|
|
|
19
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.
|
|
20
22
|
|
|
21
|
-
Every run
|
|
23
|
+
Every run checks its result before it finishes. Merging is not checking: the installer knows it wrote a file, not whether the result still works. So it runs the harness invariants in `scripts/check-harness.js`, from the upstream checkout against the repo it just wrote, writes nothing to run them, 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
24
|
|
|
23
25
|
`scripts/harness-files.tsv` is the table behind all of this, one row per path.
|
|
24
26
|
|
|
@@ -51,7 +53,7 @@ The run reports each path as it works on it: the policy, the mode Git records, w
|
|
|
51
53
|
| `CLAUDE.md` | One line, `@AGENTS.md`, because Claude Code reads `CLAUDE.md` rather than `AGENTS.md`. |
|
|
52
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. |
|
|
53
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. |
|
|
54
|
-
| `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
|
|
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 `node scripts/check-harness.js`. |
|
|
55
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. |
|
|
56
58
|
| `.agents/skills/<name>/` | The canonical, vendored copy of each skill (`SKILL.md` plus its reference files). |
|
|
57
59
|
| `.agents/hooks/` | Harness-neutral hook scripts (see [Hooks](#hooks)). |
|
|
@@ -59,7 +61,7 @@ The run reports each path as it works on it: the policy, the mode Git records, w
|
|
|
59
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. |
|
|
60
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. |
|
|
61
63
|
| `.claude/` | Claude Code wiring: `skills/*` and `agents` are symlinks into `.agents/`, `settings.json` wires the hooks. |
|
|
62
|
-
| `.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
|
|
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 that finds the repo root and hands over to `scripts/githook.js`, which holds what every hook decides, so nothing is chosen in a file the suite cannot drive. `post-merge` works out what the merge brought in and 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 reads `TODO.md` and checks the shape of the loose-ends ledger against the todo-md standard — the staged copy, whose problems block the commit, or an unstaged one, whose problems are only reported, since the commit does not carry it: 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. |
|
|
63
65
|
| `docs/agents/` | Per-repo configuration the skills read: issue tracker, triage labels, domain-doc rules. |
|
|
64
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. |
|
|
65
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. |
|
|
@@ -70,7 +72,7 @@ The run reports each path as it works on it: the policy, the mode Git records, w
|
|
|
70
72
|
|
|
71
73
|
## Skills
|
|
72
74
|
|
|
73
|
-
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`
|
|
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.
|
|
74
76
|
|
|
75
77
|
```bash
|
|
76
78
|
npx skills add mattpocock/skills -s wait-what -a claude-code codex -y # add a skill
|
|
@@ -79,11 +81,11 @@ npx skills update # newer versi
|
|
|
79
81
|
node scripts/skills.js relink # after any of the above, and after writing a local skill
|
|
80
82
|
```
|
|
81
83
|
|
|
82
|
-
- Relink
|
|
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 `node scripts/check-harness.js` fails if a per-skill link is missing or the folder link points anywhere but `.agents/skills`.
|
|
83
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.
|
|
84
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.
|
|
85
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`.
|
|
86
|
-
- 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
|
|
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 scripts/check-harness.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`.
|
|
87
89
|
|
|
88
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.
|
|
89
91
|
|
|
@@ -95,11 +97,11 @@ Three Node scripts in `.agents/hooks/`, run with the `node` on your PATH (Window
|
|
|
95
97
|
| --- | --- | --- |
|
|
96
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. |
|
|
97
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. |
|
|
98
|
-
| `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
|
|
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 `scripts/check-harness.js` when a path its invariants read changed (the skills and their links, the routing, the Git hooks, the licence rows), runs the hook suite in the upstream when harness code or a table changed, and refuses in-place edits of vendored skills. |
|
|
99
101
|
|
|
100
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.
|
|
101
103
|
|
|
102
|
-
`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
|
|
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. A case that needs a file planted, or one kept absent, runs against a temporary root instead of the checkout, which a hook finds through `CLAUDE_PROJECT_DIR` and a script through `--root=<dir>`. The suite then runs the in-process checks in `self-checks.js` and the decision tables in `tables.js`, and holds the upstream to the same harness invariants a target runs. `check-edit.js` runs that suite itself whenever a hook, a script under `scripts/`, a table there or a Git hook changes. The suite stays upstream; a target runs `node scripts/check-harness.js`.
|
|
103
105
|
|
|
104
106
|
## Agents
|
|
105
107
|
|
|
@@ -123,7 +125,7 @@ What each tool reads, what is already in the repo, and what you must create for
|
|
|
123
125
|
| Need | File | In repo |
|
|
124
126
|
| --- | --- | --- |
|
|
125
127
|
| Instructions | `CLAUDE.md` containing `@AGENTS.md` (Claude Code does not read `AGENTS.md` itself) | yes |
|
|
126
|
-
| Skills | `.claude/skills
|
|
128
|
+
| Skills | `.claude/skills` → symlink to `../.agents/skills` | yes |
|
|
127
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 |
|
|
128
130
|
| Agents | `.claude/agents` → symlink to `../.agents/agents`; frontmatter `name`, `description` (+ optional `tools`, `model`, `skills`) | yes |
|
|
129
131
|
| Per-developer overrides | `.claude/settings.local.json` (git-ignored) | no |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salaros/ai-harness",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
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
|
|
26
|
-
|
|
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.
|
|
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
|
|
17
|
-
//
|
|
18
|
-
//
|
|
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
|
|
21
|
-
process.chdir(
|
|
22
|
-
return
|
|
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 };
|
|
@@ -188,11 +188,13 @@ function policies(templateDir) {
|
|
|
188
188
|
// First match wins, so the table's order is its precedence. A row ending in / covers everything under it.
|
|
189
189
|
// `optional:<flag>` is seeded only when the run asked for it, and is otherwise not installed at all:
|
|
190
190
|
// the docs site is the case, useful to some projects and dead weight in the rest.
|
|
191
|
-
|
|
191
|
+
// `wants` answers whether the run asked for an optional part, so the table's meaning does not depend
|
|
192
|
+
// on the process's own argv and a test can ask what a repo would get either way.
|
|
193
|
+
function policyFor(rows, file, wants) {
|
|
192
194
|
const row = rows.find(r => r.path.endsWith("/") ? file.startsWith(r.path) : file === r.path);
|
|
193
195
|
if (!row) return "merge"; // anything the upstream ships and nobody classified is harness
|
|
194
196
|
if (!row.policy.startsWith("optional:")) return row.policy;
|
|
195
|
-
return
|
|
197
|
+
return wants(row.policy.slice("optional:".length)) ? "seed" : "template";
|
|
196
198
|
}
|
|
197
199
|
|
|
198
200
|
// Every path the upstream tracks, with the mode Git recorded. Mode 120000 is a symlink, and the
|
|
@@ -349,6 +351,69 @@ const isCrlf = text => (text.match(CRLF) || []).length * 2 > (text.match(LF) ||
|
|
|
349
351
|
const toLf = text => text.replace(CRLF, "\n");
|
|
350
352
|
const asFound = (text, crlf) => crlf ? text.replace(LF, "\r\n") : text;
|
|
351
353
|
|
|
354
|
+
// ---------------------------------------------------------------- the decision
|
|
355
|
+
//
|
|
356
|
+
// What happens to one file the target already has, decided apart from doing it. Everything these two
|
|
357
|
+
// read is an argument, including the three things they cannot compute -- the base's text, the search
|
|
358
|
+
// for a base when the receipt has none, and the three-way merge itself -- so the caller passes them
|
|
359
|
+
// in and the loop below is left reading, writing and reporting.
|
|
360
|
+
//
|
|
361
|
+
// Both answer the same shape: `outcome` is the word the run prints, `bucket` the summary list it
|
|
362
|
+
// belongs in (null for a file nothing happened to), and `text` what to write, or null to write
|
|
363
|
+
// nothing. Splitting them this way is what makes the table of cases testable: an install rewrites
|
|
364
|
+
// someone else's repository, and every branch below used to need a git checkout and a temp tree to
|
|
365
|
+
// reach even once.
|
|
366
|
+
|
|
367
|
+
// A file with no lines to merge: it is the upstream's copy or it is the project's, and the base
|
|
368
|
+
// decides which. A logo the project replaced stays replaced.
|
|
369
|
+
function decideBinary({ held, theirs, hasBase, adopt }, { baseBytes }) {
|
|
370
|
+
if (same(held, theirs)) return { outcome: "unchanged", bucket: null, text: null };
|
|
371
|
+
const was = hasBase ? baseBytes() : null;
|
|
372
|
+
if (adopt || same(held, was)) return { outcome: adopt ? "adopted" : "written", bucket: "written", text: theirs };
|
|
373
|
+
return { outcome: hasBase ? "yours, binary" : "yours, no base", bucket: "kept", text: null };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// A text file. `raw` is what is on disk, in whatever line endings it has; `theirs` is the upstream's,
|
|
377
|
+
// always LF. The comparison and the merge happen in LF and the result is written back in the endings
|
|
378
|
+
// the file already had, so a Windows checkout does not read as edited from top to bottom.
|
|
379
|
+
function decideText({ policy, raw, theirs, hasBase, adopt }, { baseText, recoverBase, merge }) {
|
|
380
|
+
const crlf = isCrlf(raw);
|
|
381
|
+
const ours = toLf(raw);
|
|
382
|
+
const keep = { outcome: hasBase ? "yours, new here" : "yours, no base", bucket: "kept", text: null };
|
|
383
|
+
|
|
384
|
+
if (ours === theirs) return { outcome: "unchanged", bucket: null, text: null };
|
|
385
|
+
// Before the base logic, not inside it: a repo that needs adopting usually has a receipt
|
|
386
|
+
// already, written by the install that kept the stale files in the first place.
|
|
387
|
+
if (adopt) return { outcome: "adopted", bucket: "written", text: asFound(theirs, crlf) };
|
|
388
|
+
// Markers an earlier run wrote and nobody resolved. Left to the merge, the marked-up file is now
|
|
389
|
+
// its own nearest base, so the merge takes it whole, the run says "unchanged" and a half-merged
|
|
390
|
+
// harness passes as settled. Named instead, and the run exits 1 until someone resolves it or
|
|
391
|
+
// --adopt above throws it away.
|
|
392
|
+
if (MARKED.test(ours)) return { outcome: "STILL OPEN", bucket: "conflicted", text: null };
|
|
393
|
+
|
|
394
|
+
// A reconcile file is one the harness cannot work around: AGENTS.md is the map every agent reads
|
|
395
|
+
// and holds the table docs-check parses, and docs/README.md says what the chain puts where.
|
|
396
|
+
// Keeping a stale one leaves a repo that looks installed and behaves like the version it came
|
|
397
|
+
// from, so these are merged even when the receipt is missing. Nothing in the upstream's history
|
|
398
|
+
// matching means this copy was written by hand, and an empty base makes the whole file one
|
|
399
|
+
// conflict -- the honest answer: both versions are there to read, and the run exits 1.
|
|
400
|
+
let from = hasBase ? baseText() : null;
|
|
401
|
+
if (from === null && policy === "reconcile") from = recoverBase(ours);
|
|
402
|
+
if (from === null && policy === "reconcile") from = "";
|
|
403
|
+
if (from === null) return keep;
|
|
404
|
+
|
|
405
|
+
if (ours === from) return { outcome: "written", bucket: "written", text: asFound(theirs, crlf) };
|
|
406
|
+
const merged = merge(from, ours, theirs);
|
|
407
|
+
if (merged.failed) return { outcome: "yours, merge failed", bucket: "kept", text: null };
|
|
408
|
+
const result = asFound(merged.text, crlf);
|
|
409
|
+
if (merged.conflicts) return { outcome: "CONFLICT", bucket: "conflicted", text: result };
|
|
410
|
+
// A file that keeps a local edit merges cleanly on every later run and comes out the same every
|
|
411
|
+
// time. Reported as merged each run it reads as churn, and the reader goes looking for a change
|
|
412
|
+
// nobody made, so what the run did is decided by the result, not the route.
|
|
413
|
+
if (result === raw) return { outcome: "unchanged", bucket: null, text: null };
|
|
414
|
+
return { outcome: "merged", bucket: "merged", text: result };
|
|
415
|
+
}
|
|
416
|
+
|
|
352
417
|
function write(target, file, text, exec) {
|
|
353
418
|
const full = path.join(target, file);
|
|
354
419
|
if (dryRun) return;
|
|
@@ -361,42 +426,20 @@ function write(target, file, text, exec) {
|
|
|
361
426
|
|
|
362
427
|
// Merging is not checking. The installer knows it wrote a file; it cannot know whether the result
|
|
363
428
|
// 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.
|
|
365
|
-
//
|
|
366
|
-
// project carrying one, so they do not travel.
|
|
429
|
+
// this repo does not have, a skill nothing links to, an upstream with no licence row. Those are the
|
|
430
|
+
// harness invariants, and scripts/check-harness.js holds them as functions of a root.
|
|
367
431
|
//
|
|
368
|
-
// So they
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
// the
|
|
372
|
-
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
}
|
|
432
|
+
// So they run from the upstream checkout against the target, and nothing is written into the target
|
|
433
|
+
// to run them. The upstream's copy rather than the one just installed, so the check is the one that
|
|
434
|
+
// matches the files this run wrote. The suite's fixtures stay upstream: they prove the harness
|
|
435
|
+
// scripts, which the upstream's own CI has already done.
|
|
436
|
+
function selfCheck(target, templateDir) {
|
|
437
|
+
const script = path.join(templateDir, "scripts", "check-harness.js");
|
|
438
|
+
if (!fs.existsSync(script)) return { skipped: "this upstream ref has no scripts/check-harness.js" };
|
|
439
|
+
phase("self check: the harness invariants, run from the upstream against this repo");
|
|
440
|
+
const harness = require(script);
|
|
441
|
+
const r = harness.check(target);
|
|
442
|
+
return { failed: r.failed.length > 0, summary: r.summary, output: harness.format(r) };
|
|
400
443
|
}
|
|
401
444
|
|
|
402
445
|
// ---------------------------------------------------------------- the run
|
|
@@ -440,13 +483,14 @@ function main() {
|
|
|
440
483
|
}
|
|
441
484
|
|
|
442
485
|
const rows = policies(templateDir);
|
|
486
|
+
const wants = name => flag(`--${name}`);
|
|
443
487
|
const files = templateFiles(templateDir);
|
|
444
488
|
const skills = [];
|
|
445
489
|
|
|
446
490
|
phase(`${files.length} path(s) in ${ref} at ${head.slice(0, 8)}`);
|
|
447
491
|
for (const entry of files) {
|
|
448
492
|
const { file, link: isLink, exec } = entry;
|
|
449
|
-
const policy = policyFor(rows, file);
|
|
493
|
+
const policy = policyFor(rows, file, wants);
|
|
450
494
|
const m = mode(entry);
|
|
451
495
|
const theirs = blob(templateDir, head, file);
|
|
452
496
|
// Git listed the path a moment ago, so failing to read it is the checkout being unhappy
|
|
@@ -491,54 +535,20 @@ function main() {
|
|
|
491
535
|
}
|
|
492
536
|
// merge
|
|
493
537
|
if (!exists) { write(target, file, theirs, exec); step(policy, m, "written", file, "written"); continue; }
|
|
494
|
-
|
|
495
|
-
//
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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");
|
|
538
|
+
|
|
539
|
+
// Everything the decision needs, read here; what to do with its answer, done here. The
|
|
540
|
+
// decision itself is decideBinary/decideText, which touch neither git nor the disk.
|
|
541
|
+
const hasBase = base !== null;
|
|
542
|
+
const held = Buffer.isBuffer(theirs) ? fs.readFileSync(full) : fs.readFileSync(full, "utf8");
|
|
543
|
+
const { outcome, bucket, text } = Buffer.isBuffer(theirs)
|
|
544
|
+
? decideBinary({ held, theirs, hasBase, adopt }, { baseBytes: () => blob(templateDir, base, file) })
|
|
545
|
+
: decideText({ policy, raw: held, theirs, hasBase, adopt }, {
|
|
546
|
+
baseText: () => blob(templateDir, base, file),
|
|
547
|
+
recoverBase: ours => recoverBase(templateDir, file, ours),
|
|
548
|
+
merge: threeWay,
|
|
549
|
+
});
|
|
550
|
+
if (text !== null) write(target, file, text, exec);
|
|
551
|
+
step(policy, m, outcome, file, bucket);
|
|
542
552
|
}
|
|
543
553
|
|
|
544
554
|
phase("skeletons a project starts with");
|
|
@@ -552,8 +562,8 @@ function main() {
|
|
|
552
562
|
updated: new Date().toISOString().slice(0, 10),
|
|
553
563
|
}, null, 2) + "\n");
|
|
554
564
|
finish(target);
|
|
555
|
-
// After finish(), because the
|
|
556
|
-
if (!flag("--no-check")) notes.check = selfCheck(target, templateDir
|
|
565
|
+
// After finish(), because the invariants check the links relink has just written.
|
|
566
|
+
if (!flag("--no-check")) notes.check = selfCheck(target, templateDir);
|
|
557
567
|
}
|
|
558
568
|
report(target, head, ref, base);
|
|
559
569
|
} finally {
|
|
@@ -653,19 +663,23 @@ function report(target, head, ref, base) {
|
|
|
653
663
|
}
|
|
654
664
|
if (notes.conflicted.length) {
|
|
655
665
|
list("CONFLICTED, resolve the markers by hand", notes.conflicted, true);
|
|
656
|
-
say(`\nEach one holds <<<<<<< yours / ======= / >>>>>>> upstream (new). Resolve them, then
|
|
666
|
+
say(`\nEach one holds <<<<<<< yours / ======= / >>>>>>> upstream (new). Resolve them, then check the harness:\n node scripts/check-harness.js`);
|
|
657
667
|
}
|
|
658
668
|
const check = notes.check;
|
|
659
669
|
if (check && check.skipped) say(`\nself check skipped: ${check.skipped}`);
|
|
660
670
|
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}
|
|
671
|
+
else if (check) say(`\nself check: ${check.summary}`);
|
|
662
672
|
|
|
663
673
|
if (!dryRun) {
|
|
664
|
-
|
|
665
|
-
say(
|
|
666
|
-
say(` node scripts/githooks-init.js${suite ? " && node .agents/hooks/test.js" : " && node scripts/docs-check.js"}`);
|
|
674
|
+
say(`\nIn ${target}, point Git at the hooks once per clone, then check the harness:`);
|
|
675
|
+
say(` node scripts/githooks-init.js && node scripts/check-harness.js`);
|
|
667
676
|
}
|
|
668
677
|
if (notes.conflicted.length || notes.unreadable.length || (check && check.failed)) process.exit(1);
|
|
669
678
|
}
|
|
670
679
|
|
|
671
|
-
|
|
680
|
+
// The decision, and the two pure helpers under it, so the suite can put a case in and read the
|
|
681
|
+
// answer out rather than building a git checkout to reach one branch. Everything else here writes to
|
|
682
|
+
// somebody's repository and stays behind main().
|
|
683
|
+
module.exports = { policyFor, decideText, decideBinary, lineCounts, overlap, NEAREST };
|
|
684
|
+
|
|
685
|
+
if (require.main === module) main();
|