ai-foreman 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/claude.js +4 -3
- package/dist/adapters/codex.d.ts +2 -0
- package/dist/adapters/codex.js +7 -2
- package/dist/branch/git.js +3 -8
- package/dist/buildApproval.js +3 -9
- package/dist/buildRuns.d.ts +1 -0
- package/dist/buildRuns.js +6 -1
- package/dist/cli/doctor.js +3 -1
- package/dist/cli/start.js +63 -13
- package/dist/cli/tickets.d.ts +0 -1
- package/dist/cli/tickets.js +11 -22
- package/dist/continuity.d.ts +0 -2
- package/dist/continuity.js +30 -34
- package/dist/dispatchNotSent.d.ts +4 -0
- package/dist/dispatchNotSent.js +10 -0
- package/dist/foreman.d.ts +5 -12
- package/dist/foreman.js +19 -85
- package/dist/gitExclude.d.ts +16 -0
- package/dist/gitExclude.js +46 -0
- package/dist/graph/turn.d.ts +1 -1
- package/dist/graph/turn.js +1 -1
- package/dist/handoffs.d.ts +3 -3
- package/dist/handoffs.js +5 -32
- package/dist/humanDecision.d.ts +7 -0
- package/dist/humanDecision.js +11 -0
- package/dist/observability.js +3 -8
- package/dist/qaBuildGate.js +13 -1
- package/dist/qaPreparation.js +21 -7
- package/dist/qaPreparationStore.d.ts +11 -0
- package/dist/qaPreparationStore.js +44 -4
- package/dist/qaRecovery.d.ts +0 -3
- package/dist/qaRecovery.js +1 -24
- package/dist/qaReview.js +32 -22
- package/dist/qaSnapshot.js +19 -14
- package/dist/qaStructuredResponse.d.ts +8 -0
- package/dist/qaStructuredResponse.js +24 -0
- package/dist/qaTimeline.js +3 -5
- package/dist/recoveryOwners.d.ts +32 -0
- package/dist/recoveryOwners.js +87 -0
- package/dist/runtimePreflight.d.ts +11 -0
- package/dist/runtimePreflight.js +77 -0
- package/dist/sources/sourceRegistry.d.ts +0 -3
- package/dist/sources/sourceRegistry.js +0 -42
- package/dist/structuredOperation.js +26 -4
- package/dist/ticketPlanning.d.ts +0 -3
- package/dist/ticketPlanning.js +0 -23
- package/dist/ticketPopulationPaging.js +23 -1
- package/dist/workflowDb.d.ts +16 -1
- package/dist/workflowDb.js +89 -22
- package/package.json +11 -3
package/dist/qaRecovery.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
3
|
import { chmodSync, closeSync, cpSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readlinkSync, readdirSync, realpathSync, renameSync, writeFileSync, } from "node:fs";
|
|
4
4
|
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { parseStrictJson, validateQaFailureReport, QA_FAILURE_REPORT_MAX_BYTES } from "rafi-spec";
|
|
6
6
|
import { calculateFrozenQaStateDigest, captureFrozenQaSource } from "./qaSnapshot.js";
|
|
7
7
|
import { WorkflowDb } from "./workflowDb.js";
|
|
8
8
|
export const QA_RECOVERY_ROOT = ".foreman/qa-report-recovery";
|
|
@@ -520,29 +520,6 @@ export function renderQaRecoveryAcknowledgementInstruction(packet, relativePath)
|
|
|
520
520
|
JSON.stringify({ packet: packet.manifest.packetDigest, reviewed_state: packet.manifest.reviewedStateDigest, required_resources_read: "all" }),
|
|
521
521
|
].join("\n");
|
|
522
522
|
}
|
|
523
|
-
export function validateQaRecoveryAcknowledgement(text, packet, options = {}) {
|
|
524
|
-
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
525
|
-
const expected = `RAFI_QA_RECOVERY_ACK packet="${packet.manifest.packetDigest}" reviewed_state="${packet.manifest.reviewedStateDigest}" required_resources_read="all"`;
|
|
526
|
-
const errors = [];
|
|
527
|
-
if (lines[0] !== expected)
|
|
528
|
-
errors.push("missing or malformed exact recovery-packet acknowledgement");
|
|
529
|
-
const acknowledgement = /^RAFI_QA_RECOVERY_ACK packet="([a-f0-9]{64})" reviewed_state="([a-f0-9]{64})" required_resources_read="(all)"$/.exec(lines[0] ?? "");
|
|
530
|
-
if (acknowledgement) {
|
|
531
|
-
try {
|
|
532
|
-
parseProviderContract(qaRecoveryAcknowledgementV1Schema, { packet: acknowledgement[1], reviewed_state: acknowledgement[2], required_resources_read: acknowledgement[3] }, "RAFI_QA_RECOVERY_ACK");
|
|
533
|
-
}
|
|
534
|
-
catch (error) {
|
|
535
|
-
errors.push(error instanceof Error ? error.message : String(error));
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
if (options.continuityAlreadyValidated) {
|
|
539
|
-
if (lines.length !== 1)
|
|
540
|
-
errors.push("recovery acknowledgement must not contain additional text");
|
|
541
|
-
}
|
|
542
|
-
else if (lines.length !== 2 || !/^RAFI_CONTINUITY_DELTA(?:\s|$)/.test(lines[1] ?? ""))
|
|
543
|
-
errors.push("exactly one continuity record must follow the recovery acknowledgement");
|
|
544
|
-
return errors;
|
|
545
|
-
}
|
|
546
523
|
export function qaRecoveryInventory(packet) {
|
|
547
524
|
return packet.manifest.resources.map((resource) => `${resource.path}\t${resource.purpose}\t${resource.mediaType}\t${resource.bytes}\t${resource.digest}\t${resource.integrityPolicy}`).join("\n");
|
|
548
525
|
}
|
package/dist/qaReview.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { describeLegacyQaTranscript } from "./legacyCommunicationDisplay.js";
|
|
2
1
|
import { BoundedTurnEvents, boundedUtf8, receiptFingerprint } from "./boundedTurnEvidence.js";
|
|
3
|
-
import { qaStructuredResponse, qaStructuredStatus } from "./qaStructuredResponse.js";
|
|
2
|
+
import { describeNativeQaResponse, qaStructuredResponse, qaStructuredStatus } from "./qaStructuredResponse.js";
|
|
4
3
|
import { journalStructuredDispatch, operationInstruction, operationPayload, operationResponse, nativePolicy } from "./structuredOperation.js";
|
|
5
4
|
import { durableOwnerDispatch } from "./structuredDispatchAuthority.js";
|
|
6
5
|
import { providerWireContract, qaVerdictOutput, qaRecoveryOutput, initializationOutput, remediationOutput } from "rafi-spec";
|
|
@@ -19,10 +18,10 @@ import { formatRecoveryCommand } from "./recoveryGuidance.js";
|
|
|
19
18
|
import { assertBuildAssignmentReconciled, BuildAssignmentRejected, captureBuildSource } from "./buildAssignment.js";
|
|
20
19
|
import { checkQaPrerequisites } from "./qaPrerequisites.js";
|
|
21
20
|
import { boundedQaHistory } from "./qaHandbackHistory.js";
|
|
22
|
-
import {
|
|
21
|
+
import { qaFailureReportV1Schema, } from "rafi-spec";
|
|
23
22
|
import { SessionUnavailableError } from "./adapters/sessionFailure.js";
|
|
24
23
|
import { currentActivity, withActivityPhase } from "./activity.js";
|
|
25
|
-
import { buildQaInstruction
|
|
24
|
+
import { buildQaInstruction } from "./foreman.js";
|
|
26
25
|
import { captureFrozenQaSourceAsync, captureProspectiveGitTree, createDisposableQaSnapshotAsync, prospectiveGitTreeMatches, QaSourceInstabilityError } from "./qaSnapshot.js";
|
|
27
26
|
import { loadTicketSetupConfigWithDefaults } from "./tickets/setupConfig.js";
|
|
28
27
|
import { randomUUID } from "node:crypto";
|
|
@@ -84,6 +83,9 @@ function pauseQaReview(opts, stage, detail, frozenState) {
|
|
|
84
83
|
* The allowance is shared by all protocol failures for this ticket and survives restart.
|
|
85
84
|
* oneReview's finally closes the predecessor before the loop allocates a new snapshot.
|
|
86
85
|
*/
|
|
86
|
+
/** Frozen source of the review an automatic fresh retry replaces, so a retry
|
|
87
|
+
* that cannot be dispatched safely still pauses at that exact boundary. */
|
|
88
|
+
const freshRetryState = new WeakMap();
|
|
87
89
|
function retryFreshQaReview(opts, stage, detail, frozenState) {
|
|
88
90
|
const db = new WorkflowDb(opts.recovery.projectDir);
|
|
89
91
|
let retry = false;
|
|
@@ -102,6 +104,7 @@ function retryFreshQaReview(opts, stage, detail, frozenState) {
|
|
|
102
104
|
}
|
|
103
105
|
if (!retry)
|
|
104
106
|
return pauseQaReview(opts, stage, `${detail}; automatic fresh QA review allowance exhausted`, frozenState);
|
|
107
|
+
freshRetryState.set(opts, frozenState);
|
|
105
108
|
opts.resumedRecovery = undefined;
|
|
106
109
|
currentActivity()?.update("qa-recovery", `${opts.ticket.id}: ${detail}. Running a complete fresh QA review.`);
|
|
107
110
|
opts.evidence?.({ cycle: 0, outcome: "qa_fresh_review_retry", detail });
|
|
@@ -366,7 +369,18 @@ async function runIsolatedQaOwned(opts) {
|
|
|
366
369
|
opts.qaHistory = [...history];
|
|
367
370
|
reviewAttempt += 1;
|
|
368
371
|
const attemptId = randomUUID();
|
|
369
|
-
|
|
372
|
+
let review;
|
|
373
|
+
try {
|
|
374
|
+
review = await withActivityPhase(`running QA review ${cycle} (${automaticFixes}/${maxBuilderFixes} fixes used)`, () => observedQaReview(opts, { cycle, reviewAttempt, remediationGeneration, attemptId }));
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
// An automatic fresh review that graph-access revocation blocks before
|
|
378
|
+
// dispatch pauses safely instead of failing the build.
|
|
379
|
+
const frozen = freshRetryState.get(opts);
|
|
380
|
+
if (frozen && /Graph access changed/.test(String(error)))
|
|
381
|
+
return pauseQaReview(opts, "graph-access-changed", String(error).replace(/^Error: /, ""), frozen);
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
370
384
|
if (review.outcome === "retry-modification") {
|
|
371
385
|
const durableFixes = persistedQaFixCount(opts);
|
|
372
386
|
cycle += Math.max(0, durableFixes - automaticFixes);
|
|
@@ -898,8 +912,8 @@ async function oneReview(opts, identity) {
|
|
|
898
912
|
return pauseQaReview(opts, "invalid-continuity", `QA continuity record was invalid: ${turn.continuityErrors.join("; ")}`, reviewedSnapshot.frozenState);
|
|
899
913
|
if (!responseContract.valid && !turn.failure) {
|
|
900
914
|
const retained = resumedPacket
|
|
901
|
-
? await retainInvalidResumedResponse(opts, identity, resumedPacket, snapshot, turn, responseContract.errors, qaEvents)
|
|
902
|
-
: await retainInvalidQaResponse(opts, identity, snapshot, qa, qaHandle, handoff, turn, responseContract.errors, qaEvents);
|
|
915
|
+
? await retainInvalidResumedResponse(opts, identity, resumedPacket, snapshot, turn, responseContract.errors, qaEvents, true)
|
|
916
|
+
: await retainInvalidQaResponse(opts, identity, snapshot, qa, qaHandle, handoff, turn, responseContract.errors, qaEvents, true);
|
|
903
917
|
return retained.result;
|
|
904
918
|
}
|
|
905
919
|
if (turn.isError || turn.failure)
|
|
@@ -990,7 +1004,7 @@ function finishDurableQaReview(opts, attemptId, patch) {
|
|
|
990
1004
|
function qaFindingRefs(runId, ticketId, reviewAttemptId, reportDigest, rawFindingIds) {
|
|
991
1005
|
return createQaFindingRefs({ runId, ticketId, reviewAttemptId, reportDigest, rawFindingIds });
|
|
992
1006
|
}
|
|
993
|
-
async function retainInvalidResumedResponse(opts, identity, initialPacket, snapshot, originalTurn, originalErrors, qaEvents) {
|
|
1007
|
+
async function retainInvalidResumedResponse(opts, identity, initialPacket, snapshot, originalTurn, originalErrors, qaEvents, retryFresh = false) {
|
|
994
1008
|
const originalResponse = effectiveTurnText(originalTurn);
|
|
995
1009
|
let packet = appendQaRecoveryResource(initialPacket, `qa-responses/resumed-${identity.attemptId}.txt`, originalResponse, {
|
|
996
1010
|
purpose: "Exact first cleaned QA report response after resumed packet acknowledgement", exact: true,
|
|
@@ -998,9 +1012,10 @@ async function retainInvalidResumedResponse(opts, identity, initialPacket, snaps
|
|
|
998
1012
|
await awaitQaTurnEvent(qaEvents, originalTurn);
|
|
999
1013
|
packet = await appendTurnObservation(packet, `resumed-original-${identity.attemptId}`, originalTurn, originalTurn.hostInstruction, originalErrors, "resumed-fail-closed", qaEvents);
|
|
1000
1014
|
packet = appendQaRecoveryResource(packet, `validation/resumed-${identity.attemptId}.json`, {
|
|
1001
|
-
status:
|
|
1015
|
+
status: describeNativeQaResponse(originalResponse).status, errors: originalErrors, recoveryStage: "resumed-fail-closed",
|
|
1002
1016
|
}, { purpose: "Validation result for the first resumed QA report response" });
|
|
1003
|
-
|
|
1017
|
+
const resumedDetail = `Resumed QA response failed validation. Evidence retained at ${packet.directory}. ${originalErrors.map(error => sanitizePreview(error)).join("; ")}`;
|
|
1018
|
+
return { outcome: "failed", result: retryFresh ? retryFreshQaReview(opts, "resumed-invalid-contract", resumedDetail, snapshot.frozenState) : pauseQaReview(opts, "resumed-invalid-contract", resumedDetail, snapshot.frozenState) };
|
|
1004
1019
|
}
|
|
1005
1020
|
export function buildQaReviewHandoff(ticket, builderSummary, diffDigest, validationChecklist, changeSummary, history = []) {
|
|
1006
1021
|
return [
|
|
@@ -1011,7 +1026,7 @@ export function buildQaReviewHandoff(ticket, builderSummary, diffDigest, validat
|
|
|
1011
1026
|
`Prior authoritative QA/report and Builder-remediation history: ${history.length ? JSON.stringify(boundedQaHistory(history)) : "(none; perform a complete first review)"}`,
|
|
1012
1027
|
`Failure report JSON Schema: ${JSON.stringify(qaFailureReportV1Schema)}`,
|
|
1013
1028
|
QA_COMMAND_DISCIPLINE,
|
|
1014
|
-
"Submit the native qa-verdict payload.
|
|
1029
|
+
"Submit the native qa-verdict payload. Every verdict, passed or failed, includes coverage and verifiedInstructionIds. Coverage holds the required coverage claims when a verification contract exists; otherwise it is null. A failed verdict also includes the complete failure report.",
|
|
1015
1030
|
"Be very thorough. Don't leave anything out. Make sure all required fields are added. Triple check your work to ensure nothing is left out and all required fields are added and populated correctly.",
|
|
1016
1031
|
"Do not change source, configuration, documentation, tickets, or control files. Only ignored cache, coverage, and build output is allowed.",
|
|
1017
1032
|
].join("\n");
|
|
@@ -1032,7 +1047,7 @@ export async function compactWithRetry(adapter) {
|
|
|
1032
1047
|
export const QA_REPORT_RECOVERY_MENU = [
|
|
1033
1048
|
"Try a complete fresh QA review.", "Manually fix saved JSON for human inspection.", "Give QA specific instructions for a fresh review.", "Pause.",
|
|
1034
1049
|
];
|
|
1035
|
-
async function retainInvalidQaResponse(opts, identity, snapshot, originalQa, qaHandle, reviewPrompt, originalTurn, originalErrors, qaEvents) {
|
|
1050
|
+
async function retainInvalidQaResponse(opts, identity, snapshot, originalQa, qaHandle, reviewPrompt, originalTurn, originalErrors, qaEvents, retryFresh = false) {
|
|
1036
1051
|
const originalResponse = effectiveTurnText(originalTurn);
|
|
1037
1052
|
const runId = opts.recovery?.runId ?? `volatile-${identity.attemptId}`;
|
|
1038
1053
|
const continuity = qaContinuityRecoveryContext(opts);
|
|
@@ -1041,7 +1056,7 @@ async function retainInvalidQaResponse(opts, identity, snapshot, originalQa, qaH
|
|
|
1041
1056
|
projectDir: opts.recovery?.projectDir ?? opts.builderWorktree, frozenState: snapshot.frozenState,
|
|
1042
1057
|
runId, ticketId: opts.ticket.id, cycle: identity.cycle, reviewAttempt: identity.reviewAttempt,
|
|
1043
1058
|
reviewAttemptId: identity.attemptId,
|
|
1044
|
-
recoveryStage: "same-session", correctionTurns: 0, reportJson:
|
|
1059
|
+
recoveryStage: "same-session", correctionTurns: 0, reportJson: describeNativeQaResponse(originalResponse).reportJson ?? "{}\n",
|
|
1045
1060
|
resources: {
|
|
1046
1061
|
ticket: { value: opts.ticket, purpose: "Complete authoritative ticket definition" },
|
|
1047
1062
|
"qa-review-prompt": { value: reviewPrompt, purpose: "Exact original QA review instruction", exactText: true },
|
|
@@ -1052,7 +1067,7 @@ async function retainInvalidQaResponse(opts, identity, snapshot, originalQa, qaH
|
|
|
1052
1067
|
"original-provider-prompt": { value: originalTurn.providerInstruction ?? { unavailable: "adapter did not expose provider-dispatched instruction" }, purpose: "Exact provider-dispatched original QA instruction or explicit unavailable marker", exactText: typeof originalTurn.providerInstruction === "string" },
|
|
1053
1068
|
"original-raw-response": { value: originalTurn.transportEvidence ?? { unavailable: true }, purpose: "Retained bounded transport references" },
|
|
1054
1069
|
"original-cleaned-response": { value: originalTurn.transportEvidence ?? { unavailable: true }, purpose: "Retained bounded cleaned evidence references" },
|
|
1055
|
-
"original-validation": { value: { errors: originalErrors, parsedStatus:
|
|
1070
|
+
"original-validation": { value: { errors: originalErrors, parsedStatus: describeNativeQaResponse(originalResponse).status, responseBytes: Buffer.byteLength(originalResponse), turn: turnMetadata(originalTurn) }, purpose: "Parsed status, validator result, and complete host-observable turn metadata" },
|
|
1056
1071
|
"qa-history": { value: opts.qaHistory ?? [], purpose: "Earlier valid reports, observations, fixes, and unresolved history" },
|
|
1057
1072
|
"tool-events": { value: qaEvents instanceof BoundedTurnEvents ? qaEvents.metadata() : { unavailable: true }, purpose: "All QA tool/activity events available through BuilderEvent, or an explicit unavailable marker" },
|
|
1058
1073
|
continuity: { value: continuity, purpose: "Cumulative continuity checkpoints, later host facts, handoff lineage, and recovery history" },
|
|
@@ -1072,10 +1087,11 @@ async function retainInvalidQaResponse(opts, identity, snapshot, originalQa, qaH
|
|
|
1072
1087
|
}
|
|
1073
1088
|
let packet = existingPacket ? appendQaRecoveryAttempt(existingPacket, packetInput) : createQaRecoveryPacket(packetInput);
|
|
1074
1089
|
packet = appendQaRecoveryResource(packet, "qa-responses/native-receipt.json", originalTurn.structured ?? { unavailable: true }, { purpose: "Original native provider receipt retained without changing or repeating completed QA work" });
|
|
1075
|
-
|
|
1090
|
+
const invalidDetail = `QA response failed validation. Evidence retained at ${packet.directory}. ${originalErrors.join("; ")}`;
|
|
1091
|
+
return { outcome: "failed", result: retryFresh ? retryFreshQaReview(opts, "invalid-native-qa-response", invalidDetail, snapshot.frozenState) : pauseQaReview(opts, "invalid-native-qa-response", invalidDetail, snapshot.frozenState) };
|
|
1076
1092
|
}
|
|
1077
1093
|
async function exhausted(packet, originalResponse, opts, prefix = "QA review requires an explicit recovery decision", qa, snapshot, liveSession = Boolean(qa), unavailableOperatorReason, requireFreshReviewReason) {
|
|
1078
|
-
const synopsis =
|
|
1094
|
+
const synopsis = describeNativeQaResponse(originalResponse).issues?.trim();
|
|
1079
1095
|
let currentPrefix = prefix;
|
|
1080
1096
|
if (packet.manifest.pendingAction !== "operator-menu") {
|
|
1081
1097
|
packet = updateQaRecoveryPosition(packet, "operator-menu", packet.manifest.correctionTurns, "operator-menu");
|
|
@@ -1138,12 +1154,6 @@ function normalizeUnsupportedOperator(result, reason) {
|
|
|
1138
1154
|
? { outcome: "failed", result: { outcome: "needs-human", detail: `Requested QA recovery route is unavailable because ${reason}` } }
|
|
1139
1155
|
: result;
|
|
1140
1156
|
}
|
|
1141
|
-
function extractReportBody(text) {
|
|
1142
|
-
const start = text.split(/\r?\n/).findIndex((line) => line.trim() === QA_FAILURE_REPORT_START);
|
|
1143
|
-
const lines = text.split(/\r?\n/);
|
|
1144
|
-
const end = lines.findIndex((line, index) => index > start && line.trim() === QA_FAILURE_REPORT_END);
|
|
1145
|
-
return start >= 0 && end > start ? lines.slice(start + 1, end).join("\n") : "{}\n";
|
|
1146
|
-
}
|
|
1147
1157
|
function boundedBuilderSummary(text) { return Buffer.from(text).subarray(0, 16 * 1024).toString(); }
|
|
1148
1158
|
function latestBuilderResponse(opts) {
|
|
1149
1159
|
return opts.state.builderResponseHistory?.filter((entry) => entry.ticketId === opts.ticket.id).at(-1)?.response ?? opts.builderSummary;
|
package/dist/qaSnapshot.js
CHANGED
|
@@ -499,6 +499,25 @@ async function cloneRepositoryAsync(root, review, head) {
|
|
|
499
499
|
* commands in the independent clone can resolve the same installed toolchain.
|
|
500
500
|
*/
|
|
501
501
|
function projectDependencyTrees(sourceRoot, reviewRoot, uncommitted = false) {
|
|
502
|
+
// Ignored top-level directories (vendored checkouts, runtimes) are also
|
|
503
|
+
// environment. A committed clone links one only when the clone ignores it
|
|
504
|
+
// too, so the link never appears as an untracked QA edit. Link these first:
|
|
505
|
+
// projecting a nested node_modules would otherwise create the parent as a
|
|
506
|
+
// plain directory and hide the checkout (e.g. vendor/starnet/.git).
|
|
507
|
+
for (const entry of readdirSync(sourceRoot, { withFileTypes: true })) {
|
|
508
|
+
if (!entry.isDirectory() || [".git", ".rafi", ".foreman", "node_modules"].includes(entry.name))
|
|
509
|
+
continue;
|
|
510
|
+
const source = join(sourceRoot, entry.name);
|
|
511
|
+
const target = join(reviewRoot, entry.name);
|
|
512
|
+
if (existsSync(target))
|
|
513
|
+
continue;
|
|
514
|
+
const ignored = spawnSync("git", ["-C", sourceRoot, "check-ignore", "-q", "--", entry.name]).status === 0;
|
|
515
|
+
if (!ignored)
|
|
516
|
+
continue;
|
|
517
|
+
if (!uncommitted && spawnSync("git", ["-C", reviewRoot, "check-ignore", "-q", "--", entry.name]).status !== 0)
|
|
518
|
+
continue;
|
|
519
|
+
symlinkSync(source, target, "dir");
|
|
520
|
+
}
|
|
502
521
|
const parents = ["", ...readdirSync(sourceRoot, { withFileTypes: true })
|
|
503
522
|
.filter((entry) => entry.isDirectory() && entry.name !== ".git" && entry.name !== "node_modules")
|
|
504
523
|
.flatMap((entry) => {
|
|
@@ -519,20 +538,6 @@ function projectDependencyTrees(sourceRoot, reviewRoot, uncommitted = false) {
|
|
|
519
538
|
mkdirSync(dirname(target), { recursive: true });
|
|
520
539
|
symlinkSync(source, target, "dir");
|
|
521
540
|
}
|
|
522
|
-
if (!uncommitted)
|
|
523
|
-
return;
|
|
524
|
-
for (const entry of readdirSync(sourceRoot, { withFileTypes: true })) {
|
|
525
|
-
if (!entry.isDirectory() || [".git", ".rafi", ".foreman", "node_modules"].includes(entry.name))
|
|
526
|
-
continue;
|
|
527
|
-
const source = join(sourceRoot, entry.name);
|
|
528
|
-
const target = join(reviewRoot, entry.name);
|
|
529
|
-
if (existsSync(target))
|
|
530
|
-
continue;
|
|
531
|
-
const ignored = spawnSync("git", ["-C", sourceRoot, "check-ignore", "-q", "--", entry.name]).status === 0;
|
|
532
|
-
if (!ignored)
|
|
533
|
-
continue;
|
|
534
|
-
symlinkSync(source, target, "dir");
|
|
535
|
-
}
|
|
536
541
|
}
|
|
537
542
|
function hasHeadSync(cwd) {
|
|
538
543
|
const result = spawnSync("git", ["-C", resolve(cwd), "rev-parse", "--verify", "HEAD"], { stdio: "ignore" });
|
|
@@ -8,3 +8,11 @@ export declare function remediationStructuredResponse(turn: TurnResult, expected
|
|
|
8
8
|
handoffId?: string;
|
|
9
9
|
findings?: QaFindingRefV2[];
|
|
10
10
|
}): BuilderQaRemediationContract;
|
|
11
|
+
/** Describe a retained native qa-verdict response for evidence and display.
|
|
12
|
+
* It never authorizes a result; workflow decisions use qaStructuredResponse. */
|
|
13
|
+
export declare function describeNativeQaResponse(text: string): {
|
|
14
|
+
status: "qa_pass" | "qa_fail" | "blocked" | "needs_input" | "unknown";
|
|
15
|
+
valid: boolean;
|
|
16
|
+
issues?: string;
|
|
17
|
+
reportJson?: string;
|
|
18
|
+
};
|
|
@@ -55,3 +55,27 @@ export function remediationStructuredResponse(turn, expected) {
|
|
|
55
55
|
return { valid: false, errors: [String(error)], status: "unknown", fields: {} };
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
+
/** Describe a retained native qa-verdict response for evidence and display.
|
|
59
|
+
* It never authorizes a result; workflow decisions use qaStructuredResponse. */
|
|
60
|
+
export function describeNativeQaResponse(text) {
|
|
61
|
+
let value;
|
|
62
|
+
try {
|
|
63
|
+
value = JSON.parse(text);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return { status: "unknown", valid: false, issues: "Response is not native JSON" };
|
|
67
|
+
}
|
|
68
|
+
const result = value?.result;
|
|
69
|
+
if (value?.operation !== "qa-verdict" || !result || typeof result !== "object")
|
|
70
|
+
return { status: "unknown", valid: false, issues: "Response is not a qa-verdict result" };
|
|
71
|
+
if (result.kind === "blocked")
|
|
72
|
+
return { status: "blocked", valid: true, issues: typeof result.reason === "string" ? result.reason : undefined };
|
|
73
|
+
if (result.kind === "needs-input")
|
|
74
|
+
return { status: "needs_input", valid: true, issues: typeof result.question === "string" ? result.question : undefined };
|
|
75
|
+
const summary = typeof result.summary === "string" ? result.summary : undefined;
|
|
76
|
+
if (result.kind === "completed" && result.payload?.kind === "passed")
|
|
77
|
+
return { status: "qa_pass", valid: true, issues: summary };
|
|
78
|
+
if (result.kind === "completed" && result.payload?.kind === "failed")
|
|
79
|
+
return { status: "qa_fail", valid: true, issues: summary, reportJson: JSON.stringify(result.payload.report ?? {}) };
|
|
80
|
+
return { status: "unknown", valid: false, issues: summary };
|
|
81
|
+
}
|
package/dist/qaTimeline.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { decisionBelongsToWork } from "./buildInterventions.js";
|
|
2
2
|
import { evidenceRecord } from "./qaEvidenceReader.js";
|
|
3
|
-
import {
|
|
3
|
+
import { describeNativeQaResponse } from "./qaStructuredResponse.js";
|
|
4
4
|
/** All counters are scoped rows. A changed digest is never a code-change explanation. */
|
|
5
5
|
export function buildQaTimeline(snapshot, workId) {
|
|
6
6
|
const scoped = (table) => (snapshot.rows[table] ?? []).filter(row => row.ticket_id === workId);
|
|
@@ -25,10 +25,8 @@ export function buildQaTimeline(snapshot, workId) {
|
|
|
25
25
|
if (/correction|repair/.test(slot))
|
|
26
26
|
counters.corrections++;
|
|
27
27
|
const bytes = typeof receipt?.cleanedResponseDigest === "string" ? snapshot.blobs.get(receipt.cleanedResponseDigest) : undefined;
|
|
28
|
-
const contract = bytes ?
|
|
29
|
-
|
|
30
|
-
if (typeof receipt?.structuredResponseDigest === "string")
|
|
31
|
-
classification = "unavailable";
|
|
28
|
+
const contract = bytes ? describeNativeQaResponse(bytes.toString("utf8")) : undefined;
|
|
29
|
+
const classification = contract ? contract.valid ? contract.status : "malformed" : "unavailable";
|
|
32
30
|
if (slot === "initial" && classification === "malformed")
|
|
33
31
|
counters.malformedResponses++;
|
|
34
32
|
if (slot === "initial" && classification === "blocked")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface RecoveryOwner {
|
|
2
|
+
source: "build-admission" | "project-lease" | "supervisor" | "owned-process";
|
|
3
|
+
runId: string;
|
|
4
|
+
pid: number | null;
|
|
5
|
+
processStart?: string;
|
|
6
|
+
host?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface CompletedPlanApproval {
|
|
9
|
+
runId: string;
|
|
10
|
+
originalWork: unknown;
|
|
11
|
+
state: {
|
|
12
|
+
planId?: string;
|
|
13
|
+
revision?: number;
|
|
14
|
+
digest?: string;
|
|
15
|
+
decisionReceipt?: unknown;
|
|
16
|
+
} & Record<string, unknown>;
|
|
17
|
+
updatedAt: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Reads completed `rafi plan` approval runs without migrating. They are
|
|
21
|
+
* planning state that predates any build, so a build reset must carry them
|
|
22
|
+
* into the fresh database; build recovery refuses to resume without one.
|
|
23
|
+
*/
|
|
24
|
+
export declare function readCompletedPlanApprovals(dbPath: string): CompletedPlanApproval[];
|
|
25
|
+
/**
|
|
26
|
+
* Lists every process that may still own a recovery database, without
|
|
27
|
+
* migrating it. Archiving tools need this because opening a database from an
|
|
28
|
+
* older Rafi through WorkflowDb runs the structured-communication cutover, and
|
|
29
|
+
* a stale build admission left by a dead process makes that cutover fail.
|
|
30
|
+
* Unknown or malformed tables are skipped; callers decide liveness.
|
|
31
|
+
*/
|
|
32
|
+
export declare function readRecoveryOwners(dbPath: string): RecoveryOwner[];
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
/**
|
|
4
|
+
* Reads completed `rafi plan` approval runs without migrating. They are
|
|
5
|
+
* planning state that predates any build, so a build reset must carry them
|
|
6
|
+
* into the fresh database; build recovery refuses to resume without one.
|
|
7
|
+
*/
|
|
8
|
+
export function readCompletedPlanApprovals(dbPath) {
|
|
9
|
+
if (!existsSync(dbPath))
|
|
10
|
+
return [];
|
|
11
|
+
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
12
|
+
try {
|
|
13
|
+
db.pragma("query_only = ON");
|
|
14
|
+
if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='workflow_runs'").get())
|
|
15
|
+
return [];
|
|
16
|
+
const rows = db.prepare("SELECT run_id,original_work_json,state_json,updated_at FROM workflow_runs WHERE kind='plan' AND status='completed' ORDER BY updated_at")
|
|
17
|
+
.all();
|
|
18
|
+
const approvals = [];
|
|
19
|
+
for (const row of rows) {
|
|
20
|
+
try {
|
|
21
|
+
approvals.push({ runId: row.run_id, originalWork: JSON.parse(row.original_work_json), state: JSON.parse(row.state_json), updatedAt: row.updated_at });
|
|
22
|
+
}
|
|
23
|
+
catch { /* a malformed historical row is not an approval */ }
|
|
24
|
+
}
|
|
25
|
+
return approvals;
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
db.close();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Lists every process that may still own a recovery database, without
|
|
33
|
+
* migrating it. Archiving tools need this because opening a database from an
|
|
34
|
+
* older Rafi through WorkflowDb runs the structured-communication cutover, and
|
|
35
|
+
* a stale build admission left by a dead process makes that cutover fail.
|
|
36
|
+
* Unknown or malformed tables are skipped; callers decide liveness.
|
|
37
|
+
*/
|
|
38
|
+
export function readRecoveryOwners(dbPath) {
|
|
39
|
+
if (!existsSync(dbPath))
|
|
40
|
+
return [];
|
|
41
|
+
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
|
|
42
|
+
try {
|
|
43
|
+
db.pragma("query_only = ON");
|
|
44
|
+
const has = (table) => Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(table));
|
|
45
|
+
const owners = [];
|
|
46
|
+
if (has("build_admission")) {
|
|
47
|
+
const row = db.prepare("SELECT record_json FROM build_admission WHERE singleton=1").get();
|
|
48
|
+
if (row) {
|
|
49
|
+
let record;
|
|
50
|
+
try {
|
|
51
|
+
record = JSON.parse(row.record_json);
|
|
52
|
+
}
|
|
53
|
+
catch { /* malformed: report as unverifiable */ }
|
|
54
|
+
owners.push({ source: "build-admission", runId: String(record?.runId ?? ""), pid: typeof record?.pid === "number" ? record.pid : null, processStart: record?.processStart, host: record?.host });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (has("project_lease")) {
|
|
58
|
+
for (const row of db.prepare("SELECT * FROM project_lease").all()) {
|
|
59
|
+
owners.push({ source: "project-lease", runId: String(row.run_id ?? ""), pid: typeof row.pid === "number" ? row.pid : null, processStart: row.process_start ?? undefined, host: row.host ?? undefined });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (has("supervisor_leases") && has("workflow_runs")) {
|
|
63
|
+
const rows = db.prepare(`SELECT s.run_id,s.state_json FROM supervisor_leases s JOIN workflow_runs r USING(run_id)
|
|
64
|
+
WHERE r.kind='build' AND r.status NOT IN ('completed','cancelled','superseded')`).all();
|
|
65
|
+
for (const row of rows) {
|
|
66
|
+
let state;
|
|
67
|
+
try {
|
|
68
|
+
state = JSON.parse(row.state_json);
|
|
69
|
+
}
|
|
70
|
+
catch { /* malformed: report as unverifiable */ }
|
|
71
|
+
// Matches the prior reset rule: only a supervisor recorded as running owns the run.
|
|
72
|
+
if (state && state.status !== "running")
|
|
73
|
+
continue;
|
|
74
|
+
owners.push({ source: "supervisor", runId: row.run_id, pid: typeof state?.pid === "number" ? state.pid : null, processStart: state?.processStart });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (has("build_owned_processes")) {
|
|
78
|
+
for (const row of db.prepare("SELECT run_id,pid,process_start,host FROM build_owned_processes WHERE state<>'quiescent'").all()) {
|
|
79
|
+
owners.push({ source: "owned-process", runId: String(row.run_id), pid: typeof row.pid === "number" ? row.pid : null, processStart: row.process_start ?? undefined, host: row.host ?? undefined });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return owners;
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
db.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface RuntimeCheck {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
label: string;
|
|
4
|
+
detail: string;
|
|
5
|
+
fix?: string;
|
|
6
|
+
}
|
|
7
|
+
/** Minimal semver check for the range forms Rafi declares: "24.x", "^13.0.3", "13.0.3". */
|
|
8
|
+
export declare function satisfiesRange(version: string, range: string): boolean;
|
|
9
|
+
export declare function checkRuntimeEnvironment(): RuntimeCheck[];
|
|
10
|
+
/** Text for a failed preflight, or undefined when every check passed. */
|
|
11
|
+
export declare function runtimePreflightFailure(checks?: RuntimeCheck[]): string | undefined;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime environment checks that must run before Rafi's main modules load.
|
|
3
|
+
* Uses only Node built-ins so it still reports clearly on an unsupported
|
|
4
|
+
* Node, where importing the CLI itself fails with an opaque SyntaxError.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
const REINSTALL_FIX = "reinstall Rafi under the active Node (npm install -g @rafi-ai/cli); in a Rafi workspace run `corepack pnpm install --frozen-lockfile`";
|
|
10
|
+
function packageJson() {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Minimal semver check for the range forms Rafi declares: "24.x", "^13.0.3", "13.0.3". */
|
|
19
|
+
export function satisfiesRange(version, range) {
|
|
20
|
+
const parse = (value) => value.replace(/^[v^~=]/, "").split(".").map(part => Number.parseInt(part, 10));
|
|
21
|
+
const [major, minor = 0, patch = 0] = parse(version);
|
|
22
|
+
const spec = range.trim();
|
|
23
|
+
if (/^\d+\.x(\.x)?$/.test(spec))
|
|
24
|
+
return major === Number.parseInt(spec, 10);
|
|
25
|
+
const [wantMajor, wantMinor = 0, wantPatch = 0] = parse(spec);
|
|
26
|
+
if (spec.startsWith("^"))
|
|
27
|
+
return major === wantMajor && (minor > wantMinor || minor === wantMinor && patch >= wantPatch);
|
|
28
|
+
return major === wantMajor && minor === wantMinor && patch === wantPatch;
|
|
29
|
+
}
|
|
30
|
+
export function checkRuntimeEnvironment() {
|
|
31
|
+
const pkg = packageJson();
|
|
32
|
+
const checks = [];
|
|
33
|
+
const nodeRange = pkg.engines?.node;
|
|
34
|
+
const nodeSupported = !nodeRange || satisfiesRange(process.versions.node, nodeRange);
|
|
35
|
+
if (nodeRange) {
|
|
36
|
+
const ok = nodeSupported;
|
|
37
|
+
checks.push({ ok, label: `node ${nodeRange}`, detail: `${process.version} (${process.execPath})`,
|
|
38
|
+
...(ok ? {} : { fix: `run Rafi with Node ${nodeRange}, e.g. \`nvm use 24\`, and reinstall it there` }) });
|
|
39
|
+
}
|
|
40
|
+
const sqliteRange = pkg.dependencies?.["better-sqlite3"];
|
|
41
|
+
const require = createRequire(import.meta.url);
|
|
42
|
+
let installed;
|
|
43
|
+
try {
|
|
44
|
+
installed = require("better-sqlite3/package.json").version;
|
|
45
|
+
}
|
|
46
|
+
catch { /* reported below */ }
|
|
47
|
+
if (sqliteRange) {
|
|
48
|
+
const ok = Boolean(installed && satisfiesRange(installed, sqliteRange));
|
|
49
|
+
checks.push({ ok, label: `better-sqlite3 ${sqliteRange}`, detail: installed ? `installed ${installed}` : "not installed",
|
|
50
|
+
...(ok ? {} : { fix: `the installed native SQLite module does not match the declared version; ${REINSTALL_FIX}` }) });
|
|
51
|
+
}
|
|
52
|
+
// Only probe the native binding on a supported Node with the declared
|
|
53
|
+
// version: under an unsupported Node the probe can abort the process
|
|
54
|
+
// (uncatchable N-API fatal error, seen on Node 18), and a mismatched module
|
|
55
|
+
// may load and then crash later (seen with 12.10 under Node 24).
|
|
56
|
+
if (nodeSupported && installed && (!sqliteRange || satisfiesRange(installed, sqliteRange))) {
|
|
57
|
+
try {
|
|
58
|
+
const Database = require("better-sqlite3");
|
|
59
|
+
new Database(":memory:").close();
|
|
60
|
+
checks.push({ ok: true, label: "native SQLite loads", detail: `Node ABI ${process.versions.modules}` });
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
64
|
+
const abi = /NODE_MODULE_VERSION (\d+)/.exec(message)?.[1];
|
|
65
|
+
checks.push({ ok: false, label: "native SQLite loads", detail: abi ? `built for Node ABI ${abi}, active Node ABI ${process.versions.modules}` : message.split("\n")[0],
|
|
66
|
+
fix: `rebuild the native module for this Node: ${REINSTALL_FIX}` });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return checks;
|
|
70
|
+
}
|
|
71
|
+
/** Text for a failed preflight, or undefined when every check passed. */
|
|
72
|
+
export function runtimePreflightFailure(checks = checkRuntimeEnvironment()) {
|
|
73
|
+
const failed = checks.filter(check => !check.ok);
|
|
74
|
+
if (!failed.length)
|
|
75
|
+
return undefined;
|
|
76
|
+
return ["rafi: this runtime environment cannot run Rafi:", ...failed.map(check => ` - ${check.label}: ${check.detail}${check.fix ? `\n fix: ${check.fix}` : ""}`)].join("\n");
|
|
77
|
+
}
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import type { ProjectSourceEntry, ProjectSourceLocator, ProjectSourceType, SourceRegistryConfig, SourceSnapshotStorage, TicketSetupSource } from "rafi-spec";
|
|
2
|
-
export declare const SOURCE_REQUEST_START = "RAFI_SOURCE_REQUEST_START";
|
|
3
|
-
export declare const SOURCE_REQUEST_END = "RAFI_SOURCE_REQUEST_END";
|
|
4
2
|
export interface StructuredSourceRequest {
|
|
5
3
|
type?: ProjectSourceType;
|
|
6
4
|
description?: string;
|
|
@@ -38,7 +36,6 @@ export declare function removeSource(registry: SourceRegistryConfig, id: string)
|
|
|
38
36
|
export declare function discardStagedSourceCaptures(projectDir: string, original: SourceRegistryConfig, staged: SourceRegistryConfig): void;
|
|
39
37
|
/** Compatibility bridge for legacy ticket setup callers. Existing entries and versions are retained. */
|
|
40
38
|
export declare function appendLegacyTicketSources(registry: SourceRegistryConfig, sources: TicketSetupSource[]): SourceRegistryConfig;
|
|
41
|
-
export declare function extractSourceRequests(output: string): StructuredSourceRequest[];
|
|
42
39
|
/** Deterministically recognize only an entire obvious locator; all other language stays pending for agent interpretation. */
|
|
43
40
|
export declare function sourceRequestFromAnswer(answer: string, projectDir: string): StructuredSourceRequest;
|
|
44
41
|
export declare function registerSourceRequests(projectDir: string, registry: SourceRegistryConfig, requests: StructuredSourceRequest[], opts?: {
|
|
@@ -8,10 +8,7 @@ import { parse, stringify } from "yaml";
|
|
|
8
8
|
import { fetchAndSnapshotUrl, normalizePublicHttpUrl, snapshotExternalLocalFile } from "../tickets/sourceFetch.js";
|
|
9
9
|
import { fetchJiraIssues, fetchLinearIssues } from "../tickets/importer.js";
|
|
10
10
|
import { withActivityPhase } from "../activity.js";
|
|
11
|
-
import { structuredSourceRequestSchema, zodIssues } from "rafi-spec";
|
|
12
11
|
const execFileAsync = promisify(execFile);
|
|
13
|
-
export const SOURCE_REQUEST_START = "RAFI_SOURCE_REQUEST_START";
|
|
14
|
-
export const SOURCE_REQUEST_END = "RAFI_SOURCE_REQUEST_END";
|
|
15
12
|
const CONFIG_FILE = "rafi-config.yaml";
|
|
16
13
|
const DEFAULT_REGISTRY = { version: 1, snapshot_storage: "local", entries: [] };
|
|
17
14
|
export function emptySourceRegistry(storage = "local") {
|
|
@@ -138,45 +135,6 @@ export function appendLegacyTicketSources(registry, sources) {
|
|
|
138
135
|
migrateTicketSource(next, source, warnings);
|
|
139
136
|
return next;
|
|
140
137
|
}
|
|
141
|
-
export function extractSourceRequests(output) {
|
|
142
|
-
const requests = [];
|
|
143
|
-
let cursor = 0;
|
|
144
|
-
while (true) {
|
|
145
|
-
const start = output.indexOf(SOURCE_REQUEST_START, cursor);
|
|
146
|
-
if (start < 0)
|
|
147
|
-
break;
|
|
148
|
-
const end = output.indexOf(SOURCE_REQUEST_END, start + SOURCE_REQUEST_START.length);
|
|
149
|
-
if (end < 0)
|
|
150
|
-
throw new Error(`source request is missing ${SOURCE_REQUEST_END}`);
|
|
151
|
-
const body = output.slice(start + SOURCE_REQUEST_START.length, end).trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
|
152
|
-
let value;
|
|
153
|
-
try {
|
|
154
|
-
value = JSON.parse(body);
|
|
155
|
-
}
|
|
156
|
-
catch (error) {
|
|
157
|
-
throw new Error(`source request is invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
158
|
-
}
|
|
159
|
-
const values = Array.isArray(value) ? value : [value];
|
|
160
|
-
for (const item of values) {
|
|
161
|
-
const parsed = structuredSourceRequestSchema.safeParse(item);
|
|
162
|
-
if (!parsed.success)
|
|
163
|
-
throw new Error(`source request contract validation failed:\n${zodIssues(parsed.error).map((issue) => `- ${issue}`).join("\n")}`);
|
|
164
|
-
requests.push(parsed.data);
|
|
165
|
-
}
|
|
166
|
-
cursor = end + SOURCE_REQUEST_END.length;
|
|
167
|
-
}
|
|
168
|
-
// Compatibility with the original single-line protocol. Preserve the complete remainder.
|
|
169
|
-
for (const match of output.matchAll(/^SOURCE_REQUEST:\s*(.+)$/gim)) {
|
|
170
|
-
const legacy = structuredSourceRequestSchema.safeParse({ description: match[1].trim() });
|
|
171
|
-
// Legacy lines are intentionally converted into the same typed contract;
|
|
172
|
-
// they remain description-only pending requests after registration, but
|
|
173
|
-
// they no longer bypass shape validation.
|
|
174
|
-
if (!legacy.success)
|
|
175
|
-
throw new Error(`legacy source request contract validation failed:\n${zodIssues(legacy.error).map((issue) => `- ${issue}`).join("\n")}`);
|
|
176
|
-
requests.push(legacy.data);
|
|
177
|
-
}
|
|
178
|
-
return requests;
|
|
179
|
-
}
|
|
180
138
|
/** Deterministically recognize only an entire obvious locator; all other language stays pending for agent interpretation. */
|
|
181
139
|
export function sourceRequestFromAnswer(answer, projectDir) {
|
|
182
140
|
const value = answer.trim();
|