@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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/cli/dist/commands/check.js +244 -0
  4. package/cli/dist/commands/install.js +230 -0
  5. package/cli/dist/commands/migrate.js +122 -0
  6. package/cli/dist/lib/ask.js +27 -0
  7. package/cli/dist/lib/blocks.js +93 -0
  8. package/cli/dist/lib/detect.js +105 -0
  9. package/cli/dist/lib/fsx.js +36 -0
  10. package/cli/dist/lib/git.js +30 -0
  11. package/cli/dist/lib/glob.js +30 -0
  12. package/cli/dist/lib/harness.js +33 -0
  13. package/cli/dist/lib/migrate-plan.js +233 -0
  14. package/cli/dist/lib/schema.js +70 -0
  15. package/cli/dist/lib/secrets.js +28 -0
  16. package/cli/dist/lib/skills.js +63 -0
  17. package/cli/dist/lib/upstream.js +16 -0
  18. package/cli/dist/lib/verdict.js +15 -0
  19. package/cli/dist/reins.js +122 -0
  20. package/docs/README.ko.md +151 -0
  21. package/harness/CHANGELOG.md +10 -0
  22. package/harness/VERSION +1 -0
  23. package/harness/knowledge-seeds/node/2026-10-25_frozen-lockfile.md +24 -0
  24. package/harness/knowledge-seeds/node/2026-10-25_mutating-lint-is-not-a-gate.md +19 -0
  25. package/harness/policy/budget.yaml +13 -0
  26. package/harness/policy/common-red-lines.yaml +25 -0
  27. package/harness/policy/stops.yaml +29 -0
  28. package/harness/policy/transitions.yaml +13 -0
  29. package/harness/roles/advisor.md +15 -0
  30. package/harness/roles/code-review.md +21 -0
  31. package/harness/roles/deploy.md +17 -0
  32. package/harness/roles/docs.md +16 -0
  33. package/harness/roles/implementer.md +22 -0
  34. package/harness/roles/lookup.md +19 -0
  35. package/harness/roles/pl.md +21 -0
  36. package/harness/roles/planner.md +20 -0
  37. package/harness/roles/pm.md +23 -0
  38. package/harness/roles/qa-design.md +19 -0
  39. package/harness/roles/qa-run.md +16 -0
  40. package/harness/roles/verify-tool.md +19 -0
  41. package/harness/schemas/bus-request.schema.json +17 -0
  42. package/harness/schemas/bus-response.schema.json +14 -0
  43. package/harness/schemas/intent.schema.json +13 -0
  44. package/harness/schemas/knowledge.schema.json +16 -0
  45. package/harness/schemas/plan.schema.json +10 -0
  46. package/harness/schemas/spec.schema.json +8 -0
  47. package/harness/schemas/ticket.schema.json +15 -0
  48. package/harness/templates/agents-md-blocks.md +26 -0
  49. package/harness/templates/bus/request.yaml +14 -0
  50. package/harness/templates/bus/response.yaml +9 -0
  51. package/harness/templates/intent.md +23 -0
  52. package/harness/templates/knowledge.md +20 -0
  53. package/harness/templates/plan.md +23 -0
  54. package/harness/templates/spec.md +17 -0
  55. package/harness/templates/ticket.md +29 -0
  56. package/package.json +43 -4
  57. package/skills/reins-intent/SKILL.md +35 -0
  58. package/skills/reins-loop/SKILL.md +46 -0
  59. package/skills/reins-setup/SKILL.md +27 -0
