@bluenyang/reins 0.0.0-stage → 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 +21 -0
- package/README.md +149 -2
- package/cli/dist/commands/check.js +244 -0
- package/cli/dist/commands/install.js +230 -0
- package/cli/dist/commands/migrate.js +122 -0
- package/cli/dist/lib/ask.js +27 -0
- package/cli/dist/lib/blocks.js +93 -0
- package/cli/dist/lib/detect.js +105 -0
- package/cli/dist/lib/fsx.js +36 -0
- package/cli/dist/lib/git.js +30 -0
- package/cli/dist/lib/glob.js +30 -0
- package/cli/dist/lib/harness.js +33 -0
- package/cli/dist/lib/migrate-plan.js +233 -0
- package/cli/dist/lib/schema.js +70 -0
- package/cli/dist/lib/secrets.js +28 -0
- package/cli/dist/lib/skills.js +63 -0
- package/cli/dist/lib/upstream.js +16 -0
- package/cli/dist/lib/verdict.js +15 -0
- package/cli/dist/reins.js +122 -0
- package/docs/README.ko.md +151 -0
- package/harness/CHANGELOG.md +10 -0
- package/harness/VERSION +1 -0
- package/harness/knowledge-seeds/node/2026-10-25_frozen-lockfile.md +24 -0
- package/harness/knowledge-seeds/node/2026-10-25_mutating-lint-is-not-a-gate.md +19 -0
- package/harness/policy/budget.yaml +13 -0
- package/harness/policy/common-red-lines.yaml +25 -0
- package/harness/policy/stops.yaml +29 -0
- package/harness/policy/transitions.yaml +13 -0
- package/harness/roles/advisor.md +15 -0
- package/harness/roles/code-review.md +21 -0
- package/harness/roles/deploy.md +17 -0
- package/harness/roles/docs.md +16 -0
- package/harness/roles/implementer.md +22 -0
- package/harness/roles/lookup.md +19 -0
- package/harness/roles/pl.md +21 -0
- package/harness/roles/planner.md +20 -0
- package/harness/roles/pm.md +23 -0
- package/harness/roles/qa-design.md +19 -0
- package/harness/roles/qa-run.md +16 -0
- package/harness/roles/verify-tool.md +19 -0
- package/harness/schemas/bus-request.schema.json +17 -0
- package/harness/schemas/bus-response.schema.json +14 -0
- package/harness/schemas/intent.schema.json +13 -0
- package/harness/schemas/knowledge.schema.json +16 -0
- package/harness/schemas/plan.schema.json +10 -0
- package/harness/schemas/spec.schema.json +8 -0
- package/harness/schemas/ticket.schema.json +15 -0
- package/harness/templates/agents-md-blocks.md +26 -0
- package/harness/templates/bus/request.yaml +14 -0
- package/harness/templates/bus/response.yaml +9 -0
- package/harness/templates/intent.md +23 -0
- package/harness/templates/knowledge.md +20 -0
- package/harness/templates/plan.md +23 -0
- package/harness/templates/spec.md +17 -0
- package/harness/templates/ticket.md +29 -0
- package/package.json +43 -4
- package/skills/reins-intent/SKILL.md +35 -0
- package/skills/reins-loop/SKILL.md +46 -0
- package/skills/reins-setup/SKILL.md +27 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BlueNyang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,150 @@
|
|
|
1
|
-
#
|
|
1
|
+
# reins
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[한국어](docs/README.ko.md)
|
|
4
|
+
|
|
5
|
+
reins installs, migrates and updates an agent harness for a repository: roles for coding agents, a
|
|
6
|
+
loop policy, schema-checked handoff files, a knowledge store and gates. A script, `reins check`,
|
|
7
|
+
decides at fixed points whether the loop may continue, so an agent stays inside the role it was
|
|
8
|
+
given and does what was specified. People make the gate decisions.
|
|
9
|
+
|
|
10
|
+
It exists because twelve repositories of BlueNyang and Team Croffle share one harness lineage and
|
|
11
|
+
share its problems: agents stepping outside the request, one session that implements and also
|
|
12
|
+
judges, knowledge lost in gitignored space, no rule for stopping, copies that drift apart, and a
|
|
13
|
+
loop that works only with Claude. The full list (P1–P6) is in [docs/proposal.md](docs/proposal.md).
|
|
14
|
+
|
|
15
|
+
## Status
|
|
16
|
+
|
|
17
|
+
Version 0.1.0, pre-release, round one of the plan. It is not on npm yet and has no Claude Code
|
|
18
|
+
plugin manifest, so `npx @bluenyang/reins` does not work today; build it from source (see
|
|
19
|
+
[Quick start](#quick-start-from-source)). Generated vendor files are verified for Claude Code only.
|
|
20
|
+
|
|
21
|
+
reins develops itself through its own loop. Intent 001 (`reins status`) went intent → spec → plan →
|
|
22
|
+
tickets; the checks at T1 to T5 passed, and Codex did the code review. Roles bound to other vendors
|
|
23
|
+
ran through their CLIs (`codex exec`, `agy -p`) in that run: the lead agent called them following
|
|
24
|
+
the `reins-loop` skill. The CLI does not dispatch to vendors by itself yet.
|
|
25
|
+
|
|
26
|
+
## The ladder
|
|
27
|
+
|
|
28
|
+
There are three tiers. Each one contains the one below it, and the tiers share one source.
|
|
29
|
+
|
|
30
|
+
| Tier | What it has |
|
|
31
|
+
|---|---|
|
|
32
|
+
| harness | the verifier at commit and push (T2, T4, T6), red lines, generated permissions |
|
|
33
|
+
| loop | the plan → work → test → release cycle with fewer roles; same transition points and stop conditions |
|
|
34
|
+
| graph | all twelve roles, schema-checked handoff files and per-role model bindings |
|
|
35
|
+
|
|
36
|
+
`reins install <tier>` takes any of the three. Round one targets the graph tier; the plan is to
|
|
37
|
+
carve the loop and harness tiers out of it in 0.2, using measured cost as input.
|
|
38
|
+
|
|
39
|
+
## How it works
|
|
40
|
+
|
|
41
|
+
**Roles.** There are twelve: pm, planner, pl, implementer, lookup, verify-tool, code-review,
|
|
42
|
+
qa-design, qa-run, deploy, docs and advisor. Each role file lists what the role must not do. Role
|
|
43
|
+
separation is not model separation: every role can run on one model, and `bindings.yaml` assigns a
|
|
44
|
+
vendor and model per role. By design, a role bound to a vendor that is not detected falls back to its
|
|
45
|
+
fallback, else to the pm model, so all twelve roles can run on Claude alone.
|
|
46
|
+
|
|
47
|
+
**Red lines.** Ten rules (C1–C10) apply to every role and are enforced by the verifier, for
|
|
48
|
+
example: no implementing "to pass" (C1), no editing managed blocks, policy or roles (C2), no
|
|
49
|
+
editing one's own permission file (C3), no reading secrets (C4), no bypassing hooks (C5), and no
|
|
50
|
+
push, PR, merge or release outside the deploy role (C6). The list is in [docs/design.md](docs/design.md) section 2.
|
|
51
|
+
|
|
52
|
+
**The verifier.** `reins check` runs at transition points and prints one verdict. It does not call
|
|
53
|
+
a model; roles write their judgement to a handoff file and the verifier applies it.
|
|
54
|
+
|
|
55
|
+
| Verdict | Exit code | Meaning |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| CONTINUE | 0 | go on |
|
|
58
|
+
| HOLD | 10 | the user must answer |
|
|
59
|
+
| STOP | 20 | fixable without a person; the stop records where, why (an id from A1 to G2) and how to resume |
|
|
60
|
+
| ESCALATE | 30 | the failure chain: pl → pm → advisor → user |
|
|
61
|
+
|
|
62
|
+
The 24 stop conditions A1–G2 cover irreversible actions, open decisions, collapsed premises,
|
|
63
|
+
repeated failure, cost, rule violations and user interruption.
|
|
64
|
+
|
|
65
|
+
**The chain and the two human decisions.** Work moves through four files:
|
|
66
|
+
`intent.md` → `spec.md` → `plan.md` → tickets. A person decides at two points: the user approves
|
|
67
|
+
the intent, and the user may veto the three-to-five-line summary of the spec. Everything else is
|
|
68
|
+
reviewed by roles. Verification output is pasted as produced; a bare "passed" is not a pass.
|
|
69
|
+
|
|
70
|
+
**Layout.**
|
|
71
|
+
|
|
72
|
+
| Path | Committed | Holds |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `AGENTS.md` | yes | managed blocks written by the tool, project sections written by you |
|
|
75
|
+
| `.agents/` | yes | `intent/`, `knowledge/`, `harness/` (roles, policy, schemas, templates, `bindings.yaml`, `gates.yaml`), `skills/` |
|
|
76
|
+
| `.claude/` | yes | generated from `.agents/`; do not edit |
|
|
77
|
+
| `.loctx/` | no (gitignored) | tickets, reports, handoff `bus/`, `history/`, local roadmap |
|
|
78
|
+
|
|
79
|
+
## Quick start from source
|
|
80
|
+
|
|
81
|
+
Requires Node 20 or later and pnpm.
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
git clone https://github.com/bluenyang/reins.git
|
|
85
|
+
cd reins
|
|
86
|
+
pnpm install
|
|
87
|
+
pnpm build
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Run the CLI from the root of the repository you want to set up. The repository needs at least one
|
|
91
|
+
commit (`check` fails on an empty history in 0.1.0):
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
node /path/to/reins/cli/dist/reins.js install graph --dry-run # show what would be written
|
|
95
|
+
node /path/to/reins/cli/dist/reins.js install graph --mode auto # place files, detect CLIs, take defaults
|
|
96
|
+
node /path/to/reins/cli/dist/reins.js check --drift # prints "drift: ok" when sources and output match
|
|
97
|
+
node /path/to/reins/cli/dist/reins.js status # intents, tickets, bus, last history entry
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`install` refuses over a 3rd or 4th generation harness and points to `migrate`. Hooks are proposed
|
|
101
|
+
in the install notes and added only with `--hooks`. Once published, the same commands are meant to
|
|
102
|
+
run as `npx @bluenyang/reins <command>`.
|
|
103
|
+
|
|
104
|
+
## Commands
|
|
105
|
+
|
|
106
|
+
| Command | State | Does |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| `install <harness\|loop\|graph>` | implemented | places the harness; `--mode manual\|auto`, `--hooks`, `--dry-run`, `--json` |
|
|
109
|
+
| `migrate <tier>` | implemented | moves an existing harness or `.ai/` workspace to this layout |
|
|
110
|
+
| `update` | implemented | re-applies the packaged harness without touching project sections |
|
|
111
|
+
| `configure bindings` | implemented | `--set role=model ...` or `--vendors claude,openai`; no options asks |
|
|
112
|
+
| `gate [names...]` | implemented | runs `gates.yaml` as declared and keeps the raw output in `.loctx/reports/` |
|
|
113
|
+
| `status` | implemented | read-only view of harness, intents, tickets, bus and last history |
|
|
114
|
+
| `check` | implemented | `--stage ticket-start\|pre-commit\|verdict\|pre-push\|rc-close`, or `--drift` |
|
|
115
|
+
| `metrics` | implemented | summary of `.loctx/reports/metrics.jsonl`: tokens by role, vendor, ticket and intent, knowledge stops per ticket; `metrics record` adds a role run's tokens |
|
|
116
|
+
| `recall` | not implemented | search past decisions by symptom |
|
|
117
|
+
| `doctor` | not implemented | — |
|
|
118
|
+
| `configure budget\|gates\|language` | not implemented | — |
|
|
119
|
+
|
|
120
|
+
`install`, `migrate`, `update`, `gate`, `status`, `check` and `metrics` take `--json`. Every `check` and `gate` run also appends a line to `.loctx/reports/metrics.jsonl`. `check` stages map to
|
|
121
|
+
T1 (`ticket-start`), T2 (`pre-commit`), T3 (`verdict`), T4 (`pre-push`) and T5 (`rc-close`).
|
|
122
|
+
|
|
123
|
+
## Skills
|
|
124
|
+
|
|
125
|
+
Three skills are the entry points for an agent; they follow the `SKILL.md` standard.
|
|
126
|
+
|
|
127
|
+
| Skill | Use when |
|
|
128
|
+
|---|---|
|
|
129
|
+
| `reins-setup` | installing reins, or moving an existing harness onto it |
|
|
130
|
+
| `reins-intent` | a new request arrives: questionnaire, approved intent, accepted spec |
|
|
131
|
+
| `reins-loop` | a spec is accepted: plan, tickets, implement, verify, review, history |
|
|
132
|
+
|
|
133
|
+
## Roadmap
|
|
134
|
+
|
|
135
|
+
| Version | Content |
|
|
136
|
+
|---|---|
|
|
137
|
+
| 0.1 | graph design, self-install and self-migrate of this repository, the verifier, three skills, README; 0.1.0 is published to npm |
|
|
138
|
+
| 0.2 | carve the loop and harness tiers out of the graph; generated files for Codex, Gemini and Cursor; `recall`, `doctor`, budget stops; trial migration of older harnesses |
|
|
139
|
+
| 0.3 | Claude Code plugin; application to Team Croffle repositories |
|
|
140
|
+
| 1.0 | not scheduled: reins stays on 0.x and gains features until the maintainer considers the system settled; stabilising alone does not make it 1.0 |
|
|
141
|
+
|
|
142
|
+
## Documents
|
|
143
|
+
|
|
144
|
+
- [docs/proposal.md](docs/proposal.md): problems, goal, requirements, success criteria
|
|
145
|
+
- [docs/design.md](docs/design.md): layout, roles, stop conditions, file formats, CLI
|
|
146
|
+
- Korean editions: [proposal.ko.md](docs/proposal.ko.md), [design.ko.md](docs/design.ko.md). Where an English and a Korean edition disagree, the Korean file wins.
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, join, relative } from 'node:path';
|
|
3
|
+
import { currentBranch, isAncestor, repoRoot, stagedFiles } from '../lib/git.js';
|
|
4
|
+
import { harnessDir, secretPolicy } from '../lib/harness.js';
|
|
5
|
+
import { loadSchemas, schemaFor, validateFile } from '../lib/schema.js';
|
|
6
|
+
import { DEFAULT_ALLOWED, DEFAULT_SECRET_PATTERNS, isSecretPath } from '../lib/secrets.js';
|
|
7
|
+
import { findSkillFiles, splitFrontmatter, validateSkillFile } from '../lib/skills.js';
|
|
8
|
+
import { harnessVersion, packageHarness } from '../lib/upstream.js';
|
|
9
|
+
import { parse } from 'yaml';
|
|
10
|
+
import { report } from '../lib/verdict.js';
|
|
11
|
+
const SKILL_ROOTS = ['skills', '.agents/skills', '.claude/skills'];
|
|
12
|
+
const LOCAL_DIRS = ['.loctx/', '.ai/'];
|
|
13
|
+
function gitignoreHas(root, entry) {
|
|
14
|
+
const p = join(root, '.gitignore');
|
|
15
|
+
if (!existsSync(p))
|
|
16
|
+
return false;
|
|
17
|
+
const lines = readFileSync(p, 'utf8')
|
|
18
|
+
.split('\n')
|
|
19
|
+
.map((l) => l.trim());
|
|
20
|
+
const bare = entry.replace(/\/$/, '');
|
|
21
|
+
return lines.some((l) => l === entry || l === bare || l === `/${entry}` || l === `/${bare}`);
|
|
22
|
+
}
|
|
23
|
+
/** T2 — before a commit. */
|
|
24
|
+
export function preCommit(root) {
|
|
25
|
+
const findings = [];
|
|
26
|
+
const policy = secretPolicy(root);
|
|
27
|
+
const patterns = policy?.patterns ?? DEFAULT_SECRET_PATTERNS;
|
|
28
|
+
const allowed = policy?.allowed ?? DEFAULT_ALLOWED;
|
|
29
|
+
const staged = stagedFiles(root);
|
|
30
|
+
for (const f of staged) {
|
|
31
|
+
const hit = isSecretPath(f, patterns, allowed);
|
|
32
|
+
if (hit)
|
|
33
|
+
findings.push({
|
|
34
|
+
reason: 'C4',
|
|
35
|
+
verdict: 'STOP',
|
|
36
|
+
path: f,
|
|
37
|
+
message: `staged file matches secret pattern "${hit}"`,
|
|
38
|
+
resume: `git restore --staged -- "${f}" and add it to .gitignore`,
|
|
39
|
+
});
|
|
40
|
+
for (const d of LOCAL_DIRS)
|
|
41
|
+
if (f.startsWith(d))
|
|
42
|
+
findings.push({
|
|
43
|
+
reason: 'F2',
|
|
44
|
+
verdict: 'STOP',
|
|
45
|
+
path: f,
|
|
46
|
+
message: `${d} is local-only and must not be committed`,
|
|
47
|
+
resume: `git restore --staged -- "${f}"; ensure "${d}" is in .gitignore`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const hasLocal = LOCAL_DIRS.some((d) => existsSync(join(root, d)));
|
|
51
|
+
if (hasLocal && !LOCAL_DIRS.some((d) => gitignoreHas(root, d)))
|
|
52
|
+
findings.push({
|
|
53
|
+
reason: 'F2',
|
|
54
|
+
verdict: 'STOP',
|
|
55
|
+
message: 'local workspace exists but is not in .gitignore',
|
|
56
|
+
resume: 'add ".loctx/" (or ".ai/") to .gitignore',
|
|
57
|
+
});
|
|
58
|
+
findings.push(...driftFormat(root, staged), ...driftConvention(root, staged));
|
|
59
|
+
if (staged.some((f) => f.startsWith('harness/') || f.startsWith('.agents/harness/')))
|
|
60
|
+
findings.push(...driftMatch(root));
|
|
61
|
+
return findings;
|
|
62
|
+
}
|
|
63
|
+
/** T4 — before leaving the machine. */
|
|
64
|
+
export function prePush(root, refs) {
|
|
65
|
+
const findings = [];
|
|
66
|
+
const ZERO = /^0+$/;
|
|
67
|
+
for (const line of refs) {
|
|
68
|
+
const [localRef, localSha, remoteRef, remoteSha] = line.trim().split(/\s+/);
|
|
69
|
+
if (!localSha || !remoteSha || !remoteRef)
|
|
70
|
+
continue;
|
|
71
|
+
if (ZERO.test(remoteSha) || ZERO.test(localSha))
|
|
72
|
+
continue; // new branch or deletion
|
|
73
|
+
if (!isAncestor(remoteSha, localSha, root))
|
|
74
|
+
findings.push({
|
|
75
|
+
reason: 'A1',
|
|
76
|
+
verdict: 'HOLD',
|
|
77
|
+
path: remoteRef,
|
|
78
|
+
message: `push of ${localRef ?? '?'} would rewrite ${remoteRef} (remote commit is not an ancestor)`,
|
|
79
|
+
resume: 'this is a force push — the user must run it explicitly, every time',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return findings;
|
|
83
|
+
}
|
|
84
|
+
/** Drift, format layer — Agent Skills spec on SKILL.md files. */
|
|
85
|
+
export function driftFormat(root, only) {
|
|
86
|
+
const files = findSkillFiles(SKILL_ROOTS.map((r) => join(root, r)));
|
|
87
|
+
const chosen = only ? files.filter((f) => only.includes(relative(root, f))) : files;
|
|
88
|
+
return chosen.flatMap((f) => validateSkillFile(f).map((i) => ({
|
|
89
|
+
reason: 'F2',
|
|
90
|
+
verdict: 'STOP',
|
|
91
|
+
path: relative(root, i.path),
|
|
92
|
+
message: i.message,
|
|
93
|
+
resume: 'fix the SKILL.md frontmatter; see agentskills.io/specification',
|
|
94
|
+
})));
|
|
95
|
+
}
|
|
96
|
+
/** Files the convention layer looks at: harness sources plus the installed project's agent files. */
|
|
97
|
+
function walk(dir, out = []) {
|
|
98
|
+
if (!existsSync(dir))
|
|
99
|
+
return out;
|
|
100
|
+
for (const e of readdirSync(dir)) {
|
|
101
|
+
const p = join(dir, e);
|
|
102
|
+
if (statSync(p).isDirectory())
|
|
103
|
+
walk(p, out);
|
|
104
|
+
else
|
|
105
|
+
out.push(p);
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
/** Drift, convention layer — frontmatter schemas and role files (design §6). */
|
|
110
|
+
export function driftConvention(root, only) {
|
|
111
|
+
const dir = harnessDir(root);
|
|
112
|
+
if (!dir)
|
|
113
|
+
return [];
|
|
114
|
+
const schemas = loadSchemas(dir);
|
|
115
|
+
const findings = [];
|
|
116
|
+
const candidates = [
|
|
117
|
+
...walk(join(dir, 'templates')),
|
|
118
|
+
...walk(join(dir, 'knowledge-seeds')),
|
|
119
|
+
...walk(join(root, '.agents', 'intent')),
|
|
120
|
+
...walk(join(root, '.agents', 'knowledge')),
|
|
121
|
+
...walk(join(root, '.loctx', 'ticket')),
|
|
122
|
+
...walk(join(root, '.loctx', 'bus')),
|
|
123
|
+
];
|
|
124
|
+
for (const abs of candidates) {
|
|
125
|
+
const rel = relative(root, abs);
|
|
126
|
+
if (only && !only.includes(rel))
|
|
127
|
+
continue;
|
|
128
|
+
const m = schemaFor(rel);
|
|
129
|
+
if (!m)
|
|
130
|
+
continue;
|
|
131
|
+
const v = schemas.get(m.schema);
|
|
132
|
+
if (!v)
|
|
133
|
+
continue;
|
|
134
|
+
for (const i of validateFile(v, abs, m.whole))
|
|
135
|
+
findings.push({
|
|
136
|
+
reason: 'F2',
|
|
137
|
+
verdict: 'STOP',
|
|
138
|
+
path: rel,
|
|
139
|
+
message: `${m.schema}: ${i.message}`,
|
|
140
|
+
resume: `fix the frontmatter to match ${m.schema}.schema.json`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
// role files: role == file name, must_not present
|
|
144
|
+
const roles = join(dir, 'roles');
|
|
145
|
+
if (existsSync(roles))
|
|
146
|
+
for (const f of readdirSync(roles).filter((x) => x.endsWith('.md'))) {
|
|
147
|
+
const rel = relative(root, join(roles, f));
|
|
148
|
+
if (only && !only.includes(rel))
|
|
149
|
+
continue;
|
|
150
|
+
const { data, error } = splitFrontmatter(readFileSync(join(roles, f), 'utf8'));
|
|
151
|
+
const name = basename(f, '.md');
|
|
152
|
+
if (error)
|
|
153
|
+
findings.push({
|
|
154
|
+
reason: 'F2',
|
|
155
|
+
verdict: 'STOP',
|
|
156
|
+
path: rel,
|
|
157
|
+
message: `frontmatter is not valid YAML: ${error}`,
|
|
158
|
+
});
|
|
159
|
+
else if (!data || data['role'] !== name)
|
|
160
|
+
findings.push({
|
|
161
|
+
reason: 'F2',
|
|
162
|
+
verdict: 'STOP',
|
|
163
|
+
path: rel,
|
|
164
|
+
message: `role file must declare role: ${name}`,
|
|
165
|
+
});
|
|
166
|
+
else if (!Array.isArray(data['must_not']) || data['must_not'].length === 0)
|
|
167
|
+
findings.push({
|
|
168
|
+
reason: 'F2',
|
|
169
|
+
verdict: 'STOP',
|
|
170
|
+
path: rel,
|
|
171
|
+
message: 'must_not is the enforced source and cannot be empty',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return findings;
|
|
175
|
+
}
|
|
176
|
+
/** Files in an installed harness that belong to the project, not upstream. */
|
|
177
|
+
const PROJECT_OWNED = new Set(['bindings.yaml', 'gates.yaml', 'install.yaml']);
|
|
178
|
+
/** Drift, match layer — the installed `.agents/harness/` against the packaged upstream at the pinned version (design §6). */
|
|
179
|
+
export function driftMatch(root) {
|
|
180
|
+
const installed = join(root, '.agents', 'harness');
|
|
181
|
+
const pinPath = join(installed, 'install.yaml');
|
|
182
|
+
if (!existsSync(pinPath))
|
|
183
|
+
return [];
|
|
184
|
+
let up;
|
|
185
|
+
try {
|
|
186
|
+
up = packageHarness();
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
const pin = parse(readFileSync(pinPath, 'utf8')).version;
|
|
192
|
+
const upVersion = harnessVersion(up);
|
|
193
|
+
if (pin !== upVersion)
|
|
194
|
+
return [
|
|
195
|
+
{
|
|
196
|
+
reason: 'F2',
|
|
197
|
+
verdict: 'WARN',
|
|
198
|
+
path: '.agents/harness/install.yaml',
|
|
199
|
+
message: `installed harness is v${pin}, upstream is v${upVersion}`,
|
|
200
|
+
resume: 'run `reins update`',
|
|
201
|
+
},
|
|
202
|
+
];
|
|
203
|
+
const findings = [];
|
|
204
|
+
for (const abs of walk(installed)) {
|
|
205
|
+
const rel = relative(installed, abs);
|
|
206
|
+
if (PROJECT_OWNED.has(rel))
|
|
207
|
+
continue;
|
|
208
|
+
const src = join(up, rel);
|
|
209
|
+
if (!existsSync(src))
|
|
210
|
+
continue;
|
|
211
|
+
if (readFileSync(src, 'utf8') !== readFileSync(abs, 'utf8'))
|
|
212
|
+
findings.push({
|
|
213
|
+
reason: 'F2',
|
|
214
|
+
verdict: 'STOP',
|
|
215
|
+
path: `.agents/harness/${rel}`,
|
|
216
|
+
message: `differs from upstream v${upVersion} (harness files are not edited in place)`,
|
|
217
|
+
resume: 'run `reins update` to restore it; change behaviour upstream or with an override',
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return findings;
|
|
221
|
+
}
|
|
222
|
+
export function runCheck(opts) {
|
|
223
|
+
const root = repoRoot(opts.cwd ?? process.cwd());
|
|
224
|
+
const findings = [];
|
|
225
|
+
const stage = opts.stage ?? (opts.drift ? 'drift' : 'all');
|
|
226
|
+
if (opts.stage === 'pre-commit')
|
|
227
|
+
findings.push(...preCommit(root));
|
|
228
|
+
else if (opts.stage === 'pre-push')
|
|
229
|
+
findings.push(...prePush(root, opts.pushRefs ?? []));
|
|
230
|
+
else if (opts.stage) {
|
|
231
|
+
// test / rc-close: not implemented yet — say so instead of pretending
|
|
232
|
+
findings.push({
|
|
233
|
+
reason: 'F2',
|
|
234
|
+
verdict: 'STOP',
|
|
235
|
+
message: `stage "${opts.stage}" is not implemented yet`,
|
|
236
|
+
resume: 'arrives with the loop skills (implementation step 6)',
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if (opts.drift || !opts.stage)
|
|
240
|
+
findings.push(...driftFormat(root), ...driftConvention(root), ...driftMatch(root));
|
|
241
|
+
if (!currentBranch(root))
|
|
242
|
+
findings.push({ reason: 'F2', verdict: 'STOP', message: 'not a git repository' });
|
|
243
|
+
return report(stage, findings);
|
|
244
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { stringify } from 'yaml';
|
|
4
|
+
import { applyBlocks, parseBlocks } from '../lib/blocks.js';
|
|
5
|
+
import { discoverGates, generation, stacks, vendors, } from '../lib/detect.js';
|
|
6
|
+
import { apply, listFiles } from '../lib/fsx.js';
|
|
7
|
+
import { repoRoot } from '../lib/git.js';
|
|
8
|
+
import { splitFrontmatter } from '../lib/skills.js';
|
|
9
|
+
import { harnessVersion, packageHarness } from '../lib/upstream.js';
|
|
10
|
+
/** Which upstream harness files a tier installs (design §1, tier table). */
|
|
11
|
+
function tierFiles(all, tier) {
|
|
12
|
+
return all.filter((f) => {
|
|
13
|
+
if (f.startsWith('knowledge-seeds/'))
|
|
14
|
+
return false; // copied into knowledge/, not harness/
|
|
15
|
+
if (f === 'VERSION' || f.startsWith('policy/'))
|
|
16
|
+
return true;
|
|
17
|
+
if (f === 'templates/agents-md-blocks.md')
|
|
18
|
+
return true;
|
|
19
|
+
if (tier === 'harness')
|
|
20
|
+
return false;
|
|
21
|
+
if (f.startsWith('templates/bus/'))
|
|
22
|
+
return tier === 'graph';
|
|
23
|
+
if (f.startsWith('templates/'))
|
|
24
|
+
return true;
|
|
25
|
+
return tier === 'graph'; // roles/, schemas/
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const VENDOR_OF = (model) => model.startsWith('gpt') ? 'openai' : model.startsWith('gemini') ? 'google' : 'claude';
|
|
29
|
+
const CONFIRMED_MODELS = new Set(['claude-opus-5-5', 'claude-fable-5-1']); // seen working in this environment
|
|
30
|
+
/** bindings.yaml (design §5): defaults from role files; an unavailable vendor falls back, else to pm's model. */
|
|
31
|
+
export function buildBindings(harness, available, vs, mode) {
|
|
32
|
+
const roles = {};
|
|
33
|
+
const files = readdirSync(join(harness, 'roles'))
|
|
34
|
+
.filter((f) => f.endsWith('.md'))
|
|
35
|
+
.toSorted();
|
|
36
|
+
const meta = new Map();
|
|
37
|
+
for (const f of files) {
|
|
38
|
+
const { data } = splitFrontmatter(readFileSync(join(harness, 'roles', f), 'utf8'));
|
|
39
|
+
meta.set(f.replace(/\.md$/, ''), {
|
|
40
|
+
def: String(data?.['default_binding']),
|
|
41
|
+
fb: data?.['fallback_binding'],
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
const pm = meta.get('pm')?.def ?? 'claude-opus-5-5';
|
|
45
|
+
for (const [role, m] of meta) {
|
|
46
|
+
let model = m.def;
|
|
47
|
+
let note;
|
|
48
|
+
if (!available.has(VENDOR_OF(model))) {
|
|
49
|
+
note = `${VENDOR_OF(model)} not available — default was ${model}`;
|
|
50
|
+
model = m.fb && available.has(VENDOR_OF(m.fb)) ? m.fb : pm;
|
|
51
|
+
}
|
|
52
|
+
const entry = { vendor: VENDOR_OF(model), model };
|
|
53
|
+
if (role === 'implementer')
|
|
54
|
+
entry['parallel'] = 4;
|
|
55
|
+
if (!CONFIRMED_MODELS.has(model))
|
|
56
|
+
entry['unverified'] = true;
|
|
57
|
+
if (note)
|
|
58
|
+
entry['note'] = note;
|
|
59
|
+
roles[role] = entry;
|
|
60
|
+
}
|
|
61
|
+
const doc = {
|
|
62
|
+
schema: 'reins/bindings@1',
|
|
63
|
+
mode,
|
|
64
|
+
vendors: Object.fromEntries(vs.map((v) => [
|
|
65
|
+
v.name,
|
|
66
|
+
{ cli: v.cli, detected: v.detected, available: available.has(v.name) },
|
|
67
|
+
])),
|
|
68
|
+
roles,
|
|
69
|
+
escalation: ['pl', 'pm', 'advisor', 'user'],
|
|
70
|
+
limits: {},
|
|
71
|
+
};
|
|
72
|
+
return `# generated by reins install — change with \`reins configure bindings\`\n${stringify(doc)}`;
|
|
73
|
+
}
|
|
74
|
+
export function buildGates(root, gates) {
|
|
75
|
+
const doc = {
|
|
76
|
+
schema: 'reins/gates@1',
|
|
77
|
+
gates: Object.fromEntries(gates.map((g) => [
|
|
78
|
+
g.name,
|
|
79
|
+
{ cmd: g.cmd, from: g.from, ...(g.optional ? { optional: true } : {}) },
|
|
80
|
+
])),
|
|
81
|
+
order: gates.map((g) => g.name),
|
|
82
|
+
mutating_forbidden: true,
|
|
83
|
+
evidence: 'raw',
|
|
84
|
+
};
|
|
85
|
+
void root;
|
|
86
|
+
return `# discovered by reins install — the loop reads only this file (design §5)\n${stringify(doc)}`;
|
|
87
|
+
}
|
|
88
|
+
/** The stub install leaves; migrate may replace it with the project's real raw roadmap. */
|
|
89
|
+
export const ROADMAP_STUB = '# ROADMAP (local)\n\n— 산발적 아이디어를 여기에. 확정되면 docs/ROADMAP.md\n';
|
|
90
|
+
const LOCTX_README = `# .loctx — 이 머신·이 개발자만 (gitignore)
|
|
91
|
+
|
|
92
|
+
| 경로 | 무엇 |
|
|
93
|
+
|---|---|
|
|
94
|
+
| ROADMAP.local.md | 날 것의 큰 그림 — 산발적 아이디어, 미확정 기능을 예상 버전별로. 확정되면 docs/ROADMAP.md |
|
|
95
|
+
| ticket/ | 지금 하는 일 하나 = 파일 하나. 끝나면 history에 한 줄 쓰고 삭제 |
|
|
96
|
+
| reports/ | 테스트·게이트 증거 (툴체인 출력 그대로) |
|
|
97
|
+
| pr/ release/ | PR 본문·릴리스 노트 초안 |
|
|
98
|
+
| context/ | 개인 컨텍스트 — 대화·메모 |
|
|
99
|
+
| bus/ | 에이전트간 인계 파일 (스키마 강제, 끝나면 삭제) |
|
|
100
|
+
| test/ | 로컬 스크래치·휘발성 벤치마크 |
|
|
101
|
+
| history/ | 버전별 완료 보고서 — 노하우. 공유할 결정은 .agents/knowledge/ 로 |
|
|
102
|
+
`;
|
|
103
|
+
const hookLine = (stage) => `npx @bluenyang/reins check --stage ${stage}`;
|
|
104
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
105
|
+
export async function runInstall(o) {
|
|
106
|
+
const root = repoRoot(o.cwd ?? process.cwd());
|
|
107
|
+
const gen = generation(root);
|
|
108
|
+
const notes = [];
|
|
109
|
+
if ((gen === 'gen3' || gen === 'gen4') && !o.overExisting)
|
|
110
|
+
return {
|
|
111
|
+
root,
|
|
112
|
+
generation: gen,
|
|
113
|
+
notes,
|
|
114
|
+
refused: `an existing ${gen} harness is here — run \`reins migrate ${o.tier}\` instead`,
|
|
115
|
+
};
|
|
116
|
+
const up = packageHarness();
|
|
117
|
+
const version = harnessVersion(up);
|
|
118
|
+
const writes = [];
|
|
119
|
+
const add = (path, content, why) => writes.push({ path, content, why });
|
|
120
|
+
const addIfMissing = (path, content, why) => {
|
|
121
|
+
if (!existsSync(join(root, path)))
|
|
122
|
+
add(path, content, why);
|
|
123
|
+
};
|
|
124
|
+
// 1. upstream harness files for the tier
|
|
125
|
+
for (const f of tierFiles(listFiles(up), o.tier))
|
|
126
|
+
add(`.agents/harness/${f}`, readFileSync(join(up, f), 'utf8'), 'upstream harness');
|
|
127
|
+
// 2. questions (manual) / detection (auto)
|
|
128
|
+
const vs = (o.detectVendors ?? vendors)();
|
|
129
|
+
const detected = vs.filter((v) => v.detected).map((v) => v.name);
|
|
130
|
+
const avail = new Set(['claude']); // claude is the floor (R22)
|
|
131
|
+
if (o.tier === 'graph') {
|
|
132
|
+
const ans = await o.asker.ask(`vendors you can use (claude,openai,google,cursor) — detected: ${detected.join(',') || 'none'}`, ['claude', ...detected.filter((d) => d !== 'claude')].join(','));
|
|
133
|
+
for (const v of ans.split(',').map((s) => s.trim()))
|
|
134
|
+
if (['claude', 'openai', 'google', 'cursor'].includes(v))
|
|
135
|
+
avail.add(v);
|
|
136
|
+
}
|
|
137
|
+
const language = await o.asker.ask('language of documents the user reads (ko, en, …)', 'ko');
|
|
138
|
+
const proposal = discoverGates(root);
|
|
139
|
+
const gateList = proposal.gates.map((g) => `${g.name}=${g.cmd}`).join(', ') || 'none found';
|
|
140
|
+
const okGates = await o.asker.ask(`gates found: ${gateList} — use them? (y/n)`, 'y');
|
|
141
|
+
if (!/^y/i.test(okGates))
|
|
142
|
+
notes.push('gates not confirmed — edit .agents/harness/gates.yaml, then `reins check --dry-run`');
|
|
143
|
+
for (const x of proposal.excluded)
|
|
144
|
+
notes.push(`excluded from gates: ${x.name} (${x.why})`);
|
|
145
|
+
// 3. generated configuration
|
|
146
|
+
if (o.tier === 'graph')
|
|
147
|
+
add('.agents/harness/bindings.yaml', buildBindings(up, avail, vs, o.mode), 'role ↔ model bindings');
|
|
148
|
+
add('.agents/harness/gates.yaml', buildGates(root, /^y/i.test(okGates) ? proposal.gates : []), 'declared gates');
|
|
149
|
+
add('.agents/harness/install.yaml', `# version pin and install settings (R25) — read by reins update\n${stringify({ schema: 'reins/install@1', tier: o.tier, version, mode: o.mode, language })}`, 'version pin');
|
|
150
|
+
// 4. layout skeleton (design §1); never overwrite project files
|
|
151
|
+
if (o.tier !== 'harness') {
|
|
152
|
+
addIfMissing('.agents/intent/.gitkeep', '', 'intent folders live here');
|
|
153
|
+
addIfMissing('.agents/knowledge/.gitkeep', '', 'explicit knowledge');
|
|
154
|
+
for (const s of stacks(root)) {
|
|
155
|
+
const seeds = join(up, 'knowledge-seeds', s);
|
|
156
|
+
if (existsSync(seeds))
|
|
157
|
+
for (const f of listFiles(seeds))
|
|
158
|
+
addIfMissing(`.agents/knowledge/${f}`, readFileSync(join(seeds, f), 'utf8'), `seed for ${s}`);
|
|
159
|
+
}
|
|
160
|
+
add('.loctx/.gitignore', '*\n', 'double ignore (kestrel pattern)');
|
|
161
|
+
add('.loctx/README.md', LOCTX_README, 'local workspace layout');
|
|
162
|
+
addIfMissing('.loctx/ROADMAP.local.md', ROADMAP_STUB, 'raw roadmap');
|
|
163
|
+
}
|
|
164
|
+
addIfMissing('.agents/skills/.gitkeep', '', 'skills (Agent Skills standard path)');
|
|
165
|
+
addIfMissing('.agents/SKILLS.md', '# Skills\n\n| skill | does |\n|---|---|\n', 'skill index');
|
|
166
|
+
const gi = existsSync(join(root, '.gitignore'))
|
|
167
|
+
? readFileSync(join(root, '.gitignore'), 'utf8')
|
|
168
|
+
: '';
|
|
169
|
+
if (!/^\/?\.loctx\/?$/m.test(gi))
|
|
170
|
+
add('.gitignore', `${gi.replace(/\s*$/, '')}\n\n# reins local workspace\n.loctx/\n`, 'keep .loctx out of git');
|
|
171
|
+
// 5. AGENTS.md managed blocks, CLAUDE.md pointer
|
|
172
|
+
const agentsPath = join(root, 'AGENTS.md');
|
|
173
|
+
const agents = existsSync(agentsPath) ? readFileSync(agentsPath, 'utf8') : '# AGENTS.md\n';
|
|
174
|
+
const blocks = parseBlocks(readFileSync(join(up, 'templates/agents-md-blocks.md'), 'utf8'));
|
|
175
|
+
const br = applyBlocks(agents, blocks);
|
|
176
|
+
if (br.text !== agents)
|
|
177
|
+
add('AGENTS.md', br.text, `managed blocks: ${[...br.inserted, ...br.replaced].join(', ')}`);
|
|
178
|
+
for (const s of br.skipped)
|
|
179
|
+
notes.push(`AGENTS.md block "${s.name}" not placed: ${s.why}`);
|
|
180
|
+
const claude = join(root, 'CLAUDE.md');
|
|
181
|
+
if (!existsSync(claude))
|
|
182
|
+
add('CLAUDE.md', '@AGENTS.md\n', 'Claude Code reads AGENTS.md');
|
|
183
|
+
else if (!readFileSync(claude, 'utf8').includes('@AGENTS.md'))
|
|
184
|
+
notes.push('CLAUDE.md does not import @AGENTS.md — add it');
|
|
185
|
+
// 6. hooks: propose only, unless asked (user decision 2026-10-25)
|
|
186
|
+
const husky = existsSync(join(root, '.husky'));
|
|
187
|
+
for (const stage of ['pre-commit', 'pre-push']) {
|
|
188
|
+
const p = join(root, '.husky', stage);
|
|
189
|
+
const cur = existsSync(p) ? readFileSync(p, 'utf8') : '';
|
|
190
|
+
if (/reins(\.js)?\s+check/.test(cur))
|
|
191
|
+
continue; // already calls the verifier
|
|
192
|
+
if (husky && o.hooks)
|
|
193
|
+
add(`.husky/${stage}`, `${cur.replace(/\s*$/, '')}\n# reins:begin hooks\n${hookLine(stage)}\n# reins:end hooks\n`.replace(/^\n/, ''), 'hook');
|
|
194
|
+
else
|
|
195
|
+
notes.push(`hook proposal (${husky ? 'husky' : 'no hook tool found — lefthook or husky'}): run \`${hookLine(stage)}\` in ${stage}; re-run with --hooks to add it`);
|
|
196
|
+
}
|
|
197
|
+
// 7. legacy workspace — migrate owns it
|
|
198
|
+
if (existsSync(join(root, '.ai')) && !o.overExisting)
|
|
199
|
+
notes.push('legacy workspace .ai/ found — left untouched; run `reins migrate ' +
|
|
200
|
+
o.tier +
|
|
201
|
+
'` to move it into .agents/ and .loctx/');
|
|
202
|
+
notes.push('vendor files (.claude/agents, settings.json) are generated in a later step — `reins update` once the generator ships');
|
|
203
|
+
notes.push('next: in your agent, run the setup skill (/reins-setup) — the PM reads the project and proposes AGENTS.md project sections');
|
|
204
|
+
// 8. apply, then record (only when something changed)
|
|
205
|
+
const applied = apply(root, writes, !!o.dryRun);
|
|
206
|
+
let history;
|
|
207
|
+
const changedAny = applied.created.length + applied.changed.length > 0;
|
|
208
|
+
if (changedAny && !o.dryRun && o.tier !== 'harness') {
|
|
209
|
+
const now = o.now ?? new Date();
|
|
210
|
+
// local time, like every other history file name (<YYYY-MM-DD-HHmm>)
|
|
211
|
+
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}`;
|
|
212
|
+
history = `.loctx/history/${stamp}_install-${o.tier}.md`;
|
|
213
|
+
const body = [
|
|
214
|
+
`decisions: install-${o.tier}`,
|
|
215
|
+
'',
|
|
216
|
+
`# reins install ${o.tier} (${o.mode}) — v${version}`,
|
|
217
|
+
'',
|
|
218
|
+
`- generation before: ${gen}`,
|
|
219
|
+
`- created: ${applied.created.length} · changed: ${applied.changed.length} · unchanged: ${applied.unchanged.length}`,
|
|
220
|
+
...applied.created.map((p) => ` - + ${p}`),
|
|
221
|
+
...applied.changed.map((p) => ` - ~ ${p}`),
|
|
222
|
+
'',
|
|
223
|
+
'## notes',
|
|
224
|
+
...notes.map((n) => `- ${n}`),
|
|
225
|
+
'',
|
|
226
|
+
].join('\n');
|
|
227
|
+
apply(root, [{ path: history, content: body, why: 'install record' }], false);
|
|
228
|
+
}
|
|
229
|
+
return { root, generation: gen, applied, notes, history };
|
|
230
|
+
}
|