specrails-core 5.4.0 → 5.5.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/README.md +2 -0
- package/dist/agent-runtime/capabilities.d.ts +3 -2
- package/dist/agent-runtime/capabilities.js +10 -2
- package/dist/agent-runtime/capabilities.js.map +1 -1
- package/dist/agent-runtime/cli-executor.js +10 -5
- package/dist/agent-runtime/cli-executor.js.map +1 -1
- package/dist/agent-runtime/cli-process.d.ts +1 -0
- package/dist/agent-runtime/cli-process.js +13 -2
- package/dist/agent-runtime/cli-process.js.map +1 -1
- package/dist/agent-runtime/cli.js +2 -1
- package/dist/agent-runtime/cli.js.map +1 -1
- package/dist/agent-runtime/compact/architect.d.ts +349 -0
- package/dist/agent-runtime/compact/architect.js +371 -0
- package/dist/agent-runtime/compact/architect.js.map +1 -0
- package/dist/agent-runtime/compact/chat-client.d.ts +83 -0
- package/dist/agent-runtime/compact/chat-client.js +307 -0
- package/dist/agent-runtime/compact/chat-client.js.map +1 -0
- package/dist/agent-runtime/compact/developer.d.ts +160 -0
- package/dist/agent-runtime/compact/developer.js +823 -0
- package/dist/agent-runtime/compact/developer.js.map +1 -0
- package/dist/agent-runtime/compact/environment.d.ts +62 -0
- package/dist/agent-runtime/compact/environment.js +213 -0
- package/dist/agent-runtime/compact/environment.js.map +1 -0
- package/dist/agent-runtime/compact/exit-code-honesty.d.ts +8 -0
- package/dist/agent-runtime/compact/exit-code-honesty.js +78 -0
- package/dist/agent-runtime/compact/exit-code-honesty.js.map +1 -0
- package/dist/agent-runtime/compact/guarded-loop.d.ts +73 -0
- package/dist/agent-runtime/compact/guarded-loop.js +320 -0
- package/dist/agent-runtime/compact/guarded-loop.js.map +1 -0
- package/dist/agent-runtime/compact/prompt-inputs.d.ts +42 -0
- package/dist/agent-runtime/compact/prompt-inputs.js +59 -0
- package/dist/agent-runtime/compact/prompt-inputs.js.map +1 -0
- package/dist/agent-runtime/compact/reviewer.d.ts +24 -0
- package/dist/agent-runtime/compact/reviewer.js +178 -0
- package/dist/agent-runtime/compact/reviewer.js.map +1 -0
- package/dist/agent-runtime/compact/step.d.ts +70 -0
- package/dist/agent-runtime/compact/step.js +136 -0
- package/dist/agent-runtime/compact/step.js.map +1 -0
- package/dist/agent-runtime/compact/test-reachability.d.ts +30 -0
- package/dist/agent-runtime/compact/test-reachability.js +147 -0
- package/dist/agent-runtime/compact/test-reachability.js.map +1 -0
- package/dist/agent-runtime/config.js +40 -10
- package/dist/agent-runtime/config.js.map +1 -1
- package/dist/agent-runtime/core-host.d.ts +4 -4
- package/dist/agent-runtime/core-host.js +8 -3
- package/dist/agent-runtime/core-host.js.map +1 -1
- package/dist/agent-runtime/executor-types.d.ts +37 -3
- package/dist/agent-runtime/executor-types.js +7 -2
- package/dist/agent-runtime/executor-types.js.map +1 -1
- package/dist/agent-runtime/graph/nodes.d.ts +4 -2
- package/dist/agent-runtime/graph/nodes.js +160 -53
- package/dist/agent-runtime/graph/nodes.js.map +1 -1
- package/dist/agent-runtime/graph/roles.d.ts +7 -2
- package/dist/agent-runtime/graph/roles.js +19 -11
- package/dist/agent-runtime/graph/roles.js.map +1 -1
- package/dist/agent-runtime/graph/state.d.ts +2 -1
- package/dist/agent-runtime/graph/state.js +2 -1
- package/dist/agent-runtime/graph/state.js.map +1 -1
- package/dist/agent-runtime/guardrails.d.ts +15 -0
- package/dist/agent-runtime/guardrails.js +76 -0
- package/dist/agent-runtime/guardrails.js.map +1 -0
- package/dist/agent-runtime/openai-executor.d.ts +25 -1
- package/dist/agent-runtime/openai-executor.js +67 -122
- package/dist/agent-runtime/openai-executor.js.map +1 -1
- package/dist/agent-runtime/openspec-tool-server.js +3 -3
- package/dist/agent-runtime/openspec-tool-server.js.map +1 -1
- package/dist/agent-runtime/openspec.d.ts +76 -1
- package/dist/agent-runtime/openspec.js +70 -4
- package/dist/agent-runtime/openspec.js.map +1 -1
- package/dist/agent-runtime/prompts.d.ts +21 -2
- package/dist/agent-runtime/prompts.js +56 -6
- package/dist/agent-runtime/prompts.js.map +1 -1
- package/dist/agent-runtime/repository-context.js +39 -12
- package/dist/agent-runtime/repository-context.js.map +1 -1
- package/dist/agent-runtime/role-routing.js +1 -0
- package/dist/agent-runtime/role-routing.js.map +1 -1
- package/dist/agent-runtime/workflow-types.d.ts +2 -0
- package/dist/agent-runtime/workflow.js +1 -1
- package/dist/agent-runtime/workflow.js.map +1 -1
- package/dist/installer/runtime/pipeline-state.d.ts +1 -0
- package/dist/installer/runtime/pipeline-state.js +35 -4
- package/dist/installer/runtime/pipeline-state.js.map +1 -1
- package/docs/agent-runtime.md +8 -0
- package/docs/ci-cd.md +6 -3
- package/package.json +2 -2
- package/schemas/agent-runtime.schema.json +10 -2
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { detectCheckCommand, installEnvironment, runGroupCheck } from './environment.js';
|
|
3
|
+
import { unreachedTestFiles, unreachedTestsReason } from './test-reachability.js';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { AgentExecutionError } from '../executor-types.js';
|
|
6
|
+
import { artifactPath } from '../openspec.js';
|
|
7
|
+
import { readVerificationEvidence } from '../../installer/runtime/pipeline-state.js';
|
|
8
|
+
import { bounded } from './prompt-inputs.js';
|
|
9
|
+
import { finalJson, on, openspecCall, strings, text, toolStep } from './step.js';
|
|
10
|
+
/** Tool calls one task group may spend before it must report. */
|
|
11
|
+
export const DEFAULT_TASK_TOOL_BUDGET = 25;
|
|
12
|
+
/** Frozen planning artifacts: the developer edits application code, never the plan. */
|
|
13
|
+
const FROZEN_PATH = /(?:^|[\\/])openspec[\\/]/i;
|
|
14
|
+
const DEVELOPER_TOOLS = ['list_files', 'read_file', 'read_lines', 'search_text', 'get_diff', 'write_file', 'apply_patch'];
|
|
15
|
+
const stringList = { type: 'array', items: { type: 'string' } };
|
|
16
|
+
export const TASK_RESULT_SCHEMA = { type: 'object', additionalProperties: false, required: ['summary', 'files', 'tests', 'verification', 'incomplete'], properties: { summary: { type: 'string' }, files: stringList, tests: stringList, verification: { type: 'string' }, incomplete: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['task', 'reason'], properties: { task: { type: 'string' }, reason: { type: 'string' } } } } } };
|
|
17
|
+
/** Parses the `## N. Title` / `- [ ] N.M text` layout the OpenSpec tasks template prescribes. */
|
|
18
|
+
export function parseTaskGroups(markdown) {
|
|
19
|
+
const groups = [];
|
|
20
|
+
let current;
|
|
21
|
+
for (const line of markdown.split('\n')) {
|
|
22
|
+
const heading = /^##\s+(\d+)\.\s*(.*)$/.exec(line);
|
|
23
|
+
if (heading) {
|
|
24
|
+
current = { index: Number(heading[1]), title: heading[2].trim(), tasks: [] };
|
|
25
|
+
groups.push(current);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const task = /^\s*-\s+\[([ xX])\]\s+((\d+(?:\.\d+)?)\s+)?(.*)$/.exec(line);
|
|
29
|
+
if (!task)
|
|
30
|
+
continue;
|
|
31
|
+
if (!current) {
|
|
32
|
+
current = { index: groups.length + 1, title: 'Tasks', tasks: [] };
|
|
33
|
+
groups.push(current);
|
|
34
|
+
}
|
|
35
|
+
current.tasks.push({ id: task[3] ?? `${current.index}.${current.tasks.length + 1}`, text: task[4].trim(), done: task[1] !== ' ' });
|
|
36
|
+
}
|
|
37
|
+
return groups;
|
|
38
|
+
}
|
|
39
|
+
/** Paths written or patched by successful tool calls in a loop transcript. */
|
|
40
|
+
export function writtenFiles(messages) {
|
|
41
|
+
const calls = new Map();
|
|
42
|
+
const files = [];
|
|
43
|
+
for (const message of messages) {
|
|
44
|
+
if (message.role === 'assistant' && Array.isArray(message.tool_calls)) {
|
|
45
|
+
for (const call of message.tool_calls) {
|
|
46
|
+
if (call.function.name !== 'write_file' && call.function.name !== 'apply_patch')
|
|
47
|
+
continue;
|
|
48
|
+
try {
|
|
49
|
+
const args = JSON.parse(call.function.arguments);
|
|
50
|
+
if (typeof args.path === 'string')
|
|
51
|
+
calls.set(call.id, args.path);
|
|
52
|
+
}
|
|
53
|
+
catch { /* malformed: not executed */ }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else if (message.role === 'tool' && typeof message.tool_call_id === 'string') {
|
|
57
|
+
const file = calls.get(message.tool_call_id);
|
|
58
|
+
if (file && typeof message.content === 'string' && !/^\s*\{\s*"error"/.test(message.content) && !files.includes(file))
|
|
59
|
+
files.push(file);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return files;
|
|
63
|
+
}
|
|
64
|
+
/** Relative source/test paths a task sentence names (`src/game.js`, `tests/smoke.test.js`). */
|
|
65
|
+
export function namedFiles(task) {
|
|
66
|
+
// A plan may spell a path relative to the test file (`../src/tetris.js`,
|
|
67
|
+
// `./tetromino.js`): the evidence gate checks repository-relative paths, so
|
|
68
|
+
// the leading `./` and `../` segments are dropped and a bare `./x.js` keeps
|
|
69
|
+
// only its basename to be located by extension family under any root dir.
|
|
70
|
+
const matches = task.match(/(?:^|[\s`'"(,])((?:\.{1,2}\/)*(?:[\w.-]+\/)*[\w.-]+\.(?:[cm]?[jt]sx?|py|go|rs|java|kt|swift|cs|rb|php|c|cc|cpp|h|hpp|vue|svelte|html|css|scss|sql|json|ya?ml|toml|md))(?=[\s`'"),.:;]|$)/g) ?? [];
|
|
71
|
+
return [...new Set(matches.map(match => match.replace(/^[\s`'"(,]/, '').trim().replace(/^(?:\.{1,2}\/)+/, '')))].filter(file => file.includes('/') ? !/^openspec\//.test(file) : false);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Turns the two failure shapes small models never fix on their own into
|
|
75
|
+
* concrete, file-precise correction items: an import that resolves to a file
|
|
76
|
+
* that does not exist (the host locates the real one), and a missing module.
|
|
77
|
+
* Generic Node/Jest/TS and Python signatures; anything else passes through.
|
|
78
|
+
*/
|
|
79
|
+
export function diagnoseVerificationFailure(feedback, roots) {
|
|
80
|
+
const items = [];
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
for (const match of feedback.matchAll(/Cannot find module '([^']+)' from '([^']+)'/g)) {
|
|
83
|
+
const [, target, from] = match;
|
|
84
|
+
if (!target || !from || seen.has(target + from))
|
|
85
|
+
continue;
|
|
86
|
+
seen.add(target + from);
|
|
87
|
+
if (!target.startsWith('.')) {
|
|
88
|
+
items.push(`${from}: the import '${target}' is a package that is not installed or a file that does not exist; add the dependency or fix the import`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const base = path.basename(target).replace(/\.[cm]?[jt]sx?$/, '');
|
|
92
|
+
const found = roots.flatMap(root => findFiles(root, base)).map(file => file.replace(/\\/g, '/'));
|
|
93
|
+
const fromDir = path.posix.dirname(from.replace(/\\/g, '/'));
|
|
94
|
+
const suggestion = found.length ? ` The file exists at ${found.slice(0, 3).join(' / ')}; from ${from} the correct import is '${found.map(file => { const rel = path.posix.relative(fromDir, file.replace(/\.[cm]?[jt]sx?$/, '')); return rel.startsWith('.') ? rel : './' + rel; }).slice(0, 1)[0]}'.` : ` No file named ${base} exists anywhere in the repository: create it or point the import at the real module.`;
|
|
95
|
+
items.push(`${from}: the import '${target}' does not resolve.${suggestion}`);
|
|
96
|
+
}
|
|
97
|
+
for (const match of feedback.matchAll(/ModuleNotFoundError: No module named '([^']+)'/g)) {
|
|
98
|
+
const name = match[1];
|
|
99
|
+
if (!name || seen.has(name))
|
|
100
|
+
continue;
|
|
101
|
+
seen.add(name);
|
|
102
|
+
items.push(`Python cannot import '${name}': create that module in the package or fix the import path`);
|
|
103
|
+
}
|
|
104
|
+
return items;
|
|
105
|
+
}
|
|
106
|
+
function findFiles(root, base, depth = 0) {
|
|
107
|
+
if (depth > 6)
|
|
108
|
+
return [];
|
|
109
|
+
let entries = [];
|
|
110
|
+
try {
|
|
111
|
+
entries = readdirSync(root);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
if (entry === 'node_modules' || entry.startsWith('.') || entry === 'openspec')
|
|
119
|
+
continue;
|
|
120
|
+
const full = path.join(root, entry);
|
|
121
|
+
let stat;
|
|
122
|
+
try {
|
|
123
|
+
stat = statSync(full);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (stat.isDirectory())
|
|
129
|
+
out.push(...findFiles(full, base, depth + 1).map(file => path.join(entry, file)));
|
|
130
|
+
else if (entry.replace(/\.[cm]?[jt]sx?$/, '') === base && /\.[cm]?[jt]sx?$/.test(entry))
|
|
131
|
+
out.push(entry);
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
/** Application source/test files under the roots (bounded), excluding plans, deps and dot dirs. */
|
|
136
|
+
export function repositoryInventory(roots, limit = 60) {
|
|
137
|
+
const out = [];
|
|
138
|
+
const walk = (dir, rel, depth) => {
|
|
139
|
+
if (depth > 6 || out.length >= limit)
|
|
140
|
+
return;
|
|
141
|
+
let entries = [];
|
|
142
|
+
try {
|
|
143
|
+
entries = readdirSync(dir);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
if (out.length >= limit)
|
|
150
|
+
return;
|
|
151
|
+
if (entry === 'node_modules' || entry === 'target' || entry === 'openspec' || entry.startsWith('.'))
|
|
152
|
+
continue;
|
|
153
|
+
const full = path.join(dir, entry);
|
|
154
|
+
let stat;
|
|
155
|
+
try {
|
|
156
|
+
stat = statSync(full);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (stat.isDirectory())
|
|
162
|
+
walk(full, rel ? `${rel}/${entry}` : entry, depth + 1);
|
|
163
|
+
else if (/\.(?:[cm]?[jt]sx?|py|go|rs|java|kt|swift|cs|rb|php|c|cc|cpp|h|hpp|vue|svelte)$/.test(entry))
|
|
164
|
+
out.push(rel ? `${rel}/${entry}` : entry);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
for (const root of roots)
|
|
168
|
+
walk(root, '', 0);
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
const LANGUAGE_BY_EXT = { ts: 'TypeScript', tsx: 'TypeScript', mts: 'TypeScript', cts: 'TypeScript', js: 'JavaScript', jsx: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript', py: 'Python', go: 'Go', rs: 'Rust', java: 'Java', kt: 'Kotlin', swift: 'Swift', cs: 'C#', rb: 'Ruby', php: 'PHP', c: 'C', cc: 'C++', cpp: 'C++', h: 'C', hpp: 'C++', vue: 'Vue', svelte: 'Svelte' };
|
|
172
|
+
/**
|
|
173
|
+
* The repository's primary language (most source files; a tsconfig.json or a
|
|
174
|
+
* TypeScript `main` breaks a JS/TS tie towards TypeScript) plus every other
|
|
175
|
+
* language present. Advisory: a mixed repository keeps every language, the
|
|
176
|
+
* profile only tells the model which one NEW files default to.
|
|
177
|
+
*/
|
|
178
|
+
export function languageProfile(roots) {
|
|
179
|
+
const counts = new Map();
|
|
180
|
+
for (const file of repositoryInventory(roots, 400)) {
|
|
181
|
+
const language = LANGUAGE_BY_EXT[path.extname(file).slice(1).toLowerCase()];
|
|
182
|
+
if (language)
|
|
183
|
+
counts.set(language, (counts.get(language) ?? 0) + 1);
|
|
184
|
+
}
|
|
185
|
+
const hasTsConfig = roots.some(root => existsSync(path.join(root, 'tsconfig.json')));
|
|
186
|
+
const tsMain = roots.some(root => { try {
|
|
187
|
+
return /\.[cm]?tsx?$/.test(String(JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')).main ?? ''));
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return false;
|
|
191
|
+
} });
|
|
192
|
+
if ((hasTsConfig || tsMain) && !counts.has('TypeScript'))
|
|
193
|
+
counts.set('TypeScript', 0);
|
|
194
|
+
if (!counts.size)
|
|
195
|
+
return undefined;
|
|
196
|
+
const ranked = [...counts.entries()].sort((a, b) => {
|
|
197
|
+
if (b[1] !== a[1])
|
|
198
|
+
return b[1] - a[1];
|
|
199
|
+
if ((hasTsConfig || tsMain) && (a[0] === 'TypeScript') !== (b[0] === 'TypeScript'))
|
|
200
|
+
return a[0] === 'TypeScript' ? -1 : 1;
|
|
201
|
+
return a[0].localeCompare(b[0]);
|
|
202
|
+
});
|
|
203
|
+
// A JS/TS tie (or a TS project whose only files so far are JS config) still resolves to TypeScript when the project declares it.
|
|
204
|
+
if ((hasTsConfig || tsMain) && counts.has('TypeScript') && ranked[0][0] === 'JavaScript' && (counts.get('JavaScript') - counts.get('TypeScript')) <= 2)
|
|
205
|
+
ranked.sort((a, b) => (a[0] === 'TypeScript' ? -1 : b[0] === 'TypeScript' ? 1 : 0));
|
|
206
|
+
return { primary: ranked[0][0], others: ranked.slice(1).map(([language]) => language) };
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* A new file that would duplicate an existing sibling in the primary language
|
|
210
|
+
* (`src/board.js` next to `src/board.ts`): the sibling to extend, else undefined.
|
|
211
|
+
* Rewriting an existing file or adding a file in a secondary language with no
|
|
212
|
+
* primary-language twin is never flagged, so mixed repositories keep working.
|
|
213
|
+
*/
|
|
214
|
+
export function duplicateSibling(roots, file, primary) {
|
|
215
|
+
const ext = path.extname(file).slice(1).toLowerCase();
|
|
216
|
+
if (!ext || LANGUAGE_BY_EXT[ext] === primary)
|
|
217
|
+
return undefined;
|
|
218
|
+
const stem = file.slice(0, -ext.length - 1);
|
|
219
|
+
for (const root of roots) {
|
|
220
|
+
if (existsSync(path.join(root, file)))
|
|
221
|
+
return undefined;
|
|
222
|
+
for (const [candidate, language] of Object.entries(LANGUAGE_BY_EXT)) {
|
|
223
|
+
if (language !== primary)
|
|
224
|
+
continue;
|
|
225
|
+
const sibling = `${stem}.${candidate}`;
|
|
226
|
+
if (existsSync(path.join(root, sibling)))
|
|
227
|
+
return sibling;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* A NEW source file in a language the repository does not use at all
|
|
234
|
+
* (`src/feature.ts` in a JavaScript-only game): the reason to refuse, else
|
|
235
|
+
* undefined. Only fires when the repository is monolingual — a mixed
|
|
236
|
+
* repository may legitimately grow a second language — and never for
|
|
237
|
+
* rewrites, config/data files or a language family sibling (a .ts repo
|
|
238
|
+
* adding .tsx is fine).
|
|
239
|
+
*/
|
|
240
|
+
export function foreignLanguageFile(roots, file, languages) {
|
|
241
|
+
const ext = path.extname(file).slice(1).toLowerCase();
|
|
242
|
+
const language = LANGUAGE_BY_EXT[ext];
|
|
243
|
+
if (!language || language === languages.primary || languages.others.length)
|
|
244
|
+
return undefined;
|
|
245
|
+
if (roots.some(root => existsSync(path.join(root, file))))
|
|
246
|
+
return undefined;
|
|
247
|
+
return `"${file}" is ${language}, but this repository is ${languages.primary}-only. Write the same behaviour in ${languages.primary} inside the existing modules; do not add a second language.`;
|
|
248
|
+
}
|
|
249
|
+
/** Existing test directories (relative, POSIX) among the conventional names, e.g. `tests`, `test`, `__tests__`, `spec`. */
|
|
250
|
+
export function existingTestDirs(roots) {
|
|
251
|
+
const names = ['tests', 'test', '__tests__', 'spec', 'specs'];
|
|
252
|
+
return [...new Set(roots.flatMap(root => names.filter(name => { try {
|
|
253
|
+
return statSync(path.join(root, name)).isDirectory();
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return false;
|
|
257
|
+
} })))];
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* A NEW test file outside the directory the repository already keeps its
|
|
261
|
+
* tests in (`src/hold.test.js` next to an existing `tests/`): the reason to
|
|
262
|
+
* refuse, else undefined. Co-located tests (`foo.test.js` beside `foo.js`)
|
|
263
|
+
* are accepted when the repository has no test directory at all.
|
|
264
|
+
*/
|
|
265
|
+
export function misplacedTestFile(roots, file) {
|
|
266
|
+
const normalized = file.replace(/\\/g, '/');
|
|
267
|
+
if (!/(?:^|\/)[^/]+\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)test_[^/]+\.py$|_test\.(?:go|py|rs|rb)$/.test(normalized))
|
|
268
|
+
return undefined;
|
|
269
|
+
if (roots.some(root => existsSync(path.join(root, file))))
|
|
270
|
+
return undefined;
|
|
271
|
+
const dirs = existingTestDirs(roots);
|
|
272
|
+
if (!dirs.length || dirs.some(dir => normalized === dir || normalized.startsWith(dir + '/')))
|
|
273
|
+
return undefined;
|
|
274
|
+
return `"${file}" is a test file outside the repository's test directory (${dirs.join(', ')}); write it under ${dirs[0]}/ so the test command finds it.`;
|
|
275
|
+
}
|
|
276
|
+
const TEST_FILE = /^(.+)\.(?:test|spec)\.[cm]?[jt]sx?$/;
|
|
277
|
+
/**
|
|
278
|
+
* For a NEW test file whose name extends a module's canonical test file
|
|
279
|
+
* (`tests/board.tick.test.ts` next to `tests/board.test.ts`), that canonical
|
|
280
|
+
* file; else undefined. The canonical file itself and rewrites never flag.
|
|
281
|
+
*/
|
|
282
|
+
export function siblingTestFile(roots, file) {
|
|
283
|
+
const base = path.basename(file), dir = path.dirname(file);
|
|
284
|
+
const stem = TEST_FILE.exec(base)?.[1];
|
|
285
|
+
if (!stem || !stem.includes('.'))
|
|
286
|
+
return undefined;
|
|
287
|
+
const module = stem.split('.')[0];
|
|
288
|
+
const canonical = new RegExp(`^${module.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\.(?:test|spec)\.[cm]?[jt]sx?$`);
|
|
289
|
+
for (const root of roots) {
|
|
290
|
+
if (existsSync(path.join(root, file)))
|
|
291
|
+
return undefined;
|
|
292
|
+
let entries = [];
|
|
293
|
+
try {
|
|
294
|
+
entries = readdirSync(path.join(root, dir));
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const existing = entries.find(entry => canonical.test(entry));
|
|
300
|
+
if (existing)
|
|
301
|
+
return (dir === '.' ? existing : `${dir}/${existing}`).split(path.sep).join('/');
|
|
302
|
+
}
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
const SIBLING_EXTENSIONS = { js: ['ts', 'tsx', 'jsx', 'mjs', 'cjs', 'mts', 'cts'], ts: ['tsx', 'js', 'jsx', 'mts', 'cts', 'mjs', 'cjs'], jsx: ['tsx', 'js', 'ts'], tsx: ['jsx', 'ts', 'js'], mjs: ['js', 'ts', 'mts'], cjs: ['js', 'ts', 'cts'], mts: ['ts', 'mjs'], cts: ['ts', 'cjs'] };
|
|
306
|
+
/** The absolute path a task-named file resolves to: the exact path, else the same stem in a sibling extension of the same language family. */
|
|
307
|
+
export function locateNamedFile(roots, file) {
|
|
308
|
+
for (const root of roots)
|
|
309
|
+
if (existsSync(path.join(root, file)))
|
|
310
|
+
return path.join(root, file);
|
|
311
|
+
const ext = path.extname(file).slice(1).toLowerCase();
|
|
312
|
+
const stem = ext ? file.slice(0, -ext.length - 1) : file;
|
|
313
|
+
for (const alternative of SIBLING_EXTENSIONS[ext] ?? [])
|
|
314
|
+
for (const root of roots)
|
|
315
|
+
if (existsSync(path.join(root, `${stem}.${alternative}`)))
|
|
316
|
+
return path.join(root, `${stem}.${alternative}`);
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
/** Extra tool calls a correction group gets on top of the task budget: it must read AND patch, and the feedback is long. */
|
|
320
|
+
export const CORRECTION_BUDGET_BONUS = 10;
|
|
321
|
+
/**
|
|
322
|
+
* Source excerpts around every `file:line` the feedback names (Jest/Babel
|
|
323
|
+
* `path/file.js: … (30:56)`, TS `file.ts(12,3)` / `file.ts:12:3`, mocha
|
|
324
|
+
* `at … (file.js:44:9)`), read by the HOST so a correction round starts with
|
|
325
|
+
* the offending lines in front of the model instead of spending its tool
|
|
326
|
+
* budget re-reading the whole repository (observed: 19 reads, 2 patches).
|
|
327
|
+
*/
|
|
328
|
+
export function feedbackExcerpts(feedback, roots, options = {}) {
|
|
329
|
+
const context = options.context ?? 15, maxBytes = options.maxBytes ?? 8000;
|
|
330
|
+
const refs = new Map();
|
|
331
|
+
const add = (file, line) => {
|
|
332
|
+
if (!/\.(?:[cm]?[jt]sx?|py|go|rs|java|kt|swift|cs|rb|php|c|cc|cpp|h|hpp|vue|svelte)$/.test(file) || /node_modules|^\/?openspec\//.test(file))
|
|
333
|
+
return;
|
|
334
|
+
const set = refs.get(file) ?? new Set();
|
|
335
|
+
set.add(line);
|
|
336
|
+
refs.set(file, set);
|
|
337
|
+
};
|
|
338
|
+
// Windows paths (`C:\...\RUNNER~1\file.js`, 8.3 short names included) are accepted and folded to `/` so the node_modules/openspec guards and the root lookup see one shape.
|
|
339
|
+
for (const match of feedback.matchAll(/((?:[A-Za-z]:)?(?:[\/\\]|[\w.~-]+[\/\\])?[\w.~\/\\-]+\.(?:[cm]?[jt]sx?|py|go|rs|java|kt|swift|cs|rb|php|c|cc|cpp|h|hpp|vue|svelte))(?::(\d+)(?::\d+)?|\((\d+),\d+\)|:[^\n]{0,160}?\((\d+):\d+\))/g)) {
|
|
340
|
+
const line = Number(match[2] ?? match[3] ?? match[4]);
|
|
341
|
+
if (Number.isFinite(line) && line > 0)
|
|
342
|
+
add(match[1].replace(/\\/g, '/'), line);
|
|
343
|
+
}
|
|
344
|
+
const chunks = [];
|
|
345
|
+
let bytes = 0;
|
|
346
|
+
for (const [file, lines] of refs) {
|
|
347
|
+
const absolute = path.isAbsolute(file) ? file : roots.map(root => path.join(root, file)).find(candidate => existsSync(candidate));
|
|
348
|
+
if (!absolute || !existsSync(absolute))
|
|
349
|
+
continue;
|
|
350
|
+
let text;
|
|
351
|
+
try {
|
|
352
|
+
text = readFileSync(absolute, 'utf8');
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
const all = text.split('\n');
|
|
358
|
+
const relative = (roots.map(root => path.relative(root, absolute)).find(rel => rel && !rel.startsWith('..')) ?? file).split(path.sep).join('/');
|
|
359
|
+
for (const line of [...lines].sort((a, b) => a - b).slice(0, 4)) {
|
|
360
|
+
const from = Math.max(1, line - context), to = Math.min(all.length, line + context);
|
|
361
|
+
const body = all.slice(from - 1, to).map((row, index) => `${String(from + index).padStart(4)}${from + index === line ? '>' : ' '} ${row}`).join('\n');
|
|
362
|
+
const chunk = `--- ${relative} (lines ${from}-${to}, error at ${line}) ---\n${body}`;
|
|
363
|
+
if (bytes + chunk.length > maxBytes)
|
|
364
|
+
return chunks.join('\n\n');
|
|
365
|
+
chunks.push(chunk);
|
|
366
|
+
bytes += chunk.length;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return chunks.join('\n\n');
|
|
370
|
+
}
|
|
371
|
+
/** An existing file this long is edited with apply_patch, never regenerated whole (observed: a 1.4k-line test rewritten twice in one group, ~25 minutes of generation). */
|
|
372
|
+
export const REWRITE_MAX_LINES = 150;
|
|
373
|
+
/** Above this many lines, `read_file` answers with an outline (head + index of definitions) instead of the whole file. */
|
|
374
|
+
export const OUTLINE_MIN_LINES = 400;
|
|
375
|
+
const OUTLINE_HEAD_LINES = 120;
|
|
376
|
+
const DEFINITION_LINE = /^\s*(?:(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*\w+|(?:export\s+)?class\s+\w+|(?:export\s+)?(?:const|let|var)\s+\w+\s*=\s*(?:async\s*)?(?:function\b|\([^)]*\)\s*=>|\w+\s*=>)|(?:static\s+|async\s+|get\s+|set\s+)?[A-Za-z_$][\w$]*\s*\([^)]*\)\s*\{|(?:describe|it|test|suite|context)\s*\(|def\s+\w+|func\s+(?:\([^)]*\)\s*)?\w+|(?:pub\s+)?fn\s+\w+)/;
|
|
377
|
+
/**
|
|
378
|
+
* A large file as an outline: the first lines (imports, top-level state) and
|
|
379
|
+
* an index of definitions with their line numbers, so the model asks for the
|
|
380
|
+
* range it needs with read_lines instead of putting 15k tokens of test file
|
|
381
|
+
* into the transcript on every read (observed: 4-minute prefills).
|
|
382
|
+
*/
|
|
383
|
+
export function outlineLargeFile(file, content) {
|
|
384
|
+
const lines = content.split('\n');
|
|
385
|
+
if (lines.length <= OUTLINE_MIN_LINES)
|
|
386
|
+
return undefined;
|
|
387
|
+
const index = [];
|
|
388
|
+
for (let i = OUTLINE_HEAD_LINES; i < lines.length && index.length < 120; i++)
|
|
389
|
+
if (DEFINITION_LINE.test(lines[i]))
|
|
390
|
+
index.push(`L${i + 1}: ${lines[i].trim().slice(0, 110)}`);
|
|
391
|
+
return JSON.stringify({ outline: true, path: file, totalLines: lines.length, head: lines.slice(0, OUTLINE_HEAD_LINES).join('\n'), definitions: index, note: `${file} has ${lines.length} lines; this is its first ${OUTLINE_HEAD_LINES} lines plus an index of definitions. Read only the range you need with read_lines(path, startLine, endLine); do not read the whole file.` });
|
|
392
|
+
}
|
|
393
|
+
/** Files a model writes to narrate its own work; never part of a task, always noise for the reviewer (observed: fix_summary.json, task-summary.json in the repository root). */
|
|
394
|
+
export const NARRATION_FILE = /(?:^|\/)(?:[\w.-]*(?:summary|progress|scratch|handoff)[\w.-]*)\.(?:json|md|txt)$/i;
|
|
395
|
+
/** Media/binary extensions a text-only tool can never produce correctly. */
|
|
396
|
+
export const BINARY_ASSET = /\.(?:wav|mp3|ogg|flac|m4a|aac|png|jpe?g|gif|webp|bmp|ico|woff2?|ttf|otf|eot|mp4|webm|mov|zip|gz|tar|pdf|wasm)$/i;
|
|
397
|
+
/** Extra write-only calls a task group gets when it spent its whole budget reading. */
|
|
398
|
+
export const WRITE_EXTENSION_CALLS = 6;
|
|
399
|
+
/** Tool calls the in-place fix round after a failed group check may spend. */
|
|
400
|
+
export const GROUP_FIX_BUDGET = 20;
|
|
401
|
+
/**
|
|
402
|
+
* One bounded correction round for a group whose check just failed: the exact
|
|
403
|
+
* output plus host-read excerpts around every reported file:line, the group's
|
|
404
|
+
* tasks as context, and the developer tools. Returns the files it wrote.
|
|
405
|
+
*/
|
|
406
|
+
async function fixGroupInPlace(env, group, output, budget, evidence, extraTools, languages, headline = `The repository's test command failed right after your changes. Fix the failure with the tools; patch the exact files and lines the output names (apply_patch, or rewrite the file); do not list or re-read the repository beyond those files`) {
|
|
407
|
+
const excerpts = feedbackExcerpts(output, env.toolset.roots);
|
|
408
|
+
const written = [];
|
|
409
|
+
try {
|
|
410
|
+
const loop = await toolStep(env, {
|
|
411
|
+
system: `${headline}; do not edit anything under openspec/. Finish with one JSON object: {"summary":"what you fixed","files":["path"],"tests":[],"verification":"none","incomplete":[]}.`,
|
|
412
|
+
user: `${/^Test file/.test(output) ? 'Host finding' : 'Failing test output'} (bounded):\n${bounded(output, 6000)}${excerpts ? `\n\nSource around each reported error (already read for you):\n${excerpts}` : ''}\n\nThe group you just implemented (group ${group.index}: ${group.title}):\n${group.tasks.map(task => `- ${task.id} ${task.text}`).join('\n')}\nEdit only inside: ${env.toolset.roots.join(', ')}.`,
|
|
413
|
+
tools: DEVELOPER_TOOLS, maxToolCalls: Math.min(budget, GROUP_FIX_BUDGET), extraTools: extraTools,
|
|
414
|
+
writeExtension: { tools: ['write_file', 'apply_patch'], extraCalls: 4 },
|
|
415
|
+
execute: async (name, args) => {
|
|
416
|
+
if ((name === 'write_file' || name === 'apply_patch') && typeof args.path === 'string' && FROZEN_PATH.test(args.path))
|
|
417
|
+
return JSON.stringify({ error: `"${args.path}" is under openspec/: the planning artifacts are frozen.` });
|
|
418
|
+
if (name === 'write_file' && typeof args.path === 'string' && BINARY_ASSET.test(args.path))
|
|
419
|
+
return JSON.stringify({ error: `"${args.path}" is a binary asset; there is no shell to generate or download media. Produce the effect in application code instead.` });
|
|
420
|
+
if (name === 'write_file' && typeof args.path === 'string' && NARRATION_FILE.test(args.path) && !/(?:^|\/)README\.md$/i.test(args.path))
|
|
421
|
+
return JSON.stringify({ error: `"${args.path}" is a narration file, not part of any task; do not write summary/progress/notes files.` });
|
|
422
|
+
if (name === 'write_file' && typeof args.path === 'string') {
|
|
423
|
+
const existing = env.toolset.roots.map(root => path.join(root, args.path)).find(file => existsSync(file));
|
|
424
|
+
if (existing && readFileSync(existing, 'utf8').split('\n').length > REWRITE_MAX_LINES)
|
|
425
|
+
return JSON.stringify({ error: `"${args.path}" already exists and is large: change it with apply_patch, never regenerate it whole.` });
|
|
426
|
+
}
|
|
427
|
+
if (name === 'read_file' && typeof args.path === 'string') {
|
|
428
|
+
const existing = env.toolset.roots.map(root => path.join(root, args.path)).find(file => existsSync(file));
|
|
429
|
+
if (existing) {
|
|
430
|
+
const outline = outlineLargeFile(args.path, readFileSync(existing, 'utf8'));
|
|
431
|
+
if (outline)
|
|
432
|
+
return outline;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (on(env, 'primary-language') && name === 'write_file' && typeof args.path === 'string' && languages) {
|
|
436
|
+
const foreign = foreignLanguageFile(env.toolset.roots, args.path, languages);
|
|
437
|
+
if (foreign)
|
|
438
|
+
return JSON.stringify({ error: foreign });
|
|
439
|
+
}
|
|
440
|
+
if (on(env, 'one-test-per-module') && name === 'write_file' && typeof args.path === 'string') {
|
|
441
|
+
const misplaced = misplacedTestFile(env.toolset.roots, args.path);
|
|
442
|
+
if (misplaced)
|
|
443
|
+
return JSON.stringify({ error: misplaced });
|
|
444
|
+
}
|
|
445
|
+
if (on(env, 'duplicate-sibling') && name === 'write_file' && typeof args.path === 'string' && languages) {
|
|
446
|
+
const twin = duplicateSibling(env.toolset.roots, args.path, languages.primary);
|
|
447
|
+
if (twin)
|
|
448
|
+
return JSON.stringify({ error: `"${args.path}" would duplicate "${twin}"; extend it instead.` });
|
|
449
|
+
}
|
|
450
|
+
if (name === 'read_verification_evidence' && evidence)
|
|
451
|
+
return JSON.stringify(readVerificationEvidence(evidence, args));
|
|
452
|
+
const outcome = await env.toolset.execute(name, args);
|
|
453
|
+
if ((name === 'write_file' || name === 'apply_patch') && typeof args.path === 'string' && !/"error"/.test(String(outcome).slice(0, 40)))
|
|
454
|
+
written.push(args.path);
|
|
455
|
+
return outcome;
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
void loop;
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
if (!(error instanceof AgentExecutionError) || (error.code !== 'tool_loop' && error.code !== 'invalid_response'))
|
|
462
|
+
throw error;
|
|
463
|
+
env.onEvent?.({ kind: 'text', text: `Group ${group.index} fix round stopped — ${error.message}.` });
|
|
464
|
+
}
|
|
465
|
+
return [...new Set(written)];
|
|
466
|
+
}
|
|
467
|
+
/** Test files among `reported` that the repository's own test command (per root) does not execute; empty when no command is detected. */
|
|
468
|
+
export function unreachedGroupTests(roots, reported) {
|
|
469
|
+
return roots.flatMap(root => { const check = detectCheckCommand(root); return check ? unreachedTestFiles(root, [check], reported) : []; });
|
|
470
|
+
}
|
|
471
|
+
/** A source file that exists but carries no implementation: a handful of lines, a placeholder import, or only TODOs. */
|
|
472
|
+
export function isStubFile(file, minLines = 15) {
|
|
473
|
+
let text = '';
|
|
474
|
+
try {
|
|
475
|
+
text = readFileSync(file, 'utf8');
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
if (/from ['"]\?\?\?['"]|\bTODO\b.*implement|throw new Error\(['"]not implemented/i.test(text))
|
|
481
|
+
return true;
|
|
482
|
+
if (/\.(?:test|spec)\.[cm]?[jt]sx?$|_test\.py$|^test_/.test(path.basename(file)))
|
|
483
|
+
return text.split('\n').filter(line => line.trim()).length < 5;
|
|
484
|
+
if (/\.(?:json|ya?ml|toml|md)$/.test(file) || /(?:^|[\\/])(?:index|mod)\.[cm]?[jt]sx?$/.test(file))
|
|
485
|
+
return false;
|
|
486
|
+
const code = text.split('\n').filter(line => line.trim() && !/^\s*(?:\/\/|#|\*|\/\*)/.test(line));
|
|
487
|
+
// A barrel (only re-exports / imports) is complete however short it is —
|
|
488
|
+
// observed: a 7-line src/exports.js re-declared a stub on every pass, the
|
|
489
|
+
// task never ticked and the developer rewrote it for 20 minutes.
|
|
490
|
+
if (code.length && code.every(line => /^\s*(?:export\s+(?:\*|\{|type\s*\{|default\s+\w+\s*;?$)[^;]*(?:from\s+['"][^'"]+['"])?\s*;?\s*$|export\s+\{[^}]*\}\s*;?\s*$|import\s|module\.exports\s*=|exports\.\w+\s*=|(?:const|let|var)\s+\w+\s*=\s*require\()/.test(line)))
|
|
491
|
+
return false;
|
|
492
|
+
return code.length < minLines;
|
|
493
|
+
}
|
|
494
|
+
/** Ticks the given task ids; every other byte of tasks.md is preserved, as the developer write rule requires. */
|
|
495
|
+
export function tickTasks(markdown, ids) {
|
|
496
|
+
return markdown.split('\n').map(line => {
|
|
497
|
+
const task = /^(\s*-\s+)\[ \](\s+)(\d+(?:\.\d+)?)(\s.*)?$/.exec(line);
|
|
498
|
+
return task && ids.has(task[3]) ? `${task[1]}[x]${task[2]}${task[3]}${task[4] ?? ''}` : line;
|
|
499
|
+
}).join('\n');
|
|
500
|
+
}
|
|
501
|
+
function readArtifact(status, name) {
|
|
502
|
+
try {
|
|
503
|
+
return readFileSync(artifactPath(status.changeRoot, name), 'utf8');
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
return '';
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* The compact developer: one bounded mini-loop per task group with the full
|
|
511
|
+
* developer tool set, the host ticking tasks.md and saving progress after each
|
|
512
|
+
* group, and the DEVELOPER_OUTPUT_SCHEMA object assembled from the group results.
|
|
513
|
+
*/
|
|
514
|
+
export async function runCompactDeveloper(env, options = {}) {
|
|
515
|
+
const budget = options.taskToolBudget ?? DEFAULT_TASK_TOOL_BUDGET;
|
|
516
|
+
const loaded = await openspecCall(env, { action: 'load_skill' });
|
|
517
|
+
const status = loaded.planning?.status ?? await openspecCall(env, { action: 'status' });
|
|
518
|
+
const tasksMarkdown = readArtifact(status, 'tasks.md');
|
|
519
|
+
const groups = parseTaskGroups(tasksMarkdown);
|
|
520
|
+
let pending = groups.filter(group => group.tasks.some(task => !task.done));
|
|
521
|
+
// A correction pass arrives with every task already ticked: without this the
|
|
522
|
+
// loop below runs nothing, reports "already checked" and the reviewer sees
|
|
523
|
+
// the same issues again (observed: three rounds, scores 82 → 58 → 42, zero
|
|
524
|
+
// edits). Turn the feedback into one synthetic correction group instead.
|
|
525
|
+
if (!pending.length && env.inputs.feedback && on(env, 'synthetic-corrections')) {
|
|
526
|
+
const diagnosed = diagnoseVerificationFailure(env.inputs.feedback, env.toolset.roots);
|
|
527
|
+
const items = [...diagnosed, ...env.inputs.feedback.split('\n').map(line => line.replace(/^[\s*\-•]+/, '').trim()).filter(line => /^(?:[\w./-]+\.\w+\s*[:(]|[A-Z(])/.test(line) && line.length > 20 && !/^##/.test(line))].slice(0, 8);
|
|
528
|
+
pending = [{ index: groups.length + 1, title: 'Review corrections', tasks: (items.length ? items : ['Address every issue listed in the feedback']).map((text, n) => ({ id: `R.${n + 1}`, text, done: false })) }];
|
|
529
|
+
}
|
|
530
|
+
const shared = [
|
|
531
|
+
`Requested work (frozen scope):\n${bounded(env.inputs.scope, 3000)}`,
|
|
532
|
+
`Design (excerpt):\n${bounded(readArtifact(status, 'design.md'), 3000)}`,
|
|
533
|
+
`Specs (excerpt):\n${bounded((status.artifactPaths.specs?.existingOutputPaths ?? []).map(file => { try {
|
|
534
|
+
return readFileSync(path.isAbsolute(file) ? file : path.join(status.changeRoot, file), 'utf8');
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
return '';
|
|
538
|
+
} }).join('\n\n'), 3000)}`,
|
|
539
|
+
...(env.inputs.feedback ? [`Feedback from the host (fix precisely):\n${bounded(env.inputs.feedback, 4000)}`] : []),
|
|
540
|
+
...(loaded.savedProgress?.record?.progress ? [`Saved progress from an earlier session (advisory):\n${bounded(JSON.stringify(loaded.savedProgress.record.progress), 1500)}`] : []),
|
|
541
|
+
].join('\n\n');
|
|
542
|
+
const evidence = env.request.openspec?.evidenceScope;
|
|
543
|
+
const extraTools = evidence ? [{ type: 'function', function: { name: 'read_verification_evidence', description: 'Read host verification evidence by opaque id (from the feedback) with bounded cursors.', parameters: { type: 'object', additionalProperties: false, properties: { id: { type: 'string' }, section: { type: 'string', enum: ['summary', 'stdout', 'stderr', 'source'] }, sourceId: { type: 'string' }, cursor: { type: 'string' }, limit: { type: 'integer', minimum: 1, maximum: 100 } } } } }] : [];
|
|
544
|
+
const summaries = [], files = new Set(), tests = new Set(), verification = [], incomplete = [], completed = [];
|
|
545
|
+
for (const group of pending) {
|
|
546
|
+
const open = group.tasks.filter(task => !task.done);
|
|
547
|
+
// A small model sometimes gives up on a late group with an excuse the disk
|
|
548
|
+
// contradicts ("no source files to test against" while src/ is populated).
|
|
549
|
+
// The host does not accept that excuse: it retries the group ONCE with the
|
|
550
|
+
// real inventory in front of the model. A second surrender stands.
|
|
551
|
+
let contradiction = '';
|
|
552
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
553
|
+
const correction = group.title === 'Review corrections' || env.stance === 'fixer';
|
|
554
|
+
const groupBudget = correction ? budget + CORRECTION_BUDGET_BONUS : budget;
|
|
555
|
+
const excerpts = correction && env.inputs.feedback ? feedbackExcerpts(env.inputs.feedback, env.toolset.roots) : '';
|
|
556
|
+
// A "tests" group that arrives after every earlier group already wrote its
|
|
557
|
+
// tests must extend them, not rewrite them (observed: smoke.test.js and
|
|
558
|
+
// game-logic.test.js rewritten twice in the final group).
|
|
559
|
+
const existingTests = /test|spec|verif/i.test(group.title) && !correction ? repositoryInventory(env.toolset.roots, 200).filter(file => /(?:\.|_|\/)(?:test|spec)s?[./]/i.test(file)) : [];
|
|
560
|
+
const testsNote = existingTests.length ? `\n\nTest files that already exist (written by earlier groups): ${existingTests.slice(0, 20).join(', ')}. EXTEND them with the missing cases (read, then apply_patch); do not rewrite them and do not create parallel files for the same module.` : '';
|
|
561
|
+
const correctionNote = correction ? `\n\nThis is a CORRECTION round: the feedback names exact files and lines. Patch those lines directly with apply_patch (or rewrite the file); do not list or re-read the repository beyond the files named.${excerpts ? `\n\nSource around each reported error (already read for you):\n${excerpts}` : ''}` : '';
|
|
562
|
+
// The FIXER stance for correction rounds: the verifier's exact output and
|
|
563
|
+
// the host-read excerpts lead, only the plan tasks whose files the failure
|
|
564
|
+
// names follow, and neither the frozen scope/design/specs dump nor the
|
|
565
|
+
// OpenSpec apply guidance is sent — a correction is a repair, not an
|
|
566
|
+
// implementation (observed: the developer re-read every file before its
|
|
567
|
+
// first patch when the plan dump preceded the feedback).
|
|
568
|
+
const fixerFiles = correction && env.inputs.feedback ? new Set(feedbackExcerpts(env.inputs.feedback, env.toolset.roots).match(/^--- (\S+) \(/gm)?.map(line => line.slice(4, -2)) ?? []) : new Set();
|
|
569
|
+
const relevantTasks = correction ? groups.flatMap(other => other.tasks).filter(task => namedFiles(task.text).some(file => [...fixerFiles].some(hit => hit.endsWith(file) || file.endsWith(hit)))).slice(0, 6) : [];
|
|
570
|
+
// The stance text is the host-editable fixer definition (Settings ▸ Agent
|
|
571
|
+
// prompts ▸ Fixer) when the prompt carries one; the compact mechanics
|
|
572
|
+
// (which tools, the openspec/ freeze, the JSON contract) are appended here.
|
|
573
|
+
const fixerStance = env.stance === 'fixer' && env.inputs.definition ? env.inputs.definition : `You are the FIXER of a Specrails pipeline. The repository's verification just failed; your only job is to make it pass with minimal, precise edits. Read the failing output and the excerpts first; patch exactly the files and lines they name; never re-implement features, never rename or restructure.`;
|
|
574
|
+
const fixerSystem = `${fixerStance}\n\nMechanics: use apply_patch for exact fragments (rewrite a file only when a patch cannot express the change); never list or read the repository beyond the files the failure names; never edit anything under openspec/. Finish with one JSON object: {"summary":"what you fixed","files":["path"],"tests":["test path"],"verification":"none","incomplete":[{"task":"${open[0].id}","reason":"why"}]} listing under "incomplete" only failures you could not fix.`;
|
|
575
|
+
const fixerUser = correction ? `Verification output (exact, bounded):\n${bounded(env.inputs.feedback ?? '', 6000)}${excerpts ? `\n\nSource around each reported error (already read for you):\n${excerpts}` : ''}${relevantTasks.length ? `\n\nPlan tasks that cover the failing files (context, already implemented):\n${relevantTasks.map(task => `- ${task.id} ${task.text.slice(0, 300)}`).join('\n')}` : ''}\n\nFailures to fix (group ${group.index}: ${group.title}):\n${open.map(task => `- ${task.id} ${task.text}`).join('\n')}\nEdit only inside: ${env.toolset.roots.join(', ')}.` : '';
|
|
576
|
+
// Each task group gets the full wall-clock budget: the previous groups'
|
|
577
|
+
// work is already ticked and verified, so a slow third group must not be
|
|
578
|
+
// paid for with the first two's minutes.
|
|
579
|
+
env.resetDeadline?.();
|
|
580
|
+
env.onEvent?.({ kind: 'text', text: `Compact ${correction ? 'fixer' : 'developer'}: task group ${group.index} (${group.title}) — ${open.length} task${open.length === 1 ? '' : 's'}, ${groupBudget} tool calls.${attempt ? ' (retry: the previous excuse contradicted the repository)' : ''}` });
|
|
581
|
+
// Recomputed per group: the scaffold group decides the language the later
|
|
582
|
+
// groups must follow (observed: a TS scaffold, then board.js/piece.js twins
|
|
583
|
+
// of index.ts in the next group). Advisory in prose, enforced only for twins.
|
|
584
|
+
const languages = languageProfile(env.toolset.roots);
|
|
585
|
+
const languageNote = languages && on(env, 'primary-language') ? `\n\nPrimary language: ${languages.primary}${languages.others.length ? ` (also present: ${languages.others.join(', ')})` : ''}. New source files use the primary language unless the task or an existing sibling file says otherwise; never re-implement an existing module in another language.` : '';
|
|
586
|
+
let loop;
|
|
587
|
+
// Files this group wrote, tracked at the tool boundary so a group that
|
|
588
|
+
// ends without any reply still gets credited for what landed on disk.
|
|
589
|
+
const groupWrites = [];
|
|
590
|
+
try {
|
|
591
|
+
loop = await toolStep(env, {
|
|
592
|
+
system: correction ? fixerSystem : `Implement the listed tasks in the repository with the tools. Read before you edit; write complete files with write_file or exact fragments with apply_patch; keep changes minimal and consistent with the existing code; add or extend tests for what you change. Every test file you create must be executed by the repository's test command: when you add one, wire it into the test script or runner config in the same group — a test nobody runs proves nothing. There is NO shell here: you cannot run scripts, install packages, download or generate binary assets (audio, images, fonts) — anything that must exist at runtime is produced by application code (for example synthesize sounds with the Web Audio API instead of shipping .wav files), and a generator script you cannot run is worthless. Do not edit anything under openspec/. Finish with one JSON object: {"summary":"what you changed","files":["path"],"tests":["test path"],"verification":"commands you could not run: none","incomplete":[{"task":"${open[0].id}","reason":"why"}]} listing under "incomplete" only tasks you did not finish.`,
|
|
593
|
+
user: correction ? fixerUser : `${shared}${contradiction}${languageNote}${testsNote}${correctionNote}\n\nTasks for this step (group ${group.index}: ${group.title}):\n${open.map(task => `- ${task.id} ${task.text}`).join('\n')}\nEdit only inside: ${env.toolset.roots.join(', ')}.`,
|
|
594
|
+
tools: DEVELOPER_TOOLS, maxToolCalls: groupBudget, extraTools,
|
|
595
|
+
writeExtension: { tools: ['write_file', 'apply_patch'], extraCalls: WRITE_EXTENSION_CALLS },
|
|
596
|
+
execute: async (name, args) => {
|
|
597
|
+
if (on(env, 'frozen-plan-writes') && (name === 'write_file' || name === 'apply_patch') && typeof args.path === 'string' && FROZEN_PATH.test(args.path))
|
|
598
|
+
return JSON.stringify({ error: `"${args.path}" is under openspec/: the planning artifacts are frozen. Implement the task in application source and test files instead (for example src/ or tests/).` });
|
|
599
|
+
// The plan, design and specs are already in this prompt: browsing
|
|
600
|
+
// openspec/ spends the budget on what the model was just given
|
|
601
|
+
// (observed: 5 of 25 calls listing and reading the change directory).
|
|
602
|
+
if ((name === 'read_file' || name === 'list_files' || name === 'read_lines' || name === 'search_text') && typeof args.path === 'string' && FROZEN_PATH.test(args.path + '/'))
|
|
603
|
+
return JSON.stringify({ error: `"${args.path}" is the frozen plan you already received in this prompt (scope, design, specs, tasks). Do not read openspec/; spend the budget on application files.` });
|
|
604
|
+
// An empty write_file over a populated file is a deletion in disguise (observed:
|
|
605
|
+
// a correction pass left src/index.ts at 0 lines). Ask for intent instead.
|
|
606
|
+
if (on(env, 'empty-write') && name === 'write_file' && typeof args.path === 'string' && typeof args.content === 'string' && !args.content.trim()) {
|
|
607
|
+
const target = env.toolset.roots.map(root => path.join(root, args.path)).find(file => existsSync(file));
|
|
608
|
+
if (target && readFileSync(target, 'utf8').trim())
|
|
609
|
+
return JSON.stringify({ error: `"${args.path}" already has content; an empty write_file would erase it. Write the full new content, or apply_patch the fragment you want to change.` });
|
|
610
|
+
}
|
|
611
|
+
// One test file per module: a small model otherwise adds board.tick.test.ts,
|
|
612
|
+
// tick-verification.test.ts… on every correction pass (observed: 10 suites for 7 modules).
|
|
613
|
+
if (on(env, 'one-test-per-module') && name === 'write_file' && typeof args.path === 'string') {
|
|
614
|
+
const sibling = siblingTestFile(env.toolset.roots, args.path);
|
|
615
|
+
if (sibling)
|
|
616
|
+
return JSON.stringify({ error: `"${args.path}" would add a second test file for the module "${sibling}" already covers. Extend "${sibling}" (read it, then write_file or apply_patch it) instead of creating a new test file.` });
|
|
617
|
+
}
|
|
618
|
+
// primary-language, enforced: a monolingual repository never grows a
|
|
619
|
+
// second language through a stub (observed: `src/feature.ts` with
|
|
620
|
+
// `export const feature = 2` in a vanilla-JS game, twice).
|
|
621
|
+
// No shell ⇒ no way to produce real binary assets: a `write_file` of a
|
|
622
|
+
// .wav/.png/… lands a 4-byte "RIFF" stub (observed) and a generator
|
|
623
|
+
// script nobody can run. Refuse with the runtime alternative.
|
|
624
|
+
if (name === 'write_file' && typeof args.path === 'string' && BINARY_ASSET.test(args.path))
|
|
625
|
+
return JSON.stringify({ error: `"${args.path}" is a binary asset; write_file only writes UTF-8 text and there is no shell to generate or download media. Produce the effect in application code instead (synthesize audio with the Web Audio API, draw graphics on the canvas), or make the feature fail open when the asset is absent.` });
|
|
626
|
+
if (name === 'write_file' && typeof args.path === 'string' && NARRATION_FILE.test(args.path) && !/(?:^|\/)README\.md$/i.test(args.path))
|
|
627
|
+
return JSON.stringify({ error: `"${args.path}" is a narration file, not part of any task; the host records your summary from your final JSON reply. Do not write summary/progress/notes files.` });
|
|
628
|
+
if (name === 'write_file' && typeof args.path === 'string') {
|
|
629
|
+
const existing = env.toolset.roots.map(root => path.join(root, args.path)).find(file => existsSync(file));
|
|
630
|
+
if (existing) {
|
|
631
|
+
const lines = readFileSync(existing, 'utf8').split('\n').length;
|
|
632
|
+
if (lines > REWRITE_MAX_LINES)
|
|
633
|
+
return JSON.stringify({ error: `"${args.path}" already exists with ${lines} lines: do not regenerate it whole. Read the region you need with read_lines and change it with apply_patch (exact oldText → newText); several small patches are fine.` });
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if (name === 'read_file' && typeof args.path === 'string') {
|
|
637
|
+
const existing = env.toolset.roots.map(root => path.join(root, args.path)).find(file => existsSync(file));
|
|
638
|
+
if (existing) {
|
|
639
|
+
const outline = outlineLargeFile(args.path, readFileSync(existing, 'utf8'));
|
|
640
|
+
if (outline)
|
|
641
|
+
return outline;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
if (name === 'list_files' && typeof args.path === 'string') {
|
|
645
|
+
const listed = await env.toolset.execute(name, args);
|
|
646
|
+
try {
|
|
647
|
+
const parsed = JSON.parse(listed);
|
|
648
|
+
if (Array.isArray(parsed.entries))
|
|
649
|
+
return JSON.stringify({ ...parsed, entries: parsed.entries.filter(entry => entry.name !== 'openspec') });
|
|
650
|
+
}
|
|
651
|
+
catch { /* not json */ }
|
|
652
|
+
return listed;
|
|
653
|
+
}
|
|
654
|
+
if (on(env, 'primary-language') && name === 'write_file' && typeof args.path === 'string' && languages) {
|
|
655
|
+
const foreign = foreignLanguageFile(env.toolset.roots, args.path, languages);
|
|
656
|
+
if (foreign)
|
|
657
|
+
return JSON.stringify({ error: foreign });
|
|
658
|
+
}
|
|
659
|
+
// A new test file goes where the repository keeps its tests (observed: `src/hold.test.js` beside an existing `tests/`, never run by the test script).
|
|
660
|
+
if (on(env, 'one-test-per-module') && name === 'write_file' && typeof args.path === 'string') {
|
|
661
|
+
const misplaced = misplacedTestFile(env.toolset.roots, args.path);
|
|
662
|
+
if (misplaced)
|
|
663
|
+
return JSON.stringify({ error: misplaced });
|
|
664
|
+
}
|
|
665
|
+
if (on(env, 'duplicate-sibling') && name === 'write_file' && typeof args.path === 'string' && languages) {
|
|
666
|
+
const twin = duplicateSibling(env.toolset.roots, args.path, languages.primary);
|
|
667
|
+
if (twin)
|
|
668
|
+
return JSON.stringify({ error: `"${args.path}" would duplicate "${twin}" in ${languages.primary}, the repository's primary language. Extend "${twin}" (read it, then write_file or apply_patch it) instead of re-implementing it in another language.` });
|
|
669
|
+
}
|
|
670
|
+
if (name === 'read_verification_evidence' && evidence)
|
|
671
|
+
return JSON.stringify(readVerificationEvidence(evidence, args));
|
|
672
|
+
const outcome = await env.toolset.execute(name, args);
|
|
673
|
+
if ((name === 'write_file' || name === 'apply_patch') && typeof args.path === 'string' && !/"error"/.test(String(outcome).slice(0, 40)))
|
|
674
|
+
groupWrites.push(args.path);
|
|
675
|
+
return outcome;
|
|
676
|
+
},
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
catch (error) {
|
|
680
|
+
// A small model stuck re-reading the same file (tool_loop), or one that
|
|
681
|
+
// answers nothing even after the nudge (invalid_response), must not sink
|
|
682
|
+
// a step whose earlier groups already produced verified code: close THIS
|
|
683
|
+
// group as incomplete, credit the files it did write, and let the
|
|
684
|
+
// workflow continue (observed: a 9B developer wrote five modules, then
|
|
685
|
+
// went silent and the whole run failed).
|
|
686
|
+
if (!(error instanceof AgentExecutionError) || (error.code !== 'tool_loop' && !(error.code === 'invalid_response' && on(env, 'silent-group-closure'))))
|
|
687
|
+
throw error;
|
|
688
|
+
const written = [...new Set(groupWrites)];
|
|
689
|
+
env.onEvent?.({ kind: 'text', text: `Compact developer: group ${group.index} stopped — ${error.message}; ${written.length ? `recorded ${written.length} written file${written.length === 1 ? '' : 's'}, ` : ''}its tasks stay open.` });
|
|
690
|
+
incomplete.push(...open.slice(0, 20).map(task => ({ task: task.id, reason: error.message })));
|
|
691
|
+
summaries.push(`Group ${group.index} (${group.title}): stopped — ${error.message}${written.length ? `; wrote ${written.join(', ')}` : ''}`);
|
|
692
|
+
for (const file of written)
|
|
693
|
+
files.add(file);
|
|
694
|
+
for (const file of written.filter(file => /(?:\.|_|\/)(?:test|spec)s?[./]/i.test(file)))
|
|
695
|
+
tests.add(file);
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
let result;
|
|
699
|
+
try {
|
|
700
|
+
result = await finalJson(env, 'task', loop.messages, loop.text, TASK_RESULT_SCHEMA, value => text(value.summary) ? undefined : '"summary" must be a non-empty string');
|
|
701
|
+
}
|
|
702
|
+
catch (error) {
|
|
703
|
+
// A small model sometimes ends a group with prose (or nothing) instead of
|
|
704
|
+
// the result object, even after the structured retry. The work it did is
|
|
705
|
+
// still on disk: reconstruct the result from the successful write calls
|
|
706
|
+
// and leave the group's tasks open, so the graph continues instead of
|
|
707
|
+
// failing a step whose earlier groups already landed code.
|
|
708
|
+
if (!(error instanceof AgentExecutionError) || error.code !== 'invalid_response')
|
|
709
|
+
throw error;
|
|
710
|
+
const written = writtenFiles(loop.messages);
|
|
711
|
+
env.onEvent?.({ kind: 'text', text: `Compact developer: group ${group.index} gave no structured result; recorded ${written.length} written file${written.length === 1 ? '' : 's'} and left its tasks open.` });
|
|
712
|
+
result = { summary: `Group ${group.index}: no structured reply; ${written.length ? `wrote ${written.join(', ')}` : 'no files written'}.`, files: written, tests: written.filter(file => /(?:\.|_|\/)(?:test|spec)s?[./]/i.test(file)), verification: 'none', incomplete: open.map(task => ({ task: task.id, reason: 'the model ended the step without a structured result' })) };
|
|
713
|
+
}
|
|
714
|
+
const unfinished = (Array.isArray(result.incomplete) ? result.incomplete : []).flatMap(item => {
|
|
715
|
+
const entry = item && typeof item === 'object' && !Array.isArray(item) ? item : undefined;
|
|
716
|
+
return entry && text(entry.task) ? [{ task: text(entry.task).slice(0, 1000), reason: text(entry.reason, 'not completed').slice(0, 2000) }] : [];
|
|
717
|
+
});
|
|
718
|
+
if (attempt === 0 && on(env, 'inventory-retry') && unfinished.length && strings(result.files, 100, 500).length === 0) {
|
|
719
|
+
const claimsEmpty = unfinished.some(item => /\bno\b[^.;:]{0,40}\b(?:files?|code|implementation|engine|modules?|sources?)\b|(?:is|are) missing|does not exist|not (?:been )?(?:implemented|created|found|present)|nothing to (?:test|build)/i.test(item.reason));
|
|
720
|
+
const inventory = repositoryInventory(env.toolset.roots);
|
|
721
|
+
if (claimsEmpty && inventory.length) {
|
|
722
|
+
contradiction = `\n\nThe repository is NOT empty. It already contains these application files (read them, then implement the task on top of them):\n${inventory.map(file => `- ${file}`).join('\n')}\nYour previous reply claimed otherwise; that excuse is not accepted.`;
|
|
723
|
+
env.onEvent?.({ kind: 'text', text: `Compact developer: group ${group.index} claimed the repository lacks the code it needs, but ${inventory.length} application file${inventory.length === 1 ? '' : 's'} exist; retrying with the inventory.` });
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
const unfinishedIds = new Set(open.filter(task => unfinished.some(item => item.task.includes(task.id) || task.text.includes(item.task))).map(task => task.id));
|
|
728
|
+
// Evidence-gated ticking: a task that names files is done only when those
|
|
729
|
+
// files exist. A model that reports "done" for src/game.js it never wrote
|
|
730
|
+
// would otherwise tick the task and starve the next verify of a reason.
|
|
731
|
+
const missingEvidence = new Map();
|
|
732
|
+
for (const task of open) {
|
|
733
|
+
if (unfinishedIds.has(task.id) || !on(env, 'evidence-gated-ticking'))
|
|
734
|
+
continue;
|
|
735
|
+
// A plan written before any code exists may say `src/engine.js` while the
|
|
736
|
+
// developer (rightly) followed the repository's primary language and
|
|
737
|
+
// wrote `src/engine.ts`: the same module in a sibling extension is evidence.
|
|
738
|
+
const files = namedFiles(task.text);
|
|
739
|
+
const located = new Map(files.map(file => [file, locateNamedFile(env.toolset.roots, file)]));
|
|
740
|
+
const missing = files.filter(file => !located.get(file));
|
|
741
|
+
const stubs = files.filter(file => located.get(file) && isStubFile(located.get(file)));
|
|
742
|
+
if (missing.length || stubs.length) {
|
|
743
|
+
missingEvidence.set(task.id, [...missing, ...stubs.map(file => `${file} (stub)`)]);
|
|
744
|
+
unfinishedIds.add(task.id);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
for (const [id, missing] of missingEvidence) {
|
|
748
|
+
incomplete.push({ task: id, reason: `named files are missing or still stubs: ${missing.join(', ')}` });
|
|
749
|
+
env.onEvent?.({ kind: 'text', text: `Compact developer: task ${id} stays open — ${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} missing or still a stub.` });
|
|
750
|
+
}
|
|
751
|
+
const done = open.filter(task => !unfinishedIds.has(task.id));
|
|
752
|
+
summaries.push(`Group ${group.index} (${group.title}): ${text(result.summary).slice(0, 4000)}`);
|
|
753
|
+
const groupFiles = new Set(strings(result.files, 100, 500));
|
|
754
|
+
for (const file of groupFiles)
|
|
755
|
+
files.add(file);
|
|
756
|
+
// Manifests written by this group get their dependencies installed by the host, so verify checks the code and not the environment.
|
|
757
|
+
if (on(env, 'environment-repair') && [...groupFiles].some(file => /(?:^|[\\/])(?:package\.json|requirements\.txt|pyproject\.toml|go\.mod|Cargo\.toml)$/.test(file)))
|
|
758
|
+
for (const outcome of installEnvironment(env.toolset.roots, { onEvent: env.onEvent, lockfileRepair: on(env, 'lockfile-repair') }))
|
|
759
|
+
verification.push(outcome.detail);
|
|
760
|
+
for (const file of strings(result.tests, 100, 500))
|
|
761
|
+
tests.add(file);
|
|
762
|
+
if (text(result.verification))
|
|
763
|
+
verification.push(text(result.verification).slice(0, 500));
|
|
764
|
+
// verify-per-group: run the repository's own test command now and let the
|
|
765
|
+
// SAME group fix a failure while its context is fresh (one bounded round),
|
|
766
|
+
// instead of discovering five broken suites at the end of the step.
|
|
767
|
+
if (on(env, 'verify-per-group') && groupFiles.size && !correction) {
|
|
768
|
+
const check = runGroupCheck(env.toolset.roots, { onEvent: env.onEvent });
|
|
769
|
+
if (check.ran && !check.ok) {
|
|
770
|
+
env.onEvent?.({ kind: 'text', text: `Group ${group.index} check failed (${check.command}); fixing in place before the next group.` });
|
|
771
|
+
const fixed = await fixGroupInPlace(env, group, check.output, budget, evidence, extraTools, languages);
|
|
772
|
+
for (const file of fixed) {
|
|
773
|
+
files.add(file);
|
|
774
|
+
groupFiles.add(file);
|
|
775
|
+
}
|
|
776
|
+
const again = runGroupCheck(env.toolset.roots, { onEvent: env.onEvent });
|
|
777
|
+
env.onEvent?.({ kind: 'text', text: again.ok ? `Group ${group.index} check passed after the fix.` : `Group ${group.index} check still failing (${again.command}); the host verify will report it.` });
|
|
778
|
+
verification.push(`group ${group.index} check ${again.ok ? 'passed' : 'failed'}: ${again.command ?? ''}`);
|
|
779
|
+
}
|
|
780
|
+
else if (check.ran) {
|
|
781
|
+
env.onEvent?.({ kind: 'text', text: `Group ${group.index} check passed (${check.command}).` });
|
|
782
|
+
// test-reachability, at group altitude: the check passed, but does it
|
|
783
|
+
// RUN the test files this group wrote? An enumerating test script
|
|
784
|
+
// skips a new file silently (observed: a browser suite with six
|
|
785
|
+
// failing cases shipped green). Ask the same group to wire it in while
|
|
786
|
+
// its context is fresh; the host verify re-checks at the end regardless.
|
|
787
|
+
if (on(env, 'test-reachability')) {
|
|
788
|
+
const unreached = unreachedGroupTests(env.toolset.roots, [...groupFiles, ...strings(result.tests, 100, 500)]);
|
|
789
|
+
if (unreached.length) {
|
|
790
|
+
env.onEvent?.({ kind: 'text', text: `Group ${group.index} wrote ${unreached.join(', ')} but ${check.command} does not run ${unreached.length === 1 ? 'it' : 'them'}; asking the developer to wire ${unreached.length === 1 ? 'it' : 'them'} in.` });
|
|
791
|
+
const fixed = await fixGroupInPlace(env, group, `${unreachedTestsReason(unreached)}\nThe repository's test command is: ${check.command}.`, budget, evidence, extraTools, languages, `The repository's test command does not execute test files you just wrote. Wire them into the test command (edit the package.json "test" script or the runner configuration so every test file runs), then make them pass; read only the manifest, the runner config and the test files named`);
|
|
792
|
+
for (const file of fixed) {
|
|
793
|
+
files.add(file);
|
|
794
|
+
groupFiles.add(file);
|
|
795
|
+
}
|
|
796
|
+
const again = runGroupCheck(env.toolset.roots, { onEvent: env.onEvent });
|
|
797
|
+
const still = unreachedGroupTests(env.toolset.roots, unreached);
|
|
798
|
+
env.onEvent?.({ kind: 'text', text: still.length ? `Group ${group.index}: ${still.join(', ')} still not run by the test command; the host verify will report it.` : again.ok ? `Group ${group.index}: tests wired in and the check passed.` : `Group ${group.index}: tests wired in but the check now fails (${again.command}); the host verify will report it.` });
|
|
799
|
+
verification.push(`group ${group.index} test reachability ${still.length ? `unresolved: ${still.join(', ')}` : 'ok'}${again.ran && !again.ok ? `; check failed: ${again.command ?? ''}` : ''}`);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
incomplete.push(...unfinished.slice(0, 20));
|
|
805
|
+
completed.push(...done.map(task => `${task.id} ${task.text}`));
|
|
806
|
+
const real = done.filter(task => !task.id.startsWith('R.'));
|
|
807
|
+
if (real.length)
|
|
808
|
+
await openspecCall(env, { action: 'write_artifact', path: 'tasks.md', content: tickTasks(readArtifact(status, 'tasks.md'), new Set(real.map(task => task.id))) });
|
|
809
|
+
try {
|
|
810
|
+
await openspecCall(env, { action: 'write_progress', progress: { summary: text(result.summary, 'No summary').slice(0, 1600), completedTasks: completed.slice(-20).map(item => item.slice(0, 400)), nextTasks: pending.filter(other => other.index > group.index).flatMap(other => other.tasks.filter(task => !task.done).map(task => `${task.id} ${task.text}`.slice(0, 400))).slice(0, 20), checks: [], blockers: unfinished.map(item => `${item.task}: ${item.reason}`.slice(0, 500)).slice(0, 12) } });
|
|
811
|
+
}
|
|
812
|
+
catch (error) {
|
|
813
|
+
env.onEvent?.({ kind: 'text', text: `Progress handoff not saved: ${error instanceof Error ? error.message : String(error)}` });
|
|
814
|
+
}
|
|
815
|
+
break;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
if (!pending.length)
|
|
819
|
+
summaries.push('Every task in tasks.md was already checked; nothing to implement.');
|
|
820
|
+
const structured = { summary: summaries.join('\n'), files: [...files], tests: [...tests], verification: verification.length ? verification.join('; ') : 'none', incomplete };
|
|
821
|
+
return { text: JSON.stringify(structured), usage: env.client.usage, structured };
|
|
822
|
+
}
|
|
823
|
+
//# sourceMappingURL=developer.js.map
|