specrails-core 6.6.0 → 6.6.2
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.
|
@@ -414,7 +414,7 @@ export class CliExecutor {
|
|
|
414
414
|
request.onEvent?.({ kind: 'usage', usage: parsed.usage });
|
|
415
415
|
if (parsed.failureKind) {
|
|
416
416
|
const message = parsed.failureKind === 'max_turns'
|
|
417
|
-
? `${this.provider} reached the configured limit of ${request.maxTurns ?? 100} turns for ${request.role}.
|
|
417
|
+
? `${this.provider} reached the configured limit of ${request.maxTurns ?? 100} turns for ${request.role}. Raise maxTurns for role ${request.role}${request.maxTurns === undefined ? ' (unset: Core default 100)' : ''} and start a new run; partial changes stay in the worktree for inspection.`
|
|
418
418
|
: parsed.failureKind === 'cost_budget' ? `${this.provider} reached its configured cost budget.` : `${this.provider} exhausted structured output retries.`;
|
|
419
419
|
throw new AgentExecutionError(message, parsed.failureKind, parsed.usage);
|
|
420
420
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { archivedOpenSpecChange, archiveOpenSpecChange } from '../../graph/artifacts.js';
|
|
2
|
+
import { describeArchivePreflight, previewOpenSpecArchive } from '../../graph/archive-preflight.js';
|
|
2
3
|
import { OpenSpecCommandError, resolveOpenSpecCli, runOpenSpec } from '../../openspec.js';
|
|
3
4
|
import { EngineError } from '../contracts.js';
|
|
4
5
|
import { boundedText, json, paramsSchema, text } from './shared.js';
|
|
@@ -39,7 +40,16 @@ export function openSpecPieces(bindings) {
|
|
|
39
40
|
const item = report.items?.find(entry => entry.id === change && entry.type === 'change');
|
|
40
41
|
if (!item || typeof item.valid !== 'boolean')
|
|
41
42
|
throw new EngineError('openspec_invalid_output', 'OpenSpec validation did not report the requested change');
|
|
42
|
-
|
|
43
|
+
// The planning role only learns why it was sent back through history.
|
|
44
|
+
const note = (suffix, value) => [{ id: context.frame.attemptId + ':' + suffix, transition: context.frame.transition, attempt: context.frame.attempt, ordinal: 0, nodePath: context.frame.nodePath, text: boundedText(value) }];
|
|
45
|
+
if (!exited || !item.valid) {
|
|
46
|
+
const issues = (item.issues ?? []).filter(issue => issue.level === 'ERROR').slice(0, 20).map(issue => `- ${issue.path ? issue.path + ': ' : ''}${issue.message ?? 'invalid'}`);
|
|
47
|
+
return { outcome: 'fail', output: json(report), history: note('validate', [`OpenSpec validation failed for change ${change}.`, ...issues].join('\n')) };
|
|
48
|
+
}
|
|
49
|
+
const preflight = await previewOpenSpecArchive(deps.context.artifactRoot, change, context.signal);
|
|
50
|
+
if (!preflight.valid)
|
|
51
|
+
return { outcome: 'fail', output: json({ ...report, archivePreflight: preflight }), history: note('archive-preflight', describeArchivePreflight(change, preflight)) };
|
|
52
|
+
return { outcome: 'pass', output: json(report) };
|
|
43
53
|
},
|
|
44
54
|
}, {
|
|
45
55
|
descriptor: { kind: 'openspec-archive', paramsSchema: { ...schema, properties: { ...schema.properties, requiresVerified: { type: 'boolean' }, reviewedCandidate: { type: 'string' }, approvedCandidate: { type: 'string' } } }, outcomes: ['next', 'failed'], effect: 'write', requiresAI: false },
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface ArchiveRequirementFinding {
|
|
2
|
+
path: string;
|
|
3
|
+
line: number;
|
|
4
|
+
requirement: string;
|
|
5
|
+
}
|
|
6
|
+
export type ArchivePreflight = {
|
|
7
|
+
valid: true;
|
|
8
|
+
} | {
|
|
9
|
+
valid: false;
|
|
10
|
+
reason: string;
|
|
11
|
+
findings: ArchiveRequirementFinding[];
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Requirements OpenSpec rejects when it rebuilds a main spec on archive: its spec parser reads only
|
|
15
|
+
* the first non-empty body line before any heading and needs SHALL or MUST there. Delta validation
|
|
16
|
+
* skips `**Key**:` metadata lines, so a delta (or a pre-existing main-spec requirement) can pass
|
|
17
|
+
* `openspec validate` and still abort the archive.
|
|
18
|
+
*/
|
|
19
|
+
export declare function requirementsWithoutKeyword(markdown: string): Array<{
|
|
20
|
+
line: number;
|
|
21
|
+
requirement: string;
|
|
22
|
+
}>;
|
|
23
|
+
/**
|
|
24
|
+
* Dry-runs `openspec archive` on a disposable copy of `openspec/`, so a change that validates but
|
|
25
|
+
* cannot be archived is caught while its author can still edit the plan. Nothing in the
|
|
26
|
+
* artifact root is written.
|
|
27
|
+
*/
|
|
28
|
+
export declare function previewOpenSpecArchive(artifactRoot: string, change: string, signal?: AbortSignal): Promise<ArchivePreflight>;
|
|
29
|
+
/** Plain-text guidance for the planning role's history. */
|
|
30
|
+
export declare function describeArchivePreflight(change: string, preflight: Extract<ArchivePreflight, {
|
|
31
|
+
valid: false;
|
|
32
|
+
}>): string;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { cpSync, existsSync, lstatSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { artifactPath, OpenSpecCommandError, resolveOpenSpecCli, runOpenSpec } from '../openspec.js';
|
|
5
|
+
const ANSI = new RegExp(String.fromCharCode(27) + '\\[[0-9;]*m', 'g');
|
|
6
|
+
/**
|
|
7
|
+
* Requirements OpenSpec rejects when it rebuilds a main spec on archive: its spec parser reads only
|
|
8
|
+
* the first non-empty body line before any heading and needs SHALL or MUST there. Delta validation
|
|
9
|
+
* skips `**Key**:` metadata lines, so a delta (or a pre-existing main-spec requirement) can pass
|
|
10
|
+
* `openspec validate` and still abort the archive.
|
|
11
|
+
*/
|
|
12
|
+
export function requirementsWithoutKeyword(markdown) {
|
|
13
|
+
const lines = markdown.split('\n'), findings = [];
|
|
14
|
+
for (let index = 0; index < lines.length; index++) {
|
|
15
|
+
const header = lines[index].match(/^###\s+Requirement:\s*(.*)$/);
|
|
16
|
+
if (!header)
|
|
17
|
+
continue;
|
|
18
|
+
let body;
|
|
19
|
+
for (let next = index + 1; next < lines.length; next++) {
|
|
20
|
+
const text = lines[next].trim();
|
|
21
|
+
if (text.startsWith('#'))
|
|
22
|
+
break;
|
|
23
|
+
if (text) {
|
|
24
|
+
body = next;
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const text = body === undefined ? header[1] : lines[body];
|
|
29
|
+
if (!text.includes('SHALL') && !text.includes('MUST'))
|
|
30
|
+
findings.push({ line: (body ?? index) + 1, requirement: header[1].trim() });
|
|
31
|
+
}
|
|
32
|
+
return findings;
|
|
33
|
+
}
|
|
34
|
+
function assertNoSymlinks(root) {
|
|
35
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
36
|
+
if (entry.isSymbolicLink())
|
|
37
|
+
throw new Error('OpenSpec archive refuses symlink trees');
|
|
38
|
+
if (entry.isDirectory())
|
|
39
|
+
assertNoSymlinks(path.join(root, entry.name));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function capabilityFindings(artifactRoot, change) {
|
|
43
|
+
const deltas = path.join(artifactRoot, 'openspec/changes', change, 'specs');
|
|
44
|
+
if (!existsSync(deltas))
|
|
45
|
+
return [];
|
|
46
|
+
const findings = [];
|
|
47
|
+
for (const capability of readdirSync(deltas).sort()) {
|
|
48
|
+
for (const relative of [`openspec/changes/${change}/specs/${capability}/spec.md`, `openspec/specs/${capability}/spec.md`]) {
|
|
49
|
+
const file = path.join(artifactRoot, relative);
|
|
50
|
+
if (!existsSync(file) || !lstatSync(file).isFile())
|
|
51
|
+
continue;
|
|
52
|
+
for (const finding of requirementsWithoutKeyword(readFileSync(file, 'utf8')))
|
|
53
|
+
findings.push({ path: relative, ...finding });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return findings;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Dry-runs `openspec archive` on a disposable copy of `openspec/`, so a change that validates but
|
|
60
|
+
* cannot be archived is caught while its author can still edit the plan. Nothing in the
|
|
61
|
+
* artifact root is written.
|
|
62
|
+
*/
|
|
63
|
+
export async function previewOpenSpecArchive(artifactRoot, change, signal) {
|
|
64
|
+
const source = artifactPath(artifactRoot, 'openspec');
|
|
65
|
+
assertNoSymlinks(source);
|
|
66
|
+
const staging = mkdtempSync(path.join(os.tmpdir(), 'specrails-archive-preflight-'));
|
|
67
|
+
try {
|
|
68
|
+
cpSync(source, path.join(staging, 'openspec'), { recursive: true });
|
|
69
|
+
let output;
|
|
70
|
+
try {
|
|
71
|
+
output = await runOpenSpec(resolveOpenSpecCli(), staging, ['archive', change, '--yes'], signal);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (!(error instanceof OpenSpecCommandError) || error.exitCode === undefined || error.exitCode < 1)
|
|
75
|
+
throw error;
|
|
76
|
+
output = `${error.stdout}\n${error.message}`;
|
|
77
|
+
}
|
|
78
|
+
const archiveRoot = path.join(staging, 'openspec/changes/archive');
|
|
79
|
+
const archived = existsSync(archiveRoot) && readdirSync(archiveRoot).some(name => name.endsWith('-' + change));
|
|
80
|
+
if (archived && !existsSync(path.join(staging, 'openspec/changes', change)))
|
|
81
|
+
return { valid: true };
|
|
82
|
+
const reason = output.replace(ANSI, '').split('\n').map(line => line.trimEnd()).filter(Boolean).join('\n').slice(-2000);
|
|
83
|
+
return { valid: false, reason, findings: capabilityFindings(artifactRoot, change) };
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
rmSync(staging, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Plain-text guidance for the planning role's history. */
|
|
90
|
+
export function describeArchivePreflight(change, preflight) {
|
|
91
|
+
const located = preflight.findings.map(finding => `- ${finding.path}:${finding.line} "${finding.requirement}"`).join('\n');
|
|
92
|
+
return [
|
|
93
|
+
`OpenSpec validates change ${change}, but archiving it would fail. OpenSpec's archive output:`,
|
|
94
|
+
preflight.reason,
|
|
95
|
+
...(located ? ['Requirements whose first body line has no SHALL or MUST (OpenSpec reads only that line when it rebuilds a main spec):', located,
|
|
96
|
+
'Put the SHALL/MUST statement on the line right after "### Requirement: …". For a requirement that only exists in openspec/specs, restate it under "## MODIFIED Requirements" in this change\'s delta with the corrected first line.'] : []),
|
|
97
|
+
].join('\n');
|
|
98
|
+
}
|
package/package.json
CHANGED