specrails-core 6.6.5 → 6.7.1
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/dist/agent-runtime/capabilities.d.ts +5 -2
- package/dist/agent-runtime/capabilities.js +8 -2
- package/dist/agent-runtime/change-scope.d.ts +2 -0
- package/dist/agent-runtime/change-scope.js +5 -0
- package/dist/agent-runtime/cli-executor.js +8 -1
- package/dist/agent-runtime/cli.d.ts +6 -1
- package/dist/agent-runtime/cli.js +15 -4
- package/dist/agent-runtime/compact/reviewer.js +3 -1
- package/dist/agent-runtime/config.js +16 -5
- package/dist/agent-runtime/efficiency-summary.d.ts +7 -0
- package/dist/agent-runtime/efficiency-summary.js +7 -1
- package/dist/agent-runtime/engine/__fixtures__/implementation-fixture.js +1 -1
- package/dist/agent-runtime/engine/checkpoint/database.d.ts +9 -0
- package/dist/agent-runtime/engine/checkpoint/database.js +40 -3
- package/dist/agent-runtime/engine/checkpoint/ledger.d.ts +1 -0
- package/dist/agent-runtime/engine/checkpoint/ledger.js +7 -2
- package/dist/agent-runtime/engine/cli.d.ts +12 -0
- package/dist/agent-runtime/engine/cli.js +15 -4
- package/dist/agent-runtime/engine/definition-schema.d.ts +1 -1
- package/dist/agent-runtime/engine/definition-schema.js +1 -0
- package/dist/agent-runtime/engine/definition-types.d.ts +1 -1
- package/dist/agent-runtime/engine/definition-types.js +1 -1
- package/dist/agent-runtime/engine/definition-validator.js +8 -0
- package/dist/agent-runtime/engine/efficiency-summary.d.ts +7 -0
- package/dist/agent-runtime/engine/graph-description.d.ts +3 -3
- package/dist/agent-runtime/engine/invocation-context.js +2 -0
- package/dist/agent-runtime/engine/piece-registry.d.ts +1 -1
- package/dist/agent-runtime/engine/piece-registry.js +1 -1
- package/dist/agent-runtime/engine/pieces/artifact-contract.d.ts +5 -0
- package/dist/agent-runtime/engine/pieces/artifact-contract.js +6 -2
- package/dist/agent-runtime/engine/pieces/derivation-coverage.d.ts +30 -0
- package/dist/agent-runtime/engine/pieces/derivation-coverage.js +95 -0
- package/dist/agent-runtime/engine/pieces/derive-change.d.ts +50 -0
- package/dist/agent-runtime/engine/pieces/derive-change.js +209 -0
- package/dist/agent-runtime/engine/pieces/index.js +2 -1
- package/dist/agent-runtime/engine/pieces/openspec.d.ts +16 -1
- package/dist/agent-runtime/engine/pieces/openspec.js +39 -30
- package/dist/agent-runtime/engine/pieces/role-turn.js +26 -5
- package/dist/agent-runtime/engine/preflight.d.ts +2 -1
- package/dist/agent-runtime/engine/preflight.js +3 -1
- package/dist/agent-runtime/engine/regex.d.ts +12 -2
- package/dist/agent-runtime/engine/regex.js +25 -11
- package/dist/agent-runtime/engine/run-status.d.ts +34 -0
- package/dist/agent-runtime/engine/run-status.js +7 -1
- package/dist/agent-runtime/engine/runs.d.ts +56 -0
- package/dist/agent-runtime/engine/runs.js +88 -5
- package/dist/agent-runtime/engine/steering/inbox.d.ts +15 -0
- package/dist/agent-runtime/engine/steering/inbox.js +21 -1
- package/dist/agent-runtime/engine/storage/private-path.js +7 -1
- package/dist/agent-runtime/evaluation.js +1 -1
- package/dist/agent-runtime/executor-types.d.ts +19 -0
- package/dist/agent-runtime/executor-types.js +10 -0
- package/dist/agent-runtime/failure-classification.d.ts +51 -0
- package/dist/agent-runtime/failure-classification.js +110 -0
- package/dist/agent-runtime/graph/artifacts.d.ts +3 -1
- package/dist/agent-runtime/graph/artifacts.js +5 -1
- package/dist/agent-runtime/graph/baseline.d.ts +50 -0
- package/dist/agent-runtime/graph/baseline.js +145 -0
- package/dist/agent-runtime/graph/capability-map.d.ts +29 -0
- package/dist/agent-runtime/graph/capability-map.js +65 -0
- package/dist/agent-runtime/graph/clarifications.d.ts +75 -0
- package/dist/agent-runtime/graph/clarifications.js +158 -0
- package/dist/agent-runtime/graph/nodes.js +178 -19
- package/dist/agent-runtime/graph/review-brief-record.d.ts +25 -0
- package/dist/agent-runtime/graph/review-brief-record.js +26 -0
- package/dist/agent-runtime/graph/review-brief.d.ts +73 -0
- package/dist/agent-runtime/graph/review-brief.js +127 -0
- package/dist/agent-runtime/graph/roles.js +5 -3
- package/dist/agent-runtime/graph/spec-trace-record.d.ts +33 -0
- package/dist/agent-runtime/graph/spec-trace-record.js +65 -0
- package/dist/agent-runtime/graph/state.d.ts +8 -0
- package/dist/agent-runtime/index.d.ts +7 -0
- package/dist/agent-runtime/index.js +7 -0
- package/dist/agent-runtime/openspec.d.ts +1 -0
- package/dist/agent-runtime/openspec.js +1 -1
- package/dist/agent-runtime/project-rules.d.ts +139 -0
- package/dist/agent-runtime/project-rules.js +683 -0
- package/dist/agent-runtime/prompts.d.ts +32 -1
- package/dist/agent-runtime/prompts.js +182 -7
- package/dist/agent-runtime/role-routing.d.ts +2 -1
- package/dist/agent-runtime/session/application/active-session.d.ts +43 -2
- package/dist/agent-runtime/session/application/active-session.js +163 -7
- package/dist/agent-runtime/session/application/session-service.d.ts +2 -2
- package/dist/agent-runtime/session/domain/descriptor.js +5 -0
- package/dist/agent-runtime/session/domain/interruption.js +1 -0
- package/dist/agent-runtime/session/domain/policy.js +2 -0
- package/dist/agent-runtime/session/domain/redaction.d.ts +19 -0
- package/dist/agent-runtime/session/domain/redaction.js +71 -0
- package/dist/agent-runtime/session/domain/types.d.ts +17 -1
- package/dist/agent-runtime/session/drivers/claude/argv.d.ts +14 -0
- package/dist/agent-runtime/session/drivers/claude/argv.js +19 -2
- package/dist/agent-runtime/session/drivers/claude/driver.js +11 -1
- package/dist/agent-runtime/session/drivers/claude/translator.d.ts +4 -0
- package/dist/agent-runtime/session/drivers/claude/translator.js +16 -3
- package/dist/agent-runtime/session/drivers/codex/driver.js +2 -0
- package/dist/agent-runtime/session/drivers/executor/driver.js +2 -0
- package/dist/agent-runtime/session/host/protocol.d.ts +12 -0
- package/dist/agent-runtime/session/host/protocol.js +10 -2
- package/dist/agent-runtime/session/host/server.js +6 -5
- package/dist/agent-runtime/session/index.d.ts +1 -1
- package/dist/agent-runtime/session/journal/sqlite-journal.js +6 -2
- package/dist/agent-runtime/session/ports.d.ts +6 -2
- package/dist/agent-runtime/session/testing/driver-conformance.js +16 -2
- package/dist/agent-runtime/session/testing/memory-journal.js +4 -2
- package/dist/installer/cli.js +10 -1
- package/dist/installer/commands/init.js +14 -3
- package/dist/installer/phases/scaffold.js +56 -5
- package/dist/pipeline/pipeline-state.d.ts +22 -0
- package/dist/pipeline/pipeline-state.js +55 -4
- package/dist/pipeline/spec-trace.d.ts +134 -0
- package/dist/pipeline/spec-trace.js +392 -0
- package/dist/shared/secret-redaction.d.ts +42 -0
- package/dist/shared/secret-redaction.js +184 -0
- package/integration-contract.json +177 -7
- package/package.json +7 -7
- package/schemas/agent-runtime.schema.json +3 -1
- package/schemas/workflow-definition.schema.json +1 -0
- package/templates/settings/codex-config.toml +3 -2
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project rules inventory (`specrails-core rules inventory`, also run by `init`).
|
|
3
|
+
*
|
|
4
|
+
* Two halves with a hard line between them:
|
|
5
|
+
* - deterministic: discover the gates a repository already enforces (lint and
|
|
6
|
+
* format configs, strict TypeScript, architecture tests, git hooks, CI
|
|
7
|
+
* workflows, OpenSpec validation) and parse the rule statements written in
|
|
8
|
+
* CLAUDE.md, AGENTS.md and CONTRIBUTING.md, each with `file:line`;
|
|
9
|
+
* - one AI call: classify every parsed statement as `enforcedBy` (a gate),
|
|
10
|
+
* `automatable` (with the check it would need) or `humanOnly`. The model never
|
|
11
|
+
* adds, drops or rewrites a statement; offline or without a provider every
|
|
12
|
+
* rule stays `unclassified`.
|
|
13
|
+
*
|
|
14
|
+
* The result is `.specrails/rules.json`. The runtime reads it back to give the
|
|
15
|
+
* reviewer the `humanOnly` rules as human checks and to offer the discovered
|
|
16
|
+
* gate commands as the default verification proposal.
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
export const RULES_INVENTORY_VERSION = 1;
|
|
22
|
+
export const RULES_FILE = '.specrails/rules.json';
|
|
23
|
+
export const RULE_SOURCE_FILES = ['CLAUDE.md', 'AGENTS.md', 'CONTRIBUTING.md', '.github/CONTRIBUTING.md', 'docs/CONTRIBUTING.md'];
|
|
24
|
+
export const GATE_KINDS = ['lint', 'format', 'typecheck', 'test', 'architecture', 'hook', 'ci', 'spec'];
|
|
25
|
+
export const RULE_STATUSES = ['enforcedBy', 'automatable', 'humanOnly', 'unclassified'];
|
|
26
|
+
const SKIP_DIRECTORIES = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'coverage', 'vendor', 'target', '.specrails', '.next', '.nuxt', '.venv', 'venv', '__pycache__', '.turbo', '.cache', 'tmp']);
|
|
27
|
+
const MAX_WALK_ENTRIES = 20_000;
|
|
28
|
+
const MAX_RULES = 200;
|
|
29
|
+
const MAX_RULE_CHARS = 600;
|
|
30
|
+
const MAX_CI_GATES = 20;
|
|
31
|
+
const CHECK_COMMAND = /\b(test|tests|lint|eslint|prettier|biome|tsc|typecheck|type-check|vitest|jest|mocha|pytest|ruff|mypy|flake8|black|clippy|fmt|go\s+vet|go\s+test|cargo\s+test|rubocop|rspec|phpunit|phpstan|openspec\s+validate|check|verify|validate|audit)\b/i;
|
|
32
|
+
const posix = (file) => file.split(path.sep).join('/');
|
|
33
|
+
function readText(file) {
|
|
34
|
+
try {
|
|
35
|
+
return readFileSync(file, 'utf8');
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function isFile(file) {
|
|
42
|
+
try {
|
|
43
|
+
return statSync(file).isFile();
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function isDirectory(file) {
|
|
50
|
+
try {
|
|
51
|
+
return statSync(file).isDirectory();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function listDirectory(directory) {
|
|
58
|
+
try {
|
|
59
|
+
return readdirSync(directory).sort();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** JSON with comments and trailing commas (tsconfig, biome.jsonc). */
|
|
66
|
+
export function parseJsonc(text) {
|
|
67
|
+
let out = '';
|
|
68
|
+
let inString = false;
|
|
69
|
+
for (let index = 0; index < text.length; index++) {
|
|
70
|
+
const char = text[index];
|
|
71
|
+
if (inString) {
|
|
72
|
+
out += char;
|
|
73
|
+
if (char === '\\') {
|
|
74
|
+
out += text[index + 1] ?? '';
|
|
75
|
+
index++;
|
|
76
|
+
}
|
|
77
|
+
else if (char === '"')
|
|
78
|
+
inString = false;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (char === '"') {
|
|
82
|
+
inString = true;
|
|
83
|
+
out += char;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (char === '/' && text[index + 1] === '/') {
|
|
87
|
+
while (index < text.length && text[index] !== '\n')
|
|
88
|
+
index++;
|
|
89
|
+
out += '\n';
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (char === '/' && text[index + 1] === '*') {
|
|
93
|
+
index += 2;
|
|
94
|
+
while (index < text.length && !(text[index] === '*' && text[index + 1] === '/'))
|
|
95
|
+
index++;
|
|
96
|
+
index++;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
out += char;
|
|
100
|
+
}
|
|
101
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'));
|
|
102
|
+
}
|
|
103
|
+
function packageRunner(root) {
|
|
104
|
+
if (isFile(path.join(root, 'pnpm-lock.yaml')))
|
|
105
|
+
return script => `pnpm run ${script}`;
|
|
106
|
+
if (isFile(path.join(root, 'yarn.lock')))
|
|
107
|
+
return script => `yarn run ${script}`;
|
|
108
|
+
if (isFile(path.join(root, 'bun.lockb')) || isFile(path.join(root, 'bun.lock')))
|
|
109
|
+
return script => `bun run ${script}`;
|
|
110
|
+
return script => script === 'test' ? 'npm test' : `npm run ${script}`;
|
|
111
|
+
}
|
|
112
|
+
function scriptKind(name) {
|
|
113
|
+
if (/(^|:)arch(itecture)?(:|$)/.test(name))
|
|
114
|
+
return 'architecture';
|
|
115
|
+
if (/^lint(:[a-z0-9:_-]+)?$/.test(name))
|
|
116
|
+
return 'lint';
|
|
117
|
+
if (/^(format|fmt|prettier)(:check)?$/.test(name) || /^format:check$/.test(name))
|
|
118
|
+
return 'format';
|
|
119
|
+
if (/^(typecheck|type-check|types|tsc|check-types|check:types)$/.test(name))
|
|
120
|
+
return 'typecheck';
|
|
121
|
+
if (/^test(:unit)?$/.test(name))
|
|
122
|
+
return 'test';
|
|
123
|
+
if (/^(openspec|spec|specs)(:validate)?$/.test(name))
|
|
124
|
+
return 'spec';
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
/** Walk the repository for architecture tests and boundary configs, bounded and skipping generated trees. */
|
|
128
|
+
function architectureSources(root) {
|
|
129
|
+
const found = [];
|
|
130
|
+
const queue = [{ dir: root, depth: 0 }];
|
|
131
|
+
let seen = 0;
|
|
132
|
+
while (queue.length && seen < MAX_WALK_ENTRIES) {
|
|
133
|
+
const { dir, depth } = queue.shift();
|
|
134
|
+
for (const name of listDirectory(dir)) {
|
|
135
|
+
if (++seen > MAX_WALK_ENTRIES)
|
|
136
|
+
break;
|
|
137
|
+
const full = path.join(dir, name);
|
|
138
|
+
let stats;
|
|
139
|
+
try {
|
|
140
|
+
stats = statSync(full);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (stats.isDirectory()) {
|
|
146
|
+
if (depth < 6 && !SKIP_DIRECTORIES.has(name) && !name.startsWith('.'))
|
|
147
|
+
queue.push({ dir: full, depth: depth + 1 });
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (/^(architecture|arch|boundaries|layering|dependency-rules)[._-].*\.(test|spec)\.[cm]?[jt]sx?$/i.test(name)
|
|
151
|
+
|| /^(architecture|arch)\.(test|spec)\.[cm]?[jt]sx?$/i.test(name)
|
|
152
|
+
|| /^test_(architecture|arch|boundaries|layering)\.py$/i.test(name)
|
|
153
|
+
|| /Arch(itecture)?Test\.(java|kt|cs)$/.test(name))
|
|
154
|
+
found.push(posix(path.relative(root, full)));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return found.sort();
|
|
158
|
+
}
|
|
159
|
+
function testRunner(packageJson) {
|
|
160
|
+
const deps = { ...packageJson?.dependencies, ...packageJson?.devDependencies };
|
|
161
|
+
if ('vitest' in deps)
|
|
162
|
+
return 'npx vitest run';
|
|
163
|
+
if ('jest' in deps)
|
|
164
|
+
return 'npx jest';
|
|
165
|
+
if ('mocha' in deps)
|
|
166
|
+
return 'npx mocha';
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
/** `run:` commands of a GitHub workflow (single line, literal `|` and folded `>` block scalars). */
|
|
170
|
+
export function workflowRunCommands(text) {
|
|
171
|
+
const commands = [];
|
|
172
|
+
const lines = text.split(/\r?\n/);
|
|
173
|
+
for (let index = 0; index < lines.length; index++) {
|
|
174
|
+
const match = /^(\s*)(?:-\s+)?run:\s*(.*)$/.exec(lines[index]);
|
|
175
|
+
if (!match)
|
|
176
|
+
continue;
|
|
177
|
+
const value = match[2].trim();
|
|
178
|
+
if (value && !/^[|>][-+]?\s*(#.*)?$/.test(value)) {
|
|
179
|
+
commands.push(value.replace(/^(['"])(.*)\1$/, '$2'));
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const folded = value.startsWith('>');
|
|
183
|
+
const indent = match[1].length;
|
|
184
|
+
const block = [];
|
|
185
|
+
for (index++; index < lines.length; index++) {
|
|
186
|
+
const line = lines[index];
|
|
187
|
+
if (line.trim() && line.length - line.trimStart().length <= indent) {
|
|
188
|
+
index--;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
block.push(line.trim());
|
|
192
|
+
}
|
|
193
|
+
if (folded) {
|
|
194
|
+
// Folded scalars join lines with spaces; a blank line separates commands.
|
|
195
|
+
commands.push(...block.join('\n').split(/\n\s*\n/).map(part => part.split('\n').join(' ').trim()).filter(Boolean));
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
let pending = '';
|
|
199
|
+
for (const command of block) {
|
|
200
|
+
if (!command || command.startsWith('#'))
|
|
201
|
+
continue;
|
|
202
|
+
// Backslash continuations form one command.
|
|
203
|
+
if (command.endsWith('\\')) {
|
|
204
|
+
pending += command.slice(0, -1).trim() + ' ';
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
commands.push(pending + command);
|
|
208
|
+
pending = '';
|
|
209
|
+
}
|
|
210
|
+
if (pending.trim())
|
|
211
|
+
commands.push(pending.trim());
|
|
212
|
+
}
|
|
213
|
+
return commands;
|
|
214
|
+
}
|
|
215
|
+
/** Setup and shell plumbing, never a check on its own. */
|
|
216
|
+
const NOT_A_CHECK = /^(echo|printf|cd|export|mkdir|curl|wget|tar|unzip|chmod|sudo|apt|apt-get|brew|choco|winget|rm|cp|mv|ls|cat|source|set|if|then|else|fi|for|while|do|done|case|esac|git\s+config|test\s+-[a-z]|\[)\b|^\[/;
|
|
217
|
+
/** The commands a simple hook runs; a scripted hook (control flow, variables, many lines) is one gate that runs the hook itself. */
|
|
218
|
+
function hookCommands(text, relative) {
|
|
219
|
+
const lines = text.split(/\r?\n/).map(line => line.trim()).filter(line => line && !line.startsWith('#') && !line.includes('husky.sh') && !/^(exit\b|set\s)/.test(line));
|
|
220
|
+
const scripted = lines.length > 3 || lines.some(line => /^(if|for|while|case|function)\b|[(){}]\s*$|^[A-Za-z_][A-Za-z0-9_]*=|^\./.test(line));
|
|
221
|
+
return scripted ? [`sh ${relative}`] : lines;
|
|
222
|
+
}
|
|
223
|
+
/** Deterministic gate discovery for one repository root. */
|
|
224
|
+
export function discoverGates(root) {
|
|
225
|
+
const gates = [];
|
|
226
|
+
const add = (gate) => {
|
|
227
|
+
if (!gate.command.trim() || gates.some(prior => prior.command === gate.command))
|
|
228
|
+
return;
|
|
229
|
+
let id = gate.id;
|
|
230
|
+
for (let suffix = 2; gates.some(prior => prior.id === id); suffix++)
|
|
231
|
+
id = `${gate.id}-${suffix}`;
|
|
232
|
+
gates.push({ ...gate, id });
|
|
233
|
+
};
|
|
234
|
+
const exists = (relative) => isFile(path.join(root, relative));
|
|
235
|
+
const first = (candidates) => candidates.find(exists);
|
|
236
|
+
let packageJson;
|
|
237
|
+
try {
|
|
238
|
+
packageJson = JSON.parse(readText(path.join(root, 'package.json')) ?? 'null') ?? undefined;
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
packageJson = undefined;
|
|
242
|
+
}
|
|
243
|
+
const scripts = (packageJson?.scripts && typeof packageJson.scripts === 'object' ? packageJson.scripts : {});
|
|
244
|
+
const run = packageRunner(root);
|
|
245
|
+
const scriptKinds = new Map();
|
|
246
|
+
for (const name of Object.keys(scripts).sort()) {
|
|
247
|
+
const kind = scriptKind(name);
|
|
248
|
+
if (!kind || typeof scripts[name] !== 'string')
|
|
249
|
+
continue;
|
|
250
|
+
if (!scriptKinds.has(kind))
|
|
251
|
+
scriptKinds.set(kind, name);
|
|
252
|
+
add({ id: name.replace(/[^a-z0-9]+/gi, '-').toLowerCase(), kind, command: run(name), source: 'package.json' });
|
|
253
|
+
}
|
|
254
|
+
// Lint and format configurations without a package script still name a runnable tool.
|
|
255
|
+
const eslint = first(['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs', 'eslint.config.ts', '.eslintrc', '.eslintrc.js', '.eslintrc.cjs', '.eslintrc.json', '.eslintrc.yml', '.eslintrc.yaml']);
|
|
256
|
+
if (eslint && !scriptKinds.has('lint'))
|
|
257
|
+
add({ id: 'eslint', kind: 'lint', command: 'npx eslint .', source: eslint });
|
|
258
|
+
const biome = first(['biome.json', 'biome.jsonc']);
|
|
259
|
+
if (biome && !scriptKinds.has('lint'))
|
|
260
|
+
add({ id: 'biome', kind: 'lint', command: 'npx biome check .', source: biome });
|
|
261
|
+
const prettier = first(['.prettierrc', '.prettierrc.json', '.prettierrc.yml', '.prettierrc.yaml', '.prettierrc.js', '.prettierrc.cjs', '.prettierrc.mjs', 'prettier.config.js', 'prettier.config.cjs', 'prettier.config.mjs']);
|
|
262
|
+
if (prettier && !scriptKinds.has('format'))
|
|
263
|
+
add({ id: 'prettier', kind: 'format', command: 'npx prettier --check .', source: prettier });
|
|
264
|
+
const pyproject = readText(path.join(root, 'pyproject.toml')) ?? '';
|
|
265
|
+
if (exists('ruff.toml') || exists('.ruff.toml') || /^\[tool\.ruff/m.test(pyproject))
|
|
266
|
+
add({ id: 'ruff', kind: 'lint', command: 'ruff check .', source: exists('ruff.toml') ? 'ruff.toml' : exists('.ruff.toml') ? '.ruff.toml' : 'pyproject.toml' });
|
|
267
|
+
if (/^\[tool\.black\]/m.test(pyproject))
|
|
268
|
+
add({ id: 'black', kind: 'format', command: 'black --check .', source: 'pyproject.toml' });
|
|
269
|
+
if (/^\[tool\.mypy\]/m.test(pyproject) || exists('mypy.ini'))
|
|
270
|
+
add({ id: 'mypy', kind: 'typecheck', command: 'mypy .', source: exists('mypy.ini') ? 'mypy.ini' : 'pyproject.toml' });
|
|
271
|
+
const golangci = first(['.golangci.yml', '.golangci.yaml', '.golangci.toml']);
|
|
272
|
+
if (golangci)
|
|
273
|
+
add({ id: 'golangci-lint', kind: 'lint', command: 'golangci-lint run', source: golangci });
|
|
274
|
+
if (exists('Cargo.toml')) {
|
|
275
|
+
if (exists('rustfmt.toml') || exists('.rustfmt.toml'))
|
|
276
|
+
add({ id: 'rustfmt', kind: 'format', command: 'cargo fmt --check', source: exists('rustfmt.toml') ? 'rustfmt.toml' : '.rustfmt.toml' });
|
|
277
|
+
if (exists('clippy.toml') || exists('.clippy.toml'))
|
|
278
|
+
add({ id: 'clippy', kind: 'lint', command: 'cargo clippy -- -D warnings', source: exists('clippy.toml') ? 'clippy.toml' : '.clippy.toml' });
|
|
279
|
+
}
|
|
280
|
+
const rubocop = first(['.rubocop.yml']);
|
|
281
|
+
if (rubocop)
|
|
282
|
+
add({ id: 'rubocop', kind: 'lint', command: 'bundle exec rubocop', source: rubocop });
|
|
283
|
+
// Strict TypeScript is a gate only through the command that type-checks it.
|
|
284
|
+
const tsconfigText = readText(path.join(root, 'tsconfig.json'));
|
|
285
|
+
if (tsconfigText !== undefined) {
|
|
286
|
+
let strict = false;
|
|
287
|
+
try {
|
|
288
|
+
strict = parseJsonc(tsconfigText)?.compilerOptions?.strict === true;
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
strict = false;
|
|
292
|
+
}
|
|
293
|
+
if (strict && !scriptKinds.has('typecheck'))
|
|
294
|
+
add({ id: 'tsc-strict', kind: 'typecheck', command: 'npx tsc --noEmit', source: 'tsconfig.json' });
|
|
295
|
+
}
|
|
296
|
+
const runner = testRunner(packageJson);
|
|
297
|
+
for (const file of architectureSources(root)) {
|
|
298
|
+
const command = /\.py$/.test(file) ? `pytest ${file}` : /\.(java|kt|cs)$/.test(file) ? undefined : runner ? `${runner} ${file}` : undefined;
|
|
299
|
+
if (command)
|
|
300
|
+
add({ id: 'architecture-tests', kind: 'architecture', command, source: file });
|
|
301
|
+
}
|
|
302
|
+
const cruiser = first(['.dependency-cruiser.js', '.dependency-cruiser.cjs', '.dependency-cruiser.json']);
|
|
303
|
+
if (cruiser)
|
|
304
|
+
add({ id: 'dependency-cruiser', kind: 'architecture', command: `npx depcruise --config ${cruiser} .`, source: cruiser });
|
|
305
|
+
// Git hooks: the commands they run (husky, .githooks), or the hook manager.
|
|
306
|
+
for (const hooksDir of ['.husky', '.githooks']) {
|
|
307
|
+
for (const hook of listDirectory(path.join(root, hooksDir))) {
|
|
308
|
+
if (!['pre-commit', 'pre-push', 'commit-msg'].includes(hook))
|
|
309
|
+
continue;
|
|
310
|
+
const relative = `${hooksDir}/${hook}`;
|
|
311
|
+
for (const command of hookCommands(readText(path.join(root, relative)) ?? '', relative))
|
|
312
|
+
add({ id: `hook-${hook}`, kind: 'hook', command, source: relative });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (exists('.pre-commit-config.yaml'))
|
|
316
|
+
add({ id: 'pre-commit', kind: 'hook', command: 'pre-commit run --all-files', source: '.pre-commit-config.yaml' });
|
|
317
|
+
const lefthook = first(['lefthook.yml', 'lefthook.yaml', '.lefthook.yml']);
|
|
318
|
+
if (lefthook)
|
|
319
|
+
add({ id: 'lefthook', kind: 'hook', command: 'npx lefthook run pre-commit', source: lefthook });
|
|
320
|
+
// CI workflows: their check-like `run:` steps.
|
|
321
|
+
let ciGates = 0;
|
|
322
|
+
for (const workflow of listDirectory(path.join(root, '.github', 'workflows')).filter(name => /\.ya?ml$/.test(name))) {
|
|
323
|
+
const relative = `.github/workflows/${workflow}`;
|
|
324
|
+
const text = readText(path.join(root, relative)) ?? '';
|
|
325
|
+
// Only workflows that gate changes (pull requests or pushes); release and scheduled jobs are not checks.
|
|
326
|
+
if (!/^\s*(on:.*\b(pull_request|push)\b|(pull_request|pull_request_target|push)\s*:)/m.test(text))
|
|
327
|
+
continue;
|
|
328
|
+
for (const command of workflowRunCommands(text)) {
|
|
329
|
+
if (ciGates >= MAX_CI_GATES || !CHECK_COMMAND.test(command) || NOT_A_CHECK.test(command) || /[$`{}]|\|\||^(Write-Host|node\s+-e)\b/.test(command) || command.length > 500)
|
|
330
|
+
continue;
|
|
331
|
+
const before = gates.length;
|
|
332
|
+
add({ id: `ci-${workflow.replace(/\.ya?ml$/, '')}`, kind: 'ci', command, source: relative });
|
|
333
|
+
if (gates.length > before)
|
|
334
|
+
ciGates++;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (isDirectory(path.join(root, 'openspec')) && !scriptKinds.has('spec'))
|
|
338
|
+
add({ id: 'openspec-validate', kind: 'spec', command: 'npx openspec validate --all --strict', source: 'openspec/' });
|
|
339
|
+
return gates;
|
|
340
|
+
}
|
|
341
|
+
/** An imperative opening: list items outside a rules heading count when they start with one. */
|
|
342
|
+
const IMPERATIVE = /^(use|keep|prefer|avoid|follow|preserve|run|add|update|put|treat|report|append|ensure|never|always|do|don't|only|reuse|apply|bind|include|write|read|check|test|document|name|validate)\b/i;
|
|
343
|
+
const STRONG = /\b(must|never|always|shall|do not|don't|required|forbidden|not allowed)\b/i;
|
|
344
|
+
const RULE_HEADING = /\b(rule|rules|convention|conventions|guideline|guidelines|policy|policies|invariant|invariants|standard|standards|behaviou?r|design|style|contract|contracts|requirement|requirements|do|don't|must)\b/i;
|
|
345
|
+
function cleanStatement(text) {
|
|
346
|
+
return text
|
|
347
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
348
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
349
|
+
.replace(/<\/?(?:a|b|i|em|strong|code|kbd|sup|sub|span|div|p|br|img|details|summary|small)(?:\s[^>]*)?\/?>/gi, '')
|
|
350
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
351
|
+
.replace(/__([^_]+)__/g, '$1')
|
|
352
|
+
.replace(/\s+/g, ' ')
|
|
353
|
+
.trim();
|
|
354
|
+
}
|
|
355
|
+
function isStatement(text, underRuleHeading) {
|
|
356
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
357
|
+
if (words.length < 4 || text.length < 16)
|
|
358
|
+
return false;
|
|
359
|
+
if (/^[A-Z][\w /&-]*:$/.test(text))
|
|
360
|
+
return false;
|
|
361
|
+
return STRONG.test(text) || underRuleHeading || IMPERATIVE.test(text);
|
|
362
|
+
}
|
|
363
|
+
export function ruleId(file, text) {
|
|
364
|
+
return 'r-' + createHash('sha256').update(file + '\0' + text.toLowerCase()).digest('hex').slice(0, 12);
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Rule statements of one instruction file: list items (with their wrapped
|
|
368
|
+
* continuation lines) and strongly normative paragraph sentences, outside code
|
|
369
|
+
* fences, tables and Specrails-managed blocks.
|
|
370
|
+
*/
|
|
371
|
+
export function parseRuleStatements(text, file) {
|
|
372
|
+
const out = [];
|
|
373
|
+
const lines = text.split(/\r?\n/);
|
|
374
|
+
let fence = false;
|
|
375
|
+
let managed = false;
|
|
376
|
+
let heading = '';
|
|
377
|
+
let current;
|
|
378
|
+
const flush = () => {
|
|
379
|
+
if (!current)
|
|
380
|
+
return;
|
|
381
|
+
const cleaned = cleanStatement(current.text);
|
|
382
|
+
const underRule = RULE_HEADING.test(heading);
|
|
383
|
+
if (current.list) {
|
|
384
|
+
if (isStatement(cleaned, underRule))
|
|
385
|
+
out.push({ text: cleaned.slice(0, MAX_RULE_CHARS), source: `${file}:${current.line}` });
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
// Paragraphs contribute their strongly normative sentences only.
|
|
389
|
+
for (const sentence of cleaned.split(/(?<=[.!?])\s+(?=[A-Z`])/)) {
|
|
390
|
+
if (STRONG.test(sentence) && isStatement(sentence, true))
|
|
391
|
+
out.push({ text: sentence.slice(0, MAX_RULE_CHARS), source: `${file}:${current.line}` });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
current = undefined;
|
|
395
|
+
};
|
|
396
|
+
for (let index = 0; index < lines.length; index++) {
|
|
397
|
+
const raw = lines[index];
|
|
398
|
+
const line = raw.trimEnd();
|
|
399
|
+
if (line.includes('specrails-managed:start')) {
|
|
400
|
+
flush();
|
|
401
|
+
managed = true;
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (line.includes('specrails-managed:end')) {
|
|
405
|
+
managed = false;
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (managed)
|
|
409
|
+
continue;
|
|
410
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
411
|
+
flush();
|
|
412
|
+
fence = !fence;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (fence)
|
|
416
|
+
continue;
|
|
417
|
+
const headingMatch = /^#{1,6}\s+(.*)$/.exec(line);
|
|
418
|
+
if (headingMatch) {
|
|
419
|
+
flush();
|
|
420
|
+
heading = headingMatch[1];
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
if (!line.trim() || /^\s*\|/.test(line) || /^\s*(<!--|-->)/.test(line) || /^\s*>/.test(line) || /^\s*[-*_]{3,}\s*$/.test(line)) {
|
|
424
|
+
flush();
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const item = /^(\s*)(?:[-*+]|\d+[.)])\s+(.*)$/.exec(line);
|
|
428
|
+
if (item) {
|
|
429
|
+
flush();
|
|
430
|
+
current = { text: item[2].replace(/^\[[ xX]\]\s+/, ''), line: index + 1, list: true };
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
if (current) {
|
|
434
|
+
current.text += ' ' + line.trim();
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
current = { text: line.trim(), line: index + 1, list: false };
|
|
438
|
+
}
|
|
439
|
+
flush();
|
|
440
|
+
return out;
|
|
441
|
+
}
|
|
442
|
+
/** Every rule statement of the instruction files present in `root`, deduplicated, with stable ids. */
|
|
443
|
+
export function collectRuleStatements(root) {
|
|
444
|
+
const rules = [];
|
|
445
|
+
const seen = new Set();
|
|
446
|
+
for (const file of RULE_SOURCE_FILES) {
|
|
447
|
+
const text = readText(path.join(root, file));
|
|
448
|
+
if (text === undefined)
|
|
449
|
+
continue;
|
|
450
|
+
for (const statement of parseRuleStatements(text, file)) {
|
|
451
|
+
const id = ruleId(file, statement.text);
|
|
452
|
+
if (seen.has(id))
|
|
453
|
+
continue;
|
|
454
|
+
seen.add(id);
|
|
455
|
+
rules.push({ id, text: statement.text, source: statement.source, status: 'unclassified' });
|
|
456
|
+
if (rules.length >= MAX_RULES)
|
|
457
|
+
return rules;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return rules;
|
|
461
|
+
}
|
|
462
|
+
export const RULES_CLASSIFICATION_SCHEMA = {
|
|
463
|
+
type: 'object',
|
|
464
|
+
additionalProperties: false,
|
|
465
|
+
required: ['rules'],
|
|
466
|
+
properties: {
|
|
467
|
+
rules: {
|
|
468
|
+
type: 'array',
|
|
469
|
+
items: {
|
|
470
|
+
type: 'object',
|
|
471
|
+
additionalProperties: false,
|
|
472
|
+
required: ['id', 'status'],
|
|
473
|
+
properties: {
|
|
474
|
+
id: { type: 'string' },
|
|
475
|
+
status: { enum: ['enforcedBy', 'automatable', 'humanOnly'] },
|
|
476
|
+
enforcedBy: { type: 'string' },
|
|
477
|
+
proposedCheck: { type: 'string' },
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
export function classificationPrompt(gates, rules) {
|
|
484
|
+
return [
|
|
485
|
+
'You classify the written rules of a software project. Do not edit files, run commands or call tools: answer from the lists below.',
|
|
486
|
+
'',
|
|
487
|
+
'For each rule decide exactly one status:',
|
|
488
|
+
'- `enforcedBy`: an existing gate below (or a test the repository already has) fails when the rule is broken. Set `enforcedBy` to the gate id, or to the named test or check when no gate id fits.',
|
|
489
|
+
'- `automatable`: no gate enforces it today, but a deterministic check could (a lint rule, a test, a script). Set `proposedCheck` to that check in one sentence.',
|
|
490
|
+
'- `humanOnly`: only a person can judge it (product intent, naming, UX, prose, process). No extra field.',
|
|
491
|
+
'When unsure between `enforcedBy` and `automatable`, choose `automatable`: claim enforcement only when a listed gate plainly covers the rule.',
|
|
492
|
+
'',
|
|
493
|
+
'## Gates discovered in the repository',
|
|
494
|
+
...(gates.length ? gates.map(gate => `- \`${gate.id}\` (${gate.kind}, from ${gate.source}): \`${gate.command}\``) : ['- none']),
|
|
495
|
+
'',
|
|
496
|
+
'## Rules (id: text, source)',
|
|
497
|
+
...rules.map(rule => `- ${rule.id}: ${rule.text} (${rule.source})`),
|
|
498
|
+
'',
|
|
499
|
+
'Reply with exactly one JSON object and nothing else: {"rules":[{"id":"<id>","status":"enforcedBy|automatable|humanOnly","enforcedBy":"…","proposedCheck":"…"}]}, one entry per rule id above, ids copied verbatim. Never add, drop or rephrase a rule.',
|
|
500
|
+
].join('\n') + '\n';
|
|
501
|
+
}
|
|
502
|
+
function jsonObject(text) {
|
|
503
|
+
const trimmed = text.trim().replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '');
|
|
504
|
+
for (const candidate of [trimmed, trimmed.slice(trimmed.indexOf('{'), trimmed.lastIndexOf('}') + 1)]) {
|
|
505
|
+
try {
|
|
506
|
+
const value = JSON.parse(candidate);
|
|
507
|
+
if (value && typeof value === 'object' && !Array.isArray(value))
|
|
508
|
+
return value;
|
|
509
|
+
}
|
|
510
|
+
catch { /* next candidate */ }
|
|
511
|
+
}
|
|
512
|
+
return undefined;
|
|
513
|
+
}
|
|
514
|
+
/** Applies a model reply to the parsed rules: only known ids and statuses; text and source never change. */
|
|
515
|
+
export function applyClassification(rules, reply) {
|
|
516
|
+
const entries = Array.isArray(reply.rules) ? reply.rules : [];
|
|
517
|
+
const byId = new Map();
|
|
518
|
+
for (const entry of entries)
|
|
519
|
+
if (entry && typeof entry === 'object' && typeof entry.id === 'string')
|
|
520
|
+
byId.set(entry.id, entry);
|
|
521
|
+
let classified = 0;
|
|
522
|
+
const out = rules.map((rule) => {
|
|
523
|
+
const entry = byId.get(rule.id);
|
|
524
|
+
const status = entry?.status;
|
|
525
|
+
const base = { id: rule.id, text: rule.text, source: rule.source, status: 'unclassified' };
|
|
526
|
+
if (status === 'enforcedBy' && typeof entry.enforcedBy === 'string' && entry.enforcedBy.trim()) {
|
|
527
|
+
classified++;
|
|
528
|
+
return { ...base, status, enforcedBy: entry.enforcedBy.trim().slice(0, 200) };
|
|
529
|
+
}
|
|
530
|
+
if (status === 'automatable') {
|
|
531
|
+
classified++;
|
|
532
|
+
return { ...base, status, ...(typeof entry.proposedCheck === 'string' && entry.proposedCheck.trim() ? { proposedCheck: entry.proposedCheck.trim().slice(0, 400) } : {}) };
|
|
533
|
+
}
|
|
534
|
+
if (status === 'humanOnly') {
|
|
535
|
+
classified++;
|
|
536
|
+
return { ...base, status };
|
|
537
|
+
}
|
|
538
|
+
return base;
|
|
539
|
+
});
|
|
540
|
+
return { rules: out, classified };
|
|
541
|
+
}
|
|
542
|
+
export function readRulesInventory(file) {
|
|
543
|
+
const text = readText(file);
|
|
544
|
+
if (text === undefined)
|
|
545
|
+
return undefined;
|
|
546
|
+
try {
|
|
547
|
+
const value = JSON.parse(text);
|
|
548
|
+
if (!value || value.schemaVersion !== RULES_INVENTORY_VERSION || !Array.isArray(value.gates) || !Array.isArray(value.rules))
|
|
549
|
+
return undefined;
|
|
550
|
+
return value;
|
|
551
|
+
}
|
|
552
|
+
catch {
|
|
553
|
+
return undefined;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
function writeAtomic(file, value) {
|
|
557
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
558
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
559
|
+
writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', 'utf8');
|
|
560
|
+
renameSync(temporary, file);
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Builds and writes `.specrails/rules.json`. Rules whose statement did not
|
|
564
|
+
* change keep their previous classification, so a re-run (and `init`) only
|
|
565
|
+
* classifies what is new; the classification never blocks the inventory.
|
|
566
|
+
*/
|
|
567
|
+
export async function runRulesInventory(options) {
|
|
568
|
+
const root = path.resolve(options.root);
|
|
569
|
+
const artifactRoot = path.resolve(options.artifactRoot ?? root);
|
|
570
|
+
const file = path.join(artifactRoot, RULES_FILE);
|
|
571
|
+
const now = (options.now ?? (() => new Date()))().toISOString();
|
|
572
|
+
const gates = discoverGates(root);
|
|
573
|
+
const parsed = collectRuleStatements(root);
|
|
574
|
+
const previous = options.reclassify ? undefined : readRulesInventory(file);
|
|
575
|
+
const cached = new Map((previous?.rules ?? []).filter(rule => rule.status !== 'unclassified').map(rule => [rule.id, rule]));
|
|
576
|
+
let rules = parsed.map(rule => {
|
|
577
|
+
const prior = cached.get(rule.id);
|
|
578
|
+
return prior ? { ...prior, text: rule.text, source: rule.source } : rule;
|
|
579
|
+
});
|
|
580
|
+
const pending = rules.filter(rule => rule.status === 'unclassified');
|
|
581
|
+
let classification;
|
|
582
|
+
if (!rules.length)
|
|
583
|
+
classification = { status: 'empty' };
|
|
584
|
+
else if (!pending.length)
|
|
585
|
+
classification = { ...(previous?.classification ?? {}), status: 'cached' };
|
|
586
|
+
else if (options.classify === false)
|
|
587
|
+
classification = { status: 'offline', reason: `${pending.length} rule(s) left unclassified; run specrails-core rules inventory with a configured provider to classify them` };
|
|
588
|
+
else
|
|
589
|
+
classification = await classifyPending();
|
|
590
|
+
const inventory = { schemaVersion: RULES_INVENTORY_VERSION, generatedAt: now, gates, rules, classification };
|
|
591
|
+
writeAtomic(file, inventory);
|
|
592
|
+
return { file, inventory };
|
|
593
|
+
async function classifyPending() {
|
|
594
|
+
// Loaded on demand: the prompt and node readers of rules.json never pull in the executors.
|
|
595
|
+
const [{ loadRuntimeConfig }, { createExecutorRegistry }] = await Promise.all([import('./config.js'), import('./executors.js')]);
|
|
596
|
+
let config = options.config;
|
|
597
|
+
const configFile = path.join(artifactRoot, '.specrails', 'agent-runtime.json');
|
|
598
|
+
try {
|
|
599
|
+
config ??= existsSync(configFile) ? loadRuntimeConfig(configFile, { registeredProviderIds: Object.keys(options.executors ?? {}) }) : undefined;
|
|
600
|
+
}
|
|
601
|
+
catch (error) {
|
|
602
|
+
return { status: 'unavailable', reason: `Runtime configuration is invalid: ${error instanceof Error ? error.message : String(error)}` };
|
|
603
|
+
}
|
|
604
|
+
if (!config)
|
|
605
|
+
return { status: 'unavailable', reason: `No provider configured (${posix(path.relative(artifactRoot, configFile))} is missing); rules left unclassified` };
|
|
606
|
+
const agent = config.agents.architect;
|
|
607
|
+
try {
|
|
608
|
+
const registry = options.registry ?? createExecutorRegistry(config, { executors: options.executors });
|
|
609
|
+
const result = await registry.execute(agent.provider, {
|
|
610
|
+
role: 'prompt', instructions: 'none', artifacts: 'none', access: 'read',
|
|
611
|
+
prompt: classificationPrompt(gates, pending), cwd: root, allowedRoots: [root],
|
|
612
|
+
...(agent.model ? { model: agent.model } : {}), ...(agent.effort ? { effort: agent.effort } : {}),
|
|
613
|
+
maxTurns: 4, timeoutMs: options.timeoutMs ?? config.limits?.timeoutMs ?? 10 * 60_000,
|
|
614
|
+
outputSchema: RULES_CLASSIFICATION_SCHEMA,
|
|
615
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
616
|
+
});
|
|
617
|
+
const reply = result.structured ?? jsonObject(result.text);
|
|
618
|
+
if (!reply)
|
|
619
|
+
return { status: 'failed', provider: agent.provider, ...(agent.model ? { model: agent.model } : {}), reason: 'The classifier did not reply with a JSON object; rules left unclassified', usage: result.usage };
|
|
620
|
+
const applied = applyClassification(pending, reply);
|
|
621
|
+
const byId = new Map(applied.rules.map(rule => [rule.id, rule]));
|
|
622
|
+
rules = rules.map(rule => byId.get(rule.id) ?? rule);
|
|
623
|
+
const missing = pending.length - applied.classified;
|
|
624
|
+
return {
|
|
625
|
+
status: 'classified', provider: agent.provider, ...(agent.model ? { model: agent.model } : {}), classifiedAt: now, usage: result.usage,
|
|
626
|
+
...(missing ? { reason: `${missing} rule(s) the classifier did not answer stay unclassified` } : {}),
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
return { status: 'failed', provider: agent.provider, reason: error instanceof Error ? error.message : String(error) };
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/** Reads `.specrails/rules.json` next to the run's backlog, else in the artifact repository. */
|
|
635
|
+
export function projectRulesContext(context) {
|
|
636
|
+
for (const root of [context.backlogRoot, context.artifactRoot]) {
|
|
637
|
+
const inventory = readRulesInventory(path.join(root, RULES_FILE));
|
|
638
|
+
if (!inventory)
|
|
639
|
+
continue;
|
|
640
|
+
const humanOnly = inventory.rules.filter(rule => rule.status === 'humanOnly' && typeof rule.text === 'string').slice(0, 30).map(rule => ({ text: rule.text, source: rule.source }));
|
|
641
|
+
const gates = inventory.gates.filter(gate => typeof gate.command === 'string' && GATE_KINDS.includes(gate.kind)).slice(0, 20).map(gate => ({ id: gate.id, kind: gate.kind, command: gate.command }));
|
|
642
|
+
return humanOnly.length || gates.length ? { humanOnly, gates } : undefined;
|
|
643
|
+
}
|
|
644
|
+
return undefined;
|
|
645
|
+
}
|
|
646
|
+
function summary(inventory, file) {
|
|
647
|
+
const count = (status) => inventory.rules.filter(rule => rule.status === status).length;
|
|
648
|
+
return [
|
|
649
|
+
`Project rules inventory written to ${file}`,
|
|
650
|
+
` gates: ${inventory.gates.length}${inventory.gates.length ? ' (' + inventory.gates.map(gate => gate.id).join(', ') + ')' : ''}`,
|
|
651
|
+
` rules: ${inventory.rules.length} — enforcedBy ${count('enforcedBy')}, automatable ${count('automatable')}, humanOnly ${count('humanOnly')}, unclassified ${count('unclassified')}`,
|
|
652
|
+
` classification: ${inventory.classification.status}${inventory.classification.reason ? ' — ' + inventory.classification.reason : ''}`,
|
|
653
|
+
].join('\n') + '\n';
|
|
654
|
+
}
|
|
655
|
+
/** `specrails-core rules inventory [--root <dir>] [--artifact-root <dir>] [--offline] [--reclassify] [--json]`. */
|
|
656
|
+
export async function runRulesCommand(flags, positionals, io = {}) {
|
|
657
|
+
const stdout = io.stdout ?? ((text) => { process.stdout.write(text); });
|
|
658
|
+
const stderr = io.stderr ?? ((text) => { process.stderr.write(text); });
|
|
659
|
+
if (positionals[0] !== 'inventory' || positionals.length > 1) {
|
|
660
|
+
stderr('Usage: specrails-core rules inventory [--root <dir>] [--artifact-root <dir>] [--offline] [--reclassify] [--json]\n');
|
|
661
|
+
return 1;
|
|
662
|
+
}
|
|
663
|
+
for (const key of ['root', 'artifact-root']) {
|
|
664
|
+
if (flags[key] !== undefined && typeof flags[key] !== 'string') {
|
|
665
|
+
stderr(`--${key} needs a directory\n`);
|
|
666
|
+
return 1;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const root = path.resolve(typeof flags.root === 'string' ? flags.root : process.cwd());
|
|
670
|
+
if (!isDirectory(root)) {
|
|
671
|
+
stderr(`Not a directory: ${root}\n`);
|
|
672
|
+
return 1;
|
|
673
|
+
}
|
|
674
|
+
const { file, inventory } = await runRulesInventory({
|
|
675
|
+
root,
|
|
676
|
+
artifactRoot: typeof flags['artifact-root'] === 'string' ? flags['artifact-root'] : root,
|
|
677
|
+
classify: flags.offline !== true,
|
|
678
|
+
reclassify: flags.reclassify === true,
|
|
679
|
+
...(io.executors ? { executors: io.executors } : {}),
|
|
680
|
+
});
|
|
681
|
+
stdout(flags.json === true ? JSON.stringify({ type: 'rules-inventory', file, ...inventory }) + '\n' : summary(inventory, file));
|
|
682
|
+
return 0;
|
|
683
|
+
}
|