jules-orchestrator-kit 0.74.0 → 0.75.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/CHANGELOG.md +27 -1
- package/JULES_RULES_TEMPLATE.md +3 -0
- package/README.md +4 -2
- package/bin/agentctl.mjs +395 -23
- package/package.json +1 -1
- package/scripts/ci-scope-guard.mjs +226 -23
- package/scripts/run-tests.mjs +5 -1
- package/src/bidi-guard.mjs +190 -15
- package/src/ci-templates.mjs +184 -21
- package/src/config.mjs +40 -1
- package/src/engine.mjs +342 -48
- package/src/evidence.mjs +22 -5
- package/src/git.mjs +15 -0
- package/src/migrate.mjs +493 -0
- package/src/ops/command-registry.mjs +93 -4
- package/src/ops/doctor-registry.mjs +184 -32
- package/src/ops/pr-harvest.mjs +22 -2
- package/src/ops/toolchain-diagnostics.mjs +253 -0
- package/src/ops/tui-menu.mjs +192 -0
- package/src/process-tree.mjs +146 -0
- package/src/prompt-guard.mjs +9 -0
- package/src/provider.mjs +14 -0
- package/src/scaffold.mjs +49 -1
- package/src/scope-guard.mjs +73 -5
- package/src/secret-scanner.mjs +14 -2
- package/src/security.mjs +41 -30
- package/src/self-audit.mjs +237 -3
- package/src/state.mjs +123 -13
- package/src/uninstall.mjs +227 -0
- package/src/wizard-init.mjs +11 -0
- package/src/wizard-task.mjs +17 -22
package/src/engine.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadConfig, resolveTrustedPolicy } from "./config.mjs";
|
|
1
|
+
import { loadConfig, resolveTrustedPolicy, normalizePath } from "./config.mjs";
|
|
2
2
|
import { isTestPath } from "./test-paths.mjs";
|
|
3
3
|
import { isPlaceholderTestScript, isSrcLayout } from "./stack-detector.mjs";
|
|
4
4
|
import { checkCollectionFloor } from "./ops/test-collection.mjs";
|
|
@@ -10,9 +10,9 @@ import { withBudget, appendLedger, getQueueDir, ensureDir, rollbackBudgetReserva
|
|
|
10
10
|
import { resolveDailyLimit, recordObservedCeiling, isDailyQuotaRejection, resolveAmbientIdentity } from "./budget.mjs";
|
|
11
11
|
import { sanitizeUntrustedData, buildAgentEnvelope } from "./prompt-guard.mjs";
|
|
12
12
|
import { recordVerifyRun, readVerifyRuns, flakyVerdict } from "./flaky-ledger.mjs";
|
|
13
|
-
import fs, { readdirSync, readFileSync, renameSync, existsSync } from "node:fs";
|
|
13
|
+
import fs, { readdirSync, readFileSync, writeFileSync, renameSync, existsSync } from "node:fs";
|
|
14
14
|
import { join, basename } from "node:path";
|
|
15
|
-
import { createHash } from "node:crypto";
|
|
15
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
16
16
|
import { appendTelemetryBestEffort as appendTelemetry } from "./telemetry.mjs";
|
|
17
17
|
|
|
18
18
|
import { spawn } from "node:child_process";
|
|
@@ -207,6 +207,7 @@ export async function gate(opts = {}) {
|
|
|
207
207
|
process.env.JULES_ALLOW_COMMAND_FILE_CHANGES === "1" ||
|
|
208
208
|
process.env.AGENT_ALLOW_COMMAND_FILE_CHANGES === "true" ||
|
|
209
209
|
process.env.AGENT_ALLOW_COMMAND_FILE_CHANGES === "1",
|
|
210
|
+
protectedPatterns: trustedScope.protectedPatterns,
|
|
210
211
|
});
|
|
211
212
|
// Report the violation against the link the change actually introduced, not
|
|
212
213
|
// against a path the diff never names — the operator has to be able to find it.
|
|
@@ -336,8 +337,18 @@ export async function gate(opts = {}) {
|
|
|
336
337
|
// failures stop reaching the exit code — the gate then sees exit 0 and
|
|
337
338
|
// approves a change whose tests failed. src/perf.mjs already strips these for
|
|
338
339
|
// the same reason; the gate, which is the one that decides, did not.
|
|
340
|
+
// Ambient PR waiver labels and HEAD_SHA must also be stripped so verification
|
|
341
|
+
// suites and child audits run hermetically without inherited waivers.
|
|
339
342
|
for (const key of Object.keys(testEnv)) {
|
|
340
|
-
if (
|
|
343
|
+
if (
|
|
344
|
+
key.startsWith("NODE_TEST_") ||
|
|
345
|
+
key.startsWith("NODE_CHANNEL_") ||
|
|
346
|
+
key === "PR_LABELS" ||
|
|
347
|
+
key === "HEAD_SHA" ||
|
|
348
|
+
key.startsWith("JULES_ALLOW_")
|
|
349
|
+
) {
|
|
350
|
+
delete testEnv[key];
|
|
351
|
+
}
|
|
341
352
|
}
|
|
342
353
|
|
|
343
354
|
let flakyVerdictResult = null;
|
|
@@ -815,12 +826,69 @@ export async function repair(failure, opts = {}) {
|
|
|
815
826
|
threshold: 2,
|
|
816
827
|
});
|
|
817
828
|
|
|
829
|
+
const repairId = `repair-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
830
|
+
let repairDir = null;
|
|
831
|
+
|
|
818
832
|
let currentFailure = failure;
|
|
819
833
|
const initialFingerprint = fingerprintFailureState(currentFailure, root);
|
|
820
834
|
breaker.observe(initialFingerprint);
|
|
821
835
|
whackAMole.recordTestOutcome(currentFailure.stderr || currentFailure.message);
|
|
822
836
|
let extraPromptDirective = null;
|
|
823
837
|
|
|
838
|
+
const persistFailedAttempt = (n, diff, diagnostics) => {
|
|
839
|
+
const relDir = normalizePath(join(".agent/state/repairs", repairId));
|
|
840
|
+
const absDir = join(root, relDir);
|
|
841
|
+
ensureDir(absDir);
|
|
842
|
+
const patchName = `attempt-${n}.patch`;
|
|
843
|
+
const diagName = `attempt-${n}-diagnostics.json`;
|
|
844
|
+
const patchRel = normalizePath(join(relDir, patchName));
|
|
845
|
+
const diagRel = normalizePath(join(relDir, diagName));
|
|
846
|
+
writeFileSync(join(absDir, patchName), redactSecrets(String(diff || "")), "utf-8");
|
|
847
|
+
const payload = {
|
|
848
|
+
...diagnostics,
|
|
849
|
+
patchPath: patchRel,
|
|
850
|
+
diagnosticsPath: diagRel,
|
|
851
|
+
};
|
|
852
|
+
writeFileSync(join(absDir, diagName), JSON.stringify(payload, null, 2) + "\n", "utf-8");
|
|
853
|
+
repairDir = relDir;
|
|
854
|
+
return payload;
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
const captureAttemptDiff = () => {
|
|
858
|
+
try {
|
|
859
|
+
// Working-tree mode retains what the repair attempt left on disk; the
|
|
860
|
+
// default committed mode only shows the last commit and would hide
|
|
861
|
+
// uncommitted agent edits that operators need to review.
|
|
862
|
+
const base = config.baseBranch || config.base || "main";
|
|
863
|
+
return diffText(root, base, "working-tree") || "";
|
|
864
|
+
} catch (_) {
|
|
865
|
+
return "";
|
|
866
|
+
}
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
const buildGateDiagnostics = (gateRes) => {
|
|
870
|
+
const failingPhase = (gateRes.phases || []).find((p) => p && p.ok === false) || null;
|
|
871
|
+
const verifyPhase = (gateRes.phases || []).find((p) => p && p.phase === "verify") || null;
|
|
872
|
+
const failureInfo = verifyPhase?.failure || null;
|
|
873
|
+
const testResult = verifyPhase?.testResult || null;
|
|
874
|
+
const stderr = failureInfo?.stderr || testResult?.stderr || gateRes.error || "";
|
|
875
|
+
const stdout = failureInfo?.stdout || testResult?.stdout || "";
|
|
876
|
+
return {
|
|
877
|
+
code: gateRes.code ?? null,
|
|
878
|
+
phase: failingPhase?.phase || "verify",
|
|
879
|
+
command: failureInfo?.command || testResult?.command || null,
|
|
880
|
+
exitCode: failureInfo?.exitCode ?? testResult?.status ?? null,
|
|
881
|
+
stderr: redactSecrets(String(stderr).slice(0, 8000)),
|
|
882
|
+
stdout: redactSecrets(String(stdout).slice(0, 4000)),
|
|
883
|
+
messages: Array.isArray(failureInfo?.diagnostics) ? failureInfo.diagnostics.map((d) => redactSecrets(String(d))) : [],
|
|
884
|
+
error: redactSecrets(
|
|
885
|
+
String(gateRes.error || failureInfo?.stderr || testResult?.stderr || "Gate re-verification failed")
|
|
886
|
+
.split("\n")[0]
|
|
887
|
+
.slice(0, 500)
|
|
888
|
+
),
|
|
889
|
+
};
|
|
890
|
+
};
|
|
891
|
+
|
|
824
892
|
for (let n = 1; n <= maxRetries; n++) {
|
|
825
893
|
if (progressBus && progressToken) {
|
|
826
894
|
progressBus.reportProgress(progressToken, Math.round((n / maxRetries) * 100), 100, `OODA Repair attempt ${n}/${maxRetries}`);
|
|
@@ -847,7 +915,14 @@ export async function repair(failure, opts = {}) {
|
|
|
847
915
|
const retryAfterMs = err.retryAfterMs || 60000;
|
|
848
916
|
const backoffSec = Math.ceil(retryAfterMs / 1000);
|
|
849
917
|
console.warn(`[PROVIDER_INFRASTRUCTURE_FAILURE] ${err.name}: ${err.message}. Recommended backoff: ${backoffSec}s.`);
|
|
850
|
-
attempts.push({
|
|
918
|
+
attempts.push({
|
|
919
|
+
n,
|
|
920
|
+
ok: false,
|
|
921
|
+
phase: "dispatch",
|
|
922
|
+
error: err.message,
|
|
923
|
+
providerError: true,
|
|
924
|
+
retryAfterMs,
|
|
925
|
+
});
|
|
851
926
|
appendTelemetry(root, "ooda_repair_attempt", { attempt: n, ok: false, error: err.message, providerError: true });
|
|
852
927
|
return {
|
|
853
928
|
ok: false,
|
|
@@ -856,9 +931,10 @@ export async function repair(failure, opts = {}) {
|
|
|
856
931
|
error: err.message,
|
|
857
932
|
retryAfterMs,
|
|
858
933
|
providerError: true,
|
|
934
|
+
repairDir,
|
|
859
935
|
};
|
|
860
936
|
}
|
|
861
|
-
attempts.push({ n, ok: false, error: err.message });
|
|
937
|
+
attempts.push({ n, ok: false, phase: "dispatch", error: err.message });
|
|
862
938
|
appendTelemetry(root, "ooda_repair_attempt", { attempt: n, ok: false, error: err.message });
|
|
863
939
|
break;
|
|
864
940
|
}
|
|
@@ -871,6 +947,11 @@ export async function repair(failure, opts = {}) {
|
|
|
871
947
|
dryRun: opts.dryRun,
|
|
872
948
|
pollIntervalMs: opts.pollIntervalMs,
|
|
873
949
|
maxPollAttempts: opts.maxPollAttempts,
|
|
950
|
+
autoApprovePlan: opts.autoApprovePlan || opts.autoApprove,
|
|
951
|
+
autoReply: opts.autoReply,
|
|
952
|
+
maxAutoReplies: opts.maxAutoReplies,
|
|
953
|
+
autoReplyFn: opts.autoReplyFn,
|
|
954
|
+
task: opts.task || { id: `repair-${n}`, title: `OODA Auto-Repair Attempt ${n}`, prompt: repairPrompt },
|
|
874
955
|
});
|
|
875
956
|
|
|
876
957
|
// The gate below is the authority on whether the change works, so it runs
|
|
@@ -898,17 +979,21 @@ export async function repair(failure, opts = {}) {
|
|
|
898
979
|
}
|
|
899
980
|
}
|
|
900
981
|
|
|
901
|
-
|
|
902
|
-
n,
|
|
903
|
-
session,
|
|
904
|
-
ok: true,
|
|
905
|
-
poll: pollVerdict ? { status: pollVerdict.status, terminal: pollVerdict.terminal } : null,
|
|
906
|
-
});
|
|
907
|
-
appendTelemetry(root, "ooda_repair_attempt", { attempt: n, ok: true });
|
|
982
|
+
const poll = pollVerdict ? { status: pollVerdict.status, terminal: pollVerdict.terminal } : null;
|
|
908
983
|
|
|
909
984
|
// Re-verify after repair attempt
|
|
910
985
|
const gateRes = await gate({ root, config, fix: false, progressBus, progressToken });
|
|
986
|
+
const attemptDiff = captureAttemptDiff();
|
|
911
987
|
if (gateRes.ok) {
|
|
988
|
+
attempts.push({
|
|
989
|
+
n,
|
|
990
|
+
session,
|
|
991
|
+
ok: true,
|
|
992
|
+
verified: true,
|
|
993
|
+
diff: attemptDiff,
|
|
994
|
+
poll,
|
|
995
|
+
});
|
|
996
|
+
appendTelemetry(root, "ooda_repair_attempt", { attempt: n, ok: true, verified: true });
|
|
912
997
|
breaker.reset();
|
|
913
998
|
whackAMole.reset();
|
|
914
999
|
recordRemediation(root, {
|
|
@@ -917,9 +1002,27 @@ export async function repair(failure, opts = {}) {
|
|
|
917
1002
|
remediationHint: `Resolved on repair attempt #${n}`,
|
|
918
1003
|
targetFiles: currentFailure.targetFiles || [],
|
|
919
1004
|
});
|
|
920
|
-
return { ok: true, attempts, finalStatus: "PASSED" };
|
|
1005
|
+
return { ok: true, attempts, finalStatus: "PASSED", repairDir };
|
|
921
1006
|
}
|
|
922
1007
|
|
|
1008
|
+
const diagnostics = persistFailedAttempt(n, attemptDiff, buildGateDiagnostics(gateRes));
|
|
1009
|
+
attempts.push({
|
|
1010
|
+
n,
|
|
1011
|
+
session,
|
|
1012
|
+
ok: false,
|
|
1013
|
+
verified: false,
|
|
1014
|
+
diff: attemptDiff,
|
|
1015
|
+
diagnostics,
|
|
1016
|
+
poll,
|
|
1017
|
+
});
|
|
1018
|
+
appendTelemetry(root, "ooda_repair_attempt", {
|
|
1019
|
+
attempt: n,
|
|
1020
|
+
ok: false,
|
|
1021
|
+
verified: false,
|
|
1022
|
+
code: gateRes.code ?? null,
|
|
1023
|
+
phase: diagnostics.phase,
|
|
1024
|
+
});
|
|
1025
|
+
|
|
923
1026
|
currentFailure = gateRes.phases.find((p) => p.phase === "verify")?.testResult || failure;
|
|
924
1027
|
const currentFingerprint = fingerprintFailureState(currentFailure, root);
|
|
925
1028
|
|
|
@@ -943,6 +1046,7 @@ export async function repair(failure, opts = {}) {
|
|
|
943
1046
|
finalStatus: "DETERMINISTIC_REGRESSION",
|
|
944
1047
|
reason: `Identical failure state fingerprint (${currentFingerprint}) observed during attempt #${n}`,
|
|
945
1048
|
fingerprint: currentFingerprint,
|
|
1049
|
+
repairDir,
|
|
946
1050
|
};
|
|
947
1051
|
}
|
|
948
1052
|
}
|
|
@@ -963,39 +1067,66 @@ export async function repair(failure, opts = {}) {
|
|
|
963
1067
|
});
|
|
964
1068
|
} catch (_) {}
|
|
965
1069
|
|
|
966
|
-
return { ok: false, attempts, finalStatus: "OODA_EXHAUSTED" };
|
|
1070
|
+
return { ok: false, attempts, finalStatus: "OODA_EXHAUSTED", repairDir };
|
|
967
1071
|
|
|
968
1072
|
}
|
|
969
1073
|
|
|
970
1074
|
/**
|
|
971
|
-
*
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
* Evaluates whether a task's verification oracle or goal is already satisfied on the current working tree.
|
|
975
|
-
* Prevents redundant session dispatch and API budget burning.
|
|
1075
|
+
* A passing generic test suite does not prove that a requested feature exists.
|
|
1076
|
+
* Only an explicit goal-specific check may suppress dispatch. This is advisory:
|
|
1077
|
+
* the supplied command must return 0 exactly when the requested state exists.
|
|
976
1078
|
*/
|
|
977
1079
|
export async function checkTaskPremise(task = {}, opts = {}) {
|
|
978
1080
|
const root = opts.config?._root || opts.root || process.cwd();
|
|
979
|
-
const
|
|
980
|
-
|
|
981
|
-
|
|
1081
|
+
const goalCheck = task.goalCheck ?? opts.goalCheck;
|
|
1082
|
+
const evidence = { revision: null, goalCheckSha256: null, exitCode: null, durationMs: null };
|
|
1083
|
+
const unknown = (reasonCode, reason) => ({ satisfied: false, status: "UNKNOWN", reasonCode, reason, evidence });
|
|
1084
|
+
if (typeof goalCheck !== "string" || !goalCheck.trim()) {
|
|
1085
|
+
return unknown("NO_GOAL_CHECK", "No explicit goal-specific check supplied; a passing verification suite is not proof that the task is complete.");
|
|
982
1086
|
}
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
1087
|
+
const command = goalCheck.trim();
|
|
1088
|
+
evidence.goalCheckSha256 = sha256(command);
|
|
1089
|
+
if (isPlaceholderTestScript(command)) {
|
|
1090
|
+
return unknown("PLACEHOLDER_GOAL_CHECK", "Goal check is a no-op or incapable of establishing the requested state.");
|
|
1091
|
+
}
|
|
1092
|
+
const genericCommands = [
|
|
1093
|
+
task.verifyCmd, task.verify,
|
|
1094
|
+
opts.config?.verify?.test, opts.config?.verify?.unit,
|
|
1095
|
+
opts.config?.verify?.lint, opts.config?.verify?.build,
|
|
1096
|
+
].filter((value) => typeof value === "string").map((value) => value.trim());
|
|
1097
|
+
if (genericCommands.includes(command)) {
|
|
1098
|
+
return unknown("GENERIC_VERIFY_IS_NOT_GOAL_PROOF", "Goal check matches the generic verification command; provide a separate objective-specific condition.");
|
|
992
1099
|
}
|
|
993
1100
|
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
1101
|
+
// The check runs against the working tree. A clean tree and stable HEAD are
|
|
1102
|
+
// prerequisites for attributing a successful result to a committed revision.
|
|
1103
|
+
const snapshot = () => {
|
|
1104
|
+
try {
|
|
1105
|
+
const head = runCmd(["git", "rev-parse", "HEAD"], { cwd: root, ignoreError: true, timeout: 5000 });
|
|
1106
|
+
const state = runCmd(["git", "status", "--porcelain", "--untracked-files=all"], { cwd: root, ignoreError: true, timeout: 5000 });
|
|
1107
|
+
if (head.status !== 0 || state.status !== 0 || !/^[0-9a-f]{40,64}$/.test(head.stdout.trim())) return null;
|
|
1108
|
+
return { revision: head.stdout.trim(), clean: !state.stdout.trim() };
|
|
1109
|
+
} catch (_) {
|
|
1110
|
+
return null;
|
|
1111
|
+
}
|
|
998
1112
|
};
|
|
1113
|
+
const before = snapshot();
|
|
1114
|
+
if (!before) return unknown("GIT_STATE_UNAVAILABLE", "Cannot establish the Git revision and working-tree state.");
|
|
1115
|
+
evidence.revision = before.revision;
|
|
1116
|
+
if (!before.clean) return unknown("DIRTY_WORKTREE", "The working tree is not clean; goal proof cannot be attributed to the committed revision.");
|
|
1117
|
+
|
|
1118
|
+
const { runVerificationProbe } = await import("./wizard-oracle.mjs");
|
|
1119
|
+
const probe = await runVerificationProbe(command, root, { timeoutMs: opts.timeoutMs || 30_000 });
|
|
1120
|
+
evidence.exitCode = probe.code;
|
|
1121
|
+
evidence.durationMs = probe.durationMs;
|
|
1122
|
+
const after = snapshot();
|
|
1123
|
+
if (!after || after.revision !== before.revision || !after.clean) {
|
|
1124
|
+
return unknown("GIT_STATE_CHANGED", "The goal check changed the repository state or revision; its result cannot justify skipping dispatch.");
|
|
1125
|
+
}
|
|
1126
|
+
if (!probe.ok) {
|
|
1127
|
+
return { satisfied: false, status: "NOT_PROVEN", reasonCode: "GOAL_CHECK_NOT_SATISFIED", reason: "The goal-specific check did not establish the requested state.", evidence };
|
|
1128
|
+
}
|
|
1129
|
+
return { satisfied: true, status: "PROVEN", reasonCode: "GOAL_CHECK_SATISFIED", reason: "Explicit goal-specific check passed on an unchanged, clean Git revision.", evidence };
|
|
999
1130
|
}
|
|
1000
1131
|
|
|
1001
1132
|
/**
|
|
@@ -1013,6 +1144,54 @@ export const TERMINAL_SESSION_STATES = new Set(["COMPLETED", "FAILED"]);
|
|
|
1013
1144
|
*/
|
|
1014
1145
|
export const BLOCKING_SESSION_STATES = new Set(["AWAITING_PLAN_APPROVAL", "AWAITING_USER_FEEDBACK", "PAUSED"]);
|
|
1015
1146
|
|
|
1147
|
+
/**
|
|
1148
|
+
* Synthesizes an autonomous, envelope-grounded reply directive when Jules halts
|
|
1149
|
+
* in AWAITING_USER_FEEDBACK during unattended execution.
|
|
1150
|
+
*
|
|
1151
|
+
* @param {object|null} task - Task envelope or task definition object.
|
|
1152
|
+
* @param {string} [question] - Extracted question text from the agent.
|
|
1153
|
+
* @returns {string} Deterministic directive for the agent to continue.
|
|
1154
|
+
*/
|
|
1155
|
+
export function buildAutoReplyPrompt(task = null, question = "") {
|
|
1156
|
+
const parts = [];
|
|
1157
|
+
parts.push("Autonomous execution directive:");
|
|
1158
|
+
|
|
1159
|
+
if (task && typeof task === "object") {
|
|
1160
|
+
const scope = task.scope?.allow || task.allowedFiles || task.targetFiles;
|
|
1161
|
+
if (Array.isArray(scope) && scope.length > 0) {
|
|
1162
|
+
parts.push(`- Scope constraint: modify only files matching ${scope.join(", ")}.`);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const invariants = Array.isArray(task.invariants) ? task.invariants : [];
|
|
1166
|
+
if (invariants.length > 0) {
|
|
1167
|
+
parts.push(`- Invariants: ${invariants.slice(0, 5).join("; ")}.`);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
if (task.title || task.objective) {
|
|
1171
|
+
parts.push(`- Objective: ${task.title || task.objective}.`);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
if (task.clarifications && typeof task.clarifications === "object") {
|
|
1175
|
+
const entries = Object.entries(task.clarifications);
|
|
1176
|
+
if (entries.length > 0) {
|
|
1177
|
+
const qLower = String(question || "").toLowerCase();
|
|
1178
|
+
for (const [k, v] of entries) {
|
|
1179
|
+
if (qLower.includes(k.toLowerCase())) {
|
|
1180
|
+
parts.push(`- Pre-approved clarification (${k}): ${v}`);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
parts.push(
|
|
1188
|
+
"- Decision rule: Make reasonable engineering assumptions that satisfy tests and maintain existing API contracts.",
|
|
1189
|
+
"- Do not pause for human confirmation or additional feedback. Proceed with implementation, verification, and completion."
|
|
1190
|
+
);
|
|
1191
|
+
|
|
1192
|
+
return parts.join("\n");
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1016
1195
|
/**
|
|
1017
1196
|
* Polls the provider until the session reaches a terminal state, blocks on an
|
|
1018
1197
|
* actor, or the poll budget runs out.
|
|
@@ -1054,6 +1233,8 @@ export async function pollSessionState(provider, session, opts = {}) {
|
|
|
1054
1233
|
const startTime = Date.now();
|
|
1055
1234
|
let lastStatus = "";
|
|
1056
1235
|
let polls = 0;
|
|
1236
|
+
const repliedActivityIds = new Set();
|
|
1237
|
+
let autoRepliesCount = 0;
|
|
1057
1238
|
|
|
1058
1239
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1059
1240
|
if (Date.now() - startTime > timeoutMs) break;
|
|
@@ -1109,6 +1290,102 @@ export async function pollSessionState(provider, session, opts = {}) {
|
|
|
1109
1290
|
}
|
|
1110
1291
|
}
|
|
1111
1292
|
|
|
1293
|
+
// Autonomously answer clarifying questions if requested
|
|
1294
|
+
const isUserFeedback = status === "AWAITING_USER_FEEDBACK";
|
|
1295
|
+
const wantsAutoReply = Boolean(opts.autoReply || session.autoReply);
|
|
1296
|
+
const canSend = provider && (typeof provider.sendMessage === "function" || typeof provider.resume === "function");
|
|
1297
|
+
if (isUserFeedback && wantsAutoReply && canSend) {
|
|
1298
|
+
const maxAutoReplies = Number(opts.maxAutoReplies ?? session.maxAutoReplies ?? 3);
|
|
1299
|
+
if (autoRepliesCount >= maxAutoReplies) {
|
|
1300
|
+
return {
|
|
1301
|
+
...currentSession,
|
|
1302
|
+
status,
|
|
1303
|
+
terminal: false,
|
|
1304
|
+
blockedOn: status,
|
|
1305
|
+
autoReplyLimitExceeded: true,
|
|
1306
|
+
autoReplies: autoRepliesCount,
|
|
1307
|
+
polls,
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
let question = "";
|
|
1312
|
+
let activityId = null;
|
|
1313
|
+
if (typeof provider.listActivities === "function") {
|
|
1314
|
+
try {
|
|
1315
|
+
const actRes = await provider.listActivities(session.id, opts);
|
|
1316
|
+
const activities = Array.isArray(actRes?.activities) ? actRes.activities : [];
|
|
1317
|
+
for (let i = activities.length - 1; i >= 0; i--) {
|
|
1318
|
+
const act = activities[i];
|
|
1319
|
+
const msg = act.agentMessaged?.agentMessage || act.message;
|
|
1320
|
+
if (msg) {
|
|
1321
|
+
question = String(msg).trim();
|
|
1322
|
+
activityId = act.name || act.id || null;
|
|
1323
|
+
break;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
} catch (_) {}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// Idempotency: If this activity was already answered, wait for the agent to advance rather than duplicate
|
|
1330
|
+
if (activityId && repliedActivityIds.has(activityId)) {
|
|
1331
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
let reply = "";
|
|
1336
|
+
const taskContext = opts.task || session.task || null;
|
|
1337
|
+
if (typeof opts.autoReplyFn === "function") {
|
|
1338
|
+
try {
|
|
1339
|
+
reply = await opts.autoReplyFn({ session: currentSession, question, activityId, task: taskContext });
|
|
1340
|
+
} catch (err) {
|
|
1341
|
+
return {
|
|
1342
|
+
...currentSession,
|
|
1343
|
+
status,
|
|
1344
|
+
terminal: false,
|
|
1345
|
+
blockedOn: status,
|
|
1346
|
+
autoReplyError: `autoReplyFn failed: ${err.message}`,
|
|
1347
|
+
autoReplies: autoRepliesCount,
|
|
1348
|
+
polls,
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
} else {
|
|
1352
|
+
reply = buildAutoReplyPrompt(taskContext, question);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
if (activityId) {
|
|
1356
|
+
repliedActivityIds.add(activityId);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
try {
|
|
1360
|
+
const sendFn = typeof provider.sendMessage === "function"
|
|
1361
|
+
? provider.sendMessage.bind(provider)
|
|
1362
|
+
: provider.resume.bind(provider);
|
|
1363
|
+
await sendFn(session.id, reply, opts);
|
|
1364
|
+
autoRepliesCount += 1;
|
|
1365
|
+
if (opts.root) {
|
|
1366
|
+
appendTelemetry(opts.root, "session_auto_reply", {
|
|
1367
|
+
sessionId: session.id,
|
|
1368
|
+
question: question ? question.slice(0, 300) : null,
|
|
1369
|
+
reply: reply ? reply.slice(0, 300) : null,
|
|
1370
|
+
replyCount: autoRepliesCount,
|
|
1371
|
+
activityId,
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
1375
|
+
continue;
|
|
1376
|
+
} catch (err) {
|
|
1377
|
+
return {
|
|
1378
|
+
...currentSession,
|
|
1379
|
+
status,
|
|
1380
|
+
terminal: false,
|
|
1381
|
+
blockedOn: status,
|
|
1382
|
+
autoReplyError: err && err.message ? err.message : String(err),
|
|
1383
|
+
autoReplies: autoRepliesCount,
|
|
1384
|
+
polls,
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1112
1389
|
return { ...currentSession, status, terminal: false, blockedOn: status, polls };
|
|
1113
1390
|
}
|
|
1114
1391
|
|
|
@@ -1181,17 +1458,28 @@ export async function dispatch(task = {}, opts = {}) {
|
|
|
1181
1458
|
throw new Error(`Task prompt exceeds maximum payload limit of ${config.limits.promptKb} KB`);
|
|
1182
1459
|
}
|
|
1183
1460
|
|
|
1184
|
-
//
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1461
|
+
// Record both positive and negative decisions. A generic green test suite
|
|
1462
|
+
// must never be mistaken for proof that a requested task is complete.
|
|
1463
|
+
const premiseRequested = Boolean(opts.checkPremise || task.checkPremise);
|
|
1464
|
+
const premise = premiseRequested
|
|
1465
|
+
? (opts.dryRun
|
|
1466
|
+
? { satisfied: false, reasonCode: "DRY_RUN", reason: "Preview does not execute the goal check.", evidence: { revision: null, goalCheckSha256: null, exitCode: null, durationMs: null } }
|
|
1467
|
+
: await checkTaskPremise(task, { root, config }))
|
|
1468
|
+
: { satisfied: false, reasonCode: "PREMISE_CHECK_NOT_REQUESTED", evidence: null };
|
|
1469
|
+
appendTelemetry(root, "dispatch_decision", {
|
|
1470
|
+
taskId: task.id || task.taskId || null,
|
|
1471
|
+
decision: premise.satisfied ? "SKIP" : "DISPATCH",
|
|
1472
|
+
reasonCode: premise.reasonCode,
|
|
1473
|
+
evidence: premise.evidence,
|
|
1474
|
+
});
|
|
1475
|
+
if (premise.satisfied) {
|
|
1476
|
+
return {
|
|
1477
|
+
id: "premise-already-satisfied",
|
|
1478
|
+
status: "ALREADY_SATISFIED",
|
|
1479
|
+
skipped: true,
|
|
1480
|
+
reason: premise.reason,
|
|
1481
|
+
evidence: premise.evidence,
|
|
1482
|
+
};
|
|
1195
1483
|
}
|
|
1196
1484
|
|
|
1197
1485
|
// Redact secrets in prompt before dispatching
|
|
@@ -1608,7 +1896,13 @@ export async function probeDevServer(serverConfig = {}, root = process.cwd()) {
|
|
|
1608
1896
|
if (isWin) {
|
|
1609
1897
|
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
|
|
1610
1898
|
} else {
|
|
1611
|
-
|
|
1899
|
+
const pgid = -child.pid;
|
|
1900
|
+
process.kill(pgid, "SIGTERM");
|
|
1901
|
+
setTimeout(() => {
|
|
1902
|
+
try {
|
|
1903
|
+
process.kill(pgid, "SIGKILL");
|
|
1904
|
+
} catch (_) {}
|
|
1905
|
+
}, 2000).unref();
|
|
1612
1906
|
}
|
|
1613
1907
|
} catch (_) {}
|
|
1614
1908
|
}
|
package/src/evidence.mjs
CHANGED
|
@@ -135,14 +135,21 @@ export function computeDirectoryHash(root, options = {}) {
|
|
|
135
135
|
// The named directories stay as a fast path; when they yield nothing, walk
|
|
136
136
|
// the repository and let the shared predicate decide. The walk already
|
|
137
137
|
// skips node_modules, vendor, target and the build caches.
|
|
138
|
-
const targetDirs = options.directories || ["test", "tests", "__tests__", "spec", "specs", "src"];
|
|
138
|
+
const targetDirs = options.directories || ["test", "tests", "__tests__", "spec", "specs", "src", "scripts", "bin"];
|
|
139
139
|
for (const dirName of targetDirs) {
|
|
140
140
|
const dirPath = join(root, dirName);
|
|
141
141
|
if (existsSync(dirPath)) {
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
if (statSync(dirPath).isFile()) {
|
|
143
|
+
fileList.push(normalizePath(dirName));
|
|
144
|
+
} else {
|
|
145
|
+
const found = findFilesRecursively(dirPath, root);
|
|
146
|
+
fileList.push(...found);
|
|
147
|
+
}
|
|
144
148
|
}
|
|
145
149
|
}
|
|
150
|
+
if (existsSync(join(root, "package.json"))) {
|
|
151
|
+
fileList.push("package.json");
|
|
152
|
+
}
|
|
146
153
|
if (options.testOnly && !fileList.some((f) => isTestPath(f))) {
|
|
147
154
|
for (const f of findFilesRecursively(root, root)) {
|
|
148
155
|
if (isTestPath(f)) fileList.push(f);
|
|
@@ -191,7 +198,8 @@ export function computeDirectoryHash(root, options = {}) {
|
|
|
191
198
|
}
|
|
192
199
|
}
|
|
193
200
|
|
|
194
|
-
|
|
201
|
+
// Join with null byte to prevent path delimiter injection (F-03)
|
|
202
|
+
const treeHash = "sha256:" + sha256(hashLines.join("\0"));
|
|
195
203
|
return {
|
|
196
204
|
treeHash,
|
|
197
205
|
fileCount: hashLines.length,
|
|
@@ -511,9 +519,18 @@ export function verifyEvidenceManifest(root = process.cwd(), manifestOrPath = "m
|
|
|
511
519
|
return { ok: false, reason: "Evidence manifest hash signature mismatch (tampered file)" };
|
|
512
520
|
}
|
|
513
521
|
|
|
522
|
+
// 1.5 Verify recorded execution outcome (F-08)
|
|
523
|
+
if (manifest.status && !["passed", "success", "ok"].includes(manifest.status)) {
|
|
524
|
+
return { ok: false, reason: `Evidence manifest records failed execution outcome: ${manifest.status}` };
|
|
525
|
+
}
|
|
526
|
+
|
|
514
527
|
// 2. Verify test integrity / No test weakening
|
|
515
528
|
const currentTestState = computeDirectoryHash(root, { testOnly: true });
|
|
516
|
-
if (manifest.testIntegrity?.
|
|
529
|
+
if (!manifest.testIntegrity?.postTestHash) {
|
|
530
|
+
return { ok: false, reason: "Evidence manifest is missing test integrity baseline (empty shell)" };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (manifest.testIntegrity.tamperDetected) {
|
|
517
534
|
return {
|
|
518
535
|
ok: false,
|
|
519
536
|
reason: "Test suite tampering detected in evidence manifest",
|
package/src/git.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
2
|
+
import { killProcessTree } from "./process-tree.mjs";
|
|
2
3
|
import { readFileSync, existsSync, statSync, lstatSync, readlinkSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
|
|
3
4
|
import { join, delimiter } from "node:path";
|
|
4
5
|
import { tmpdir } from "node:os";
|
|
@@ -215,6 +216,13 @@ export function runCmd(command, opts = {}) {
|
|
|
215
216
|
throw new GateError("Empty command provided");
|
|
216
217
|
}
|
|
217
218
|
|
|
219
|
+
// On POSIX, detach so the child becomes its own process-group leader. Then a
|
|
220
|
+
// timed-out kill can signal the whole group (`kill(-pid)`) and reap workers
|
|
221
|
+
// spawned by the command. Windows omits detached: taskkill /T walks the tree
|
|
222
|
+
// without a separate group, and detached consoles behave differently under
|
|
223
|
+
// cmd.exe / CreateProcess.
|
|
224
|
+
const posixDetach = process.platform !== "win32";
|
|
225
|
+
|
|
218
226
|
try {
|
|
219
227
|
const ret = useShell
|
|
220
228
|
? spawnSync(shellCmd, {
|
|
@@ -225,6 +233,7 @@ export function runCmd(command, opts = {}) {
|
|
|
225
233
|
env: childEnv,
|
|
226
234
|
timeout,
|
|
227
235
|
maxBuffer,
|
|
236
|
+
...(posixDetach ? { detached: true } : {}),
|
|
228
237
|
})
|
|
229
238
|
: spawnSync(winSpawn ? winSpawn.file : binary, winSpawn ? winSpawn.args : args, {
|
|
230
239
|
cwd,
|
|
@@ -235,9 +244,15 @@ export function runCmd(command, opts = {}) {
|
|
|
235
244
|
env: childEnv,
|
|
236
245
|
timeout,
|
|
237
246
|
maxBuffer,
|
|
247
|
+
...(posixDetach ? { detached: true } : {}),
|
|
238
248
|
});
|
|
239
249
|
|
|
240
250
|
const isTimeout = Boolean(ret.error && (ret.error.code === "ETIMEDOUT" || (ret.signal === "SIGTERM" && ret.error.killed)));
|
|
251
|
+
// Node's spawnSync timeout kills only the direct child; reap the tree so
|
|
252
|
+
// background workers do not linger (ROADMAP_V1 reliability item).
|
|
253
|
+
if (isTimeout && ret.pid) {
|
|
254
|
+
killProcessTree(ret.pid);
|
|
255
|
+
}
|
|
241
256
|
const isNobufs = Boolean(ret.error && (ret.error.code === "ENOBUFS" || (ret.error.message && ret.error.message.includes("maxBuffer"))));
|
|
242
257
|
|
|
243
258
|
const status = typeof ret.status === "number" ? ret.status : (isTimeout ? 124 : 1);
|