specrails-core 6.6.4 → 6.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-runtime/capabilities.d.ts +5 -2
- package/dist/agent-runtime/capabilities.js +8 -2
- package/dist/agent-runtime/change-scope.d.ts +2 -0
- package/dist/agent-runtime/change-scope.js +5 -0
- package/dist/agent-runtime/cli-executor.js +7 -0
- package/dist/agent-runtime/cli.d.ts +6 -1
- package/dist/agent-runtime/cli.js +15 -4
- package/dist/agent-runtime/compact/reviewer.js +3 -1
- package/dist/agent-runtime/config.js +16 -5
- package/dist/agent-runtime/efficiency-summary.d.ts +7 -0
- package/dist/agent-runtime/efficiency-summary.js +7 -1
- package/dist/agent-runtime/engine/__fixtures__/implementation-fixture.js +1 -1
- package/dist/agent-runtime/engine/checkpoint/database.d.ts +9 -0
- package/dist/agent-runtime/engine/checkpoint/database.js +40 -3
- package/dist/agent-runtime/engine/checkpoint/ledger.d.ts +1 -0
- package/dist/agent-runtime/engine/checkpoint/ledger.js +7 -2
- package/dist/agent-runtime/engine/cli.d.ts +12 -0
- package/dist/agent-runtime/engine/cli.js +15 -4
- package/dist/agent-runtime/engine/definition-schema.d.ts +1 -1
- package/dist/agent-runtime/engine/definition-schema.js +1 -0
- package/dist/agent-runtime/engine/definition-types.d.ts +1 -1
- package/dist/agent-runtime/engine/definition-types.js +1 -1
- package/dist/agent-runtime/engine/definition-validator.js +8 -0
- package/dist/agent-runtime/engine/efficiency-summary.d.ts +7 -0
- package/dist/agent-runtime/engine/graph-description.d.ts +3 -3
- package/dist/agent-runtime/engine/invocation-context.js +2 -0
- package/dist/agent-runtime/engine/piece-registry.d.ts +1 -1
- package/dist/agent-runtime/engine/piece-registry.js +1 -1
- package/dist/agent-runtime/engine/pieces/artifact-contract.d.ts +5 -0
- package/dist/agent-runtime/engine/pieces/artifact-contract.js +6 -2
- package/dist/agent-runtime/engine/pieces/derivation-coverage.d.ts +30 -0
- package/dist/agent-runtime/engine/pieces/derivation-coverage.js +95 -0
- package/dist/agent-runtime/engine/pieces/derive-change.d.ts +50 -0
- package/dist/agent-runtime/engine/pieces/derive-change.js +209 -0
- package/dist/agent-runtime/engine/pieces/index.js +2 -1
- package/dist/agent-runtime/engine/pieces/openspec.d.ts +16 -1
- package/dist/agent-runtime/engine/pieces/openspec.js +39 -30
- package/dist/agent-runtime/engine/pieces/role-turn.js +26 -5
- package/dist/agent-runtime/engine/preflight.d.ts +2 -1
- package/dist/agent-runtime/engine/preflight.js +3 -1
- package/dist/agent-runtime/engine/regex.d.ts +12 -2
- package/dist/agent-runtime/engine/regex.js +25 -11
- package/dist/agent-runtime/engine/run-status.d.ts +34 -0
- package/dist/agent-runtime/engine/run-status.js +7 -1
- package/dist/agent-runtime/engine/runs.d.ts +56 -0
- package/dist/agent-runtime/engine/runs.js +88 -5
- package/dist/agent-runtime/engine/steering/inbox.d.ts +15 -0
- package/dist/agent-runtime/engine/steering/inbox.js +21 -1
- package/dist/agent-runtime/engine/storage/private-path.js +7 -1
- package/dist/agent-runtime/evaluation.js +1 -1
- package/dist/agent-runtime/executor-types.d.ts +19 -0
- package/dist/agent-runtime/executor-types.js +10 -0
- package/dist/agent-runtime/failure-classification.d.ts +51 -0
- package/dist/agent-runtime/failure-classification.js +110 -0
- package/dist/agent-runtime/graph/archive-preflight.d.ts +3 -18
- package/dist/agent-runtime/graph/archive-preflight.js +6 -51
- package/dist/agent-runtime/graph/artifacts.d.ts +3 -1
- package/dist/agent-runtime/graph/artifacts.js +6 -2
- package/dist/agent-runtime/graph/baseline.d.ts +50 -0
- package/dist/agent-runtime/graph/baseline.js +145 -0
- package/dist/agent-runtime/graph/capability-map.d.ts +29 -0
- package/dist/agent-runtime/graph/capability-map.js +65 -0
- package/dist/agent-runtime/graph/clarifications.d.ts +75 -0
- package/dist/agent-runtime/graph/clarifications.js +158 -0
- package/dist/agent-runtime/graph/nodes.js +178 -19
- package/dist/agent-runtime/graph/review-brief-record.d.ts +25 -0
- package/dist/agent-runtime/graph/review-brief-record.js +26 -0
- package/dist/agent-runtime/graph/review-brief.d.ts +73 -0
- package/dist/agent-runtime/graph/review-brief.js +127 -0
- package/dist/agent-runtime/graph/roles.js +5 -3
- package/dist/agent-runtime/graph/spec-trace-record.d.ts +33 -0
- package/dist/agent-runtime/graph/spec-trace-record.js +65 -0
- package/dist/agent-runtime/graph/state.d.ts +8 -0
- package/dist/agent-runtime/index.d.ts +7 -0
- package/dist/agent-runtime/index.js +7 -0
- package/dist/agent-runtime/openspec.d.ts +2 -1
- package/dist/agent-runtime/openspec.js +11 -5
- package/dist/agent-runtime/project-rules.d.ts +139 -0
- package/dist/agent-runtime/project-rules.js +683 -0
- package/dist/agent-runtime/prompts.d.ts +32 -1
- package/dist/agent-runtime/prompts.js +180 -7
- package/dist/agent-runtime/role-routing.d.ts +2 -1
- package/dist/agent-runtime/session/application/active-session.d.ts +43 -2
- package/dist/agent-runtime/session/application/active-session.js +163 -7
- package/dist/agent-runtime/session/application/session-service.d.ts +2 -2
- package/dist/agent-runtime/session/domain/descriptor.js +5 -0
- package/dist/agent-runtime/session/domain/interruption.js +1 -0
- package/dist/agent-runtime/session/domain/policy.js +2 -0
- package/dist/agent-runtime/session/domain/redaction.d.ts +19 -0
- package/dist/agent-runtime/session/domain/redaction.js +71 -0
- package/dist/agent-runtime/session/domain/types.d.ts +17 -1
- package/dist/agent-runtime/session/drivers/claude/argv.d.ts +14 -0
- package/dist/agent-runtime/session/drivers/claude/argv.js +19 -2
- package/dist/agent-runtime/session/drivers/claude/driver.js +11 -1
- package/dist/agent-runtime/session/drivers/claude/translator.d.ts +4 -0
- package/dist/agent-runtime/session/drivers/claude/translator.js +16 -3
- package/dist/agent-runtime/session/drivers/codex/driver.js +2 -0
- package/dist/agent-runtime/session/drivers/executor/driver.js +2 -0
- package/dist/agent-runtime/session/host/protocol.d.ts +12 -0
- package/dist/agent-runtime/session/host/protocol.js +10 -2
- package/dist/agent-runtime/session/host/server.js +6 -5
- package/dist/agent-runtime/session/index.d.ts +1 -1
- package/dist/agent-runtime/session/journal/sqlite-journal.js +6 -2
- package/dist/agent-runtime/session/ports.d.ts +6 -2
- package/dist/agent-runtime/session/testing/driver-conformance.js +16 -2
- package/dist/agent-runtime/session/testing/memory-journal.js +4 -2
- package/dist/installer/cli.js +10 -1
- package/dist/installer/commands/init.js +15 -4
- package/dist/installer/phases/prereqs.d.ts +1 -1
- package/dist/installer/phases/prereqs.js +2 -2
- package/dist/installer/phases/scaffold.js +59 -11
- package/dist/pipeline/pipeline-state.d.ts +15 -0
- package/dist/pipeline/pipeline-state.js +36 -3
- package/dist/pipeline/spec-trace.d.ts +134 -0
- package/dist/pipeline/spec-trace.js +392 -0
- package/dist/shared/secret-redaction.d.ts +42 -0
- package/dist/shared/secret-redaction.js +184 -0
- package/integration-contract.json +177 -7
- package/package.json +8 -8
- package/pinned-versions.json +1 -1
- package/schemas/agent-runtime.schema.json +3 -1
- package/schemas/workflow-definition.schema.json +1 -0
- package/templates/settings/codex-config.toml +3 -2
|
@@ -3,11 +3,19 @@ import { verificationFailureSummary } from '../verification-diagnostics.js';
|
|
|
3
3
|
import { addDeveloperChecks, bindPlan, expandedPlanCommands, initializeVerificationPlan, readVerificationPlan, validateProposedChecks } from '../verification-plan.js';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { OpenSpecTools } from '../openspec.js';
|
|
6
|
-
import { candidateManifest, describeModifiedFiles, fingerprintCandidate, frozenAcceptanceCriteria, inspectPipeline, recordAcceptance, transitionPipeline, validateAcceptanceReport, verifyPipeline, } from '../../pipeline/pipeline-state.js';
|
|
7
|
-
import { ARCHITECT_OUTPUT_SCHEMA, DEVELOPER_OUTPUT_SCHEMA, REVIEW_OUTPUT_SCHEMA, correctionInstructions, deepenInstructions, roleInstructions } from '../prompts.js';
|
|
6
|
+
import { candidateManifest, describeModifiedFiles, fingerprintCandidate, frozenAcceptanceCriteria, inspectPipeline, recordAcceptance, sanitizeFindingLocations, transitionPipeline, validateAcceptanceReport, verifyPipeline, } from '../../pipeline/pipeline-state.js';
|
|
7
|
+
import { ARCHITECT_OUTPUT_SCHEMA, DEVELOPER_OUTPUT_SCHEMA, REVIEW_OUTPUT_SCHEMA, baselinePlanInstructions, baselineStageInstructions, correctionInstructions, deepenInstructions, roleInstructions } from '../prompts.js';
|
|
8
8
|
import { archive, child, journal, object, parseArchitecture, proposedVerification, write, writeDesignConfidence } from './artifacts.js';
|
|
9
9
|
import { evaluateReview } from './review-policy.js';
|
|
10
|
+
import { riskFinding, settleReviewBrief } from './review-brief.js';
|
|
11
|
+
import { writeReviewBriefRecord } from './review-brief-record.js';
|
|
12
|
+
import { recordSpecTrace, specTraceFinding } from './spec-trace-record.js';
|
|
13
|
+
import { touchedRequirements } from '../../pipeline/spec-trace.js';
|
|
14
|
+
import { CAPABILITY_MAP_PATH, recordArchivedChange } from './capability-map.js';
|
|
15
|
+
import { BASELINE_BLOCKER, detectBaseline, nonTestEdits, readBaselineRecord, testEdits, validateBaselinePlan, writeBaselineRecord } from './baseline.js';
|
|
16
|
+
import { DEFAULT_CLARIFICATION_POLICY, blockingClarifications, clarificationQuestion, designAssumptions, parseClarifications, validateDesignSections, writeClarificationsRecord } from './clarifications.js';
|
|
10
17
|
import { describeDrift } from '../compact/environment.js';
|
|
18
|
+
import { projectRulesContext } from '../project-rules.js';
|
|
11
19
|
import { adoptedOutputNote, environmentFailure, hostPreconditionMessage, nondeterministicOutputBlocker, preconditionBlock, repairEnvironment, selfMutationOnly } from '../verification-repair.js';
|
|
12
20
|
import { unreachedTestFiles, unreachedTestsReason } from '../compact/test-reachability.js';
|
|
13
21
|
import { exitCodeContradiction, exitHonestyReason } from '../compact/exit-code-honesty.js';
|
|
@@ -67,6 +75,20 @@ export function developerRecord(output, text, result, provider) {
|
|
|
67
75
|
incomplete,
|
|
68
76
|
};
|
|
69
77
|
}
|
|
78
|
+
/** The passed baseline of this change, for the developer and reviewer prompts. */
|
|
79
|
+
function baselinePromptContext(context, change) {
|
|
80
|
+
const record = readBaselineRecord(context);
|
|
81
|
+
return record?.change === change && record.status === 'passed' ? { ...(record.receiptId ? { receiptId: record.receiptId } : {}), tests: record.tests, uncovered: record.detection.uncovered } : undefined;
|
|
82
|
+
}
|
|
83
|
+
/** Requirement ids the change's delta specs add, modify or rename; none when they cannot be read. */
|
|
84
|
+
function touchedRequirementIds(context, change) {
|
|
85
|
+
try {
|
|
86
|
+
return touchedRequirements(context.artifactRoot, change);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
70
92
|
/** The change so far as prompt lines; a checkout git cannot measure yields no section instead of failing the role. */
|
|
71
93
|
function measuredChange(context, change) {
|
|
72
94
|
try {
|
|
@@ -97,12 +119,20 @@ function withoutDiscarded(context, record, discarded) {
|
|
|
97
119
|
}
|
|
98
120
|
function architectNode(deps) {
|
|
99
121
|
const { context, config, change, invoke, note } = deps;
|
|
122
|
+
const policy = config.architect?.clarifications ?? DEFAULT_CLARIFICATION_POLICY;
|
|
123
|
+
const lowConfidencePauses = (config.architect?.onLowConfidence ?? 'ask') === 'ask';
|
|
100
124
|
const accept = (output) => {
|
|
101
|
-
const
|
|
125
|
+
const raw = object(output);
|
|
126
|
+
const architecture = { ...parseArchitecture(raw), clarifications: parseClarifications(raw.clarifications) };
|
|
102
127
|
if (config.efficiency?.planning === 'full' || context.repositories.length > 1 || /\b(?:migration|security|authentication|authorization|public contract|public api|migraci[oó]n|seguridad)\b/i.test(context.specs.map(spec => spec.title + ' ' + spec.description).join('\n')))
|
|
103
128
|
architecture.planningDepth = 'full';
|
|
104
129
|
// Malformed proposals are repaired inside the same session, like any other structural defect.
|
|
105
130
|
proposedVerification(context, config.verification, architecture.verification);
|
|
131
|
+
// A reply that will pause has no finished design yet; one that proceeds
|
|
132
|
+
// must carry Non-Goals (and Assumptions for its clarifications), repaired in-session.
|
|
133
|
+
const pauses = blockingClarifications(architecture.clarifications, policy).length > 0 || (architecture.confidence === 'low' && lowConfidencePauses);
|
|
134
|
+
if (!pauses)
|
|
135
|
+
validateDesignSections(context, change, { clarifications: architecture.clarifications, policy });
|
|
106
136
|
return architecture;
|
|
107
137
|
};
|
|
108
138
|
return {
|
|
@@ -121,25 +151,40 @@ function architectNode(deps) {
|
|
|
121
151
|
fresh.push(reply.answer);
|
|
122
152
|
note('architect', 'Continuing the architecture with the answer to the blocking question.');
|
|
123
153
|
}
|
|
124
|
-
const
|
|
154
|
+
const projectRules = projectRulesContext(context);
|
|
155
|
+
const prompt = roleInstructions('architect', context, change, { definition: config.rolePrompts?.architect, verification: config.verification, answers, planning: config.efficiency?.planning, clarifications: policy, ...(projectRules ? { projectRules } : {}) });
|
|
125
156
|
const first = await invoke('architect', step, { prompt, structured: true, outputSchema: ARCHITECT_OUTPUT_SCHEMA }, accept);
|
|
126
157
|
if (!first.ok)
|
|
127
158
|
return settle(first, 'Resume to run the architect again, or raise limits.timeoutMs.');
|
|
128
159
|
let architecture = first.value;
|
|
160
|
+
let sessionId = first.result.sessionId;
|
|
129
161
|
let passes = state.deepenPasses;
|
|
130
162
|
if (architecture.confidence === 'low' && passes < MAX_DEEPEN_PASSES) {
|
|
131
163
|
passes += 1;
|
|
132
164
|
note('architect', `Design confidence is low${architecture.question ? ` (${architecture.question})` : ''}; asking the architect to investigate the code further before deciding.`);
|
|
133
165
|
const second = await invoke('architect', step, { kind: 'deepen', prompt: deepenInstructions(architecture.question), fallbackPrompt: prompt + '\nPrevious response (bounded):\n' + first.text.slice(-32_000) + '\n' + deepenInstructions(architecture.question), structured: true, outputSchema: ARCHITECT_OUTPUT_SCHEMA, resumeSessionId: first.result.sessionId }, accept);
|
|
134
|
-
if (second.ok)
|
|
166
|
+
if (second.ok) {
|
|
135
167
|
architecture = second.value;
|
|
168
|
+
sessionId = second.result.sessionId ?? sessionId;
|
|
169
|
+
}
|
|
136
170
|
else
|
|
137
171
|
note('architect', `The investigation pass could not be used (${second.error}); keeping the first design.`);
|
|
138
172
|
}
|
|
173
|
+
const blocking = blockingClarifications(architecture.clarifications, policy);
|
|
174
|
+
if (blocking.length) {
|
|
175
|
+
// High-impact questions without a safe default come before any plan:
|
|
176
|
+
// the run pauses on them and the answers return as authoritative input.
|
|
177
|
+
const lowQuestion = architecture.confidence === 'low' && lowConfidencePauses ? architecture.question : undefined;
|
|
178
|
+
writeDesignConfidence(context, change, architecture);
|
|
179
|
+
writeClarificationsRecord(context, { change, policy, status: 'awaiting-answers', clarifications: architecture.clarifications, blocking, answers });
|
|
180
|
+
transitionPipeline(context, 'architect', 'blocked', 'High-impact clarifications need answers');
|
|
181
|
+
note('architect', `The architect raised ${plural(blocking.length, 'high-impact question')} without a safe default; pausing for answers before planning.`);
|
|
182
|
+
step.interrupt({ kind: 'question', question: clarificationQuestion(blocking, lowQuestion) });
|
|
183
|
+
}
|
|
139
184
|
let assumed = false;
|
|
140
185
|
if (architecture.confidence === 'low') {
|
|
141
186
|
const question = architecture.question ?? 'The architect could not choose between several plausible designs; inspect proposal.md and state the intended behavior.';
|
|
142
|
-
if (
|
|
187
|
+
if (lowConfidencePauses) {
|
|
143
188
|
// The draft artifacts are written first so the requester can read the
|
|
144
189
|
// proposal while answering; the resumed pass replaces them.
|
|
145
190
|
writeDesignConfidence(context, change, architecture);
|
|
@@ -152,11 +197,34 @@ function architectNode(deps) {
|
|
|
152
197
|
}
|
|
153
198
|
const workflow = new OpenSpecTools(deps.openspec.architect, step.signal);
|
|
154
199
|
workflow.assertParticipation();
|
|
155
|
-
|
|
156
|
-
const specs = Object.keys((await workflow.status()).artifactPaths).length ? (applied.contextFiles.specs ?? []) : [];
|
|
157
|
-
const names = (Array.isArray(specs) ? specs : [specs]).map(file => path.basename(path.dirname(file)));
|
|
200
|
+
let applied = await workflow.assertReady();
|
|
158
201
|
const proposed = proposedVerification(context, config.verification, architecture.verification);
|
|
159
202
|
writeDesignConfidence(context, change, architecture, { assumed });
|
|
203
|
+
writeClarificationsRecord(context, { change, policy, status: 'proceeded', clarifications: architecture.clarifications, blocking: [], answers, assumptions: designAssumptions(context, change) });
|
|
204
|
+
if (architecture.clarifications.length)
|
|
205
|
+
note('architect', `Proceeding on ${plural(architecture.clarifications.length, 'clarification')} recorded under Assumptions in design.md.`);
|
|
206
|
+
if (config.baselineOnTouch) {
|
|
207
|
+
// Baseline on touch: code the blast radius reaches that no capability
|
|
208
|
+
// covers gets characterization tests and an as-built spec first.
|
|
209
|
+
const detection = detectBaseline(context, change);
|
|
210
|
+
if (!detection.uncovered.length)
|
|
211
|
+
writeBaselineRecord(context, { change, status: 'not-needed', detection, tests: [] });
|
|
212
|
+
else {
|
|
213
|
+
note('architect', `The blast radius reaches ${plural(detection.uncovered.length, 'file')} no capability covers (${detection.uncovered.slice(0, 8).join(', ')}); asking the architect for a Baseline task group.`);
|
|
214
|
+
const instructions = baselinePlanInstructions(detection.uncovered);
|
|
215
|
+
const planned = await invoke('architect', step, { kind: 'baseline', prompt: instructions, fallbackPrompt: prompt + '\n' + instructions, structured: true, outputSchema: ARCHITECT_OUTPUT_SCHEMA, ...(sessionId ? { resumeSessionId: sessionId } : {}) }, output => {
|
|
216
|
+
object(output);
|
|
217
|
+
validateBaselinePlan(context, change);
|
|
218
|
+
return true;
|
|
219
|
+
});
|
|
220
|
+
if (!planned.ok)
|
|
221
|
+
return settle(planned, 'Resume to run the architect again, or raise limits.timeoutMs.');
|
|
222
|
+
applied = await workflow.assertReady();
|
|
223
|
+
writeBaselineRecord(context, { change, status: 'planned', detection, tests: [] });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const specs = Object.keys((await workflow.status()).artifactPaths).length ? (applied.contextFiles.specs ?? []) : [];
|
|
227
|
+
const names = (Array.isArray(specs) ? specs : [specs]).map(file => path.basename(path.dirname(file)));
|
|
160
228
|
const effective = initializeVerificationPlan(context, config.verification, proposed, config.efficiency?.verification?.maxConcurrency);
|
|
161
229
|
bindPlan(context, effective);
|
|
162
230
|
const plan = expandedPlanCommands(context, effective);
|
|
@@ -170,11 +238,12 @@ function architectNode(deps) {
|
|
|
170
238
|
planningDepth: architecture.planningDepth, referencePatterns: architecture.referencePatterns, riskFlags: architecture.riskFlags,
|
|
171
239
|
...(architecture.planningReason ? { planningReason: architecture.planningReason } : {}),
|
|
172
240
|
...(architecture.question ? { question: architecture.question } : {}), ...(assumed ? { assumed: true } : {}),
|
|
241
|
+
...(architecture.clarifications.length ? { clarifications: architecture.clarifications } : {}),
|
|
173
242
|
};
|
|
174
243
|
return {
|
|
175
244
|
status: 'succeeded', next: 'developer',
|
|
176
245
|
update: { plan, unverifiedRepositories: uncovered, architecture: record, answers: fresh, deepenPasses: passes },
|
|
177
|
-
output: { change, verification: plan, unverifiedRepositories: uncovered, confidence: architecture.confidence, assumed, deepenPasses: passes },
|
|
246
|
+
output: { change, verification: plan, unverifiedRepositories: uncovered, confidence: architecture.confidence, assumed, deepenPasses: passes, clarifications: architecture.clarifications },
|
|
178
247
|
};
|
|
179
248
|
},
|
|
180
249
|
};
|
|
@@ -200,7 +269,9 @@ async function implementationVisit(deps, state, step, mode) {
|
|
|
200
269
|
const provider = (fixer ?? config.agents.developer).provider;
|
|
201
270
|
// What the run has changed so far, from git: a correction stays inside it.
|
|
202
271
|
const changes = state.development ? measuredChange(context, change) : undefined;
|
|
203
|
-
const
|
|
272
|
+
const requirementTags = touchedRequirementIds(context, change);
|
|
273
|
+
const developerPrompt = () => roleInstructions('developer', context, change, { ...(baselinePromptContext(context, change) ? { baseline: baselinePromptContext(context, change) } : {}), definition: fixing ? config.rolePrompts?.fixer : config.rolePrompts?.developer, ...(fixing ? { stance: 'fixer' } : {}), feedback, verification: state.plan, developer: state.development, ...(changes ? { changeSet: changes } : {}), ...(requirementTags.length ? { requirementTags } : {}) }) + (config.efficiency?.acceptDeveloperChecks !== false ? '\nYou may propose verificationChecks in your JSON summary. These are additive required checks, never replacements for the baseline. Use kind command with structured command/args, or kind harness with entrypoint and 1–8 relative source files (64 KiB each, 256 KiB total). Core persists harnesses outside delivery, appends the absolute entrypoint to argv and supplies SPECRAILS_CHECK_REPO_ROOT. Stable keys revise your own checks; omission retains them. Do not claim a check passed until Core has executed it.' : '');
|
|
274
|
+
let full = developerPrompt();
|
|
204
275
|
const previous = state.development;
|
|
205
276
|
// A CLI developer with a live session takes corrections and continuations
|
|
206
277
|
// as a short follow-up in that session; a configured fixer engine always
|
|
@@ -209,6 +280,14 @@ async function implementationVisit(deps, state, step, mode) {
|
|
|
209
280
|
const resumable = !fixer && previous?.sessionId !== undefined && previous.provider === provider && visits > 1;
|
|
210
281
|
if (fixer)
|
|
211
282
|
note('fixer', `Correction round on the fixer engine (${fixer.provider}/${fixer.model ?? 'provider default'}).`);
|
|
283
|
+
// Baseline on touch: characterization comes first and must pass on the unchanged code.
|
|
284
|
+
const baseline = !fixing && config.baselineOnTouch ? readBaselineRecord(context) : null;
|
|
285
|
+
if (baseline && baseline.change === change && baseline.status !== 'passed' && baseline.status !== 'not-needed') {
|
|
286
|
+
const stopped = await baselineStage(deps, step, baseline, full, provider);
|
|
287
|
+
if (stopped)
|
|
288
|
+
return stopped;
|
|
289
|
+
full = developerPrompt();
|
|
290
|
+
}
|
|
212
291
|
// Out-of-scope state before the turn: afterwards the host undoes exactly what the turn did outside the scope.
|
|
213
292
|
const guard = captureOutOfScope(context, change);
|
|
214
293
|
const outcome = await invoke('developer', step, {
|
|
@@ -262,6 +341,54 @@ async function implementationVisit(deps, state, step, mode) {
|
|
|
262
341
|
output: { summary: record.summary, provider, ...(record.sessionId ? { sessionId: record.sessionId } : {}), files: record.files, tests: record.tests, incomplete: record.incomplete, structured: record.structured },
|
|
263
342
|
};
|
|
264
343
|
}
|
|
344
|
+
/**
|
|
345
|
+
* The baseline stage of a developer visit: the developer writes only the
|
|
346
|
+
* characterization tests and the as-built spec, Core checks that no non-test
|
|
347
|
+
* file changed and runs the verification plan on the unchanged code. A failure
|
|
348
|
+
* stops the run with an explicit host blocker; a pass is recorded with its
|
|
349
|
+
* receipt and the visit continues with the change tasks.
|
|
350
|
+
*/
|
|
351
|
+
async function baselineStage(deps, step, record, full, provider) {
|
|
352
|
+
const { context, change, invoke, note } = deps;
|
|
353
|
+
const uncovered = record.detection.uncovered;
|
|
354
|
+
const stop = (status, reason, extra = {}) => {
|
|
355
|
+
writeBaselineRecord(context, { ...record, ...extra, status, reason });
|
|
356
|
+
note('developer', reason);
|
|
357
|
+
return { status: 'blocked', error: `${reason} (${BASELINE_BLOCKER})`, usage: NO_SPEND };
|
|
358
|
+
};
|
|
359
|
+
note('developer', `Baseline stage: characterization tests for ${uncovered.slice(0, 8).join(', ')} before the change touches them.`);
|
|
360
|
+
const guard = captureOutOfScope(context, change);
|
|
361
|
+
const outcome = await invoke('developer', step, { kind: 'baseline', prompt: full + '\n' + baselineStageInstructions(uncovered), structured: true, lenient: true, outputSchema: DEVELOPER_OUTPUT_SCHEMA }, (output, text, result) => developerRecord(output, text, result, provider));
|
|
362
|
+
try {
|
|
363
|
+
discardOutOfScopeEdits(context, guard);
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
return { status: 'blocked', error: `Developer edited files outside the repository scope during the baseline stage and Core could not undo them: ${error instanceof Error ? error.message : String(error)}. Restore those files, then resume.` };
|
|
367
|
+
}
|
|
368
|
+
if (!outcome.ok)
|
|
369
|
+
return settle(outcome, 'Resume to run the baseline stage again, or raise limits.timeoutMs.');
|
|
370
|
+
const changed = changeSet(context, change).flatMap(set => set.files.map(file => ({ repositoryId: set.repositoryId, path: file.path })));
|
|
371
|
+
const edited = nonTestEdits(changed.map(file => file.path));
|
|
372
|
+
if (edited.length)
|
|
373
|
+
return stop('violated', `The baseline stage changed non-test files (${edited.slice(0, 10).join(', ')}); the module must stay untouched until its characterization tests pass on the base code. Restore those files, then resume`);
|
|
374
|
+
const tests = testEdits([...new Set([...outcome.value.tests, ...changed.map(file => file.path)])]);
|
|
375
|
+
const effective = readVerificationPlan(context);
|
|
376
|
+
const plan = effective ? expandedPlanCommands(context, effective) : [];
|
|
377
|
+
if (!effective || !plan.length)
|
|
378
|
+
return stop('failed', 'No verification command can run the characterization tests on the base code; configure one for the repository, then resume', { tests });
|
|
379
|
+
const unreached = context.repositories.flatMap(repository => unreachedTestFiles(repository.path, plan.filter(command => command.repositoryId === repository.id).map(({ command, args, cwd }) => ({ command, args, ...(cwd ? { cwd } : {}) })), tests));
|
|
380
|
+
if (unreached.length)
|
|
381
|
+
return stop('failed', `No verification command runs the characterization tests ${unreached.join(', ')}; wire them into the repository's test command, then resume`, { tests });
|
|
382
|
+
const uncoveredRepositories = context.repositories.filter(repository => !plan.some(command => command.repositoryId === repository.id)).map(repository => repository.id);
|
|
383
|
+
const receipt = await verifyPipeline(context, { kind: 'full', planHash: effective.planHash, commands: plan, ...(uncoveredRepositories.length ? { unverified: true } : {}) }, deps.onVerificationOutput, step.signal);
|
|
384
|
+
if (!receipt.valid) {
|
|
385
|
+
const failing = receipt.commands.filter(command => command.exitCode !== 0).map(command => [command.command, ...command.args].join(' '));
|
|
386
|
+
return stop('failed', `Characterization tests fail on the base code (receipt ${receipt.id}${failing.length ? ': ' + failing.join('; ') : ''}): they do not describe the current behaviour of ${uncovered.slice(0, 8).join(', ')}. Make the characterization tests describe what the code does today (never change the module to match them), then resume`, { tests, receiptId: receipt.id });
|
|
387
|
+
}
|
|
388
|
+
writeBaselineRecord(context, { ...record, status: 'passed', tests, receiptId: receipt.id, reason: undefined });
|
|
389
|
+
note('developer', `Baseline passed on the base code (receipt ${receipt.id}, ${plural(tests.length, 'characterization test file')}); continuing with the change.`);
|
|
390
|
+
return undefined;
|
|
391
|
+
}
|
|
265
392
|
function developerNode(deps) {
|
|
266
393
|
return {
|
|
267
394
|
effect: 'write', ends: ['verify'],
|
|
@@ -436,7 +563,12 @@ function verifyNode(deps) {
|
|
|
436
563
|
}
|
|
437
564
|
transitionPipeline(context, 'developer', 'done');
|
|
438
565
|
note('developer', plan.length ? `Verification passed: ${plural(plan.length, 'command')} exited 0.` : 'No verification commands available; relying on task completion and review.');
|
|
439
|
-
|
|
566
|
+
// Advisory requirement ↔ test trace, bound to this receipt; it never changes the outcome.
|
|
567
|
+
const trace = recordSpecTrace(context, deps.change, receipt, state.development?.tests ?? []);
|
|
568
|
+
if (trace)
|
|
569
|
+
note('developer', `${trace.summary}${trace.touchedUndemonstrated.length ? `; not demonstrated for this change: ${trace.touchedUndemonstrated.map(item => item.id).join(', ')}` : ''} (advisory).`);
|
|
570
|
+
const verified = trace ? { ...evidence, specTrace: trace } : evidence;
|
|
571
|
+
return { status: 'succeeded', next: 'reviewer', update: { verifyResult: verified, verifyHistory: [outcome(true)] }, output: verified, usage: NO_SPEND };
|
|
440
572
|
},
|
|
441
573
|
};
|
|
442
574
|
}
|
|
@@ -475,7 +607,11 @@ export function buildAcceptanceReport(criteria, verification, raw) {
|
|
|
475
607
|
// Reviewer inspections are evidence, never gates: only Core's own subprocess receipts are required checks.
|
|
476
608
|
for (const item of Array.isArray(input.checks) ? input.checks : [])
|
|
477
609
|
checks.push({ ...object(item), required: false });
|
|
478
|
-
|
|
610
|
+
const findings = (Array.isArray(input.findings) ? input.findings : []);
|
|
611
|
+
// The advisory spec trace is evidence for the merger, never a check: it cannot change the acceptance status.
|
|
612
|
+
// Anchors index the reviewer's own findings; appended host findings come after them, so the indexes stay valid.
|
|
613
|
+
const findingLocations = sanitizeFindingLocations(input.findingLocations, findings.length);
|
|
614
|
+
return { criteria: certified, checks, findings: verification?.specTrace ? [...findings, specTraceFinding(verification.specTrace)] : findings, ...(findingLocations.length ? { findingLocations } : {}) };
|
|
479
615
|
}
|
|
480
616
|
function reviewerNode(deps) {
|
|
481
617
|
const { context, change, invoke, note, policy, config } = deps;
|
|
@@ -497,13 +633,19 @@ function reviewerNode(deps) {
|
|
|
497
633
|
note('reviewer', `Re-review: ${plural(reReview.changes.length, 'file')} changed since the previous verdict; certifying the previous issues instead of re-reading the candidate.`);
|
|
498
634
|
// The reviewer judges the change itself, measured from git — not the whole checkout.
|
|
499
635
|
const changes = measuredChange(context, change);
|
|
500
|
-
const
|
|
636
|
+
const baselineContext = baselinePromptContext(context, change);
|
|
637
|
+
const projectRules = projectRulesContext(context);
|
|
638
|
+
const prompt = roleInstructions('reviewer', context, change, { ...(baselineContext ? { baseline: baselineContext } : {}), ...(projectRules ? { projectRules } : {}), definition: config.rolePrompts?.reviewer, feedback: feedbackFor(state), verification: state.plan, policy, criteria, developer: state.development, ...(changes ? { changeSet: changes } : {}), ...(reReview ? { reReview } : {}) });
|
|
501
639
|
const incremental = config.efficiency?.reviewMode !== 'full' && delta.mode === 'incremental' && state.review?.sessionId;
|
|
502
640
|
const followup = correctionInstructions('reviewer', feedbackFor(state), { focusedEvidence: deps.focusedCorrectionEvidence && config.efficiency?.contextMode !== 'full' }) + '\nChanges since YOUR previous reviewed candidate:\n' + JSON.stringify(delta.changes)
|
|
503
641
|
+ (changes ? '\nThe complete change under review (from git, against the run\'s base) — review these files only:\n' + changes.join('\n') : '')
|
|
504
642
|
+ '\nRecertify EVERY current acceptance criterion; previous met results are not current evidence:\n' + JSON.stringify(criteria)
|
|
505
643
|
+ '\nCurrent developer handoff:\n' + JSON.stringify(state.development);
|
|
644
|
+
// The first reply may spend the single repair turn on a malformed brief;
|
|
645
|
+
// a repaired reply keeps its verdict and only loses the brief.
|
|
646
|
+
let replies = 0;
|
|
506
647
|
const outcome = await invoke('reviewer', step, { kind: state.review ? 'correction' : 'initial', prompt: incremental ? followup : prompt, ...(incremental ? { resumeSessionId: state.review.sessionId, fallbackPrompt: prompt } : {}), structured: true, outputSchema: REVIEW_OUTPUT_SCHEMA }, output => {
|
|
648
|
+
const repairable = replies++ === 0;
|
|
507
649
|
const raw = object(output);
|
|
508
650
|
const review = evaluateReview(raw, policy);
|
|
509
651
|
const report = buildAcceptanceReport(criteria, state.verifyResult, raw.acceptance);
|
|
@@ -513,23 +655,30 @@ function reviewerNode(deps) {
|
|
|
513
655
|
catch (error) {
|
|
514
656
|
throw new Error('Invalid acceptance report: ' + (error instanceof Error ? error.message : String(error)));
|
|
515
657
|
}
|
|
516
|
-
|
|
658
|
+
const brief = settleReviewBrief(raw.brief, { repairable });
|
|
659
|
+
// Risks beyond the brief's cap stay visible as supplementary findings.
|
|
660
|
+
if (brief.overflow.length)
|
|
661
|
+
report.findings = [...report.findings, ...brief.overflow.map(riskFinding)];
|
|
662
|
+
return { ...review, report, brief };
|
|
517
663
|
});
|
|
518
664
|
if (!outcome.ok)
|
|
519
665
|
return failed(outcome.error);
|
|
520
666
|
const workflow = new OpenSpecTools(deps.openspec.reviewer, step.signal);
|
|
521
667
|
workflow.assertParticipation();
|
|
522
668
|
await workflow.assertReady();
|
|
523
|
-
const { record, approved, report } = outcome.value;
|
|
524
|
-
|
|
669
|
+
const { record, approved, report, brief } = outcome.value;
|
|
670
|
+
if (brief.diagnostic)
|
|
671
|
+
note('reviewer', brief.diagnostic);
|
|
672
|
+
const reviewedRecord = { ...record, ...(brief.brief ? { brief: brief.brief } : {}), ...(brief.diagnostic ? { briefDiagnostic: brief.diagnostic } : {}), manifest, ...(outcome.result.sessionId ? { sessionId: outcome.result.sessionId } : {}) };
|
|
525
673
|
// Acceptance evidence is bound to the exact candidate before the reviewer verdict is recorded.
|
|
526
674
|
recordAcceptance(context, report);
|
|
675
|
+
writeReviewBriefRecord(context, { change, approved, score: record.score, ...(brief.brief ? { brief: brief.brief } : {}), ...(brief.diagnostic ? { diagnostic: brief.diagnostic } : {}) });
|
|
527
676
|
if (!approved) {
|
|
528
677
|
transitionPipeline(context, 'reviewer', 'blocked', 'Review requests corrections');
|
|
529
678
|
note('reviewer', `Review requested corrections (score ${record.score}, ${plural(record.issues.length, 'issue')}); handing them to the fixer.`);
|
|
530
679
|
return { status: 'succeeded', next: 'fixer', update: { review: reviewedRecord }, output: reviewedRecord };
|
|
531
680
|
}
|
|
532
|
-
write(child(context.artifactRoot, 'openspec/changes/' + change + '/confidence-score.json'), JSON.stringify({ change, overall: record.score, aspects: record.aspects, summary: record.summary }, null, 2) + '\n');
|
|
681
|
+
write(child(context.artifactRoot, 'openspec/changes/' + change + '/confidence-score.json'), JSON.stringify({ change, overall: record.score, aspects: record.aspects, summary: record.summary, ...(brief.brief ? { brief: brief.brief } : {}) }, null, 2) + '\n');
|
|
533
682
|
transitionPipeline(context, 'reviewer', 'done');
|
|
534
683
|
note('reviewer', `Review approved with score ${record.score}: ${record.summary}`);
|
|
535
684
|
const reviewed = { ...reviewedRecord, candidateHash: fingerprintCandidate(journal(context)) };
|
|
@@ -548,7 +697,17 @@ function archiveNode(deps) {
|
|
|
548
697
|
step.reportUsage(NO_SPEND);
|
|
549
698
|
step.interrupt({ kind: 'approval', reason: 'Approve archive after inspecting the verified implementation' });
|
|
550
699
|
}
|
|
551
|
-
await archive(context, change, step.signal
|
|
700
|
+
await archive(context, change, step.signal, { beforeDone: archived => {
|
|
701
|
+
// Only journals that exclude the map from the candidate may rewrite it (older runs keep their reviewed hash).
|
|
702
|
+
if (!journal(context).artifactExclusions.includes(CAPABILITY_MAP_PATH))
|
|
703
|
+
return;
|
|
704
|
+
try {
|
|
705
|
+
recordArchivedChange(context, change, archived);
|
|
706
|
+
}
|
|
707
|
+
catch (error) {
|
|
708
|
+
deps.note('architect', `The capability map could not be updated (${error instanceof Error ? error.message : String(error)}); baseline detection will not see this change.`);
|
|
709
|
+
}
|
|
710
|
+
} });
|
|
552
711
|
const record = { archivePath: journal(context).archivePath, deliveryOwner: context.ownership.git };
|
|
553
712
|
return { status: 'succeeded', next: null, update: { archived: record }, output: record, usage: NO_SPEND };
|
|
554
713
|
},
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type PipelineContext } from '../../pipeline/pipeline-state.js';
|
|
2
|
+
import { REVIEW_BRIEF_SCHEMA_VERSION, type ReviewBrief } from './review-brief.js';
|
|
3
|
+
/** Host-readable record of the latest reviewer verdict's brief, rewritten at every verdict. */
|
|
4
|
+
export interface ReviewBriefRecord {
|
|
5
|
+
schemaVersion: typeof REVIEW_BRIEF_SCHEMA_VERSION;
|
|
6
|
+
change: string;
|
|
7
|
+
approved: boolean;
|
|
8
|
+
score: number;
|
|
9
|
+
recordedAt: string;
|
|
10
|
+
/** Null when the reviewer sent no brief or it was dropped. */
|
|
11
|
+
brief: ReviewBrief | null;
|
|
12
|
+
/** Present when a malformed brief was dropped after the repair turn. */
|
|
13
|
+
diagnostic?: string;
|
|
14
|
+
}
|
|
15
|
+
export declare const REVIEW_BRIEF_FILE = "review-brief.json";
|
|
16
|
+
export declare function reviewBriefPath(context: Pick<PipelineContext, 'backlogRoot' | 'runId'>): string;
|
|
17
|
+
export declare function writeReviewBriefRecord(context: PipelineContext, verdict: {
|
|
18
|
+
change: string;
|
|
19
|
+
approved: boolean;
|
|
20
|
+
score: number;
|
|
21
|
+
brief?: ReviewBrief;
|
|
22
|
+
diagnostic?: string;
|
|
23
|
+
}): void;
|
|
24
|
+
/** The latest recorded brief for a run, or null when no reviewer verdict was recorded (or the file is unreadable). */
|
|
25
|
+
export declare function readReviewBriefRecord(context: Pick<PipelineContext, 'backlogRoot' | 'runId'>): ReviewBriefRecord | null;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pipelineStateDirectory } from '../../pipeline/pipeline-state.js';
|
|
4
|
+
import { write } from './artifacts.js';
|
|
5
|
+
import { REVIEW_BRIEF_SCHEMA_VERSION } from './review-brief.js';
|
|
6
|
+
export const REVIEW_BRIEF_FILE = 'review-brief.json';
|
|
7
|
+
export function reviewBriefPath(context) {
|
|
8
|
+
return path.join(pipelineStateDirectory(context), REVIEW_BRIEF_FILE);
|
|
9
|
+
}
|
|
10
|
+
export function writeReviewBriefRecord(context, verdict) {
|
|
11
|
+
const record = { schemaVersion: REVIEW_BRIEF_SCHEMA_VERSION, change: verdict.change, approved: verdict.approved, score: verdict.score, recordedAt: new Date().toISOString(), brief: verdict.brief ?? null, ...(verdict.diagnostic ? { diagnostic: verdict.diagnostic } : {}) };
|
|
12
|
+
write(reviewBriefPath(context), JSON.stringify(record, null, 2) + '\n');
|
|
13
|
+
}
|
|
14
|
+
/** The latest recorded brief for a run, or null when no reviewer verdict was recorded (or the file is unreadable). */
|
|
15
|
+
export function readReviewBriefRecord(context) {
|
|
16
|
+
try {
|
|
17
|
+
const file = reviewBriefPath(context);
|
|
18
|
+
if (!existsSync(file))
|
|
19
|
+
return null;
|
|
20
|
+
const value = JSON.parse(readFileSync(file, 'utf8'));
|
|
21
|
+
return value && typeof value === 'object' && value.schemaVersion === REVIEW_BRIEF_SCHEMA_VERSION ? value : null;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reviewer's risk brief: an optional, advisory companion to the verdict that
|
|
3
|
+
* tells a human what changed, what the change leans on, what no test exercises
|
|
4
|
+
* and where to look first. It never feeds the approval gate (review-policy.ts).
|
|
5
|
+
*/
|
|
6
|
+
export declare const REVIEW_BRIEF_SCHEMA_VERSION = 1;
|
|
7
|
+
/** At most this many risks reach the brief; the rest become acceptance findings. */
|
|
8
|
+
export declare const REVIEW_BRIEF_RISK_LIMIT = 5;
|
|
9
|
+
export declare const REVIEW_RISK_SEVERITIES: readonly ['high', 'medium', 'low'];
|
|
10
|
+
export declare const REVIEW_RISK_AREAS: readonly ['data', 'security', 'api', 'ux', 'performance', 'ops', 'other'];
|
|
11
|
+
export declare const REVIEW_INVARIANT_STATUSES: readonly ['kept', 'changed', 'at-risk'];
|
|
12
|
+
export interface ReviewBehaviourChange {
|
|
13
|
+
what: string;
|
|
14
|
+
where: string;
|
|
15
|
+
userVisible: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface ReviewInvariant {
|
|
18
|
+
invariant: string;
|
|
19
|
+
evidence: string;
|
|
20
|
+
status: typeof REVIEW_INVARIANT_STATUSES[number];
|
|
21
|
+
}
|
|
22
|
+
export interface ReviewUntested {
|
|
23
|
+
path: string;
|
|
24
|
+
why: string;
|
|
25
|
+
}
|
|
26
|
+
export interface ReviewRisk {
|
|
27
|
+
severity: typeof REVIEW_RISK_SEVERITIES[number];
|
|
28
|
+
area: typeof REVIEW_RISK_AREAS[number];
|
|
29
|
+
whatToLookAt: string;
|
|
30
|
+
ref: string;
|
|
31
|
+
}
|
|
32
|
+
/** A spec claim of a touched capability that the code contradicts (reviewer check 9); may be empty. */
|
|
33
|
+
export interface ReviewDivergence {
|
|
34
|
+
capability: string;
|
|
35
|
+
requirement: string;
|
|
36
|
+
observed: string;
|
|
37
|
+
}
|
|
38
|
+
export interface ReviewBrief {
|
|
39
|
+
behaviourChanges?: ReviewBehaviourChange[];
|
|
40
|
+
invariantsTouched?: ReviewInvariant[];
|
|
41
|
+
untested?: ReviewUntested[];
|
|
42
|
+
risks?: ReviewRisk[];
|
|
43
|
+
humanChecks?: string[];
|
|
44
|
+
divergences?: ReviewDivergence[];
|
|
45
|
+
}
|
|
46
|
+
/** The optional `brief` property of REVIEW_OUTPUT_SCHEMA. Every list is optional and may be empty. */
|
|
47
|
+
export declare const REVIEW_BRIEF_SCHEMA: Record<string, unknown>;
|
|
48
|
+
/**
|
|
49
|
+
* Validates and normalizes a reviewer brief. Absent (`undefined`/`null`) means
|
|
50
|
+
* no brief; an empty object is a valid, empty brief. Risks are ordered by
|
|
51
|
+
* severity (stable within a severity) and capped at REVIEW_BRIEF_RISK_LIMIT;
|
|
52
|
+
* the rest are returned as `overflow`. Throws `Invalid review brief: …` when
|
|
53
|
+
* malformed, which the role invoker treats as repairable.
|
|
54
|
+
*/
|
|
55
|
+
export declare function parseReviewBrief(value: unknown): {
|
|
56
|
+
brief?: ReviewBrief;
|
|
57
|
+
overflow: ReviewRisk[];
|
|
58
|
+
};
|
|
59
|
+
/** One acceptance finding line for a risk that did not fit in the brief. */
|
|
60
|
+
export declare function riskFinding(risk: ReviewRisk): string;
|
|
61
|
+
export interface SettledReviewBrief {
|
|
62
|
+
brief?: ReviewBrief;
|
|
63
|
+
overflow: ReviewRisk[];
|
|
64
|
+
diagnostic?: string;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Applies the repair policy: on the first reply a malformed brief throws so the
|
|
68
|
+
* role invoker spends its single repair turn; on the repaired reply it is
|
|
69
|
+
* dropped with a diagnostic instead of failing the review.
|
|
70
|
+
*/
|
|
71
|
+
export declare function settleReviewBrief(value: unknown, options: {
|
|
72
|
+
repairable: boolean;
|
|
73
|
+
}): SettledReviewBrief;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reviewer's risk brief: an optional, advisory companion to the verdict that
|
|
3
|
+
* tells a human what changed, what the change leans on, what no test exercises
|
|
4
|
+
* and where to look first. It never feeds the approval gate (review-policy.ts).
|
|
5
|
+
*/
|
|
6
|
+
export const REVIEW_BRIEF_SCHEMA_VERSION = 1;
|
|
7
|
+
/** At most this many risks reach the brief; the rest become acceptance findings. */
|
|
8
|
+
export const REVIEW_BRIEF_RISK_LIMIT = 5;
|
|
9
|
+
const LIST_LIMIT = 20;
|
|
10
|
+
const TEXT_LIMIT = 2000;
|
|
11
|
+
export const REVIEW_RISK_SEVERITIES = ['high', 'medium', 'low'];
|
|
12
|
+
export const REVIEW_RISK_AREAS = ['data', 'security', 'api', 'ux', 'performance', 'ops', 'other'];
|
|
13
|
+
export const REVIEW_INVARIANT_STATUSES = ['kept', 'changed', 'at-risk'];
|
|
14
|
+
const text = { type: 'string', minLength: 1 };
|
|
15
|
+
const items = (properties) => ({ type: 'array', items: { type: 'object', additionalProperties: false, required: Object.keys(properties), properties } });
|
|
16
|
+
/** The optional `brief` property of REVIEW_OUTPUT_SCHEMA. Every list is optional and may be empty. */
|
|
17
|
+
export const REVIEW_BRIEF_SCHEMA = {
|
|
18
|
+
type: 'object', additionalProperties: false,
|
|
19
|
+
description: 'Optional risk brief for the human who merges the change. Advisory only: it never changes the verdict.',
|
|
20
|
+
properties: {
|
|
21
|
+
behaviourChanges: items({ what: text, where: { ...text, description: 'file:line' }, userVisible: { type: 'boolean' } }),
|
|
22
|
+
invariantsTouched: items({ invariant: text, evidence: { ...text, description: 'file:line or file:start-end' }, status: { type: 'string', enum: [...REVIEW_INVARIANT_STATUSES] } }),
|
|
23
|
+
untested: items({ path: text, why: text }),
|
|
24
|
+
risks: items({ severity: { type: 'string', enum: [...REVIEW_RISK_SEVERITIES] }, area: { type: 'string', enum: [...REVIEW_RISK_AREAS] }, whatToLookAt: text, ref: { ...text, description: 'file:line' } }),
|
|
25
|
+
humanChecks: { type: 'array', items: text },
|
|
26
|
+
divergences: items({ capability: text, requirement: text, observed: text }),
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
class BriefError extends Error {
|
|
30
|
+
constructor(detail) { super('Invalid review brief: ' + detail); }
|
|
31
|
+
}
|
|
32
|
+
function field(value, where) {
|
|
33
|
+
if (typeof value !== 'string' || !value.trim())
|
|
34
|
+
throw new BriefError(`${where} must be a non-empty string`);
|
|
35
|
+
return value.trim().slice(0, TEXT_LIMIT);
|
|
36
|
+
}
|
|
37
|
+
function member(value, allowed, where) {
|
|
38
|
+
if (typeof value !== 'string' || !allowed.includes(value))
|
|
39
|
+
throw new BriefError(`${where} must be one of ${allowed.join(', ')}`);
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
function list(value, name, parse) {
|
|
43
|
+
if (value === undefined || value === null)
|
|
44
|
+
return undefined;
|
|
45
|
+
if (!Array.isArray(value))
|
|
46
|
+
throw new BriefError(`${name} must be an array`);
|
|
47
|
+
return value.map((item, index) => {
|
|
48
|
+
const where = `${name}[${index}]`;
|
|
49
|
+
if (!item || typeof item !== 'object' || Array.isArray(item))
|
|
50
|
+
throw new BriefError(`${where} must be an object`);
|
|
51
|
+
return parse(item, where);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const SEVERITY_RANK = { high: 0, medium: 1, low: 2 };
|
|
55
|
+
/**
|
|
56
|
+
* Validates and normalizes a reviewer brief. Absent (`undefined`/`null`) means
|
|
57
|
+
* no brief; an empty object is a valid, empty brief. Risks are ordered by
|
|
58
|
+
* severity (stable within a severity) and capped at REVIEW_BRIEF_RISK_LIMIT;
|
|
59
|
+
* the rest are returned as `overflow`. Throws `Invalid review brief: …` when
|
|
60
|
+
* malformed, which the role invoker treats as repairable.
|
|
61
|
+
*/
|
|
62
|
+
export function parseReviewBrief(value) {
|
|
63
|
+
if (value === undefined || value === null)
|
|
64
|
+
return { overflow: [] };
|
|
65
|
+
if (typeof value !== 'object' || Array.isArray(value))
|
|
66
|
+
throw new BriefError('brief must be an object');
|
|
67
|
+
const raw = value;
|
|
68
|
+
const brief = {};
|
|
69
|
+
const behaviourChanges = list(raw.behaviourChanges, 'behaviourChanges', (item, where) => {
|
|
70
|
+
if (typeof item.userVisible !== 'boolean')
|
|
71
|
+
throw new BriefError(`${where}.userVisible must be a boolean`);
|
|
72
|
+
return { what: field(item.what, where + '.what'), where: field(item.where, where + '.where'), userVisible: item.userVisible };
|
|
73
|
+
});
|
|
74
|
+
const invariantsTouched = list(raw.invariantsTouched, 'invariantsTouched', (item, where) => ({
|
|
75
|
+
invariant: field(item.invariant, where + '.invariant'), evidence: field(item.evidence, where + '.evidence'), status: member(item.status, REVIEW_INVARIANT_STATUSES, where + '.status'),
|
|
76
|
+
}));
|
|
77
|
+
const untested = list(raw.untested, 'untested', (item, where) => ({ path: field(item.path, where + '.path'), why: field(item.why, where + '.why') }));
|
|
78
|
+
const risks = list(raw.risks, 'risks', (item, where) => ({
|
|
79
|
+
severity: member(item.severity, REVIEW_RISK_SEVERITIES, where + '.severity'), area: member(item.area, REVIEW_RISK_AREAS, where + '.area'),
|
|
80
|
+
whatToLookAt: field(item.whatToLookAt, where + '.whatToLookAt'), ref: field(item.ref, where + '.ref'),
|
|
81
|
+
}));
|
|
82
|
+
let humanChecks;
|
|
83
|
+
if (raw.humanChecks !== undefined && raw.humanChecks !== null) {
|
|
84
|
+
if (!Array.isArray(raw.humanChecks))
|
|
85
|
+
throw new BriefError('humanChecks must be an array');
|
|
86
|
+
humanChecks = raw.humanChecks.map((item, index) => field(item, `humanChecks[${index}]`));
|
|
87
|
+
}
|
|
88
|
+
const divergences = list(raw.divergences, 'divergences', (item, where) => ({
|
|
89
|
+
capability: field(item.capability, where + '.capability'), requirement: field(item.requirement, where + '.requirement'), observed: field(item.observed, where + '.observed'),
|
|
90
|
+
}));
|
|
91
|
+
let overflow = [];
|
|
92
|
+
if (behaviourChanges)
|
|
93
|
+
brief.behaviourChanges = behaviourChanges.slice(0, LIST_LIMIT);
|
|
94
|
+
if (invariantsTouched)
|
|
95
|
+
brief.invariantsTouched = invariantsTouched.slice(0, LIST_LIMIT);
|
|
96
|
+
if (untested)
|
|
97
|
+
brief.untested = untested.slice(0, LIST_LIMIT);
|
|
98
|
+
if (risks) {
|
|
99
|
+
const ordered = risks.map((risk, index) => ({ risk, index })).sort((a, b) => SEVERITY_RANK[a.risk.severity] - SEVERITY_RANK[b.risk.severity] || a.index - b.index).map(entry => entry.risk);
|
|
100
|
+
brief.risks = ordered.slice(0, REVIEW_BRIEF_RISK_LIMIT);
|
|
101
|
+
overflow = ordered.slice(REVIEW_BRIEF_RISK_LIMIT);
|
|
102
|
+
}
|
|
103
|
+
if (humanChecks)
|
|
104
|
+
brief.humanChecks = humanChecks.slice(0, LIST_LIMIT);
|
|
105
|
+
if (divergences)
|
|
106
|
+
brief.divergences = divergences.slice(0, LIST_LIMIT);
|
|
107
|
+
return { brief, overflow };
|
|
108
|
+
}
|
|
109
|
+
/** One acceptance finding line for a risk that did not fit in the brief. */
|
|
110
|
+
export function riskFinding(risk) {
|
|
111
|
+
return `Additional ${risk.severity} ${risk.area} risk (beyond the brief's top ${REVIEW_BRIEF_RISK_LIMIT}): ${risk.whatToLookAt} (${risk.ref})`;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Applies the repair policy: on the first reply a malformed brief throws so the
|
|
115
|
+
* role invoker spends its single repair turn; on the repaired reply it is
|
|
116
|
+
* dropped with a diagnostic instead of failing the review.
|
|
117
|
+
*/
|
|
118
|
+
export function settleReviewBrief(value, options) {
|
|
119
|
+
try {
|
|
120
|
+
return parseReviewBrief(value);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (options.repairable)
|
|
124
|
+
throw error;
|
|
125
|
+
return { overflow: [], diagnostic: `Review brief dropped after the repair turn: ${error instanceof Error ? error.message : String(error)}` };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -10,6 +10,7 @@ import { performance } from 'node:perf_hooks';
|
|
|
10
10
|
import { frozenAcceptanceCriteria, pipelineStateDirectory } from '../../pipeline/pipeline-state.js';
|
|
11
11
|
import { AgentExecutionError, unknownUsage } from '../executor-types.js';
|
|
12
12
|
import { ROLE_INSTRUCTIONS_VERSION, repairInstructions } from '../prompts.js';
|
|
13
|
+
import { asksBeforePlanning } from './clarifications.js';
|
|
13
14
|
import { parseAgentObject } from './artifacts.js';
|
|
14
15
|
/** Executor failures that mean "this session cannot be continued", not "the role failed". */
|
|
15
16
|
const SESSION_FALLBACK_CODES = new Set(['session_not_found', 'session_expired', 'session_unsupported']);
|
|
@@ -138,9 +139,10 @@ export function createRoleInvoker(deps) {
|
|
|
138
139
|
parseError = error;
|
|
139
140
|
}
|
|
140
141
|
}
|
|
141
|
-
// A genuine architect question
|
|
142
|
-
//
|
|
143
|
-
|
|
142
|
+
// A genuine architect question (low confidence, or a high-impact
|
|
143
|
+
// clarification without a safe default) can precede artifact creation.
|
|
144
|
+
// Completed role results must prove this invocation used the workflow, not an old visit.
|
|
145
|
+
if (!(role === 'architect' && (output?.confidence === 'low' || asksBeforePlanning(output?.clarifications))))
|
|
144
146
|
workflow?.assertParticipation(cursor);
|
|
145
147
|
if (structured && !output && !options.lenient)
|
|
146
148
|
throw new Error('Invalid structured role response: ' + (parseError instanceof Error ? parseError.message : 'missing object'));
|