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.
@@ -0,0 +1,63 @@
1
+ import { type PipelineContext, type PipelineRepository } from '../pipeline/pipeline-state.js';
2
+ export interface ChangedFile {
3
+ path: string;
4
+ status: 'added' | 'modified' | 'deleted';
5
+ }
6
+ export interface RepositoryChangeSet {
7
+ repositoryId: string;
8
+ scope?: string[];
9
+ files: ChangedFile[];
10
+ truncated: boolean;
11
+ }
12
+ /** An out-of-scope edit Core undid after a role turn. */
13
+ export interface DiscardedEdit {
14
+ repositoryId: string;
15
+ path: string;
16
+ restored: 'base' | 'previous' | 'removed';
17
+ }
18
+ /** A command without `cwd` runs in its repository's scope directory, never at the root of a larger checkout. */
19
+ export declare function withScopeDefault<T extends {
20
+ repositoryId: string;
21
+ cwd?: string;
22
+ }>(context: PipelineContext, command: T): T;
23
+ /** Model-proposed checks stay inside the repository scope: they prove the change, not the whole checkout. */
24
+ export declare function assertProposalInScope(context: PipelineContext, command: {
25
+ repositoryId: string;
26
+ cwd?: string;
27
+ }, what?: string): void;
28
+ /** Base commit per repository id; the first call records it. */
29
+ export declare function changeBases(context: PipelineContext): Record<string, string>;
30
+ /** Every path that differs from the base: tracked edits (staged or not), deletions and new untracked files. */
31
+ export declare function repositoryChanges(context: PipelineContext, repository: PipelineRepository, base: string, change?: string): ChangedFile[];
32
+ /** What this run changed, per repository and bounded: untracked files that predate the run are not part of it. */
33
+ export declare function changeSet(context: PipelineContext, change?: string, limit?: number): RepositoryChangeSet[];
34
+ /** Changes outside the repository scope (none when the whole checkout is the repository). */
35
+ export declare function outOfScope(repository: PipelineRepository, files: readonly ChangedFile[]): ChangedFile[];
36
+ interface SavedState {
37
+ fingerprint: string;
38
+ content?: Buffer;
39
+ mode?: number;
40
+ link?: string;
41
+ }
42
+ /** The out-of-scope state before a role turn: the host undoes only what that turn did. */
43
+ export interface ScopeGuard {
44
+ change?: string;
45
+ repositories: Array<{
46
+ id: string;
47
+ base: string;
48
+ before: Map<string, SavedState>;
49
+ }>;
50
+ }
51
+ export declare function captureOutOfScope(context: PipelineContext, change?: string): ScopeGuard;
52
+ /**
53
+ * Undoes every out-of-scope edit made since `guard` was captured. A path the
54
+ * turn changed outside the scope returns to its state before the turn: its
55
+ * base content, its earlier out-of-scope content, or absence. In-scope work,
56
+ * ignored files and out-of-scope state that predates the turn stay untouched.
57
+ */
58
+ export declare function discardOutOfScopeEdits(context: PipelineContext, guard: ScopeGuard): DiscardedEdit[];
59
+ /** Bounded prompt lines for a change set; repository ids prefix the paths only when several repositories are in scope. */
60
+ export declare function renderChangeSet(sets: readonly RepositoryChangeSet[], limit?: number): string[];
61
+ /** One human line naming discarded edits, bounded. */
62
+ export declare function describeDiscarded(edits: readonly DiscardedEdit[], multiple: boolean, limit?: number): string;
63
+ export {};
@@ -0,0 +1,297 @@
1
+ // The change a run makes, measured by the host from git rather than taken from
2
+ // a role's own summary, and the boundary it must stay inside.
3
+ //
4
+ // Observed (a monorepo app registered as the repository, busuu-courses inside
5
+ // busuu-web): the configured `yarn test` ran at the checkout root and fanned
6
+ // out to every workspace; the fixer then "repaired" .yarnrc.yml, the root
7
+ // package.json and two other apps, the reviewer rejected those unrelated
8
+ // edits, the fixer reverted them, verification failed again — a loop that
9
+ // never converged. A repository scope makes the boundary explicit: commands
10
+ // default to the package, reviews see the exact change set, and edits outside
11
+ // the scope are undone by the host before anyone verifies or reviews them.
12
+ import { spawnSync } from 'node:child_process';
13
+ import { createHash, randomUUID } from 'node:crypto';
14
+ import { lstatSync, mkdirSync, readFileSync, readlinkSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
15
+ import path from 'node:path';
16
+ import { pipelineStateDirectory, withinRepositoryScope } from '../pipeline/pipeline-state.js';
17
+ /** Git's empty tree: the base of a repository without commits. */
18
+ const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
19
+ /** Files whose change inside the scope legitimately refreshes a lockfile of an enclosing workspace. */
20
+ const MANIFESTS = new Set(['package.json', 'Cargo.toml', 'go.mod', 'pyproject.toml', 'Pipfile', 'Gemfile', 'composer.json', 'requirements.txt']);
21
+ const LOCKFILES = new Set(['package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb', 'Cargo.lock', 'go.sum', 'go.work.sum', 'poetry.lock', 'uv.lock', 'Pipfile.lock', 'Gemfile.lock', 'composer.lock', '.pnp.cjs', '.pnp.loader.mjs', '.pnp.data.json']);
22
+ const AGENT_MEMORY = ['.claude', '.codex', '.gemini', '.kimi-code'].map(root => root + '/agent-memory');
23
+ const GENERATED = new Set(['node_modules', 'coverage', '.nyc_output', '.jest-cache', '__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache', '.venv', 'venv', '.tox', '.gradle', '.cache', '.turbo']);
24
+ /** Bounded memory for restoring a previous out-of-scope state byte for byte; beyond it the base is restored. */
25
+ const RESTORE_FILE_LIMIT = 8 * 1024 * 1024;
26
+ const RESTORE_TOTAL_LIMIT = 64 * 1024 * 1024;
27
+ /** Untracked files present when the run started are host/user state (overlays, local files), not the change. */
28
+ const PREEXISTING_LIMIT = 20_000;
29
+ function gitEnv() {
30
+ // An inherited GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE must not redirect the inspection.
31
+ const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.toUpperCase().startsWith('GIT_')));
32
+ return { ...env, GIT_OPTIONAL_LOCKS: '0', GIT_LITERAL_PATHSPECS: '1', GIT_TERMINAL_PROMPT: '0' };
33
+ }
34
+ function git(root, args, allowFailure = false) {
35
+ const result = spawnSync('git', ['-c', 'core.quotePath=false', '-c', 'core.fsmonitor=false', '-C', root, ...args], { env: gitEnv(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, windowsHide: true });
36
+ const overflow = result.error?.code === 'ENOBUFS';
37
+ const ok = !result.error && result.status === 0;
38
+ if (!ok && !allowFailure)
39
+ throw new Error(`git ${args[0]} failed in ${root}: ${String(result.error?.message ?? result.stderr ?? '').trim().slice(0, 400)}`);
40
+ return { ok, stdout: result.stdout ?? '', overflow };
41
+ }
42
+ const posix = (file) => file.split(path.sep).join('/');
43
+ /** A command without `cwd` runs in its repository's scope directory, never at the root of a larger checkout. */
44
+ export function withScopeDefault(context, command) {
45
+ if (command.cwd !== undefined)
46
+ return command;
47
+ const scope = context.repositories.find(repo => repo.id === command.repositoryId)?.scope;
48
+ return scope?.length ? { ...command, cwd: scope[0] } : command;
49
+ }
50
+ /** Model-proposed checks stay inside the repository scope: they prove the change, not the whole checkout. */
51
+ export function assertProposalInScope(context, command, what = 'verification proposal') {
52
+ const repository = context.repositories.find(repo => repo.id === command.repositoryId);
53
+ if (!repository?.scope?.length || command.cwd === undefined)
54
+ return;
55
+ const relative = posix(path.relative(repository.path, path.resolve(repository.path, command.cwd)));
56
+ if (relative === '..' || relative.startsWith('../') || path.isAbsolute(relative) || !withinRepositoryScope(repository, relative)) {
57
+ throw new Error(`Invalid ${what} cwd ${JSON.stringify(command.cwd)}: it runs outside the repository scope (${repository.scope.join(', ')}). Omit cwd to run in ${repository.scope[0]}, or name a directory inside the scope.`);
58
+ }
59
+ }
60
+ /** Untracked, non-ignored paths; a checkout with an enormous untracked tree reports directories instead of files. */
61
+ function untracked(root) {
62
+ const files = git(root, ['ls-files', '--others', '--exclude-standard', '-z'], true);
63
+ if (files.ok)
64
+ return files.stdout.split('\0').filter(Boolean);
65
+ if (!files.overflow)
66
+ git(root, ['ls-files', '--others', '--exclude-standard', '-z']);
67
+ return git(root, ['ls-files', '--others', '--exclude-standard', '--directory', '-z']).stdout.split('\0').filter(Boolean);
68
+ }
69
+ function baseFile(context) { return path.join(pipelineStateDirectory(context), 'change-base.json'); }
70
+ /**
71
+ * The commit each repository's change is measured against — the host's frozen
72
+ * base when it declared one, otherwise HEAD the first time Core asks (before
73
+ * any role edits) — plus the untracked files that already existed then.
74
+ * Recorded once, so every later turn measures the same change.
75
+ */
76
+ function baseRecords(context) {
77
+ const file = baseFile(context);
78
+ let recorded = {};
79
+ try {
80
+ const value = JSON.parse(readFileSync(file, 'utf8'));
81
+ if (value.schemaVersion === 1 && value.repositories && typeof value.repositories === 'object')
82
+ recorded = value.repositories;
83
+ }
84
+ catch { /* first use */ }
85
+ let changed = false;
86
+ for (const repository of context.repositories) {
87
+ const known = recorded[repository.id];
88
+ if (known && typeof known.base === 'string' && /^[a-f0-9]{40,64}$/.test(known.base))
89
+ continue;
90
+ let base = repository.baseSha;
91
+ if (!base) {
92
+ const head = git(repository.path, ['rev-parse', '--verify', '--quiet', 'HEAD^{commit}'], true);
93
+ base = head.ok && /^[a-f0-9]{40,64}$/.test(head.stdout.trim()) ? head.stdout.trim() : EMPTY_TREE;
94
+ }
95
+ let present;
96
+ try {
97
+ present = untracked(repository.path);
98
+ }
99
+ catch { /* not a git checkout: the change cannot be measured */ }
100
+ recorded[repository.id] = { base, ...(present && present.length <= PREEXISTING_LIMIT ? { preexisting: present } : {}) };
101
+ changed = true;
102
+ }
103
+ if (changed) {
104
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
105
+ const temp = file + '.' + randomUUID() + '.tmp';
106
+ try {
107
+ writeFileSync(temp, JSON.stringify({ schemaVersion: 1, repositories: recorded }) + '\n', { flag: 'wx', mode: 0o600 });
108
+ renameSync(temp, file);
109
+ }
110
+ finally {
111
+ rmSync(temp, { force: true });
112
+ }
113
+ }
114
+ return recorded;
115
+ }
116
+ /** Base commit per repository id; the first call records it. */
117
+ export function changeBases(context) {
118
+ return Object.fromEntries(Object.entries(baseRecords(context)).map(([id, record]) => [id, record.base]));
119
+ }
120
+ /** Host and runtime state, the change's own OpenSpec artifacts and generated trees are never part of the code change. */
121
+ function ignored(context, repository, change, file, fresh) {
122
+ if (file === '.specrails' || file.startsWith('.specrails/'))
123
+ return true;
124
+ if (repository.id === context.artifactRepositoryId && change && (file === 'openspec/changes/' + change || file.startsWith('openspec/changes/' + change + '/')))
125
+ return true;
126
+ if (!fresh)
127
+ return false;
128
+ return AGENT_MEMORY.some(root => file === root || file.startsWith(root + '/')) || file.split('/').some(segment => GENERATED.has(segment));
129
+ }
130
+ /** Every path that differs from the base: tracked edits (staged or not), deletions and new untracked files. */
131
+ export function repositoryChanges(context, repository, base, change) {
132
+ const files = new Map();
133
+ const diff = git(repository.path, ['diff', '--name-status', '-z', '--no-renames', '--ignore-submodules=all', base, '--']).stdout.split('\0');
134
+ for (let i = 0; i + 1 < diff.length; i += 2) {
135
+ const status = diff[i], file = diff[i + 1];
136
+ if (!status || !file || ignored(context, repository, change, file, false))
137
+ continue;
138
+ files.set(file, status.startsWith('A') ? 'added' : status.startsWith('D') ? 'deleted' : 'modified');
139
+ }
140
+ for (const file of untracked(repository.path)) {
141
+ const relative = file.replace(/\/$/, '');
142
+ if (!files.has(relative) && !ignored(context, repository, change, relative, true))
143
+ files.set(file, 'added');
144
+ }
145
+ return [...files].map(([file, status]) => ({ path: file, status })).sort((a, b) => a.path.localeCompare(b.path));
146
+ }
147
+ /** What this run changed, per repository and bounded: untracked files that predate the run are not part of it. */
148
+ export function changeSet(context, change, limit = 5000) {
149
+ const records = baseRecords(context);
150
+ return context.repositories.map(repository => {
151
+ const record = records[repository.id];
152
+ const preexisting = new Set(record.preexisting ?? []);
153
+ const files = repositoryChanges(context, repository, record.base, change).filter(file => !(file.status === 'added' && preexisting.has(file.path)));
154
+ return { repositoryId: repository.id, ...(repository.scope ? { scope: repository.scope } : {}), files: files.slice(0, limit), truncated: files.length > limit };
155
+ });
156
+ }
157
+ function dependencyFileForScope(repository, file) {
158
+ // A dependency change in the package legitimately refreshes the lockfile (or
159
+ // Yarn's install artifacts) of a workspace that encloses the scope.
160
+ const enclosing = (directory) => (repository.scope ?? []).some(scope => directory === '.' || directory === '' || scope === directory || scope.startsWith(directory + '/'));
161
+ if (LOCKFILES.has(path.posix.basename(file)))
162
+ return enclosing(path.posix.dirname(file));
163
+ const yarn = /^(?:(.*)\/)?\.yarn\/(?:cache\/|install-state\.gz$)/.exec(file);
164
+ return yarn !== null && enclosing(yarn[1] ?? '.');
165
+ }
166
+ /** Changes outside the repository scope (none when the whole checkout is the repository). */
167
+ export function outOfScope(repository, files) {
168
+ if (!repository.scope?.length)
169
+ return [];
170
+ const manifestChanged = files.some(file => withinRepositoryScope(repository, file.path) && MANIFESTS.has(path.posix.basename(file.path)));
171
+ return files.filter(file => !withinRepositoryScope(repository, file.path.replace(/\/$/, '')) && !(manifestChanged && dependencyFileForScope(repository, file.path)));
172
+ }
173
+ function fingerprintOf(file) {
174
+ const stat = lstatSync(file, { throwIfNoEntry: false });
175
+ if (!stat)
176
+ return { fingerprint: 'missing' };
177
+ if (stat.isSymbolicLink()) {
178
+ const link = readlinkSync(file);
179
+ return { fingerprint: 'link:' + link, link };
180
+ }
181
+ if (!stat.isFile())
182
+ return { fingerprint: 'other' };
183
+ const content = readFileSync(file);
184
+ return { fingerprint: `file:${stat.mode & 0o777}:${createHash('sha256').update(content).digest('hex')}`, content, mode: stat.mode & 0o777 };
185
+ }
186
+ export function captureOutOfScope(context, change) {
187
+ const scoped = context.repositories.filter(repository => repository.scope?.length);
188
+ if (!scoped.length)
189
+ return { change, repositories: [] };
190
+ const bases = changeBases(context);
191
+ let budget = 0;
192
+ return {
193
+ change,
194
+ repositories: scoped.map(repository => {
195
+ const base = bases[repository.id];
196
+ const before = new Map();
197
+ for (const file of outOfScope(repository, repositoryChanges(context, repository, base, change))) {
198
+ const { content, ...saved } = fingerprintOf(path.join(repository.path, file.path));
199
+ const keep = content && content.length <= RESTORE_FILE_LIMIT && budget + content.length <= RESTORE_TOTAL_LIMIT;
200
+ if (keep)
201
+ budget += content.length;
202
+ before.set(file.path, keep ? { ...saved, content } : saved);
203
+ }
204
+ return { id: repository.id, base, before };
205
+ }),
206
+ };
207
+ }
208
+ function restoreFromBase(repository, base, files) {
209
+ const restored = [];
210
+ for (let i = 0; i < files.length; i += 100) {
211
+ const batch = files.slice(i, i + 100);
212
+ const tracked = base === EMPTY_TREE ? new Set() : new Set(git(repository.path, ['ls-tree', '-r', '-z', '--name-only', '--full-tree', base, '--', ...batch.map(file => file.replace(/\/$/, ''))]).stdout.split('\0').filter(Boolean));
213
+ const existing = batch.filter(file => tracked.has(file));
214
+ if (existing.length)
215
+ git(repository.path, ['restore', '--source=' + base, '--staged', '--worktree', '--', ...existing]);
216
+ const added = batch.filter(file => !tracked.has(file));
217
+ if (added.length)
218
+ git(repository.path, ['rm', '-r', '-q', '--cached', '--ignore-unmatch', '--', ...added.map(file => file.replace(/\/$/, ''))], true);
219
+ for (const file of added)
220
+ rmSync(path.join(repository.path, file), { recursive: true, force: true });
221
+ restored.push(...existing.map(file => ({ repositoryId: repository.id, path: file, restored: 'base' })), ...added.map(file => ({ repositoryId: repository.id, path: file.replace(/\/$/, ''), restored: 'removed' })));
222
+ }
223
+ return restored;
224
+ }
225
+ function restorePrevious(repository, file, saved) {
226
+ const target = path.join(repository.path, file);
227
+ rmSync(target, { recursive: true, force: true });
228
+ if (saved.fingerprint === 'missing')
229
+ return;
230
+ mkdirSync(path.dirname(target), { recursive: true });
231
+ if (saved.link !== undefined)
232
+ symlinkSync(saved.link, target);
233
+ else
234
+ writeFileSync(target, saved.content, { mode: saved.mode ?? 0o644 });
235
+ }
236
+ /**
237
+ * Undoes every out-of-scope edit made since `guard` was captured. A path the
238
+ * turn changed outside the scope returns to its state before the turn: its
239
+ * base content, its earlier out-of-scope content, or absence. In-scope work,
240
+ * ignored files and out-of-scope state that predates the turn stay untouched.
241
+ */
242
+ export function discardOutOfScopeEdits(context, guard) {
243
+ const discarded = [];
244
+ for (const entry of guard.repositories) {
245
+ const repository = context.repositories.find(repo => repo.id === entry.id);
246
+ const now = outOfScope(repository, repositoryChanges(context, repository, entry.base, guard.change)).map(file => file.path);
247
+ const changedNow = new Set(now);
248
+ // A tracked file the turn returned to its base content leaves the change smaller: that clean-up stays.
249
+ const settled = [...entry.before.keys()].filter(file => !changedNow.has(file));
250
+ const tracked = settled.length && entry.base !== EMPTY_TREE ? new Set(git(repository.path, ['ls-tree', '-r', '-z', '--name-only', '--full-tree', entry.base, '--', ...settled]).stdout.split('\0').filter(Boolean)) : new Set();
251
+ const toBase = [];
252
+ for (const file of new Set([...now, ...entry.before.keys()])) {
253
+ const saved = entry.before.get(file);
254
+ if (!saved) {
255
+ toBase.push(file);
256
+ continue;
257
+ }
258
+ if (saved.fingerprint === fingerprintOf(path.join(repository.path, file)).fingerprint)
259
+ continue;
260
+ if (!changedNow.has(file) && tracked.has(file))
261
+ continue;
262
+ if (saved.fingerprint === 'missing' || saved.link !== undefined || saved.content) {
263
+ restorePrevious(repository, file, saved);
264
+ discarded.push({ repositoryId: repository.id, path: file, restored: 'previous' });
265
+ }
266
+ else
267
+ toBase.push(file);
268
+ }
269
+ discarded.push(...restoreFromBase(repository, entry.base, toBase));
270
+ }
271
+ return discarded.sort((a, b) => a.repositoryId.localeCompare(b.repositoryId) || a.path.localeCompare(b.path));
272
+ }
273
+ /** Bounded prompt lines for a change set; repository ids prefix the paths only when several repositories are in scope. */
274
+ export function renderChangeSet(sets, limit = 150) {
275
+ const multiple = sets.length > 1;
276
+ const lines = [];
277
+ let hidden = 0;
278
+ for (const set of sets) {
279
+ for (const file of set.files) {
280
+ if (lines.length >= limit) {
281
+ hidden++;
282
+ continue;
283
+ }
284
+ lines.push(`- ${multiple ? '`' + set.repositoryId + '` ' : ''}\`${file.path}\` (${file.status})`);
285
+ }
286
+ if (set.truncated)
287
+ hidden++;
288
+ }
289
+ if (hidden)
290
+ lines.push(`- … ${hidden}${sets.some(set => set.truncated) ? '+' : ''} more not listed; compare against the base with git to see them.`);
291
+ return lines;
292
+ }
293
+ /** One human line naming discarded edits, bounded. */
294
+ export function describeDiscarded(edits, multiple, limit = 12) {
295
+ const names = edits.map(edit => (multiple ? edit.repositoryId + ':' : '') + edit.path);
296
+ return names.slice(0, limit).join(', ') + (names.length > limit ? ` and ${names.length - limit} more` : '');
297
+ }
@@ -126,7 +126,7 @@ export async function runRuntimeCommand(flags, positionals, emit = value => proc
126
126
  return 0;
127
127
  }
128
128
  if (command === 'api') {
129
- emit({ type: 'runtime-api', apiVersion: RUNTIME_API_VERSION, coreVersion: CORE_PACKAGE_VERSION, runtimeIdentity: coreRuntimeIdentity(), workflowVersions: [CORE_WORKFLOW_VERSION], capabilities: { scopedRecovery: 1, efficientRoleExecution: 1, reproducibleVerification: 1, implementationEfficiencyMetrics: 1, compactAgentLoop: 1, configurableGuardrails: 1, compactOutputBudget: 1, roleThinkingControl: 1 }, guardrails: GUARDRAIL_CATALOG });
129
+ emit({ type: 'runtime-api', apiVersion: RUNTIME_API_VERSION, coreVersion: CORE_PACKAGE_VERSION, runtimeIdentity: coreRuntimeIdentity(), workflowVersions: [CORE_WORKFLOW_VERSION], capabilities: { scopedRecovery: 1, efficientRoleExecution: 1, reproducibleVerification: 1, implementationEfficiencyMetrics: 1, compactAgentLoop: 1, configurableGuardrails: 1, compactOutputBudget: 1, roleThinkingControl: 1, repositoryScope: 1 }, guardrails: GUARDRAIL_CATALOG });
130
130
  return 0;
131
131
  }
132
132
  if (command === 'validate') {
@@ -5,12 +5,24 @@ interface EnvironmentInstall {
5
5
  command: string;
6
6
  args: string[];
7
7
  }
8
+ /** `precondition`: the install failed for a reason only the host can repair (see hostPreconditionFailure). */
8
9
  interface InstallOutcome extends EnvironmentInstall {
9
10
  ok: boolean;
10
11
  detail: string;
12
+ precondition?: string;
11
13
  }
12
14
  /** True when a verification failure looks like a missing toolchain/dependency rather than a code defect. */
13
15
  export declare function isEnvironmentFailure(exitCode: number | null | undefined, output: string): boolean;
16
+ /**
17
+ * Failures that no edit to the change can repair, because the verification
18
+ * process itself lacks something only the host can grant: an environment
19
+ * variable a configuration file references, registry credentials, registry
20
+ * network access, git credentials for a dependency. Feeding them to a fixer
21
+ * makes it edit package-manager configuration to route around the missing
22
+ * secret (observed: `.yarnrc.yml` rewritten, reverted by review, rewritten
23
+ * again). Returns a short host-facing reason, or undefined.
24
+ */
25
+ export declare function hostPreconditionFailure(output: string): string | undefined;
14
26
  /** Packages a failure output names explicitly (`Try \`npm i --save-dev @types/jest\``); the host installs exactly those. */
15
27
  export declare function suggestedPackages(output: string): string[];
16
28
  /** Dependencies package.json declares that node_modules does not hold (bounded). */
@@ -19,6 +19,8 @@ const ENVIRONMENT_SIGNATURES = [
19
19
  /jest: not found|vitest: not found|mocha: not found|tsc: not found|pytest: not found/i,
20
20
  /TS2582|Do you need to install type definitions|Try `npm i --save-dev @types\//i,
21
21
  /Module (?:[\w@./-]+) in the \w+ option was not found|Preset [\w@./-]+ not found|Cannot find module '(?:ts-jest|babel-jest|@swc\/jest|ts-node|tsx)'/i,
22
+ // Yarn Berry refuses to run scripts in a checkout that was never installed.
23
+ /doesn't seem to have been installed - running an install there might help|Couldn't find the node_modules state file - running an install might help/i,
22
24
  ];
23
25
  /** True when a verification failure looks like a missing toolchain/dependency rather than a code defect. */
24
26
  export function isEnvironmentFailure(exitCode, output) {
@@ -27,6 +29,33 @@ export function isEnvironmentFailure(exitCode, output) {
27
29
  const tail = output.slice(-6000);
28
30
  return ENVIRONMENT_SIGNATURES.some(pattern => pattern.test(tail));
29
31
  }
32
+ const REGISTRY = String.raw `(?:registry|npmjs\.org|yarnpkg\.com|npm\.pkg\.github\.com|pkgs\.dev\.azure\.com|jfrog|artifactory|nexus|pypi\.org|crates\.io|proxy\.golang\.org)`;
33
+ /**
34
+ * Failures that no edit to the change can repair, because the verification
35
+ * process itself lacks something only the host can grant: an environment
36
+ * variable a configuration file references, registry credentials, registry
37
+ * network access, git credentials for a dependency. Feeding them to a fixer
38
+ * makes it edit package-manager configuration to route around the missing
39
+ * secret (observed: `.yarnrc.yml` rewritten, reverted by review, rewritten
40
+ * again). Returns a short host-facing reason, or undefined.
41
+ */
42
+ export function hostPreconditionFailure(output) {
43
+ const text = output.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '').slice(-16_000);
44
+ const variable = /Environment variable not found \(([A-Za-z_][A-Za-z0-9_]*)\)/.exec(text);
45
+ if (variable)
46
+ return `the environment variable ${variable[1]} is not available to verification commands, and a package-manager configuration file requires it`;
47
+ if (/\bYN0041\b/.test(text) || /YN0035[\s\S]{0,400}?Response Code: 40[13]\b/.test(text) || /Response code 40[13] \((?:Unauthorized|Forbidden)\)/i.test(text)
48
+ || /npm (?:ERR!|error) code (?:E401|E403|ENEEDAUTH)\b/.test(text) || /ERR_PNPM_FETCH_40[13]\b/.test(text)
49
+ || new RegExp(String.raw `\b40[13] (?:Unauthorized|Forbidden)\b[^\n]{0,80}https?://[^\s]*${REGISTRY}`, 'i').test(text)
50
+ || /\b(?:401|403) Client Error: (?:Unauthorized|Forbidden) for url/.test(text))
51
+ return 'the package registry rejected the credentials available to verification commands (HTTP 401/403)';
52
+ if (/npm (?:ERR!|error) code (?:ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ETIMEDOUT|ECONNRESET)\b/.test(text) || /ERR_PNPM_META_FETCH_FAIL\b/.test(text)
53
+ || new RegExp(String.raw `\b(?:ENOTFOUND|EAI_AGAIN)\b[^\n]{0,200}${REGISTRY}|${REGISTRY}[^\n]{0,200}\b(?:ENOTFOUND|EAI_AGAIN)\b`, 'i').test(text))
54
+ return 'the package registry cannot be reached from the verification environment';
55
+ if (/fatal: could not read (?:Username|Password) for/.test(text) || /Permission denied \(publickey\)/.test(text))
56
+ return 'git credentials needed to fetch a dependency are not available to verification commands';
57
+ return undefined;
58
+ }
30
59
  /** Packages a failure output names explicitly (`Try \`npm i --save-dev @types/jest\``); the host installs exactly those. */
31
60
  export function suggestedPackages(output) {
32
61
  const packages = new Set();
@@ -155,7 +184,8 @@ export function installEnvironment(roots, io = {}) {
155
184
  const ok = result.status === 0;
156
185
  const detail = ok ? `installed ${plan.ecosystem} dependencies in ${path.basename(root)}` : `${plan.command} ${plan.args.join(' ')} failed in ${path.basename(root)}: ${String(result.stderr || result.error?.message || `exit ${result.status}`).trim().slice(0, 400)}`;
157
186
  io.onEvent?.({ kind: 'text', text: `Environment: ${detail}` });
158
- outcomes.push({ ...plan, ok, detail });
187
+ const precondition = ok ? undefined : hostPreconditionFailure(`${result.stdout ?? ''}\n${result.stderr ?? ''}`);
188
+ outcomes.push({ ...plan, ok, detail, ...(precondition ? { precondition } : {}) });
159
189
  }
160
190
  }
161
191
  return outcomes;
@@ -98,7 +98,11 @@ function tokenReaches(token, file, root, discovery) {
98
98
  * file. Unknown ⇒ true: only an enumerating command can prove absence.
99
99
  */
100
100
  export function commandReachesTest(root, command, file) {
101
- const target = posix(file);
101
+ // Path arguments are relative to the directory the command runs in (a
102
+ // package inside a larger checkout), while reported files are relative to
103
+ // the checkout: compare both from the command's directory.
104
+ const base = path.resolve(root, command.cwd ?? '.');
105
+ const target = posix(path.relative(base, path.resolve(root, file)));
102
106
  let judged = false;
103
107
  for (const tokens of segments(expandCommandText(root, command))) {
104
108
  if (!tokens.length)
@@ -117,7 +121,7 @@ export function commandReachesTest(root, command, file) {
117
121
  if (!paths.some(token => TEST_FILE_PATTERN.test(posix(token)) || token.includes('*') || token.endsWith('...')))
118
122
  continue;
119
123
  judged = true;
120
- if (paths.some(token => tokenReaches(token, target, root, discovery)))
124
+ if (paths.some(token => tokenReaches(token, target, base, discovery)))
121
125
  return true;
122
126
  }
123
127
  return !judged;
@@ -5,7 +5,7 @@ import { parseAgentObject } from './graph/artifacts.js';
5
5
  import { type RuntimeIdentity } from './runtime-identity.js';
6
6
  import type { WorkflowEvent, WorkflowSpan, WorkflowState } from './workflow-types.js';
7
7
  export declare const RUNTIME_API_VERSION = 1;
8
- export declare const CORE_WORKFLOW_VERSION = "6";
8
+ export declare const CORE_WORKFLOW_VERSION = "7";
9
9
  export declare const CORE_PACKAGE_VERSION: string;
10
10
  export declare function coreRuntimeIdentity(): RuntimeIdentity;
11
11
  export { parseAgentObject };
@@ -15,7 +15,7 @@ import { assertEffortSupported } from './capabilities.js';
15
15
  import { runtimePackageIntegrity } from './runtime-identity.js';
16
16
  import { readWorkflowState, runWorkflow } from './workflow.js';
17
17
  export const RUNTIME_API_VERSION = 1;
18
- export const CORE_WORKFLOW_VERSION = '6';
18
+ export const CORE_WORKFLOW_VERSION = '7';
19
19
  export const CORE_PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
20
20
  export function coreRuntimeIdentity() {
21
21
  return { packageVersion: CORE_PACKAGE_VERSION, workflowVersion: CORE_WORKFLOW_VERSION, instructionsVersion: String(ROLE_INSTRUCTIONS_VERSION), packageIntegrity: runtimePackageIntegrity(), apiVersion: 1 };
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto';
3
3
  import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { checkArchive, pipelineStateDirectory, transitionPipeline, validateVerificationRequest, } from '../../pipeline/pipeline-state.js';
6
+ import { assertProposalInScope, withScopeDefault } from '../change-scope.js';
6
7
  export const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7
8
  export function object(value) {
8
9
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -105,8 +106,10 @@ export function proposedVerification(context, configured, verification) {
105
106
  throw new Error('Invalid proposed verification command');
106
107
  return { repositoryId: command.repositoryId, command: command.command, args: command.args, ...(typeof command.cwd === 'string' && command.cwd ? { cwd: command.cwd } : {}) };
107
108
  }).filter(command => !covered.has(command.repositoryId) && context.repositories.some(repository => repository.id === command.repositoryId));
109
+ for (const command of proposed)
110
+ assertProposalInScope(context, command, 'proposed verification command');
108
111
  if (proposed.length)
109
- validateVerificationRequest(context, { kind: 'scoped', commands: proposed });
112
+ validateVerificationRequest(context, { kind: 'scoped', commands: proposed.map(command => withScopeDefault(context, command)) });
110
113
  return proposed;
111
114
  }
112
115
  /** Publish an archive prepared by the real CLI. The durable write set makes a
@@ -0,0 +1,26 @@
1
+ import type { VerificationReceipt } from '../../pipeline/pipeline-state.js';
2
+ import type { WorkflowState } from '../workflow-types.js';
3
+ /** One verify visit, as the convergence guard remembers it. */
4
+ export interface VerifyOutcome {
5
+ at: string;
6
+ candidateHash: string;
7
+ planHash: string;
8
+ passed: boolean;
9
+ /** Normalized identity of the first failure: the command plus its output with volatile details removed. */
10
+ signature?: string;
11
+ summary?: string;
12
+ }
13
+ /** Verify visits since the latest explicit resume: a human continuing a blocked run grants a fresh budget, as for attempts. */
14
+ export declare function outcomesSinceResume(history: readonly VerifyOutcome[], checkpoint: WorkflowState): VerifyOutcome[];
15
+ /** Identity and one-line description of a failed receipt (or of a host finding that failed a green receipt). */
16
+ export declare function describeFailure(receipt: Pick<VerificationReceipt, 'commands' | 'reason'>, roots: readonly string[], finding?: string): {
17
+ signature: string;
18
+ summary: string;
19
+ };
20
+ /**
21
+ * Why the failure just recorded (`current`, not yet in `history`) proves the
22
+ * correction loop is not converging, or undefined when another round can help.
23
+ */
24
+ export declare function divergence(history: readonly VerifyOutcome[], current: VerifyOutcome): string | undefined;
25
+ /** The same test for a candidate that has not been verified again yet: an unchanged failed candidate is not worth a new run. */
26
+ export declare function unchangedFailure(history: readonly VerifyOutcome[], candidateHash: string, planHash: string): VerifyOutcome | undefined;
@@ -0,0 +1,62 @@
1
+ import { fingerprint } from '../durable-store.js';
2
+ /** Verify visits since the latest explicit resume: a human continuing a blocked run grants a fresh budget, as for attempts. */
3
+ export function outcomesSinceResume(history, checkpoint) {
4
+ const resumed = [...checkpoint.events].reverse().find(event => event.type === 'workflow_resumed');
5
+ return history.filter(entry => !resumed || entry.at >= resumed.timestamp);
6
+ }
7
+ function normalized(output, roots) {
8
+ let text = output.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '');
9
+ for (const root of [...roots].sort((a, b) => b.length - a.length))
10
+ text = text.split(root).join('<repo>');
11
+ return text.split(/\r?\n/)
12
+ .map(line => line.replace(/\b[0-9a-f]{7,64}\b/gi, '#').replace(/\d+(?:[.,]\d+)*/g, '0').replace(/\s+/g, ' ').trim())
13
+ .filter(Boolean).slice(-40).join('\n');
14
+ }
15
+ /** The last line that says what went wrong, bounded. */
16
+ function headline(output) {
17
+ const lines = output.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, '').split(/\r?\n/).map(line => line.trim()).filter(Boolean);
18
+ const telling = [...lines].reverse().find(line => /error|fail|✕|×|not found|cannot|denied|refused|forbidden|unauthori[sz]ed/i.test(line));
19
+ return (telling ?? lines.at(-1) ?? '').slice(0, 300);
20
+ }
21
+ /** Identity and one-line description of a failed receipt (or of a host finding that failed a green receipt). */
22
+ export function describeFailure(receipt, roots, finding) {
23
+ if (finding)
24
+ return { signature: fingerprint({ finding: normalized(finding, roots) }), summary: finding.slice(0, 300) };
25
+ const failed = receipt.commands.find(command => command.exitCode !== 0);
26
+ if (!failed)
27
+ return { signature: fingerprint({ reason: receipt.reason ?? 'verification failed' }), summary: receipt.reason ?? 'verification failed' };
28
+ const name = failed.label ?? [failed.command, ...failed.args].join(' ');
29
+ const detail = headline(failed.output);
30
+ return {
31
+ signature: fingerprint({ check: failed.key ?? [failed.command, ...failed.args, failed.cwd], exitCode: failed.exitCode, output: normalized(failed.output, roots) }),
32
+ summary: `${name} exited ${failed.exitCode}${detail ? `: ${detail}` : ''}`.slice(0, 400),
33
+ };
34
+ }
35
+ /**
36
+ * Why the failure just recorded (`current`, not yet in `history`) proves the
37
+ * correction loop is not converging, or undefined when another round can help.
38
+ */
39
+ export function divergence(history, current) {
40
+ if (current.passed || !current.signature)
41
+ return undefined;
42
+ const cycle = history.find(entry => !entry.passed && entry.candidateHash === current.candidateHash && entry.planHash === current.planHash);
43
+ if (cycle)
44
+ return `The correction round returned the change to a state that already failed verification (${current.summary}). Running the same checks again cannot pass.`;
45
+ let repeats = 0;
46
+ for (const entry of [...history].reverse()) {
47
+ if (entry.passed || entry.signature !== current.signature)
48
+ break;
49
+ repeats++;
50
+ }
51
+ if (repeats >= 2)
52
+ return `The same verification failure survived ${repeats} correction rounds unchanged (${current.summary}). It is most likely outside what this change can fix: a pre-existing failure, another package, or the environment.`;
53
+ const previous = history.at(-1);
54
+ if (previous?.passed && history.some(entry => !entry.passed && entry.signature === current.signature)) {
55
+ return `A review correction brought back a verification failure that an earlier correction had fixed (${current.summary}). The review request and the checks pull in opposite directions; decide which one applies before resuming.`;
56
+ }
57
+ return undefined;
58
+ }
59
+ /** The same test for a candidate that has not been verified again yet: an unchanged failed candidate is not worth a new run. */
60
+ export function unchangedFailure(history, candidateHash, planHash) {
61
+ return [...history].reverse().find(entry => !entry.passed && entry.candidateHash === candidateHash && entry.planHash === planHash);
62
+ }