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}. Inspect partial changes before recovery; a higher limit requires a new run configuration.`
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
- return { outcome: exited && item.valid ? 'pass' : 'fail', output: json(report) };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specrails-core",
3
- "version": "6.6.0",
3
+ "version": "6.6.2",
4
4
  "description": "Agent workflow engine for specrails-desktop: provider-independent installer and programmatic agent runtime for Claude Code and Codex",
5
5
  "type": "module",
6
6
  "exports": {