@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,122 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { applyBlocks, parseBlocks, swapSections } from '../lib/blocks.js';
4
+ import { generation } from '../lib/detect.js';
5
+ import { repoRoot } from '../lib/git.js';
6
+ import { convertWorkToTicket, planMigration } from '../lib/migrate-plan.js';
7
+ import { packageHarness } from '../lib/upstream.js';
8
+ import { ROADMAP_STUB, runInstall } from './install.js';
9
+ const pad = (n) => String(n).padStart(2, '0');
10
+ const stampOf = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`;
11
+ function removeEmptyDirs(dir) {
12
+ if (!existsSync(dir) || !statSync(dir).isDirectory())
13
+ return;
14
+ for (const e of readdirSync(dir))
15
+ removeEmptyDirs(join(dir, e));
16
+ if (readdirSync(dir).length === 0)
17
+ rmdirSync(dir);
18
+ }
19
+ /** `reins migrate <tier>` (design §7): install if needed, move the old workspace, swap AGENTS.md sections. Idempotent. */
20
+ export async function runMigrate(o) {
21
+ const root = repoRoot(o.cwd ?? process.cwd());
22
+ const gen = generation(root);
23
+ const res = {
24
+ root,
25
+ generation: gen,
26
+ moves: [],
27
+ agents: { swapped: [], inserted: [] },
28
+ report: [],
29
+ };
30
+ if (gen === 'none')
31
+ return { ...res, refused: 'no existing harness or workspace here — use `reins install`' };
32
+ // 1. make sure the target layout exists (install is idempotent)
33
+ if (!o.dryRun) {
34
+ const inst = await runInstall({
35
+ tier: o.tier,
36
+ mode: o.mode,
37
+ asker: o.asker,
38
+ cwd: root,
39
+ detectVendors: o.detectVendors,
40
+ now: o.now,
41
+ overExisting: true,
42
+ });
43
+ if (inst.refused)
44
+ return { ...res, refused: inst.refused };
45
+ res.report.push(...inst.notes.filter((n) => !n.startsWith('next:') && !n.includes('managed') && !n.includes('not placed')));
46
+ }
47
+ // 2. move the old workspace
48
+ const plan = planMigration(root);
49
+ res.report.push(...plan.report);
50
+ for (const m of plan.moves) {
51
+ const from = join(root, m.from);
52
+ const to = join(root, m.to);
53
+ if (o.dryRun) {
54
+ res.moves.push({ ...m, result: 'planned' });
55
+ continue;
56
+ }
57
+ mkdirSync(dirname(to), { recursive: true });
58
+ const src = readFileSync(from);
59
+ if (m.action === 'ticket') {
60
+ writeFileSync(to, convertWorkToTicket(src.toString('utf8'), m.from.split('/').pop() ?? ''));
61
+ unlinkSync(from);
62
+ res.moves.push({ ...m, result: 'converted' });
63
+ }
64
+ else if (!existsSync(to) || readFileSync(to, 'utf8') === ROADMAP_STUB) {
65
+ renameSync(from, to);
66
+ res.moves.push({ ...m, result: 'moved' });
67
+ }
68
+ else if (Buffer.compare(readFileSync(to), src) === 0) {
69
+ unlinkSync(from);
70
+ res.moves.push({ ...m, result: 'same' });
71
+ }
72
+ else {
73
+ const alt = to.replace(/(\.[^./]+)?$/, '.from-ai$1');
74
+ renameSync(from, alt);
75
+ res.moves.push({ ...m, to: alt.slice(root.length + 1), result: 'kept-both' });
76
+ res.report.push(`${m.to} already existed — the old file is next to it as ${alt.slice(root.length + 1)}`);
77
+ }
78
+ }
79
+ if (!o.dryRun)
80
+ removeEmptyDirs(join(root, '.ai'));
81
+ // 3. AGENTS.md: sections reins owns become managed blocks; the originals are kept locally
82
+ const agentsPath = join(root, 'AGENTS.md');
83
+ if (existsSync(agentsPath)) {
84
+ const up = parseBlocks(readFileSync(join(packageHarness(), 'templates/agents-md-blocks.md'), 'utf8'));
85
+ const before = readFileSync(agentsPath, 'utf8');
86
+ const swap = swapSections(before, up);
87
+ const placed = applyBlocks(swap.text, up);
88
+ res.agents = { swapped: swap.swapped, inserted: placed.inserted };
89
+ if (placed.text !== before && !o.dryRun) {
90
+ writeFileSync(agentsPath, placed.text);
91
+ if (swap.removed) {
92
+ const keep = join(root, '.loctx/context/agents-md.before.md');
93
+ mkdirSync(dirname(keep), { recursive: true });
94
+ const prev = existsSync(keep) ? `${readFileSync(keep, 'utf8')}\n\n` : '';
95
+ writeFileSync(keep, `${prev}<!-- replaced by reins migrate, ${stampOf(o.now ?? new Date())} -->\n${swap.removed}\n`);
96
+ res.report.push('AGENTS.md: the replaced sections are in .loctx/context/agents-md.before.md — move project-specific rules back outside the blocks');
97
+ }
98
+ }
99
+ }
100
+ // 4. record, only when something happened
101
+ const happened = res.moves.length > 0 || res.agents.swapped.length > 0;
102
+ if (happened && !o.dryRun) {
103
+ res.history = `.loctx/history/${stampOf(o.now ?? new Date())}_migrate-${o.tier}.md`;
104
+ const count = (r) => res.moves.filter((m) => m.result === r).length;
105
+ writeFileSync(join(root, res.history), [
106
+ `decisions: migrate-${o.tier}`,
107
+ '',
108
+ `# reins migrate ${o.tier} — from ${gen}`,
109
+ '',
110
+ `- moved ${count('moved')} · converted to tickets ${count('converted')} · identical ${count('same')} · kept both ${count('kept-both')}`,
111
+ `- AGENTS.md: swapped ${res.agents.swapped.join(', ') || 'none'}; inserted ${res.agents.inserted.join(', ') || 'none'}`,
112
+ '',
113
+ '## for a person to look at',
114
+ ...res.report.map((r) => `- ${r}`),
115
+ '',
116
+ '## moves',
117
+ ...res.moves.map((m) => `- ${m.from} → ${m.to} (${m.result})`),
118
+ '',
119
+ ].join('\n'));
120
+ }
121
+ return res;
122
+ }
@@ -0,0 +1,27 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ export async function stdinAsker() {
3
+ if (process.stdin.isTTY) {
4
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
5
+ return {
6
+ async ask(q, d) {
7
+ const a = (await rl.question(`${q} [${d}] `)).trim();
8
+ return a || d;
9
+ },
10
+ close: () => rl.close(),
11
+ };
12
+ }
13
+ const chunks = [];
14
+ for await (const c of process.stdin)
15
+ chunks.push(c);
16
+ const lines = Buffer.concat(chunks).toString('utf8').split('\n');
17
+ return {
18
+ async ask(q, d) {
19
+ const a = (lines.shift() ?? '').trim();
20
+ process.stderr.write(`${q} [${d}] ${a}\n`);
21
+ return a || d;
22
+ },
23
+ close: () => { },
24
+ };
25
+ }
26
+ /** Auto mode and tests: every question takes its default. */
27
+ export const defaultsAsker = { ask: async (_q, d) => d, close: () => { } };
@@ -0,0 +1,93 @@
1
+ const BLOCK_RE = /<!-- reins:begin ([a-z-]+) v=([^ ]+) -->[\s\S]*?<!-- reins:end \1 -->/g;
2
+ const OVERRIDE_RE = /<!-- reins:override ([a-z-]+) -->/g;
3
+ export function parseBlocks(text) {
4
+ return [...text.matchAll(BLOCK_RE)].map((m) => ({
5
+ name: m[1] ?? '',
6
+ version: m[2] ?? '',
7
+ body: m[0],
8
+ }));
9
+ }
10
+ export function overrides(text) {
11
+ return new Set([...text.matchAll(OVERRIDE_RE)].map((m) => m[1] ?? ''));
12
+ }
13
+ /** The first `## ` heading inside a block, e.g. "Workflow". */
14
+ function headingOf(block) {
15
+ return /^## (.+)$/m.exec(block.body)?.[1]?.trim() ?? null;
16
+ }
17
+ /** Text with every managed block removed — what the project wrote. */
18
+ function outside(text) {
19
+ return text.replace(BLOCK_RE, '');
20
+ }
21
+ /**
22
+ * Put the upstream blocks into an AGENTS.md. Existing blocks are replaced in place; an overridden block is
23
+ * left alone; a block whose heading the project already has outside any block is skipped (migrate replaces it).
24
+ * New blocks go after the first H1 (notice) or at the end.
25
+ */
26
+ export function applyBlocks(text, upstream) {
27
+ const res = { text, inserted: [], replaced: [], skipped: [] };
28
+ const present = new Map(parseBlocks(text).map((b) => [b.name, b]));
29
+ const over = overrides(text);
30
+ for (const b of upstream) {
31
+ if (over.has(b.name)) {
32
+ res.skipped.push({ name: b.name, why: 'overridden by the project' });
33
+ continue;
34
+ }
35
+ const cur = present.get(b.name);
36
+ if (cur) {
37
+ if (cur.body !== b.body) {
38
+ res.text = res.text.replace(cur.body, b.body);
39
+ res.replaced.push(b.name);
40
+ }
41
+ continue;
42
+ }
43
+ const h = headingOf(b);
44
+ if (h && new RegExp(`^## ${h}\\s*$`, 'm').test(outside(res.text))) {
45
+ res.skipped.push({
46
+ name: b.name,
47
+ why: `the project already has a "## ${h}" section — \`reins migrate\` replaces it`,
48
+ });
49
+ continue;
50
+ }
51
+ if (b.name === 'notice') {
52
+ const h1 = /^# .*$/m.exec(res.text);
53
+ res.text = h1
54
+ ? `${res.text.slice(0, h1.index + h1[0].length)}\n\n${b.body}\n${res.text.slice(h1.index + h1[0].length)}`
55
+ : `${b.body}\n\n${res.text}`;
56
+ }
57
+ else
58
+ res.text = `${res.text.replace(/\s*$/, '')}\n\n${b.body}\n`;
59
+ res.inserted.push(b.name);
60
+ }
61
+ return res;
62
+ }
63
+ /**
64
+ * Migrate's half of the managed-block story (design §7): a project section whose heading an upstream block
65
+ * owns (`## Workflow`, `## Skills`, `## Language`) is replaced by that block. The section runs from its
66
+ * heading to the next heading of the same or a higher level. The originals are returned so nothing is lost.
67
+ */
68
+ export function swapSections(text, upstream) {
69
+ let out = text;
70
+ const swapped = [];
71
+ const removed = [];
72
+ const present = new Set(parseBlocks(text).map((b) => b.name));
73
+ for (const b of upstream) {
74
+ if (present.has(b.name))
75
+ continue;
76
+ const h = headingOf(b);
77
+ if (!h)
78
+ continue;
79
+ const re = new RegExp(`^## ${h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'm');
80
+ const m = re.exec(out);
81
+ if (!m)
82
+ continue;
83
+ const start = m.index;
84
+ const rest = out.slice(start + m[0].length);
85
+ // a section ends at the next heading of the same or higher level, or where a managed block begins
86
+ const next = /^(?:#{1,2} |<!-- reins:begin )/m.exec(rest);
87
+ const end = next ? start + m[0].length + next.index : out.length;
88
+ removed.push(out.slice(start, end).trimEnd());
89
+ out = `${out.slice(0, start)}${b.body}\n\n${out.slice(end).replace(/^\n+/, '')}`.replace(/\n{3,}/g, '\n\n');
90
+ swapped.push(b.name);
91
+ }
92
+ return { text: out.replace(/\s*$/, '\n'), swapped, removed: removed.join('\n\n') };
93
+ }
@@ -0,0 +1,105 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ export function generation(root) {
5
+ const agents = join(root, 'AGENTS.md');
6
+ const hasAgents = existsSync(agents);
7
+ if (hasAgents &&
8
+ readFileSync(agents, 'utf8').includes('<!-- reins:begin') &&
9
+ existsSync(join(root, '.agents/harness')))
10
+ return 'reins';
11
+ const skills = join(root, '.claude/skills');
12
+ const skillDirs = existsSync(skills) ? readdirSync(skills) : [];
13
+ if (skillDirs.length >= 10 && skillDirs.some((s) => s === 'work-cicd-to' || s === 'go-flow'))
14
+ return 'gen4';
15
+ if (existsSync(join(root, '.claude/commands')))
16
+ return 'gen3';
17
+ if (hasAgents)
18
+ return 'gen2';
19
+ if (existsSync(join(root, '.ai')))
20
+ return 'gen1';
21
+ return 'none';
22
+ }
23
+ function onPath(bin) {
24
+ try {
25
+ execFileSync(process.platform === 'win32' ? 'where' : 'which', [bin], { stdio: 'ignore' });
26
+ return true;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ export function vendors() {
33
+ return [
34
+ { name: 'claude', cli: 'claude', detected: onPath('claude') },
35
+ { name: 'openai', cli: 'codex', detected: onPath('codex') },
36
+ { name: 'google', cli: 'agy', detected: onPath('agy') || onPath('gemini') },
37
+ { name: 'cursor', cli: 'cursor-agent', detected: onPath('cursor-agent') },
38
+ ];
39
+ }
40
+ export function stacks(root) {
41
+ const out = [];
42
+ if (existsSync(join(root, 'package.json')))
43
+ out.push('node');
44
+ if (existsSync(join(root, 'go.mod')))
45
+ out.push('go');
46
+ if (existsSync(join(root, 'build.gradle.kts')) || existsSync(join(root, 'build.gradle')))
47
+ out.push('gradle');
48
+ if (existsSync(join(root, 'pyproject.toml')) || existsSync(join(root, 'requirements.txt')))
49
+ out.push('python');
50
+ return out;
51
+ }
52
+ export function runner(root) {
53
+ if (existsSync(join(root, 'pnpm-lock.yaml')))
54
+ return 'pnpm';
55
+ if (existsSync(join(root, 'yarn.lock')))
56
+ return 'yarn';
57
+ if (existsSync(join(root, 'package-lock.json')) || existsSync(join(root, 'package.json')))
58
+ return 'npm';
59
+ if (existsSync(join(root, 'Makefile')))
60
+ return 'make';
61
+ return null;
62
+ }
63
+ const MUTATING = /--fix\b|--write\b|\bfmt\b(?!:check)|(^|\s)format(\s|$)/;
64
+ /**
65
+ * The PM's discovery step 2–3 (design §5): read the project's scripts, drop mutating ones,
66
+ * prefer the aggregate `check` the hooks and CI already call.
67
+ */
68
+ export function discoverGates(root) {
69
+ const res = { gates: [], excluded: [] };
70
+ const run = runner(root);
71
+ const pkgPath = join(root, 'package.json');
72
+ if (run && run !== 'make' && existsSync(pkgPath)) {
73
+ const scripts = JSON.parse(readFileSync(pkgPath, 'utf8')).scripts ??
74
+ {};
75
+ const cmd = (s) => (run === 'npm' ? `npm run ${s}` : `${run} ${s}`);
76
+ for (const [name, body] of Object.entries(scripts))
77
+ if (MUTATING.test(body) && !/check/.test(name))
78
+ res.excluded.push({ name, cmd: body, why: 'mutates files (--fix/--write) — not a gate' });
79
+ const pick = (names) => names.find((n) => n in scripts && !MUTATING.test(scripts[n] ?? ''));
80
+ const check = pick(['check', 'verify', 'ci', 'lint:check']) ?? null;
81
+ if (check)
82
+ res.gates.push({ name: 'check', cmd: cmd(check), from: `package.json#scripts.${check}` });
83
+ const test = check && /\btest\b/.test(scripts[check] ?? '') ? null : pick(['test', 'test:unit']);
84
+ if (test)
85
+ res.gates.push({ name: 'test', cmd: cmd(test), from: `package.json#scripts.${test}` });
86
+ const build = pick(['build']);
87
+ if (build)
88
+ res.gates.push({ name: 'build', cmd: cmd(build), from: 'package.json#scripts.build' });
89
+ const e2e = pick(['test:e2e', 'e2e']);
90
+ if (e2e)
91
+ res.gates.push({
92
+ name: 'e2e',
93
+ cmd: cmd(e2e),
94
+ from: `package.json#scripts.${e2e}`,
95
+ optional: true,
96
+ });
97
+ }
98
+ else if (run === 'make') {
99
+ const mk = readFileSync(join(root, 'Makefile'), 'utf8');
100
+ for (const t of ['check', 'test', 'build'])
101
+ if (new RegExp(`^${t}:`, 'm').test(mk))
102
+ res.gates.push({ name: t, cmd: `make ${t}`, from: `Makefile:${t}` });
103
+ }
104
+ return res;
105
+ }
@@ -0,0 +1,36 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, relative } from 'node:path';
3
+ export function apply(root, writes, dryRun) {
4
+ const out = { created: [], changed: [], unchanged: [] };
5
+ for (const w of writes) {
6
+ const abs = join(root, w.path);
7
+ const exists = existsSync(abs);
8
+ if (exists && readFileSync(abs, 'utf8') === w.content) {
9
+ out.unchanged.push(w.path);
10
+ continue;
11
+ }
12
+ (exists ? out.changed : out.created).push(w.path);
13
+ if (!dryRun) {
14
+ mkdirSync(dirname(abs), { recursive: true });
15
+ writeFileSync(abs, w.content);
16
+ }
17
+ }
18
+ return out;
19
+ }
20
+ /** Every file under `dir`, as paths relative to `dir`. */
21
+ export function listFiles(dir) {
22
+ const out = [];
23
+ const walk = (d) => {
24
+ if (!existsSync(d))
25
+ return;
26
+ for (const e of readdirSync(d)) {
27
+ const p = join(d, e);
28
+ if (statSync(p).isDirectory())
29
+ walk(p);
30
+ else
31
+ out.push(relative(dir, p));
32
+ }
33
+ };
34
+ walk(dir);
35
+ return out.toSorted();
36
+ }
@@ -0,0 +1,30 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ function git(args, cwd) {
3
+ return execFileSync('git', args, {
4
+ cwd,
5
+ encoding: 'utf8',
6
+ stdio: ['ignore', 'pipe', 'ignore'],
7
+ }).trim();
8
+ }
9
+ export function stagedFiles(cwd) {
10
+ const out = git(['diff', '--cached', '--name-only', '--diff-filter=ACMR'], cwd);
11
+ return out ? out.split('\n') : [];
12
+ }
13
+ export function currentBranch(cwd) {
14
+ return git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
15
+ }
16
+ export function isAncestor(ancestor, descendant, cwd) {
17
+ try {
18
+ execFileSync('git', ['merge-base', '--is-ancestor', ancestor, descendant], {
19
+ cwd,
20
+ stdio: 'ignore',
21
+ });
22
+ return true;
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ export function repoRoot(cwd) {
29
+ return git(['rev-parse', '--show-toplevel'], cwd);
30
+ }
@@ -0,0 +1,30 @@
1
+ /** Minimal glob → RegExp: `**` any path, `*` within a segment, `?` one char. Matches whole path. */
2
+ export function globToRegExp(glob) {
3
+ let re = '';
4
+ for (let i = 0; i < glob.length; i++) {
5
+ const c = glob[i];
6
+ if (c === '*') {
7
+ if (glob[i + 1] === '*') {
8
+ re += '.*';
9
+ i++;
10
+ if (glob[i + 1] === '/')
11
+ i++;
12
+ }
13
+ else
14
+ re += '[^/]*';
15
+ }
16
+ else if (c === '?')
17
+ re += '[^/]';
18
+ else if (c && '.+^${}()|[]\\'.includes(c))
19
+ re += `\\${c}`;
20
+ else
21
+ re += c;
22
+ }
23
+ return new RegExp(`^(?:.*/)?${re}$`);
24
+ }
25
+ export function matchesAny(path, globs) {
26
+ for (const g of globs)
27
+ if (globToRegExp(g).test(path))
28
+ return g;
29
+ return null;
30
+ }
@@ -0,0 +1,33 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parse } from 'yaml';
4
+ /**
5
+ * Where the harness sources live: an installed project has `.agents/harness/`;
6
+ * this repository (the upstream) keeps them at `harness/`.
7
+ */
8
+ export function harnessDir(root) {
9
+ for (const d of ['.agents/harness', 'harness']) {
10
+ if (existsSync(join(root, d, 'policy')))
11
+ return join(root, d);
12
+ }
13
+ return null;
14
+ }
15
+ export function readYaml(path) {
16
+ return parse(readFileSync(path, 'utf8'));
17
+ }
18
+ export function redLines(dir) {
19
+ return readYaml(join(dir, 'policy', 'common-red-lines.yaml')).rules;
20
+ }
21
+ export function stops(dir) {
22
+ return readYaml(join(dir, 'policy', 'stops.yaml')).stops;
23
+ }
24
+ /** Secret patterns from red line C4, or null when there is no harness to read. */
25
+ export function secretPolicy(root) {
26
+ const dir = harnessDir(root);
27
+ if (!dir)
28
+ return null;
29
+ const c4 = redLines(dir).find((r) => r.id === 'C4');
30
+ if (!c4?.patterns)
31
+ return null;
32
+ return { patterns: c4.patterns, allowed: c4.allowed ?? [] };
33
+ }