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.
Files changed (123) hide show
  1. package/dist/agent-runtime/capabilities.d.ts +5 -2
  2. package/dist/agent-runtime/capabilities.js +8 -2
  3. package/dist/agent-runtime/change-scope.d.ts +2 -0
  4. package/dist/agent-runtime/change-scope.js +5 -0
  5. package/dist/agent-runtime/cli-executor.js +7 -0
  6. package/dist/agent-runtime/cli.d.ts +6 -1
  7. package/dist/agent-runtime/cli.js +15 -4
  8. package/dist/agent-runtime/compact/reviewer.js +3 -1
  9. package/dist/agent-runtime/config.js +16 -5
  10. package/dist/agent-runtime/efficiency-summary.d.ts +7 -0
  11. package/dist/agent-runtime/efficiency-summary.js +7 -1
  12. package/dist/agent-runtime/engine/__fixtures__/implementation-fixture.js +1 -1
  13. package/dist/agent-runtime/engine/checkpoint/database.d.ts +9 -0
  14. package/dist/agent-runtime/engine/checkpoint/database.js +40 -3
  15. package/dist/agent-runtime/engine/checkpoint/ledger.d.ts +1 -0
  16. package/dist/agent-runtime/engine/checkpoint/ledger.js +7 -2
  17. package/dist/agent-runtime/engine/cli.d.ts +12 -0
  18. package/dist/agent-runtime/engine/cli.js +15 -4
  19. package/dist/agent-runtime/engine/definition-schema.d.ts +1 -1
  20. package/dist/agent-runtime/engine/definition-schema.js +1 -0
  21. package/dist/agent-runtime/engine/definition-types.d.ts +1 -1
  22. package/dist/agent-runtime/engine/definition-types.js +1 -1
  23. package/dist/agent-runtime/engine/definition-validator.js +8 -0
  24. package/dist/agent-runtime/engine/efficiency-summary.d.ts +7 -0
  25. package/dist/agent-runtime/engine/graph-description.d.ts +3 -3
  26. package/dist/agent-runtime/engine/invocation-context.js +2 -0
  27. package/dist/agent-runtime/engine/piece-registry.d.ts +1 -1
  28. package/dist/agent-runtime/engine/piece-registry.js +1 -1
  29. package/dist/agent-runtime/engine/pieces/artifact-contract.d.ts +5 -0
  30. package/dist/agent-runtime/engine/pieces/artifact-contract.js +6 -2
  31. package/dist/agent-runtime/engine/pieces/derivation-coverage.d.ts +30 -0
  32. package/dist/agent-runtime/engine/pieces/derivation-coverage.js +95 -0
  33. package/dist/agent-runtime/engine/pieces/derive-change.d.ts +50 -0
  34. package/dist/agent-runtime/engine/pieces/derive-change.js +209 -0
  35. package/dist/agent-runtime/engine/pieces/index.js +2 -1
  36. package/dist/agent-runtime/engine/pieces/openspec.d.ts +16 -1
  37. package/dist/agent-runtime/engine/pieces/openspec.js +39 -30
  38. package/dist/agent-runtime/engine/pieces/role-turn.js +26 -5
  39. package/dist/agent-runtime/engine/preflight.d.ts +2 -1
  40. package/dist/agent-runtime/engine/preflight.js +3 -1
  41. package/dist/agent-runtime/engine/regex.d.ts +12 -2
  42. package/dist/agent-runtime/engine/regex.js +25 -11
  43. package/dist/agent-runtime/engine/run-status.d.ts +34 -0
  44. package/dist/agent-runtime/engine/run-status.js +7 -1
  45. package/dist/agent-runtime/engine/runs.d.ts +56 -0
  46. package/dist/agent-runtime/engine/runs.js +88 -5
  47. package/dist/agent-runtime/engine/steering/inbox.d.ts +15 -0
  48. package/dist/agent-runtime/engine/steering/inbox.js +21 -1
  49. package/dist/agent-runtime/engine/storage/private-path.js +7 -1
  50. package/dist/agent-runtime/evaluation.js +1 -1
  51. package/dist/agent-runtime/executor-types.d.ts +19 -0
  52. package/dist/agent-runtime/executor-types.js +10 -0
  53. package/dist/agent-runtime/failure-classification.d.ts +51 -0
  54. package/dist/agent-runtime/failure-classification.js +110 -0
  55. package/dist/agent-runtime/graph/archive-preflight.d.ts +3 -18
  56. package/dist/agent-runtime/graph/archive-preflight.js +6 -51
  57. package/dist/agent-runtime/graph/artifacts.d.ts +3 -1
  58. package/dist/agent-runtime/graph/artifacts.js +6 -2
  59. package/dist/agent-runtime/graph/baseline.d.ts +50 -0
  60. package/dist/agent-runtime/graph/baseline.js +145 -0
  61. package/dist/agent-runtime/graph/capability-map.d.ts +29 -0
  62. package/dist/agent-runtime/graph/capability-map.js +65 -0
  63. package/dist/agent-runtime/graph/clarifications.d.ts +75 -0
  64. package/dist/agent-runtime/graph/clarifications.js +158 -0
  65. package/dist/agent-runtime/graph/nodes.js +178 -19
  66. package/dist/agent-runtime/graph/review-brief-record.d.ts +25 -0
  67. package/dist/agent-runtime/graph/review-brief-record.js +26 -0
  68. package/dist/agent-runtime/graph/review-brief.d.ts +73 -0
  69. package/dist/agent-runtime/graph/review-brief.js +127 -0
  70. package/dist/agent-runtime/graph/roles.js +5 -3
  71. package/dist/agent-runtime/graph/spec-trace-record.d.ts +33 -0
  72. package/dist/agent-runtime/graph/spec-trace-record.js +65 -0
  73. package/dist/agent-runtime/graph/state.d.ts +8 -0
  74. package/dist/agent-runtime/index.d.ts +7 -0
  75. package/dist/agent-runtime/index.js +7 -0
  76. package/dist/agent-runtime/openspec.d.ts +2 -1
  77. package/dist/agent-runtime/openspec.js +11 -5
  78. package/dist/agent-runtime/project-rules.d.ts +139 -0
  79. package/dist/agent-runtime/project-rules.js +683 -0
  80. package/dist/agent-runtime/prompts.d.ts +32 -1
  81. package/dist/agent-runtime/prompts.js +180 -7
  82. package/dist/agent-runtime/role-routing.d.ts +2 -1
  83. package/dist/agent-runtime/session/application/active-session.d.ts +43 -2
  84. package/dist/agent-runtime/session/application/active-session.js +163 -7
  85. package/dist/agent-runtime/session/application/session-service.d.ts +2 -2
  86. package/dist/agent-runtime/session/domain/descriptor.js +5 -0
  87. package/dist/agent-runtime/session/domain/interruption.js +1 -0
  88. package/dist/agent-runtime/session/domain/policy.js +2 -0
  89. package/dist/agent-runtime/session/domain/redaction.d.ts +19 -0
  90. package/dist/agent-runtime/session/domain/redaction.js +71 -0
  91. package/dist/agent-runtime/session/domain/types.d.ts +17 -1
  92. package/dist/agent-runtime/session/drivers/claude/argv.d.ts +14 -0
  93. package/dist/agent-runtime/session/drivers/claude/argv.js +19 -2
  94. package/dist/agent-runtime/session/drivers/claude/driver.js +11 -1
  95. package/dist/agent-runtime/session/drivers/claude/translator.d.ts +4 -0
  96. package/dist/agent-runtime/session/drivers/claude/translator.js +16 -3
  97. package/dist/agent-runtime/session/drivers/codex/driver.js +2 -0
  98. package/dist/agent-runtime/session/drivers/executor/driver.js +2 -0
  99. package/dist/agent-runtime/session/host/protocol.d.ts +12 -0
  100. package/dist/agent-runtime/session/host/protocol.js +10 -2
  101. package/dist/agent-runtime/session/host/server.js +6 -5
  102. package/dist/agent-runtime/session/index.d.ts +1 -1
  103. package/dist/agent-runtime/session/journal/sqlite-journal.js +6 -2
  104. package/dist/agent-runtime/session/ports.d.ts +6 -2
  105. package/dist/agent-runtime/session/testing/driver-conformance.js +16 -2
  106. package/dist/agent-runtime/session/testing/memory-journal.js +4 -2
  107. package/dist/installer/cli.js +10 -1
  108. package/dist/installer/commands/init.js +15 -4
  109. package/dist/installer/phases/prereqs.d.ts +1 -1
  110. package/dist/installer/phases/prereqs.js +2 -2
  111. package/dist/installer/phases/scaffold.js +59 -11
  112. package/dist/pipeline/pipeline-state.d.ts +15 -0
  113. package/dist/pipeline/pipeline-state.js +36 -3
  114. package/dist/pipeline/spec-trace.d.ts +134 -0
  115. package/dist/pipeline/spec-trace.js +392 -0
  116. package/dist/shared/secret-redaction.d.ts +42 -0
  117. package/dist/shared/secret-redaction.js +184 -0
  118. package/integration-contract.json +177 -7
  119. package/package.json +8 -8
  120. package/pinned-versions.json +1 -1
  121. package/schemas/agent-runtime.schema.json +3 -1
  122. package/schemas/workflow-definition.schema.json +1 -0
  123. 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 architecture = parseArchitecture(object(output));
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 prompt = roleInstructions('architect', context, change, { definition: config.rolePrompts?.architect, verification: config.verification, answers, planning: config.efficiency?.planning });
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 ((config.architect?.onLowConfidence ?? 'ask') === 'ask') {
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
- const applied = await workflow.assertReady();
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 full = roleInstructions('developer', context, change, { definition: fixing ? config.rolePrompts?.fixer : config.rolePrompts?.developer, ...(fixing ? { stance: 'fixer' } : {}), feedback, verification: state.plan, developer: state.development, ...(changes ? { changeSet: changes } : {}) }) + (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.' : '');
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
- return { status: 'succeeded', next: 'reviewer', update: { verifyResult: evidence, verifyHistory: [outcome(true)] }, output: evidence, usage: NO_SPEND };
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
- return { criteria: certified, checks, findings: (Array.isArray(input.findings) ? input.findings : []) };
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 prompt = roleInstructions('reviewer', context, change, { definition: config.rolePrompts?.reviewer, feedback: feedbackFor(state), verification: state.plan, policy, criteria, developer: state.development, ...(changes ? { changeSet: changes } : {}), ...(reReview ? { reReview } : {}) });
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
- return { ...review, report };
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
- const reviewedRecord = { ...record, manifest, ...(outcome.result.sessionId ? { sessionId: outcome.result.sessionId } : {}) };
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 can precede artifact creation. Completed
142
- // role results must prove this invocation used the workflow, not an old visit.
143
- if (!(role === 'architect' && output?.confidence === 'low'))
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'));