@@ -0,0 +1,233 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { basename, extname, join } from 'node:path';
3
+ import { listFiles } from './fsx.js';
4
+ import { splitFrontmatter } from './skills.js';
5
+ const TOOL_EXT = new Set([
6
+ '.cjs',
7
+ '.mjs',
8
+ '.js',
9
+ '.ts',
10
+ '.sh',
11
+ '.ps1',
12
+ '.py',
13
+ '.pem',
14
+ '.key',
15
+ '.env',
16
+ '.yml',
17
+ '.yaml',
18
+ '.json',
19
+ '.toml',
20
+ '.dockerfile',
21
+ ]);
22
+ const isToolFile = (f) => TOOL_EXT.has(extname(f).toLowerCase()) || /dockerfile|compose|\.env/i.test(basename(f));
23
+ function dirFiles(root, rel) {
24
+ return listFiles(join(root, rel)).map((f) => `${rel}/${f}`);
25
+ }
26
+ /** A work file is open unless its frontmatter says done. */
27
+ function isOpen(abs) {
28
+ const { data } = splitFrontmatter(readFileSync(abs, 'utf8'));
29
+ return !data || String(data['status'] ?? 'todo') !== 'done';
30
+ }
31
+ export function planMigration(root) {
32
+ const moves = [];
33
+ const report = [];
34
+ const ai = '.ai';
35
+ if (!existsSync(join(root, ai)))
36
+ return { moves, report };
37
+ for (const entry of readdirSync(join(root, ai)).toSorted()) {
38
+ const rel = `${ai}/${entry}`;
39
+ const abs = join(root, rel);
40
+ const isDir = statSync(abs).isDirectory();
41
+ const files = isDir ? dirFiles(root, rel) : [rel];
42
+ const sub = (f) => f.slice(rel.length + 1);
43
+ switch (isDir ? entry : '') {
44
+ case 'history':
45
+ for (const f of files)
46
+ moves.push({
47
+ from: f,
48
+ to: `.loctx/history/${sub(f)}`,
49
+ action: 'move',
50
+ why: 'know-how stays local, unchanged',
51
+ });
52
+ continue;
53
+ case 'work':
54
+ for (const f of files)
55
+ if (f.endsWith('.md') && isOpen(join(root, f)))
56
+ moves.push({
57
+ from: f,
58
+ to: `.loctx/ticket/${sub(f)}`,
59
+ action: 'ticket',
60
+ why: 'open work item → ticket',
61
+ });
62
+ else
63
+ moves.push({
64
+ from: f,
65
+ to: `.loctx/history/work/${sub(f)}`,
66
+ action: 'move',
67
+ why: 'closed work item',
68
+ });
69
+ continue;
70
+ case 'release':
71
+ case 'pr':
72
+ for (const f of files)
73
+ moves.push({ from: f, to: `.loctx/${entry}/${sub(f)}`, action: 'move', why: 'drafts' });
74
+ continue;
75
+ case 'test':
76
+ for (const f of files)
77
+ if (isToolFile(f)) {
78
+ moves.push({
79
+ from: f,
80
+ to: `.loctx/_legacy/${f.slice(4)}`,
81
+ action: 'legacy',
82
+ why: 'tool file in a report folder — may hold secrets',
83
+ });
84
+ report.push(`test asset candidate (review, then move to test/ if it should be committed): ${f}`);
85
+ }
86
+ else
87
+ moves.push({
88
+ from: f,
89
+ to: `.loctx/reports/${sub(f)}`,
90
+ action: 'move',
91
+ why: 'test report',
92
+ });
93
+ continue;
94
+ case 'verify':
95
+ case 'benchmark':
96
+ case 'artifacts':
97
+ for (const f of files)
98
+ moves.push({
99
+ from: f,
100
+ to: `.loctx/_legacy/${f.slice(4)}`,
101
+ action: 'legacy',
102
+ why: 'verification assets — review before committing',
103
+ });
104
+ report.push(`${rel}/ (${files.length} files) kept in .loctx/_legacy/ — promote what should be committed to test/`);
105
+ continue;
106
+ case 'plan':
107
+ for (const f of files)
108
+ moves.push({
109
+ from: f,
110
+ to: `.loctx/context/plan/${sub(f)}`,
111
+ action: 'move',
112
+ why: 'plans before reins — kept as context',
113
+ });
114
+ if (files.length)
115
+ report.push('plans moved to .loctx/context/plan/ — open version plans become intents through the intent skill, with the user');
116
+ continue;
117
+ default:
118
+ break;
119
+ }
120
+ if (!isDir) {
121
+ if (/^ROADMAP\.local\.md$/i.test(entry) || entry === 'ROADMAP.md')
122
+ moves.push({
123
+ from: rel,
124
+ to: '.loctx/ROADMAP.local.md',
125
+ action: 'move',
126
+ why: 'raw roadmap',
127
+ });
128
+ else if (/^ROADMAP\.pub\.md$/i.test(entry)) {
129
+ moves.push({
130
+ from: rel,
131
+ to: existsSync(join(root, 'docs/ROADMAP.md'))
132
+ ? '.loctx/context/ROADMAP.pub.md'
133
+ : 'docs/ROADMAP.md',
134
+ action: 'move',
135
+ why: 'public roadmap',
136
+ });
137
+ }
138
+ else if (entry === 'README.md')
139
+ moves.push({
140
+ from: rel,
141
+ to: '.loctx/context/ai-README.md',
142
+ action: 'move',
143
+ why: 'the old workspace readme',
144
+ });
145
+ else if (entry === '.gitignore')
146
+ moves.push({
147
+ from: rel,
148
+ to: '.loctx/_legacy/ai.gitignore',
149
+ action: 'legacy',
150
+ why: 'replaced by .loctx/.gitignore',
151
+ });
152
+ else
153
+ moves.push({
154
+ from: rel,
155
+ to: `.loctx/context/${entry}`,
156
+ action: 'move',
157
+ why: 'other workspace file',
158
+ });
159
+ continue;
160
+ }
161
+ // conversation/, interview/, survey/, assets/, docs/, … → personal context
162
+ for (const f of files)
163
+ moves.push({
164
+ from: f,
165
+ to: `.loctx/context/${f.slice(4)}`,
166
+ action: 'move',
167
+ why: 'personal context',
168
+ });
169
+ }
170
+ // a second ROADMAP*.md would collide with the first
171
+ const seen = new Set();
172
+ for (const m of moves) {
173
+ if (seen.has(m.to)) {
174
+ m.to = `.loctx/context/${basename(m.from)}`;
175
+ m.why += ' (target taken)';
176
+ }
177
+ seen.add(m.to);
178
+ }
179
+ // .claude/memory (AI-GW) → knowledge; old procedures → legacy
180
+ for (const f of dirFiles(root, '.claude/memory'))
181
+ moves.push({
182
+ from: f,
183
+ to: `.agents/knowledge/${f.slice('.claude/memory/'.length)}`,
184
+ action: 'move',
185
+ why: 'shared memory → knowledge',
186
+ });
187
+ for (const d of [
188
+ '.claude/commands',
189
+ ...(existsSync(join(root, '.claude/skills'))
190
+ ? readdirSync(join(root, '.claude/skills'))
191
+ .filter((s) => s.endsWith('-workflow'))
192
+ .map((s) => `.claude/skills/${s}`)
193
+ : []),
194
+ ]) {
195
+ const fs = dirFiles(root, d);
196
+ for (const f of fs)
197
+ moves.push({
198
+ from: f,
199
+ to: `.loctx/_legacy/${f}`,
200
+ action: 'legacy',
201
+ why: 'replaced by reins skills',
202
+ });
203
+ if (fs.length)
204
+ report.push(`${d}/ moved to .loctx/_legacy/ — keep any project-specific procedure as a skill in .agents/skills/`);
205
+ }
206
+ return { moves, report };
207
+ }
208
+ /** work → ticket frontmatter (design §7): `release: <ver>-rc.N` becomes planned; the rest carries over. */
209
+ export function convertWorkToTicket(text, fileName) {
210
+ const { data, body } = splitFrontmatter(text);
211
+ const fm = data ?? {};
212
+ const rel = String(fm['release'] ?? '');
213
+ const ticket = {
214
+ // intent 000 = not yet tied to an intent; the PL links it when the work is planned again
215
+ id: `000-${Number.parseInt(String(fm['id'] ?? ''), 10) || 1}`,
216
+ intent: '000',
217
+ plan_step: Number(fm['id'] ?? 1) || 1,
218
+ title: String(fm['title'] ?? basename(fileName, '.md')),
219
+ status: ['todo', 'doing', 'blocked', 'done'].includes(String(fm['status']))
220
+ ? String(fm['status'])
221
+ : 'todo',
222
+ depends: Array.isArray(fm['depends']) ? fm['depends'] : [],
223
+ release: /rc\.(\d+)/.exec(rel) && Number(/rc\.(\d+)/.exec(rel)?.[1]) > 1 ? 'stabilization' : 'planned',
224
+ role: 'implementer',
225
+ asks: [],
226
+ ...(fm['branch'] ? { branch: fm['branch'] } : {}),
227
+ migrated_from: fileName,
228
+ };
229
+ const yaml = Object.entries(ticket)
230
+ .map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
231
+ .join('\n');
232
+ return `---\n${yaml}\n---\n${body}`;
233
+ }
@@ -0,0 +1,70 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { Ajv2020 } from 'ajv/dist/2020.js';
4
+ import { parse } from 'yaml';
5
+ import { splitFrontmatter } from './skills.js';
6
+ /** Loads every `*.schema.json` in the harness and compiles them by file name (`intent`, `bus-request`, …). */
7
+ export function loadSchemas(harness) {
8
+ const dir = join(harness, 'schemas');
9
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
10
+ const files = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.schema.json')) : [];
11
+ const ids = new Map();
12
+ for (const f of files) {
13
+ const schema = JSON.parse(readFileSync(join(dir, f), 'utf8'));
14
+ ajv.addSchema(schema); // registered under its $id, so cross-file $refs resolve
15
+ if (schema.$id)
16
+ ids.set(f.replace(/\.schema\.json$/, ''), schema.$id);
17
+ }
18
+ const out = new Map();
19
+ for (const [name, id] of ids) {
20
+ const v = ajv.getSchema(id);
21
+ if (v)
22
+ out.set(name, v);
23
+ }
24
+ return out;
25
+ }
26
+ function errors(v, path) {
27
+ return (v.errors ?? []).map((e) => ({
28
+ path,
29
+ message: `${e.instancePath || '(root)'} ${e.message ?? 'invalid'}`,
30
+ }));
31
+ }
32
+ /** Validates a Markdown file's YAML frontmatter, or a whole YAML file when `whole` is set. */
33
+ export function validateFile(v, path, whole = false) {
34
+ const text = readFileSync(path, 'utf8');
35
+ let data;
36
+ let error;
37
+ if (whole) {
38
+ try {
39
+ data = parse(text);
40
+ }
41
+ catch (e) {
42
+ error = e.message.split('\n')[0];
43
+ }
44
+ }
45
+ else
46
+ ({ data, error } = splitFrontmatter(text));
47
+ if (data == null)
48
+ return [{ path, message: error ? `not valid YAML: ${error}` : 'missing YAML frontmatter' }];
49
+ return v(data) ? [] : errors(v, path);
50
+ }
51
+ /** Which schema a file belongs to, from where it lives. */
52
+ export function schemaFor(rel) {
53
+ const name = basename(rel);
54
+ if (/(^|\/)intent\/[^/]+\/intent\.md$/.test(rel) || rel.endsWith('templates/intent.md'))
55
+ return { schema: 'intent', whole: false };
56
+ if (/(^|\/)intent\/[^/]+\/spec\.md$/.test(rel) || rel.endsWith('templates/spec.md'))
57
+ return { schema: 'spec', whole: false };
58
+ if (/(^|\/)intent\/[^/]+\/plan\.md$/.test(rel) || rel.endsWith('templates/plan.md'))
59
+ return { schema: 'plan', whole: false };
60
+ if (/(^|\/)\.loctx\/ticket\/[^/]+\.md$/.test(rel) || rel.endsWith('templates/ticket.md'))
61
+ return { schema: 'ticket', whole: false };
62
+ if (/(^|\/)(knowledge|knowledge-seeds\/[^/]+)\/[^/]+\.md$/.test(rel) ||
63
+ rel.endsWith('templates/knowledge.md'))
64
+ return { schema: 'knowledge', whole: false };
65
+ if (name.endsWith('.request.yaml') || rel.endsWith('templates/bus/request.yaml'))
66
+ return { schema: 'bus-request', whole: true };
67
+ if (name.endsWith('.response.yaml') || rel.endsWith('templates/bus/response.yaml'))
68
+ return { schema: 'bus-response', whole: true };
69
+ return null;
70
+ }
@@ -0,0 +1,28 @@
1
+ import { matchesAny } from './glob.js';
2
+ /** Fallback secret-file patterns (red line C4) when no harness policy is present. */
3
+ export const DEFAULT_SECRET_PATTERNS = [
4
+ '.env',
5
+ '.env.*',
6
+ 'secrets/**',
7
+ '*.key',
8
+ '*.pem',
9
+ '*.p12',
10
+ '*.pfx',
11
+ '*.properties',
12
+ 'id_rsa',
13
+ 'id_rsa.*',
14
+ 'id_ed25519',
15
+ 'id_ed25519.*',
16
+ ];
17
+ /** Templates that look like secrets but are fine to commit. */
18
+ export const DEFAULT_ALLOWED = [
19
+ '.env.example',
20
+ '*.example',
21
+ '*.example.*',
22
+ '*.sample',
23
+ ];
24
+ export function isSecretPath(path, patterns = DEFAULT_SECRET_PATTERNS, allowed = DEFAULT_ALLOWED) {
25
+ if (matchesAny(path, allowed))
26
+ return null;
27
+ return matchesAny(path, patterns);
28
+ }
@@ -0,0 +1,63 @@
1
+ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
2
+ import { join, basename } from 'node:path';
3
+ import { parse } from 'yaml';
4
+ const NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
5
+ const RESERVED = ['anthropic', 'claude'];
6
+ export function splitFrontmatter(text) {
7
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
8
+ if (!m)
9
+ return { data: null, body: text };
10
+ try {
11
+ const data = parse(m[1] ?? '');
12
+ return { data: data ?? {}, body: m[2] ?? '' };
13
+ }
14
+ catch (e) {
15
+ // a broken frontmatter is a finding, never a crash of the verifier
16
+ return { data: null, body: m[2] ?? '', error: e.message.split('\n')[0] };
17
+ }
18
+ }
19
+ export function validateSkillFile(path) {
20
+ const issues = [];
21
+ const dir = basename(join(path, '..'));
22
+ const text = readFileSync(path, 'utf8');
23
+ const { data, body, error } = splitFrontmatter(text);
24
+ if (!data)
25
+ return [
26
+ {
27
+ path,
28
+ message: error ? `frontmatter is not valid YAML: ${error}` : 'missing YAML frontmatter',
29
+ },
30
+ ];
31
+ const name = data['name'];
32
+ const desc = data['description'];
33
+ if (typeof name !== 'string' || name.length === 0 || name.length > 64 || !NAME_RE.test(name))
34
+ issues.push({ path, message: 'name: 1–64 chars, lowercase letters/digits/single hyphens' });
35
+ else {
36
+ if (name !== dir)
37
+ issues.push({ path, message: `name "${name}" must equal its directory "${dir}"` });
38
+ for (const w of RESERVED)
39
+ if (name.split('-').includes(w))
40
+ issues.push({ path, message: `name must not contain reserved word "${w}"` });
41
+ }
42
+ if (typeof desc !== 'string' || desc.trim().length === 0 || desc.length > 1024)
43
+ issues.push({ path, message: 'description: 1–1024 chars, non-empty' });
44
+ else if (/<[a-z][^>]*>/i.test(desc))
45
+ issues.push({ path, message: 'description must not contain XML tags' });
46
+ if (body.split('\n').length > 500)
47
+ issues.push({ path, message: 'SKILL.md body over 500 lines — move detail to references/' });
48
+ return issues;
49
+ }
50
+ /** Every `<dir>/SKILL.md` under the given roots that exist. */
51
+ export function findSkillFiles(roots) {
52
+ const out = [];
53
+ for (const root of roots) {
54
+ if (!existsSync(root))
55
+ continue;
56
+ for (const entry of readdirSync(root)) {
57
+ const p = join(root, entry, 'SKILL.md');
58
+ if (existsSync(p) && statSync(p).isFile())
59
+ out.push(p);
60
+ }
61
+ }
62
+ return out;
63
+ }
@@ -0,0 +1,16 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ /** The harness shipped with this package: `<pkg>/harness`, two levels above `cli/dist/lib`. */
5
+ export function packageHarness() {
6
+ const here = dirname(fileURLToPath(import.meta.url));
7
+ for (const up of ['../../../harness', '../../harness', '../harness']) {
8
+ const p = join(here, up);
9
+ if (existsSync(join(p, 'VERSION')))
10
+ return p;
11
+ }
12
+ throw new Error('reins: cannot find the packaged harness/ directory');
13
+ }
14
+ export function harnessVersion(dir) {
15
+ return readFileSync(join(dir, 'VERSION'), 'utf8').trim();
16
+ }
@@ -0,0 +1,15 @@
1
+ export const EXIT = { CONTINUE: 0, HOLD: 10, STOP: 20, ESCALATE: 30 };
2
+ const RANK = { CONTINUE: 0, STOP: 1, ESCALATE: 2, HOLD: 3 };
3
+ /** The report's verdict is the most demanding of its findings; HOLD outranks all; WARN never stops. */
4
+ export function report(stage, findings) {
5
+ let verdict = 'CONTINUE';
6
+ for (const f of findings)
7
+ if (f.verdict !== 'WARN' && RANK[f.verdict] > RANK[verdict])
8
+ verdict = f.verdict;
9
+ const blocking = findings.filter((f) => f.verdict !== 'WARN');
10
+ const warns = findings.length - blocking.length;
11
+ const summary = blocking.length === 0
12
+ ? `${stage}: ok${warns ? ` (${warns} warning${warns > 1 ? 's' : ''})` : ''}`
13
+ : `${stage}: ${verdict} — ${blocking.map((f) => f.reason).join(', ')}`;
14
+ return { schema: 'reins/bus/stop-report@1', stage, verdict, findings, summary };
15
+ }
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { runCheck } from './commands/check.js';
4
+ import { runInstall } from './commands/install.js';
5
+ import { runMigrate } from './commands/migrate.js';
6
+ import { defaultsAsker, stdinAsker } from './lib/ask.js';
7
+ import { EXIT } from './lib/verdict.js';
8
+ const program = new Command()
9
+ .name('reins')
10
+ .description('agent harness — install, migrate, update; check keeps agents inside their roles')
11
+ .version('0.1.0');
12
+ const notYet = (name) => () => {
13
+ process.stderr.write(`reins ${name}: not implemented yet (see docs/design.md §11)\n`);
14
+ process.exit(2);
15
+ };
16
+ program
17
+ .command('install')
18
+ .description('install the harness at a tier: harness | loop | graph')
19
+ .argument('<tier>', 'harness | loop | graph')
20
+ .option('--mode <mode>', 'manual (ask) | auto (detect and apply defaults)', 'manual')
21
+ .option('--hooks', 'add the reins check line to existing husky hooks (otherwise only proposed)')
22
+ .option('--dry-run', 'show what would be written')
23
+ .option('--json')
24
+ .action(async (tier, o) => {
25
+ if (!['harness', 'loop', 'graph'].includes(tier)) {
26
+ process.stderr.write(`reins install: tier must be harness, loop or graph (got "${tier}")\n`);
27
+ process.exit(2);
28
+ }
29
+ const asker = o.mode === 'auto' ? defaultsAsker : await stdinAsker();
30
+ const r = await runInstall({
31
+ tier: tier,
32
+ mode: o.mode,
33
+ hooks: o.hooks,
34
+ dryRun: o.dryRun,
35
+ asker,
36
+ });
37
+ asker.close();
38
+ if (o.json) {
39
+ process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
40
+ process.exit(r.refused ? 2 : 0);
41
+ }
42
+ if (r.refused) {
43
+ process.stderr.write(`reins install: ${r.refused}\n`);
44
+ process.exit(2);
45
+ }
46
+ const a = r.applied;
47
+ const verb = o.dryRun ? 'would write' : 'wrote';
48
+ process.stdout.write(`reins install ${tier} — generation before: ${r.generation}\n`);
49
+ process.stdout.write(`${verb}: ${a.created.length} new, ${a.changed.length} changed; ${a.unchanged.length} already up to date\n`);
50
+ for (const p of a.created)
51
+ process.stdout.write(` + ${p}\n`);
52
+ for (const p of a.changed)
53
+ process.stdout.write(` ~ ${p}\n`);
54
+ for (const n of r.notes)
55
+ process.stdout.write(`• ${n}\n`);
56
+ if (r.history)
57
+ process.stdout.write(`recorded: ${r.history}\n`);
58
+ });
59
+ program
60
+ .command('migrate')
61
+ .description('move an existing harness or .ai/ workspace onto the reins layout')
62
+ .argument('<tier>', 'harness | loop | graph')
63
+ .option('--mode <mode>', 'manual | auto', 'manual')
64
+ .option('--dry-run', 'show the moves without touching anything')
65
+ .option('--json')
66
+ .action(async (tier, o) => {
67
+ if (!['harness', 'loop', 'graph'].includes(tier)) {
68
+ process.stderr.write(`reins migrate: tier must be harness, loop or graph (got "${tier}")\n`);
69
+ process.exit(2);
70
+ }
71
+ const asker = o.mode === 'auto' || o.dryRun ? defaultsAsker : await stdinAsker();
72
+ const r = await runMigrate({ tier: tier, mode: o.mode, dryRun: o.dryRun, asker });
73
+ asker.close();
74
+ if (o.json) {
75
+ process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
76
+ process.exit(r.refused ? 2 : 0);
77
+ }
78
+ if (r.refused) {
79
+ process.stderr.write(`reins migrate: ${r.refused}\n`);
80
+ process.exit(2);
81
+ }
82
+ const by = (k) => r.moves.filter((m) => m.result === k).length;
83
+ process.stdout.write(`reins migrate ${tier} — from ${r.generation}${o.dryRun ? ' (dry run)' : ''}\n`);
84
+ process.stdout.write(o.dryRun
85
+ ? `would move ${r.moves.length} files\n`
86
+ : `moved ${by('moved')} · tickets ${by('converted')} · identical ${by('same')} · kept both ${by('kept-both')}\n`);
87
+ if (o.dryRun)
88
+ for (const m of r.moves)
89
+ process.stdout.write(` ${m.action === 'move' ? ' ' : m.action[0]} ${m.from} → ${m.to}\n`);
90
+ process.stdout.write(`AGENTS.md: swapped ${r.agents.swapped.join(', ') || 'none'}; inserted ${r.agents.inserted.join(', ') || 'none'}\n`);
91
+ for (const n of r.report)
92
+ process.stdout.write(`• ${n}\n`);
93
+ if (r.history)
94
+ process.stdout.write(`recorded: ${r.history}\n`);
95
+ });
96
+ for (const n of ['update', 'configure', 'recall', 'status', 'doctor'])
97
+ program.command(n).allowUnknownOption().allowExcessArguments().action(notYet(n));
98
+ program
99
+ .command('check')
100
+ .description('harness verifier: --stage pre-commit|pre-push|test|rc-close, or --drift')
101
+ .option('--stage <stage>')
102
+ .option('--drift')
103
+ .option('--json')
104
+ .action(async (o) => {
105
+ let pushRefs = [];
106
+ if (o.stage === 'pre-push' && !process.stdin.isTTY) {
107
+ const chunks = [];
108
+ for await (const c of process.stdin)
109
+ chunks.push(c);
110
+ pushRefs = Buffer.concat(chunks).toString('utf8').split('\n').filter(Boolean);
111
+ }
112
+ const r = runCheck({ stage: o.stage, drift: o.drift, pushRefs });
113
+ if (o.json)
114
+ process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
115
+ else {
116
+ process.stdout.write(`${r.summary}\n`);
117
+ for (const f of r.findings)
118
+ process.stdout.write(` [${f.reason} ${f.verdict}] ${f.path ? `${f.path}: ` : ''}${f.message}${f.resume ? `\n → ${f.resume}` : ''}\n`);
119
+ }
120
+ process.exit(EXIT[r.verdict]);
121
+ });
122
+ program.parseAsync(process.argv);