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/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 (key.startsWith("NODE_TEST_") || key.startsWith("NODE_CHANNEL_")) delete testEnv[key];
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({ n, ok: false, error: err.message, providerError: true, retryAfterMs });
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
- attempts.push({
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
- * Polls an async provider for terminal session state (COMPLETED / FAILED) before re-verification.
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 verifyCmd = task.verifyCmd || task.verify;
980
- if (!verifyCmd) {
981
- return { satisfied: false, reason: "No verification oracle specified for pre-flight premise check." };
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
- const { runVerificationProbe } = await import("./wizard-oracle.mjs");
985
- const probe = await runVerificationProbe(verifyCmd, root, { timeoutMs: opts.timeoutMs || 30_000 });
986
- if (probe.ok) {
987
- return {
988
- satisfied: true,
989
- reason: `Verification oracle '${verifyCmd}' already passes cleanly with exit code 0 on base branch.`,
990
- durationMs: probe.durationMs,
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
- return {
995
- satisfied: false,
996
- reason: `Verification oracle '${verifyCmd}' failed (exit ${probe.code}), proving task need.`,
997
- durationMs: probe.durationMs,
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
- // Pre-flight idempotency premise check
1185
- if (opts.checkPremise || task.checkPremise) {
1186
- const premise = await checkTaskPremise(task, { root, config });
1187
- if (premise.satisfied) {
1188
- return {
1189
- id: "premise-already-satisfied",
1190
- status: "ALREADY_SATISFIED",
1191
- skipped: true,
1192
- reason: premise.reason,
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
- process.kill(-child.pid, "SIGTERM");
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
- const found = findFilesRecursively(dirPath, root);
143
- fileList.push(...found);
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
- const treeHash = "sha256:" + sha256(hashLines.join("\n"));
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?.tamperDetected) {
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);