specrails-core 6.0.0 → 6.0.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/change-scope.d.ts +63 -0
- package/dist/agent-runtime/change-scope.js +297 -0
- package/dist/agent-runtime/cli.js +1 -1
- package/dist/agent-runtime/compact/environment.d.ts +12 -0
- package/dist/agent-runtime/compact/environment.js +31 -1
- package/dist/agent-runtime/compact/test-reachability.js +6 -2
- package/dist/agent-runtime/core-host.d.ts +1 -1
- package/dist/agent-runtime/core-host.js +1 -1
- package/dist/agent-runtime/graph/artifacts.js +4 -1
- package/dist/agent-runtime/graph/convergence.d.ts +26 -0
- package/dist/agent-runtime/graph/convergence.js +62 -0
- package/dist/agent-runtime/graph/nodes.js +170 -23
- package/dist/agent-runtime/graph/state.d.ts +5 -0
- package/dist/agent-runtime/graph/state.js +2 -0
- package/dist/agent-runtime/prompts.d.ts +7 -2
- package/dist/agent-runtime/prompts.js +46 -16
- package/dist/agent-runtime/repository-context.js +19 -0
- package/dist/agent-runtime/verification-plan.js +8 -3
- package/dist/pipeline/pipeline-state.d.ts +11 -0
- package/dist/pipeline/pipeline-state.js +56 -2
- package/integration-contract.json +1 -1
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import { bindVerificationPlan, pipelineStateDirectory, validateVerificationRequest } from '../pipeline/pipeline-state.js';
|
|
5
|
+
import { assertProposalInScope, withScopeDefault } from './change-scope.js';
|
|
5
6
|
import { fingerprint } from './durable-store.js';
|
|
6
7
|
import { child, write } from './graph/artifacts.js';
|
|
7
8
|
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|
@@ -39,7 +40,10 @@ export function validateProposedChecks(context, raw) {
|
|
|
39
40
|
text(check.command, 'command', 4096);
|
|
40
41
|
if (!Array.isArray(check.args) || check.args.length > 128 || check.args.some(arg => typeof arg !== 'string' || arg.includes('\0') || arg.length > 4096))
|
|
41
42
|
throw new Error('Invalid verification proposal args');
|
|
42
|
-
|
|
43
|
+
if (check.cwd !== undefined && typeof check.cwd !== 'string')
|
|
44
|
+
throw new Error('Invalid verification proposal cwd');
|
|
45
|
+
assertProposalInScope(context, check);
|
|
46
|
+
validateVerificationRequest(context, { kind: 'scoped', commands: [withScopeDefault(context, check)] });
|
|
43
47
|
if (check.kind === 'command') {
|
|
44
48
|
if (check.entrypoint !== undefined || check.files !== undefined)
|
|
45
49
|
throw new Error('Invalid command proposal: harness fields are forbidden');
|
|
@@ -123,7 +127,8 @@ export function readVerificationPlan(context) {
|
|
|
123
127
|
}
|
|
124
128
|
export function initializeVerificationPlan(context, host, architect, maxConcurrency = 1) {
|
|
125
129
|
const previous = readVerificationPlan(context);
|
|
126
|
-
|
|
130
|
+
// The key hashes the command as declared, so a scope default never renames a check.
|
|
131
|
+
const baseline = [['host', host], ['architect', architect]].flatMap(([origin, commands]) => commands.map((command, i) => entry(`${origin}:${command.key ?? `${i}-${fingerprint(normalized(command)).slice(0, 12)}`}`, command.label ?? [command.command, ...command.args].join(' ').slice(0, 256), withScopeDefault(context, command), origin)));
|
|
127
132
|
if (!Number.isSafeInteger(maxConcurrency) || maxConcurrency < 1 || maxConcurrency > 4)
|
|
128
133
|
throw new Error('Invalid verification concurrency');
|
|
129
134
|
if (previous && fingerprint(previous.baseline) !== fingerprint(baseline))
|
|
@@ -156,7 +161,7 @@ export function addDeveloperChecks(context, proposals) {
|
|
|
156
161
|
const developer = new Map(previous.developer.map(item => [item.key, item]));
|
|
157
162
|
const sources = [];
|
|
158
163
|
for (const check of checked) {
|
|
159
|
-
const command = { repositoryId: check.repositoryId, command: check.command, args: check.args, ...(check.cwd === undefined ? {} : { cwd: check.cwd }), ...(check.timeoutMs === undefined ? {} : { timeoutMs: check.timeoutMs }) };
|
|
164
|
+
const command = withScopeDefault(context, { repositoryId: check.repositoryId, command: check.command, args: check.args, ...(check.cwd === undefined ? {} : { cwd: check.cwd }), ...(check.timeoutMs === undefined ? {} : { timeoutMs: check.timeoutMs }) });
|
|
160
165
|
const files = check.files?.slice().sort((a, b) => a.path.localeCompare(b.path)).map(file => ({ path: file.path, hash: bytesHash(file.content), byteCount: Buffer.byteLength(file.content) }));
|
|
161
166
|
const harness = files ? { hash: fingerprint(files), entrypoint: check.entrypoint, sources: files } : undefined;
|
|
162
167
|
const item = entry('dev:' + check.key, check.label, command, 'developer', harness);
|
|
@@ -7,11 +7,18 @@ export interface PipelineSpec {
|
|
|
7
7
|
repositoryIds?: string[];
|
|
8
8
|
acceptanceCriteria?: string[];
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* `scope`: checkout-relative directories the repository is confined to — a
|
|
12
|
+
* package registered inside a larger checkout (a monorepo app). Edits outside
|
|
13
|
+
* them are not part of the change, and a command without `cwd` runs in the
|
|
14
|
+
* first one. Absent: the whole checkout is the repository.
|
|
15
|
+
*/
|
|
10
16
|
export interface PipelineRepository {
|
|
11
17
|
id: string;
|
|
12
18
|
name: string;
|
|
13
19
|
path: string;
|
|
14
20
|
baseSha?: string;
|
|
21
|
+
scope?: string[];
|
|
15
22
|
}
|
|
16
23
|
export interface PipelineContext {
|
|
17
24
|
schemaVersion: 1;
|
|
@@ -194,6 +201,10 @@ export interface PreviewFile {
|
|
|
194
201
|
contentHash?: string;
|
|
195
202
|
}
|
|
196
203
|
export declare function verificationEnvironment(env: NodeJS.ProcessEnv, overrides?: Record<string, string>, platform?: NodeJS.Platform): NodeJS.ProcessEnv;
|
|
204
|
+
/** True when a checkout-relative POSIX path belongs to the repository's scope (always, without a scope). */
|
|
205
|
+
export declare function withinRepositoryScope(repository: Pick<PipelineRepository, 'scope'>, relative: string): boolean;
|
|
206
|
+
/** Where a command without `cwd` runs: the first scope directory, else the checkout root. */
|
|
207
|
+
export declare function repositoryWorkingDirectory(repository: Pick<PipelineRepository, 'path' | 'scope'>): string;
|
|
197
208
|
export declare function validatePipelineContext(input: unknown): PipelineContext;
|
|
198
209
|
export declare function pipelineStateDirectory(context: PipelineContext): string;
|
|
199
210
|
export interface CandidateManifest {
|
|
@@ -83,6 +83,58 @@ function safeChild(root, relative) {
|
|
|
83
83
|
}
|
|
84
84
|
return target;
|
|
85
85
|
}
|
|
86
|
+
/** Case-insensitive filesystems: compare checkout paths the way the OS resolves them. */
|
|
87
|
+
const foldedPaths = process.platform === 'win32' || process.platform === 'darwin';
|
|
88
|
+
function samePathPrefix(file, directory) {
|
|
89
|
+
const [a, b] = foldedPaths ? [file.toLowerCase(), directory.toLowerCase()] : [file, directory];
|
|
90
|
+
return a === b || a.startsWith(b + '/');
|
|
91
|
+
}
|
|
92
|
+
/** Normalized scope: existing, symlink-free directories spelled as on disk. Undefined = whole checkout. */
|
|
93
|
+
function repositoryScope(root, raw) {
|
|
94
|
+
if (!Array.isArray(raw) || raw.length === 0 || raw.length > 32)
|
|
95
|
+
fail('Invalid repository scope: expected 1–32 directories');
|
|
96
|
+
const scope = [];
|
|
97
|
+
for (const value of raw) {
|
|
98
|
+
if (typeof value !== 'string' || !value.trim() || value.includes('\0') || value.length > 1024)
|
|
99
|
+
fail('Invalid repository scope directory');
|
|
100
|
+
const relative = value.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '').replace(/\/+$/, '');
|
|
101
|
+
// The checkout root is the whole repository: no narrower scope applies.
|
|
102
|
+
if (!relative || relative === '.')
|
|
103
|
+
return undefined;
|
|
104
|
+
if (/^[a-z]:/i.test(relative) || relative.startsWith('/') || relative.split('/').some(part => !part || part === '.' || part === '..'))
|
|
105
|
+
fail('Invalid repository scope directory: ' + value);
|
|
106
|
+
// Spell every component as the directory listing does, so git paths match it.
|
|
107
|
+
let current = root;
|
|
108
|
+
const parts = [];
|
|
109
|
+
for (const part of relative.split('/')) {
|
|
110
|
+
let name = part;
|
|
111
|
+
try {
|
|
112
|
+
const names = readdirSync(current);
|
|
113
|
+
if (!names.includes(part))
|
|
114
|
+
name = names.find(entry => entry.toLowerCase() === part.toLowerCase()) ?? part;
|
|
115
|
+
}
|
|
116
|
+
catch { /* the lstat below reports the missing directory */ }
|
|
117
|
+
current = path.join(current, name);
|
|
118
|
+
const stat = lstatSync(current, { throwIfNoEntry: false });
|
|
119
|
+
if (!stat)
|
|
120
|
+
fail('Repository scope directory does not exist: ' + value);
|
|
121
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
122
|
+
fail('Repository scope must be a real directory: ' + value);
|
|
123
|
+
parts.push(name);
|
|
124
|
+
}
|
|
125
|
+
scope.push(parts.join('/'));
|
|
126
|
+
}
|
|
127
|
+
const unique = [...new Set(scope)];
|
|
128
|
+
return unique.filter(item => !unique.some(other => other !== item && samePathPrefix(item, other)));
|
|
129
|
+
}
|
|
130
|
+
/** True when a checkout-relative POSIX path belongs to the repository's scope (always, without a scope). */
|
|
131
|
+
export function withinRepositoryScope(repository, relative) {
|
|
132
|
+
return !repository.scope?.length || repository.scope.some(directory => samePathPrefix(relative, directory));
|
|
133
|
+
}
|
|
134
|
+
/** Where a command without `cwd` runs: the first scope directory, else the checkout root. */
|
|
135
|
+
export function repositoryWorkingDirectory(repository) {
|
|
136
|
+
return repository.scope?.length ? path.join(repository.path, ...repository.scope[0].split('/')) : repository.path;
|
|
137
|
+
}
|
|
86
138
|
function atomicJson(file, value) {
|
|
87
139
|
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
88
140
|
const temp = file + '.' + randomUUID() + '.tmp';
|
|
@@ -106,7 +158,9 @@ export function validatePipelineContext(input) {
|
|
|
106
158
|
fail('Invalid repository identity');
|
|
107
159
|
if (repo.baseSha !== undefined && (typeof repo.baseSha !== 'string' || !/^[a-f0-9]{40,64}$/.test(repo.baseSha)))
|
|
108
160
|
fail('Invalid repository base SHA');
|
|
109
|
-
|
|
161
|
+
const root = directory(repo.path);
|
|
162
|
+
const scope = repo.scope === undefined ? undefined : repositoryScope(root, repo.scope);
|
|
163
|
+
return { id: repo.id, name: repo.name, path: root, ...(repo.baseSha ? { baseSha: repo.baseSha } : {}), ...(scope ? { scope } : {}) };
|
|
110
164
|
});
|
|
111
165
|
if (new Set(repositories.map((repo) => repo.id)).size !== repositories.length || new Set(repositories.map((repo) => repo.path)).size !== repositories.length)
|
|
112
166
|
fail('Duplicate repository identity or path');
|
|
@@ -711,7 +765,7 @@ function validateCommand(context, raw) {
|
|
|
711
765
|
const repo = context.repositories.find((item) => item.id === command.repositoryId);
|
|
712
766
|
if (!repo || typeof command.command !== 'string' || !command.command || command.command.includes('\0') || !Array.isArray(command.args) || !command.args.every((arg) => typeof arg === 'string' && !arg.includes('\0')))
|
|
713
767
|
fail('Invalid verification command');
|
|
714
|
-
const cwd = command.cwd === undefined ? repo
|
|
768
|
+
const cwd = command.cwd === undefined ? directory(repositoryWorkingDirectory(repo)) : directory(path.resolve(repo.path, String(command.cwd)));
|
|
715
769
|
if (!within(repo.path, cwd))
|
|
716
770
|
fail('Verification cwd escapes selected repository');
|
|
717
771
|
const env = command.env === undefined ? undefined : object(command.env);
|
|
@@ -289,7 +289,7 @@
|
|
|
289
289
|
"execution": {
|
|
290
290
|
"schemaVersion": 1,
|
|
291
291
|
"contextEnv": "SPECRAILS_EXECUTION_CONTEXT",
|
|
292
|
-
"context": "Absolute JSON file: runId, backlogRoot, backlogPath?, artifactRoot, artifactRepositoryId, repositories[{id,name,path,baseSha?}], ownership{git,backlog,worktrees:host|core}, specs[{id,title,description,repositoryIds?,acceptanceCriteria?}]",
|
|
292
|
+
"context": "Absolute JSON file: runId, backlogRoot, backlogPath?, artifactRoot, artifactRepositoryId, repositories[{id,name,path,baseSha?,scope?}], ownership{git,backlog,worktrees:host|core}, specs[{id,title,description,repositoryIds?,acceptanceCriteria?}]. scope (runtime capability repositoryScope) lists the checkout-relative directories a repository is confined to — a package registered inside a larger checkout: commands without cwd run in the first one, model-proposed checks stay inside it, edits outside it are undone after each developer/fixer turn and reviews judge the git change set against the run base.",
|
|
293
293
|
"runtime": ".specrails/runtime/pipeline.mjs",
|
|
294
294
|
"operations": [
|
|
295
295
|
"init",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "specrails-core",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.1",
|
|
4
4
|
"description": "Agent workflow engine for specrails-desktop: provider-independent installer and programmatic agent runtime for Claude Code, Codex, Gemini CLI and Kimi Code",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|