@rungs/cli 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 +196 -0
- package/modules/README.md +116 -0
- package/modules/adr/files/{{path}}/README.md +52 -0
- package/modules/adr/files/{{path}}/TEMPLATE.md +55 -0
- package/modules/adr/fragments/AGENTS.md +8 -0
- package/modules/adr/gates/adr.toml +65 -0
- package/modules/adr/module.toml +67 -0
- package/modules/audit/files/{{criteria_path}} +43 -0
- package/modules/audit/fragments/AGENTS.md +8 -0
- package/modules/audit/gates/audit.toml +48 -0
- package/modules/audit/module.toml +72 -0
- package/modules/audit/skills/assess/SKILL.md +62 -0
- package/modules/backlog/files/docs/{{root}}/BACKLOG.md +52 -0
- package/modules/backlog/files/docs/{{root}}/README.md +110 -0
- package/modules/backlog/files/docs/{{root}}/TEMPLATE.md +56 -0
- package/modules/backlog/files/docs/{{root}}/archive/README.md +13 -0
- package/modules/backlog/files/docs/{{root}}/items/README.md +13 -0
- package/modules/backlog/fragments/AGENTS.md +9 -0
- package/modules/backlog/gates/ids.toml +106 -0
- package/modules/backlog/module.toml +150 -0
- package/modules/backlog/rules/work-items.md +44 -0
- package/modules/backlog/skills/backlog-summary/SKILL.md +65 -0
- package/modules/backlog/skills/work-item/SKILL.md +96 -0
- package/modules/ci/files/{{workflow_path}} +43 -0
- package/modules/ci/gates/ci.toml +44 -0
- package/modules/ci/module.toml +74 -0
- package/modules/concurrency/files/docs/concurrent-sessions.md +87 -0
- package/modules/concurrency/fragments/AGENTS.md +10 -0
- package/modules/concurrency/fragments/gitattributes +15 -0
- package/modules/concurrency/gates/concurrency.toml +54 -0
- package/modules/concurrency/module.toml +113 -0
- package/modules/design-sync/fragments/AGENTS.md +8 -0
- package/modules/design-sync/gates/design.toml +70 -0
- package/modules/design-sync/module.toml +82 -0
- package/modules/design-sync/skills/design-align/SKILL.md +51 -0
- package/modules/design-sync/skills/design-pull/SKILL.md +50 -0
- package/modules/doc-authority/files/{{registry_path}} +59 -0
- package/modules/doc-authority/files/{{rules_path}} +51 -0
- package/modules/doc-authority/fragments/AGENTS.md +9 -0
- package/modules/doc-authority/gates/authority.toml +127 -0
- package/modules/doc-authority/module.toml +111 -0
- package/modules/findings/files/docs/{{backlog.root}}/FINDINGS.md +56 -0
- package/modules/findings/fragments/AGENTS.md +7 -0
- package/modules/findings/gates/findings.toml +68 -0
- package/modules/findings/module.toml +100 -0
- package/modules/findings/skills/record-finding/SKILL.md +63 -0
- package/modules/gates/files/.ai/gates.toml +49 -0
- package/modules/gates/fragments/AGENTS.md +8 -0
- package/modules/gates/fragments/gitignore +4 -0
- package/modules/gates/gates/structural.toml +127 -0
- package/modules/gates/module.toml +143 -0
- package/modules/gates/skills/harden-rule/SKILL.md +82 -0
- package/modules/instructions/files/.ai/rules/README.md +53 -0
- package/modules/instructions/files/AGENTS.md +104 -0
- package/modules/instructions/files/CLAUDE.md +11 -0
- package/modules/instructions/gates/core.toml +114 -0
- package/modules/instructions/module.toml +127 -0
- package/modules/release/fragments/AGENTS.md +8 -0
- package/modules/release/gates/release.toml +85 -0
- package/modules/release/module.toml +97 -0
- package/modules/release/skills/cut-release/SKILL.md +81 -0
- package/modules/session/files/{{archive}}/README.md +28 -0
- package/modules/session/files/{{path}} +56 -0
- package/modules/session/fragments/AGENTS.md +6 -0
- package/modules/session/gates/session.toml +63 -0
- package/modules/session/module.toml +72 -0
- package/modules/session/skills/close-session/SKILL.md +59 -0
- package/modules/skills/fragments/AGENTS.md +8 -0
- package/modules/skills/gates/skills.toml +94 -0
- package/modules/skills/module.toml +89 -0
- package/modules/skills/rules/skill-authoring.md +71 -0
- package/modules/specs/files/{{path}}/README.md +63 -0
- package/modules/specs/files/{{path}}/TEMPLATE.md +62 -0
- package/modules/specs/fragments/AGENTS.md +8 -0
- package/modules/specs/gates/specs.toml +119 -0
- package/modules/specs/module.toml +107 -0
- package/modules/workflows/fragments/AGENTS.md +8 -0
- package/modules/workflows/gates/workflows.toml +65 -0
- package/modules/workflows/module.toml +75 -0
- package/modules/workflows/rules/planning-tiers.md +46 -0
- package/modules/workflows/rules/reuse-decision.md +50 -0
- package/modules/workflows/skills/decompose/SKILL.md +75 -0
- package/package.json +42 -0
- package/src/add.ts +331 -0
- package/src/check.ts +216 -0
- package/src/cli.ts +411 -0
- package/src/detect.ts +246 -0
- package/src/engines.ts +260 -0
- package/src/engines2.ts +297 -0
- package/src/engines3.ts +194 -0
- package/src/glob.ts +101 -0
- package/src/lifecycle.ts +268 -0
- package/src/manifest.ts +108 -0
- package/src/render.ts +222 -0
- package/src/substitute.ts +78 -0
- package/src/types.ts +110 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { auditModules, loadAllModules } from './manifest.ts';
|
|
5
|
+
import { detect, scanRepo } from './detect.ts';
|
|
6
|
+
import { addModule, adoptableGates, registerGates, resolveInstallOrder, writeInstallRecord } from './add.ts';
|
|
7
|
+
import { render, writeReport, type Harness } from './render.ts';
|
|
8
|
+
import { resolveParams } from './substitute.ts';
|
|
9
|
+
import { appendLedger, ledgerQuestions, loadRegistry, runGates } from './check.ts';
|
|
10
|
+
import { applyUpgrade, eject, planUpgrade, PROFILES, readRecord, setupGit } from './lifecycle.ts';
|
|
11
|
+
import type { DetectResult, Manifest } from './types.ts';
|
|
12
|
+
|
|
13
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const MODULES = join(HERE, '..', 'modules');
|
|
15
|
+
|
|
16
|
+
const c = {
|
|
17
|
+
dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
|
|
18
|
+
bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
|
|
19
|
+
red: (s: string) => `\x1b[31m${s}\x1b[0m`,
|
|
20
|
+
yellow: (s: string) => `\x1b[33m${s}\x1b[0m`,
|
|
21
|
+
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
|
|
22
|
+
cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const STATE_LABEL: Record<DetectResult['state'], string> = {
|
|
26
|
+
absent: c.dim('absent'),
|
|
27
|
+
'ours-current': c.green('ours'),
|
|
28
|
+
'ours-diverged': c.yellow('diverged'),
|
|
29
|
+
theirs: c.cyan('theirs'),
|
|
30
|
+
paradigm: c.yellow('paradigm'),
|
|
31
|
+
unknown: c.red('unknown'),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function cmdModules() {
|
|
35
|
+
const mods = loadAllModules(MODULES);
|
|
36
|
+
console.log(c.bold(`\n${mods.length} modules\n`));
|
|
37
|
+
for (const m of mods) {
|
|
38
|
+
const deps = m.requires.length ? c.dim(` ← ${m.requires.join(', ')}`) : '';
|
|
39
|
+
console.log(` ${c.bold(m.name.padEnd(14))} rung ${m.rung}${deps}`);
|
|
40
|
+
console.log(` ${' '.repeat(14)} ${c.dim(m.summary)}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const issues = auditModules(mods);
|
|
44
|
+
console.log();
|
|
45
|
+
if (issues.length === 0) {
|
|
46
|
+
console.log(c.green(' audit clean') + c.dim(' — every parameter accounted for, every gate has a table and a why'));
|
|
47
|
+
} else {
|
|
48
|
+
console.log(c.red(` ${issues.length} issue(s):`));
|
|
49
|
+
for (const i of issues) console.log(` ${c.yellow(i.module)} ${c.dim(i.kind)} — ${i.detail}`);
|
|
50
|
+
}
|
|
51
|
+
console.log();
|
|
52
|
+
return issues.length === 0 ? 0 : 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function cmdDoctor(target: string) {
|
|
56
|
+
const root = resolve(target);
|
|
57
|
+
const mods = loadAllModules(MODULES);
|
|
58
|
+
console.log(c.bold(`\nrungs doctor — ${root}\n`));
|
|
59
|
+
|
|
60
|
+
const files = scanRepo(root);
|
|
61
|
+
const record = readRecord(root);
|
|
62
|
+
console.log(
|
|
63
|
+
c.dim(` scanned ${files.length} files`) +
|
|
64
|
+
(record ? c.dim(` · installed ${Object.keys(record.modules).length} module(s)`) : c.dim(' · not a rungs repo')) +
|
|
65
|
+
'\n',
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const params = resolveParams(mods, Object.fromEntries(
|
|
69
|
+
Object.entries(record?.modules ?? {}).flatMap(([n, e]) => (e.params ? [[n, e.params]] : [])),
|
|
70
|
+
));
|
|
71
|
+
const skillsDir = record?.harnesses.includes('claude') === false ? '.agents/skills' : '.claude/skills';
|
|
72
|
+
const results = mods.map((m) => {
|
|
73
|
+
const installed = record?.modules[m.name];
|
|
74
|
+
return detect(m, root, files, installed ? { ...installed, skillsDir, params_all: params } : undefined);
|
|
75
|
+
});
|
|
76
|
+
const byState = (s: DetectResult['state']) => results.filter((r) => r.state === s);
|
|
77
|
+
|
|
78
|
+
for (const r of results) {
|
|
79
|
+
const mod = mods.find((m) => m.name === r.module)!;
|
|
80
|
+
const line = ` ${r.module.padEnd(14)} ${STATE_LABEL[r.state]}`;
|
|
81
|
+
if (r.state === 'absent') {
|
|
82
|
+
console.log(c.dim(line));
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
console.log(line);
|
|
86
|
+
if (r.ours) {
|
|
87
|
+
const parts = [`v${r.ours.version}`, `${r.ours.current.length} current`];
|
|
88
|
+
if (r.ours.stale.length) parts.push(c.cyan(`${r.ours.stale.length} stale`));
|
|
89
|
+
if (r.ours.missing.length) parts.push(c.yellow(`${r.ours.missing.length} missing`));
|
|
90
|
+
if (r.ours.kept.length) parts.push(c.dim(`${r.ours.kept.length} kept (yours from the start)`));
|
|
91
|
+
console.log(c.dim(` ${parts.join(' · ')}`));
|
|
92
|
+
for (const f of r.ours.diverged.slice(0, 3)) {
|
|
93
|
+
console.log(` ${c.yellow('diverged')} ${f} ${c.dim('— yours, never overwritten')}`);
|
|
94
|
+
}
|
|
95
|
+
if (r.ours.diverged.length > 3) console.log(c.dim(` …and ${r.ours.diverged.length - 3} more`));
|
|
96
|
+
if (r.ours.stale.length || r.ours.missing.length) {
|
|
97
|
+
console.log(c.dim(' run `rungs upgrade --apply`'));
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
for (const p of r.matchedPaths.slice(0, 2)) {
|
|
102
|
+
console.log(c.dim(` ${p.count}× ${p.pattern} e.g. ${p.sample[0]}`));
|
|
103
|
+
}
|
|
104
|
+
if (r.matchedMarkers.length) console.log(c.dim(` markers: ${r.matchedMarkers.join(', ')}`));
|
|
105
|
+
for (const prop of r.proposals) {
|
|
106
|
+
console.log(` ${c.cyan('proposes')} ${prop.param} = ${c.bold(prop.value)} ${c.dim(`(${prop.evidence})`)}`);
|
|
107
|
+
}
|
|
108
|
+
for (const a of r.adoptable) {
|
|
109
|
+
console.log(` ${c.cyan('adoptable')} ${a.count} as ${a.kind} ${c.dim(`e.g. ${a.sample[0]}`)}`);
|
|
110
|
+
}
|
|
111
|
+
if (r.paradigm) {
|
|
112
|
+
console.log(` ${c.yellow('different paradigm')}: ${r.paradigm.id} ${c.dim(`(${r.paradigm.matched[0]})`)}`);
|
|
113
|
+
if (r.paradigm.note) console.log(c.dim(` ${firstSentence(r.paradigm.note)}`));
|
|
114
|
+
}
|
|
115
|
+
if (mod.threshold?.confirm) {
|
|
116
|
+
console.log(c.yellow(` threshold: ${mod.threshold.minimum}+ ${mod.threshold.metric} — add requires confirmation`));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const ours = byState('ours-current').length + byState('ours-diverged').length;
|
|
121
|
+
console.log(
|
|
122
|
+
`\n ${ours ? `${ours} installed (${byState('ours-diverged').length} diverged) · ` : ''}` +
|
|
123
|
+
`${byState('theirs').length} present · ${byState('paradigm').length} different paradigm · ` +
|
|
124
|
+
`${byState('absent').length} absent\n`,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// ADR-0005: state what this does not cover, every time. A green read is not
|
|
128
|
+
// a verified one, and a low count may mean a narrow signature rather than a
|
|
129
|
+
// clean repo.
|
|
130
|
+
console.log(c.dim(' This reports presence, never quality. It cannot tell whether an adopted'));
|
|
131
|
+
console.log(c.dim(' system is good, complete, or working — only that files are where a'));
|
|
132
|
+
console.log(c.dim(" module's files would be. Signatures under-detect on purpose.\n"));
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function firstSentence(s: string): string {
|
|
137
|
+
return s.trim().replace(/\s+/g, ' ').split(/(?<=\.)\s/)[0];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function cmdAdd(names: string[], root: string, dryRun: boolean, harnesses: Harness[], stamp: string) {
|
|
141
|
+
const mods = loadAllModules(MODULES);
|
|
142
|
+
const { order, missing } = resolveInstallOrder(names, mods);
|
|
143
|
+
if (missing.length) {
|
|
144
|
+
console.log(c.red(`\n unknown module(s): ${missing.join(', ')}\n`));
|
|
145
|
+
return 1;
|
|
146
|
+
}
|
|
147
|
+
const pulled = order.filter((m) => !names.includes(m.name));
|
|
148
|
+
const params = resolveParams(mods);
|
|
149
|
+
const skillsDir = harnesses.includes('claude') ? '.claude/skills' : '.agents/skills';
|
|
150
|
+
|
|
151
|
+
console.log(c.bold(`\nrungs add ${names.join(' ')} → ${root}${dryRun ? c.yellow(' (dry run)') : ''}\n`));
|
|
152
|
+
if (pulled.length) console.log(c.dim(` pulled in by dependency: ${pulled.map((m) => m.name).join(', ')}\n`));
|
|
153
|
+
|
|
154
|
+
const installed: Manifest[] = [];
|
|
155
|
+
const wrote = new Map<string, Set<string>>();
|
|
156
|
+
for (const mod of order) {
|
|
157
|
+
if (mod.threshold?.confirm && !dryRun && !flags.has('--confirm-threshold')) {
|
|
158
|
+
console.log(
|
|
159
|
+
c.yellow(` ${mod.name}: requires ${mod.threshold.minimum}+ ${mod.threshold.metric}.`) +
|
|
160
|
+
c.dim(' Skipped — pass --confirm-threshold to install it.\n'),
|
|
161
|
+
);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const actions = addModule(mod, root, params, { dryRun, skillsDir });
|
|
165
|
+
installed.push(mod);
|
|
166
|
+
wrote.set(mod.name, new Set(actions.filter((a) => a.disposition !== 'skip-exists' && a.disposition !== 'merge' && a.disposition !== 'gate').map((a) => a.target)));
|
|
167
|
+
const counts = new Map<string, number>();
|
|
168
|
+
for (const a of actions) counts.set(a.disposition, (counts.get(a.disposition) ?? 0) + 1);
|
|
169
|
+
console.log(` ${c.bold(mod.name.padEnd(14))} ${[...counts].map(([k, v]) => `${v} ${k}`).join(' · ')}`);
|
|
170
|
+
for (const a of actions.filter((x) => x.disposition === 'skip-exists')) {
|
|
171
|
+
console.log(c.dim(` kept ${a.target}`));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Detect what the repo already has and register it alongside (ADR-0004).
|
|
176
|
+
const repoFiles = scanRepo(root);
|
|
177
|
+
const adopted = installed.flatMap((m) =>
|
|
178
|
+
(m.detect.adopt_as ?? [])
|
|
179
|
+
.filter((a) => a.kind === 'command')
|
|
180
|
+
.flatMap((a) => adoptableGates(repoFiles, a.paths ?? [], root)),
|
|
181
|
+
);
|
|
182
|
+
if (adopted.length) {
|
|
183
|
+
console.log(
|
|
184
|
+
'\n ' + c.cyan(`adopting ${adopted.length} existing validator(s)`) +
|
|
185
|
+
' as command gates' + c.dim(' — their scripts are untouched'),
|
|
186
|
+
);
|
|
187
|
+
for (const a of adopted.slice(0, 3)) console.log(c.dim(` ${a.command}`));
|
|
188
|
+
if (adopted.length > 3) console.log(c.dim(` …and ${adopted.length - 3} more`));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Phase two: the registry's owner has created it by now.
|
|
192
|
+
const gateActions = registerGates(installed, root, dryRun, adopted);
|
|
193
|
+
if (gateActions.length) {
|
|
194
|
+
console.log(c.dim(`\n registered ${gateActions.reduce((n, a) => n + Number(a.note!.split(': ')[1].split(' ')[0]), 0)} gates from ${gateActions.length} module(s)`));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!dryRun) {
|
|
198
|
+
writeInstallRecord(root, order, params, harnesses, stamp, skillsDir, wrote);
|
|
199
|
+
const entries = render(root, harnesses);
|
|
200
|
+
writeReport(root, entries, harnesses, stamp);
|
|
201
|
+
console.log(
|
|
202
|
+
`\n rendered ${entries.filter((e) => e.target).length} file(s) · ` +
|
|
203
|
+
`${entries.filter((e) => e.degraded).length} degraded ` +
|
|
204
|
+
c.dim('→ .ai/render-report.md'),
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
console.log();
|
|
208
|
+
return 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function cmdRender(root: string, harnesses: Harness[], stamp: string) {
|
|
212
|
+
const entries = render(root, harnesses);
|
|
213
|
+
writeReport(root, entries, harnesses, stamp);
|
|
214
|
+
console.log(c.bold(`\nrungs render — ${root}\n`));
|
|
215
|
+
for (const e of entries) {
|
|
216
|
+
const lost = e.degraded ?? (e.dropped?.length ? c.dim(` (dropped ${e.dropped.join(', ')})`) : '');
|
|
217
|
+
console.log(` ${e.rule.padEnd(24)} ${e.harness.padEnd(10)} ${e.target ?? c.yellow('not emitted')}${lost}`);
|
|
218
|
+
}
|
|
219
|
+
console.log(c.dim(`\n ${entries.length} rendering(s) → .ai/render-report.md\n`));
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function cmdCheck(root: string, tier: string | undefined, stamp: string) {
|
|
224
|
+
const runs = runGates(root, tier);
|
|
225
|
+
if (!runs.length) {
|
|
226
|
+
console.log(c.yellow('\n no gates registered — is this a rungs repo?\n'));
|
|
227
|
+
return 1;
|
|
228
|
+
}
|
|
229
|
+
appendLedger(root, runs, stamp);
|
|
230
|
+
|
|
231
|
+
console.log(c.bold(`\nrungs check — ${root}${tier ? ` (${tier} tier)` : ''}\n`));
|
|
232
|
+
const mark = { pass: c.green('pass'), fail: c.red('FAIL'), unimplemented: c.yellow('unimpl'), error: c.red('error') };
|
|
233
|
+
for (const r of runs) {
|
|
234
|
+
console.log(
|
|
235
|
+
` ${mark[r.status]} ${r.id.padEnd(34)} ${c.dim(`${r.ms}ms`)}` +
|
|
236
|
+
(r.examined ? c.dim(` ${r.examined} examined`) : ''),
|
|
237
|
+
);
|
|
238
|
+
for (const f of r.findings.slice(0, 4)) {
|
|
239
|
+
console.log(` ${c.dim(f.file ? `${f.file}: ` : '')}${f.message}`);
|
|
240
|
+
}
|
|
241
|
+
if (r.findings.length > 4) console.log(c.dim(` …and ${r.findings.length - 4} more`));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const n = (s: string) => runs.filter((r) => r.status === s).length;
|
|
245
|
+
console.log(
|
|
246
|
+
`\n ${c.green(`${n('pass')} pass`)} · ${c.red(`${n('fail')} fail`)} · ` +
|
|
247
|
+
`${c.yellow(`${n('unimplemented')} unimplemented`)} · ${n('error')} error` +
|
|
248
|
+
c.dim(` (${runs.reduce((t, r) => t + r.ms, 0)}ms total)`),
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
if (n('unimplemented')) {
|
|
252
|
+
console.log(
|
|
253
|
+
c.yellow('\n Unimplemented gates are not passes.') +
|
|
254
|
+
c.dim(' A registry reporting green because most of its\n gates do nothing is the worst failure this tool could have, so they block.'),
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const { gates } = loadRegistry(root);
|
|
259
|
+
const q = ledgerQuestions(root, gates);
|
|
260
|
+
if (q.neverFired.length || q.alwaysFires.length) {
|
|
261
|
+
console.log(c.bold(`\n Ledger questions ${c.dim(`(${q.runs} recorded runs)`)}`));
|
|
262
|
+
for (const g of q.neverFired.slice(0, 3)) {
|
|
263
|
+
console.log(` ${c.cyan(g.id)} has never fired. ${c.dim(firstSentence(g.why ?? ''))}`);
|
|
264
|
+
console.log(c.dim(' Is that still a risk here, or is the gate scoped too narrowly?'));
|
|
265
|
+
}
|
|
266
|
+
for (const g of q.alwaysFires.slice(0, 3)) {
|
|
267
|
+
console.log(` ${c.cyan(g.id)} fails ${g.rate}. ${c.dim('Red by default is a gate people learn to bypass.')}`);
|
|
268
|
+
}
|
|
269
|
+
console.log(
|
|
270
|
+
c.dim('\n These are questions, not verdicts. The ledger records whether a gate ran'),
|
|
271
|
+
);
|
|
272
|
+
console.log(c.dim(' and whether it fired — never whether it is valuable. Gates invoked'));
|
|
273
|
+
console.log(c.dim(' directly, and CI runs, are not counted.'));
|
|
274
|
+
}
|
|
275
|
+
console.log();
|
|
276
|
+
return n('fail') + n('unimplemented') + n('error') > 0 ? 1 : 0;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function cmdInit(root: string, profile: string, dryRun: boolean, harnesses: Harness[], stamp: string) {
|
|
280
|
+
if (readRecord(root)) {
|
|
281
|
+
console.log(
|
|
282
|
+
c.yellow('\n this repo is already initialised.') +
|
|
283
|
+
c.dim(' Use `rungs add <module>` to install more, or `rungs upgrade`.\n'),
|
|
284
|
+
);
|
|
285
|
+
return 1;
|
|
286
|
+
}
|
|
287
|
+
const names = PROFILES[profile];
|
|
288
|
+
if (!names) {
|
|
289
|
+
console.log(c.red(`\n unknown profile '${profile}'.`) + c.dim(` Known: ${Object.keys(PROFILES).join(', ')}\n`));
|
|
290
|
+
return 1;
|
|
291
|
+
}
|
|
292
|
+
console.log(c.dim(`\n profile '${profile}' — ${names.length} modules`));
|
|
293
|
+
return cmdAdd(names, root, dryRun, harnesses, stamp);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function cmdUpgrade(root: string, apply: boolean) {
|
|
297
|
+
const record = readRecord(root);
|
|
298
|
+
if (!record) {
|
|
299
|
+
console.log(c.yellow('\n not a rungs repo — nothing to upgrade.\n'));
|
|
300
|
+
return 1;
|
|
301
|
+
}
|
|
302
|
+
const mods = loadAllModules(MODULES);
|
|
303
|
+
const plan = planUpgrade(root, mods, record);
|
|
304
|
+
console.log(c.bold(`\nrungs upgrade — ${root}${apply ? '' : c.yellow(' (preview)')}\n`));
|
|
305
|
+
|
|
306
|
+
let stale = 0;
|
|
307
|
+
let diverged = 0;
|
|
308
|
+
for (const item of plan) {
|
|
309
|
+
const counts = item.files.reduce<Record<string, number>>((a, f) => ({ ...a, [f.state]: (a[f.state] ?? 0) + 1 }), {});
|
|
310
|
+
stale += (counts.stale ?? 0) + (counts.missing ?? 0);
|
|
311
|
+
diverged += counts.diverged ?? 0;
|
|
312
|
+
const moved = item.from === item.to ? c.dim(item.to) : `${item.from} → ${c.bold(item.to)}`;
|
|
313
|
+
console.log(` ${item.module.padEnd(14)} ${moved} ${c.dim(Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(' · '))}`);
|
|
314
|
+
for (const f of item.files.filter((x) => x.state === 'diverged')) {
|
|
315
|
+
console.log(` ${c.yellow('diverged')} ${f.rel} ${c.dim('— yours, left alone')}`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (apply && stale) {
|
|
320
|
+
const written = applyUpgrade(root, mods, record, plan);
|
|
321
|
+
console.log(c.green(`\n updated ${written} file(s)`));
|
|
322
|
+
}
|
|
323
|
+
console.log(
|
|
324
|
+
`\n ${stale} to update · ${diverged} diverged\n` +
|
|
325
|
+
c.dim(' Divergence is a decision, not an error: a file you edited is never overwritten.\n') +
|
|
326
|
+
(apply ? '' : c.dim(' Run with --apply to write.\n')),
|
|
327
|
+
);
|
|
328
|
+
return 0;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function cmdEject(root: string, dryRun: boolean) {
|
|
332
|
+
if (!readRecord(root)) {
|
|
333
|
+
console.log(c.yellow('\n not a rungs repo — nothing to eject.\n'));
|
|
334
|
+
return 1;
|
|
335
|
+
}
|
|
336
|
+
const result = eject(root, loadAllModules(MODULES), dryRun);
|
|
337
|
+
console.log(c.bold(`\nrungs eject — ${root}${dryRun ? c.yellow(' (dry run)') : ''}\n`));
|
|
338
|
+
for (const a of result.actions.slice(0, 6)) console.log(c.dim(` ${a}`));
|
|
339
|
+
if (result.actions.length > 6) console.log(c.dim(` …and ${result.actions.length - 6} more`));
|
|
340
|
+
console.log(
|
|
341
|
+
`\n ${result.gates} declared gate(s) rewritten as commands.` +
|
|
342
|
+
c.dim('\n This repo no longer needs rungs installed to run its checks.\n') +
|
|
343
|
+
c.dim(' Engine fixes stop arriving with a version bump — these files are yours now.\n'),
|
|
344
|
+
);
|
|
345
|
+
return 0;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const [, , cmd, ...rest] = process.argv;
|
|
349
|
+
const flags = new Set(rest.filter((r) => r.startsWith('--')));
|
|
350
|
+
const args = rest.filter((r) => !r.startsWith('--'));
|
|
351
|
+
// Dates come from the caller, never from inside a render: a timestamp baked
|
|
352
|
+
// into generated output makes every run a diff.
|
|
353
|
+
const STAMP = process.env.RUNGS_DATE ?? new Date().toISOString().slice(0, 10);
|
|
354
|
+
const HARNESSES: Harness[] = flags.has('--copilot')
|
|
355
|
+
? ['claude', 'copilot', 'agents-md']
|
|
356
|
+
: (['claude', 'agents-md'] as Harness[]);
|
|
357
|
+
|
|
358
|
+
switch (cmd) {
|
|
359
|
+
case 'modules':
|
|
360
|
+
process.exit(cmdModules());
|
|
361
|
+
case 'doctor':
|
|
362
|
+
process.exit(cmdDoctor(args[0] ?? process.cwd()));
|
|
363
|
+
case 'check': {
|
|
364
|
+
const tier = args[1] ?? (flags.has('--full') ? 'full' : flags.has('--fast') ? 'fast' : undefined);
|
|
365
|
+
process.exit(cmdCheck(resolve(args[0] ?? process.cwd()), tier, STAMP));
|
|
366
|
+
}
|
|
367
|
+
case 'init': {
|
|
368
|
+
const profile = args[1] ?? 'tracked';
|
|
369
|
+
process.exit(cmdInit(resolve(args[0] ?? process.cwd()), profile, flags.has('--dry-run'), HARNESSES, STAMP));
|
|
370
|
+
}
|
|
371
|
+
case 'upgrade':
|
|
372
|
+
process.exit(cmdUpgrade(resolve(args[0] ?? process.cwd()), flags.has('--apply')));
|
|
373
|
+
case 'eject':
|
|
374
|
+
process.exit(cmdEject(resolve(args[0] ?? process.cwd()), flags.has('--dry-run')));
|
|
375
|
+
case 'setup': {
|
|
376
|
+
const r = setupGit(resolve(args[1] ?? process.cwd()), flags.has('--dry-run'));
|
|
377
|
+
console.log(
|
|
378
|
+
r.drivers.length
|
|
379
|
+
? `\n installed ${r.drivers.length} merge driver(s): ${r.drivers.join(', ')}` +
|
|
380
|
+
(r.rerere ? c.dim(' · rerere on') : '') +
|
|
381
|
+
c.dim('\n Declared drivers were inert until now — a fresh clone needs this once.\n')
|
|
382
|
+
: c.dim('\n no rungs merge drivers declared in .gitattributes\n'),
|
|
383
|
+
);
|
|
384
|
+
process.exit(0);
|
|
385
|
+
}
|
|
386
|
+
case 'render':
|
|
387
|
+
process.exit(cmdRender(resolve(args[0] ?? process.cwd()), HARNESSES, STAMP));
|
|
388
|
+
case 'add': {
|
|
389
|
+
const target = flags.has('--into') ? args[args.length - 1] : process.cwd();
|
|
390
|
+
const names = flags.has('--into') ? args.slice(0, -1) : args;
|
|
391
|
+
process.exit(cmdAdd(names, resolve(target), flags.has('--dry-run'), HARNESSES, STAMP));
|
|
392
|
+
}
|
|
393
|
+
default:
|
|
394
|
+
console.log(`
|
|
395
|
+
${c.bold('rungs')} — installs and maintains a repository's agentic development system
|
|
396
|
+
|
|
397
|
+
${c.bold('rungs init')} [path] [profile] scaffold a repo — minimal · tracked · disciplined · hardened · fleet
|
|
398
|
+
${c.bold('rungs doctor')} [path] detect what a repo already has, installed or not
|
|
399
|
+
${c.bold('rungs add')} <module…> [--into p] install modules, resolving dependencies and adopting what exists
|
|
400
|
+
${c.bold('rungs check')} [path] [tier] run the registered gates and record the ledger
|
|
401
|
+
${c.bold('rungs render')} [path] re-emit path-scoped rules per harness
|
|
402
|
+
${c.bold('rungs upgrade')} [path] move to newer module versions, never touching what you edited
|
|
403
|
+
${c.bold('rungs eject')} [path] materialise the engines; stop depending on rungs
|
|
404
|
+
${c.bold('rungs modules')} list the module set and audit the manifests
|
|
405
|
+
|
|
406
|
+
${c.dim('--dry-run report what would happen, write nothing')}
|
|
407
|
+
${c.dim('--apply upgrade only: write the changes')}
|
|
408
|
+
${c.dim('--copilot also emit Copilot instruction files')}
|
|
409
|
+
`);
|
|
410
|
+
process.exit(cmd ? 1 : 0);
|
|
411
|
+
}
|
package/src/detect.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import type { DetectResult, Manifest } from './types.ts';
|
|
4
|
+
import { matchAny, walk } from './glob.ts';
|
|
5
|
+
import { contentHash, emittedFiles } from './add.ts';
|
|
6
|
+
import type { Params } from './substitute.ts';
|
|
7
|
+
|
|
8
|
+
const SAMPLE = 3;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ADR-0004. Presence is decided by `paths` and `markers` only; `infer` merely
|
|
12
|
+
* *proposes* parameters, and never concludes presence — hexguard-templates has
|
|
13
|
+
* 207 well-formed `FOUND-US-###` matches and no backlog, because those are spec
|
|
14
|
+
* story ids.
|
|
15
|
+
*
|
|
16
|
+
* Signatures are biased toward false negatives throughout: a false negative
|
|
17
|
+
* creates something visible in git, while a false positive makes the CLI
|
|
18
|
+
* believe wrong things about a repo and act on that belief later.
|
|
19
|
+
*/
|
|
20
|
+
export function detect(mod: Manifest, repoRoot: string, files: string[], installed?: InstalledModule): DetectResult {
|
|
21
|
+
const result: DetectResult = {
|
|
22
|
+
module: mod.name,
|
|
23
|
+
state: 'absent',
|
|
24
|
+
matchedPaths: [],
|
|
25
|
+
matchedMarkers: [],
|
|
26
|
+
proposals: [],
|
|
27
|
+
adoptable: [],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// A module the repo installed is answered from the record, not from
|
|
31
|
+
// signatures. Signatures exist to recognise somebody *else's* structure;
|
|
32
|
+
// running them over our own would report a healthy install as "theirs" and
|
|
33
|
+
// lose the one thing the record knows and detection cannot — which files we
|
|
34
|
+
// wrote, and whether they still say what we wrote.
|
|
35
|
+
if (installed) {
|
|
36
|
+
result.ours = ownedState(mod, repoRoot, installed);
|
|
37
|
+
result.state = result.ours.diverged.length ? 'ours-diverged' : 'ours-current';
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const pattern of mod.detect.paths ?? []) {
|
|
42
|
+
const hits = matchAny(files, pattern);
|
|
43
|
+
if (hits.length) {
|
|
44
|
+
result.matchedPaths.push({ pattern, count: hits.length, sample: hits.slice(0, SAMPLE) });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const markers = mod.detect.markers ?? [];
|
|
49
|
+
if (markers.length) {
|
|
50
|
+
// Only files a marker could plausibly live in, and only ones we already
|
|
51
|
+
// have a reason to read. Scanning a whole repo for a marker string is both
|
|
52
|
+
// slow and a way to match prose that mentions one.
|
|
53
|
+
//
|
|
54
|
+
// `marker_paths` exists for the case where a file's *existence* is not
|
|
55
|
+
// discriminating but its *content* is: nearly every repo has a
|
|
56
|
+
// `.gitattributes`, and only one of the four declares a custom merge
|
|
57
|
+
// driver in it — 21 declarations against 0, 0, 0.
|
|
58
|
+
const scanPatterns = mod.detect.marker_paths ?? result.matchedPaths.map((m) => m.pattern);
|
|
59
|
+
const candidates = new Set(scanPatterns.flatMap((p) => matchAny(files, p)));
|
|
60
|
+
for (const rel of candidates) {
|
|
61
|
+
let text: string;
|
|
62
|
+
try {
|
|
63
|
+
text = readFileSync(join(repoRoot, rel), 'utf8');
|
|
64
|
+
} catch {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
for (const marker of markers) {
|
|
68
|
+
if (text.includes(marker) && !result.matchedMarkers.includes(marker)) {
|
|
69
|
+
result.matchedMarkers.push(marker);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const adopt of mod.detect.adopt_as ?? []) {
|
|
76
|
+
const hits = (adopt.paths ?? []).flatMap((p) => matchAny(files, p));
|
|
77
|
+
if (hits.length) {
|
|
78
|
+
result.adoptable.push({ kind: adopt.kind, count: hits.length, sample: hits.slice(0, SAMPLE), note: adopt.note });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A paradigm is only consulted when nothing else matched. Checking it
|
|
83
|
+
// unconditionally reported rift-forge's pulled design mirror as *both* an
|
|
84
|
+
// external authority and an in-repo design system, on a theme.ts the pattern
|
|
85
|
+
// was never meant to reach.
|
|
86
|
+
if (result.matchedPaths.length === 0 && result.adoptable.length === 0) {
|
|
87
|
+
for (const para of mod.detect.paradigm ?? []) {
|
|
88
|
+
const matched = (para.paths ?? []).flatMap((p) => matchAny(files, p));
|
|
89
|
+
if (matched.length) {
|
|
90
|
+
result.paradigm = { id: para.id, note: para.note, compare: para.compare, matched: matched.slice(0, SAMPLE) };
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// State. `ours-current` / `ours-diverged` require a rungs.toml recording a
|
|
97
|
+
// prior install; a repo without one can only be absent, theirs, or paradigm.
|
|
98
|
+
//
|
|
99
|
+
// An `adopt_as` match is ADR-0004 state 4 — "theirs, equivalent": the
|
|
100
|
+
// module's function exists in a shape we can map, even though our own
|
|
101
|
+
// structure is absent. Treating it as absent hid the single highest-value
|
|
102
|
+
// adoption in the catalogue, rift-forge's 82 registered gates.
|
|
103
|
+
if (result.matchedPaths.length > 0 || result.adoptable.length > 0 || result.matchedMarkers.length > 0) {
|
|
104
|
+
result.state = 'theirs';
|
|
105
|
+
} else if (result.paradigm) {
|
|
106
|
+
result.state = 'paradigm';
|
|
107
|
+
} else {
|
|
108
|
+
result.state = 'absent';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Proposals run only once presence is established, and are reported as
|
|
112
|
+
// proposals — never applied, never used to decide state.
|
|
113
|
+
if (result.state === 'theirs') {
|
|
114
|
+
result.proposals = infer(mod, repoRoot, files);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function infer(mod: Manifest, repoRoot: string, files: string[]) {
|
|
121
|
+
const proposals: DetectResult['proposals'] = [];
|
|
122
|
+
|
|
123
|
+
for (const rule of mod.detect.infer ?? []) {
|
|
124
|
+
if (rule.paths) {
|
|
125
|
+
// Directory-presence inference (e.g. which harnesses exist).
|
|
126
|
+
const present = Object.entries(rule.paths)
|
|
127
|
+
.filter(([, p]) => files.some((f) => f.startsWith(p.replace(/\/$/, '/'))))
|
|
128
|
+
.map(([key]) => key);
|
|
129
|
+
if (present.length) {
|
|
130
|
+
proposals.push({ param: rule.param, value: present.join(', '), evidence: 'directory present' });
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (!rule.pattern) continue;
|
|
135
|
+
|
|
136
|
+
const scope = (rule.scope ?? ['**/*.md']).flatMap((p) => matchAny(files, p));
|
|
137
|
+
const excluded = new Set((rule.exclude ?? []).flatMap((p) => matchAny(files, p)));
|
|
138
|
+
const counts = new Map<string, number>();
|
|
139
|
+
|
|
140
|
+
for (const rel of scope) {
|
|
141
|
+
if (excluded.has(rel)) continue;
|
|
142
|
+
let text: string;
|
|
143
|
+
try {
|
|
144
|
+
text = readFileSync(join(repoRoot, rel), 'utf8');
|
|
145
|
+
} catch {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
for (const m of text.matchAll(new RegExp(rule.pattern, 'gm'))) {
|
|
149
|
+
const key = m[1];
|
|
150
|
+
if (key) counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// An anchor wins outright over frequency. Counting raw occurrences made
|
|
155
|
+
// `findings` propose the *backlog's* prefix, because a findings register is
|
|
156
|
+
// full of citations to work items — more of them than of its own ids.
|
|
157
|
+
// The register's own NEXT-ID marker settles it without judgement.
|
|
158
|
+
if (rule.anchor) {
|
|
159
|
+
const anchored = new Map<string, number>();
|
|
160
|
+
for (const rel of scope) {
|
|
161
|
+
if (excluded.has(rel)) continue;
|
|
162
|
+
let text: string;
|
|
163
|
+
try {
|
|
164
|
+
text = readFileSync(join(repoRoot, rel), 'utf8');
|
|
165
|
+
} catch {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
for (const m of text.matchAll(new RegExp(rule.anchor, 'gm'))) {
|
|
169
|
+
if (m[1]) anchored.set(m[1], (anchored.get(m[1]) ?? 0) + 1);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const [best] = [...anchored].sort((a, b) => b[1] - a[1]);
|
|
173
|
+
if (best) {
|
|
174
|
+
proposals.push({ param: rule.param, value: best[0], evidence: `anchored on ${rule.anchor_name ?? 'marker'}` });
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const banned = new Set(rule.exclude_values ?? []);
|
|
180
|
+
const ranked = [...counts].filter(([k]) => !banned.has(k)).sort((a, b) => b[1] - a[1]);
|
|
181
|
+
const [top] = ranked;
|
|
182
|
+
if (top && top[1] >= (rule.min ?? 1)) {
|
|
183
|
+
proposals.push({
|
|
184
|
+
param: rule.param,
|
|
185
|
+
value: top[0],
|
|
186
|
+
evidence: `${top[1]} matches${ranked.length > 1 ? ` (next: ${ranked[1][0]} at ${ranked[1][1]})` : ''}`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return proposals;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function scanRepo(repoRoot: string): string[] {
|
|
194
|
+
return walk(repoRoot);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface InstalledModule {
|
|
198
|
+
version: string;
|
|
199
|
+
params?: Record<string, unknown>;
|
|
200
|
+
hashes?: Record<string, string>;
|
|
201
|
+
kept?: { files: string[] };
|
|
202
|
+
skillsDir?: string;
|
|
203
|
+
params_all?: Params;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The state of files this repo installed from a module.
|
|
208
|
+
*
|
|
209
|
+
* Three comparisons, and each answers a different question:
|
|
210
|
+
*
|
|
211
|
+
* absent from disk → missing, an upgrade restores it
|
|
212
|
+
* matches what we'd emit now → current
|
|
213
|
+
* matches the recorded hash → stale; ours to replace on upgrade
|
|
214
|
+
* matches neither → diverged; theirs, and never touched
|
|
215
|
+
*/
|
|
216
|
+
export function ownedState(mod: Manifest, repoRoot: string, installed: InstalledModule) {
|
|
217
|
+
const params = installed.params_all ?? {};
|
|
218
|
+
const emitted = emittedFiles(mod, params, installed.skillsDir ?? '.claude/skills');
|
|
219
|
+
const kept = new Set(installed.kept?.files ?? []);
|
|
220
|
+
const out = {
|
|
221
|
+
version: installed.version,
|
|
222
|
+
current: [] as string[],
|
|
223
|
+
stale: [] as string[],
|
|
224
|
+
diverged: [] as string[],
|
|
225
|
+
missing: [] as string[],
|
|
226
|
+
kept: [] as string[],
|
|
227
|
+
};
|
|
228
|
+
for (const [rel, wouldEmit] of emitted) {
|
|
229
|
+
// A file that already existed at install was never ours. Calling it
|
|
230
|
+
// "diverged" implies the user broke something they never touched.
|
|
231
|
+
if (kept.has(rel)) {
|
|
232
|
+
out.kept.push(rel);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const full = join(repoRoot, rel);
|
|
236
|
+
if (!existsSync(full)) {
|
|
237
|
+
out.missing.push(rel);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const onDisk = contentHash(readFileSync(full, 'utf8'));
|
|
241
|
+
if (onDisk === contentHash(wouldEmit)) out.current.push(rel);
|
|
242
|
+
else if (installed.hashes?.[rel] && onDisk === installed.hashes[rel]) out.stale.push(rel);
|
|
243
|
+
else out.diverged.push(rel);
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|