@evoclock/pi-agentic-driver 0.10.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agentic-driver/nudge.defaults.json +11 -0
- package/.githooks/pre-commit +4 -0
- package/README.md +43 -31
- package/config/codebase-inventory.schema.json +23 -0
- package/docs/codebase-inventory.md +15 -0
- package/docs/recovery-evidence-store.md +9 -0
- package/extensions/herdr-dispatch.ts +15 -2
- package/extensions/recovery-evidence.ts +14 -0
- package/extensions/task-store-adapter.ts +101 -0
- package/extensions/tasks.ts +34 -11
- package/package.json +19 -1
- package/scripts/codebase_inventory.py +105 -0
- package/scripts/codebase_inventory_gate.py +48 -0
- package/scripts/enforcement/board_claim_bridge_pi.js +204 -0
- package/scripts/enforcement/herdr_async_dispatch_pi.js +83 -30
- package/scripts/enforcement/herdr_async_seam_pi.js +347 -0
- package/scripts/enforcement/herdr_communication_pi.js +608 -95
- package/scripts/enforcement/herdr_lifecycle_pi.js +266 -13
- package/scripts/enforcement/linux_microvm_remote_fixture.sh +6 -6
- package/scripts/enforcement/native_tui_context.js +16 -0
- package/scripts/enforcement/nudge_classifier_pi.js +176 -0
- package/scripts/enforcement/nudge_config_pi.js +234 -0
- package/scripts/enforcement/nudge_ledger_pi.js +514 -0
- package/scripts/enforcement/nudge_reason_state_pi.js +135 -0
- package/scripts/enforcement/nudge_validate_pi.js +224 -0
- package/scripts/enforcement/pulse_scheduler_pi.js +15 -3
- package/scripts/enforcement/recovery_evidence_review_pi.js +269 -0
- package/scripts/enforcement/recovery_evidence_store_pi.js +170 -0
- package/scripts/enforcement/session_tasks_core_pi.js +14 -0
- package/scripts/enforcement/task_board_core_pi.js +96 -7
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// allowlist constant. Pane placement uses pane split directly; tab placement
|
|
9
9
|
// uses tab create. Both capture the returned pane identity and start once.
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
|
-
import { readFileSync, realpathSync } from "node:fs";
|
|
11
|
+
import { accessSync, constants as fsConstants, readFileSync, realpathSync, statSync } from "node:fs";
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { join, resolve } from "node:path";
|
|
14
14
|
import { spawnSync } from "node:child_process";
|
|
@@ -17,11 +17,12 @@ import {
|
|
|
17
17
|
HERDR_ROLE_POLICY,
|
|
18
18
|
resolveTrustedHerdrExecutable,
|
|
19
19
|
} from "./herdr_communication_pi.js";
|
|
20
|
-
import { isNativeTuiContext } from "./native_tui_context.js";
|
|
20
|
+
import { isAttendedElectronContext, isNativeTuiContext } from "./native_tui_context.js";
|
|
21
21
|
|
|
22
22
|
export const HERDR_SPAWN_WORKER_TOOL = "agentic_herdr_spawn_worker";
|
|
23
23
|
export const HERDR_LIFECYCLE_SCHEMA = "agentic-driver.herdr-lifecycle.v1";
|
|
24
24
|
export const HERDR_LIFECYCLE_VERSION = "spawn-worker.v1";
|
|
25
|
+
export const ATTENDED_ELECTRON_BOUNDARY = "attended-electron";
|
|
25
26
|
export const SPAWN_PLACEMENTS = Object.freeze(["tab", "right", "below"]);
|
|
26
27
|
// Herdr accepts only `right` and `down` split directions;
|
|
27
28
|
// the user-facing `below` is mapped, never forwarded.
|
|
@@ -47,7 +48,10 @@ function resolvePiModelsPath() {
|
|
|
47
48
|
}
|
|
48
49
|
return join(homedir(), ".pi", "agent", "models.json");
|
|
49
50
|
}
|
|
50
|
-
|
|
51
|
+
// A model selection is either a unique local model id or a provider followed
|
|
52
|
+
// by one or more bounded model-id segments. Pi model IDs may use colons, but
|
|
53
|
+
// provider names may not. validateSpawnParams separately bounds total length.
|
|
54
|
+
const MODEL_ID_REGEXP = /^[a-z0-9][a-z0-9._:-]{0,127}$|^[a-z0-9][a-z0-9._-]{0,63}(?:\/[a-z0-9][a-z0-9._:-]{0,127})+$/;
|
|
51
55
|
// Closed response keys. Shapes beyond `.result.move_result.pane.pane_id` are
|
|
52
56
|
// provisional from tagged-source evidence (Tranche 07) and fail closed on
|
|
53
57
|
// anything extra.
|
|
@@ -494,8 +498,8 @@ export function resolveTrustedSpawnRepository(coordinatorCwd, requestedName) {
|
|
|
494
498
|
// via the documented fixed argv `pi --list-models` table; custom models.json
|
|
495
499
|
// entries remain an additional accepted source. The roll is parsed once per
|
|
496
500
|
// resolveInstalledModel call and is never cached across calls.
|
|
497
|
-
const PROVIDER_TOKEN_REGEXP = /^[a-z0-9][a-z0-9._-]
|
|
498
|
-
const MODEL_TOKEN_REGEXP = /^[a-z0-9][a-z0-9._
|
|
501
|
+
const PROVIDER_TOKEN_REGEXP = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
502
|
+
const MODEL_TOKEN_REGEXP = /^[a-z0-9][a-z0-9._:-]{0,127}(?:\/[a-z0-9][a-z0-9._:-]{0,127})*$/i;
|
|
499
503
|
|
|
500
504
|
// Parses the fixed-width provider/model table emitted by `pi --list-models`.
|
|
501
505
|
// Only the first two whitespace tokens of each data row are read; the header
|
|
@@ -509,8 +513,10 @@ function parseListModelsTable(raw) {
|
|
|
509
513
|
const tokens = trimmed.split(/\s+/);
|
|
510
514
|
if (tokens.length < 2) continue;
|
|
511
515
|
const [provider, id] = tokens;
|
|
512
|
-
|
|
513
|
-
|
|
516
|
+
const pair = `${provider}/${id}`;
|
|
517
|
+
if (pair.length > 192 || !PROVIDER_TOKEN_REGEXP.test(provider)
|
|
518
|
+
|| !MODEL_TOKEN_REGEXP.test(id) || !MODEL_ID_REGEXP.test(pair)) continue;
|
|
519
|
+
pairs.add(pair);
|
|
514
520
|
}
|
|
515
521
|
return pairs;
|
|
516
522
|
}
|
|
@@ -529,10 +535,11 @@ function readCustomModelPairs(modelsPath) {
|
|
|
529
535
|
for (const [provider, config] of Object.entries(providers)) {
|
|
530
536
|
const models = isPlainObject(config) && Array.isArray(config.models) ? config.models : [];
|
|
531
537
|
for (const model of models) {
|
|
538
|
+
const pair = `${provider}/${model?.id}`;
|
|
532
539
|
if (isPlainObject(model) && typeof model.id === "string"
|
|
533
|
-
&& PROVIDER_TOKEN_REGEXP.test(provider)
|
|
534
|
-
&& MODEL_TOKEN_REGEXP.test(model.id)) {
|
|
535
|
-
pairs.add(
|
|
540
|
+
&& pair.length <= 192 && PROVIDER_TOKEN_REGEXP.test(provider)
|
|
541
|
+
&& MODEL_TOKEN_REGEXP.test(model.id) && MODEL_ID_REGEXP.test(pair)) {
|
|
542
|
+
pairs.add(pair);
|
|
536
543
|
}
|
|
537
544
|
}
|
|
538
545
|
}
|
|
@@ -630,9 +637,10 @@ function normalizeProcessResult(value) {
|
|
|
630
637
|
|
|
631
638
|
async function invokeHerdr(argv, canonicalRoot, options, signal) {
|
|
632
639
|
if (signal?.aborted) throw lifecycleError("aborted", "the spawn operation was aborted");
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
640
|
+
// Forward the full options object: the internal testExecutablePath seam
|
|
641
|
+
// (offline fake Herdr process boundary) must reach executable resolution,
|
|
642
|
+
// otherwise offline tests would silently spawn the production binary.
|
|
643
|
+
const executable = resolveTrustedHerdrExecutable(options);
|
|
636
644
|
const spec = {
|
|
637
645
|
executable,
|
|
638
646
|
argv: Object.freeze([...argv]),
|
|
@@ -879,6 +887,251 @@ function toolResult(details) {
|
|
|
879
887
|
};
|
|
880
888
|
}
|
|
881
889
|
|
|
890
|
+
// --- Attended Electron-specific boundary -----------------------------------
|
|
891
|
+
// An explicit, closed contract for a trusted NON-TUI host: the Electron main
|
|
892
|
+
// process. It is a separate context class (see native_tui_context.js), never
|
|
893
|
+
// a Pi-TUI representation. The host has no Herdr session pane, so this
|
|
894
|
+
// boundary NEVER reads or manufactures HERDR_PANE_ID and supports ONLY tab
|
|
895
|
+
// placement (a tab create needs no coordinator pane identity). Stages:
|
|
896
|
+
// preflightAttendedElectronSpawn — read-only validation intended to run
|
|
897
|
+
// BEFORE any board claim: context class, closed parameters, trusted
|
|
898
|
+
// repository (registry + realpath + Git root), installed model roll,
|
|
899
|
+
// trusted Herdr executable availability, and duplicate-role observation.
|
|
900
|
+
// A preflight is NOT an atomic guarantee: external state can change after
|
|
901
|
+
// it, which is why the commit stage re-validates everything and callers
|
|
902
|
+
// must preserve truthful claimed-but-not-started reconciliation.
|
|
903
|
+
// executeHerdrSpawnWorkerAttendedElectron — the single-attempt commit:
|
|
904
|
+
// one native owner confirmation from the host, then the same guarded
|
|
905
|
+
// tab create → agent start → agent get → pane get sequence with exact
|
|
906
|
+
// read-back. No retry, no automatic cleanup; failures report partial
|
|
907
|
+
// state with human cleanup guidance.
|
|
908
|
+
function attendedElectronContextError(context) {
|
|
909
|
+
if (!isAttendedElectronContext(context)) {
|
|
910
|
+
throw lifecycleError(
|
|
911
|
+
"attended_electron_context_required",
|
|
912
|
+
"the attended Electron boundary requires an explicit electron-attended context with a native confirm callback (never a Pi TUI representation)",
|
|
913
|
+
"blocked",
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
if (typeof context?.cwd !== "string" || context.cwd.trim() === "") {
|
|
917
|
+
throw lifecycleError(
|
|
918
|
+
"attended_electron_context_required",
|
|
919
|
+
"the attended Electron context requires the trusted host working directory",
|
|
920
|
+
"blocked",
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function attendedElectronParams(params) {
|
|
926
|
+
const request = validateSpawnParams(params);
|
|
927
|
+
if (request.placement !== "tab") {
|
|
928
|
+
throw lifecycleError(
|
|
929
|
+
"pane_identity_unavailable",
|
|
930
|
+
"the attended Electron host has no Herdr session pane; only tab placement is legitimate and no pane identity is manufactured or inherited",
|
|
931
|
+
"denied",
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
return request;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function resolveAttendedElectronPrerequisites(request, context, options) {
|
|
938
|
+
const canonicalRoot = resolveTrustedSpawnRepository(context.cwd, request.repository);
|
|
939
|
+
const modelArgv = resolveInstalledModel(request.model, options);
|
|
940
|
+
return { canonicalRoot, modelArgv };
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function assertHerdrExecutableAvailable(options) {
|
|
944
|
+
try {
|
|
945
|
+
// The injected test seam is a real process boundary: verify the exact
|
|
946
|
+
// injected executable exists and is executable so a missing executable
|
|
947
|
+
// fails locally, without any process spawn.
|
|
948
|
+
const injected = options?.testExecutablePath;
|
|
949
|
+
if (typeof injected === "string" && injected.trim()) {
|
|
950
|
+
const real = realpathSync(injected);
|
|
951
|
+
const stat = statSync(real);
|
|
952
|
+
accessSync(real, fsConstants.X_OK);
|
|
953
|
+
if (!stat.isFile()) throw new Error("not a file");
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
resolveTrustedHerdrExecutable(options);
|
|
957
|
+
} catch (error) {
|
|
958
|
+
if (error?.name === "HerdrLifecycleError") throw error;
|
|
959
|
+
throw lifecycleError(
|
|
960
|
+
"herdr_unavailable",
|
|
961
|
+
"the trusted Herdr executable is unavailable for the attended Electron boundary",
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
async function observeDuplicateRole(canonicalRoot, role, options, signal) {
|
|
967
|
+
const listed = await invokeHerdr(agentListArgv(), canonicalRoot, options, signal);
|
|
968
|
+
if (duplicateRoleExists(listed, role)) {
|
|
969
|
+
throw lifecycleError("duplicate_worker_name", "a live agent already holds this name", "denied");
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
export async function preflightAttendedElectronSpawn(params, context, options = {}) {
|
|
974
|
+
try {
|
|
975
|
+
attendedElectronContextError(context);
|
|
976
|
+
const request = attendedElectronParams(params);
|
|
977
|
+
assertHerdrExecutableAvailable(options);
|
|
978
|
+
const { canonicalRoot, modelArgv } = resolveAttendedElectronPrerequisites(request, context, options);
|
|
979
|
+
await observeDuplicateRole(canonicalRoot, request.role, options, null);
|
|
980
|
+
return {
|
|
981
|
+
schema: HERDR_LIFECYCLE_SCHEMA,
|
|
982
|
+
ok: true,
|
|
983
|
+
status: "preflight-verified",
|
|
984
|
+
boundary: ATTENDED_ELECTRON_BOUNDARY,
|
|
985
|
+
placement: "tab",
|
|
986
|
+
role: request.role,
|
|
987
|
+
repository: canonicalRoot,
|
|
988
|
+
modelArgv: [...modelArgv],
|
|
989
|
+
nonAuthorizing: true,
|
|
990
|
+
authorityCreated: false,
|
|
991
|
+
};
|
|
992
|
+
} catch (error) {
|
|
993
|
+
return errorResult(error);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function attendedElectronConfirmBody(role, modelArgv, canonicalRoot) {
|
|
998
|
+
return [
|
|
999
|
+
"One guarded attended-Electron composite spawn: create one tab, then start and verify one Pi agent.",
|
|
1000
|
+
`Role: ${role}`,
|
|
1001
|
+
"Placement: individual tab (the Electron host has no Herdr session pane)",
|
|
1002
|
+
`Model: ${modelArgv[1]} (argv: ${modelArgv.join(" ")})`,
|
|
1003
|
+
`Repository: ${canonicalRoot}`,
|
|
1004
|
+
"No rollback is automatic; a failed step leaves the created state for human cleanup.",
|
|
1005
|
+
].join("\n");
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
export async function executeHerdrSpawnWorkerAttendedElectron(params, context, options = {}, signal) {
|
|
1009
|
+
let request;
|
|
1010
|
+
try {
|
|
1011
|
+
attendedElectronContextError(context);
|
|
1012
|
+
request = attendedElectronParams(params);
|
|
1013
|
+
const { role, model, repository } = request;
|
|
1014
|
+
|
|
1015
|
+
// Resolve BEFORE any mutation: registry + realpath + Git root, plus the
|
|
1016
|
+
// installed model roll. Duplicate live names are denied at spawn.
|
|
1017
|
+
let canonicalRoot = resolveTrustedSpawnRepository(context.cwd, repository);
|
|
1018
|
+
const modelArgv = resolveInstalledModel(model, options);
|
|
1019
|
+
await observeDuplicateRole(canonicalRoot, role, options, signal);
|
|
1020
|
+
|
|
1021
|
+
// ONE native owner confirmation from the attended host. A denial is a
|
|
1022
|
+
// stop; a throw is a structured refusal. Never re-asked, never retried.
|
|
1023
|
+
let confirmed;
|
|
1024
|
+
try {
|
|
1025
|
+
confirmed = await context.ui.confirm(
|
|
1026
|
+
"Spawn Herdr worker (attended Electron)",
|
|
1027
|
+
attendedElectronConfirmBody(role, modelArgv, canonicalRoot),
|
|
1028
|
+
);
|
|
1029
|
+
} catch (error) {
|
|
1030
|
+
throw lifecycleError(
|
|
1031
|
+
"confirmation_failed",
|
|
1032
|
+
`the attended confirmation failed: ${typeof error?.message === "string" ? error.message : "unknown error"}`,
|
|
1033
|
+
"blocked",
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
if (confirmed !== true) {
|
|
1037
|
+
return errorResult(lifecycleError("confirmation_denied", "native confirmation was not granted", "stopped"));
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Revalidation point after confirmation, before the first mutation. A
|
|
1041
|
+
// change here is a post-confirmation race: the caller reports it as a
|
|
1042
|
+
// refusal; nothing has been mutated yet.
|
|
1043
|
+
const reconfirmedRoot = resolveTrustedSpawnRepository(context.cwd, repository);
|
|
1044
|
+
if (reconfirmedRoot !== canonicalRoot) {
|
|
1045
|
+
throw lifecycleError("repository_mismatch", "the trusted repository changed after confirmation");
|
|
1046
|
+
}
|
|
1047
|
+
canonicalRoot = reconfirmedRoot;
|
|
1048
|
+
|
|
1049
|
+
// Tab topology only: no coordinator pane identity is resolved, used, or
|
|
1050
|
+
// manufactured anywhere on this path.
|
|
1051
|
+
let tabId;
|
|
1052
|
+
let rootPaneId;
|
|
1053
|
+
let layoutRaw;
|
|
1054
|
+
try {
|
|
1055
|
+
layoutRaw = await invokeHerdr(tabCreateArgv(canonicalRoot, role), canonicalRoot, options, signal);
|
|
1056
|
+
({ rootPaneId, tabId } = parseTabCreate(layoutRaw));
|
|
1057
|
+
} catch (error) {
|
|
1058
|
+
return partialResult(error, {
|
|
1059
|
+
partial: "tab_created", paneId: rootPaneId, tabId, role, modelArgv,
|
|
1060
|
+
...(typeof layoutRaw === "string" ? { rawResponse: layoutRaw } : {}),
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
let agentStartSucceeded = false;
|
|
1065
|
+
try {
|
|
1066
|
+
// Re-check the registry between the mutations.
|
|
1067
|
+
const betweenRoot = resolveTrustedSpawnRepository(context.cwd, repository);
|
|
1068
|
+
if (betweenRoot !== canonicalRoot) {
|
|
1069
|
+
throw lifecycleError("repository_mismatch", "the trusted repository changed between mutations");
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// Start exactly once in the tab's root pane.
|
|
1073
|
+
const startCall = startArgv(role, rootPaneId, modelArgv);
|
|
1074
|
+
let startRaw;
|
|
1075
|
+
try {
|
|
1076
|
+
startRaw = await invokeHerdr(startCall, canonicalRoot, options, signal);
|
|
1077
|
+
agentStartSucceeded = true;
|
|
1078
|
+
} catch (error) {
|
|
1079
|
+
throw lifecycleError("start_failed", error?.message || "Herdr agent start failed");
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const postStartRoot = resolveTrustedSpawnRepository(context.cwd, repository);
|
|
1083
|
+
if (postStartRoot !== canonicalRoot) {
|
|
1084
|
+
throw lifecycleError("repository_mismatch", "the trusted repository changed after agent start");
|
|
1085
|
+
}
|
|
1086
|
+
canonicalRoot = postStartRoot;
|
|
1087
|
+
|
|
1088
|
+
const startedPaneId = readBackStart(startRaw, role, canonicalRoot, [...modelArgv]);
|
|
1089
|
+
if (startedPaneId !== rootPaneId) {
|
|
1090
|
+
throw lifecycleError("pane_mismatch", "agent start returned a different pane identity");
|
|
1091
|
+
}
|
|
1092
|
+
const agentRaw = await invokeHerdr(["agent", "get", role], canonicalRoot, options, signal);
|
|
1093
|
+
const agentReadBack = readBackAgent(agentRaw, role, canonicalRoot, [...modelArgv]);
|
|
1094
|
+
if (agentReadBack.paneId !== rootPaneId) {
|
|
1095
|
+
throw lifecycleError("pane_mismatch", "agent get returned a different pane identity");
|
|
1096
|
+
}
|
|
1097
|
+
const paneRaw = await invokeHerdr(["pane", "get", rootPaneId], canonicalRoot, options, signal);
|
|
1098
|
+
readBackPane(paneRaw, rootPaneId, canonicalRoot);
|
|
1099
|
+
|
|
1100
|
+
return {
|
|
1101
|
+
schema: HERDR_LIFECYCLE_SCHEMA,
|
|
1102
|
+
ok: true,
|
|
1103
|
+
status: "spawned",
|
|
1104
|
+
boundary: ATTENDED_ELECTRON_BOUNDARY,
|
|
1105
|
+
role,
|
|
1106
|
+
reportMarkers: reportMarkersForRole(role),
|
|
1107
|
+
placement: "tab",
|
|
1108
|
+
direction: null,
|
|
1109
|
+
paneId: rootPaneId,
|
|
1110
|
+
rootPaneId,
|
|
1111
|
+
tabId,
|
|
1112
|
+
modelArgv: [...modelArgv],
|
|
1113
|
+
repository: canonicalRoot,
|
|
1114
|
+
hashes: Object.freeze({
|
|
1115
|
+
request: sha256(JSON.stringify(request)),
|
|
1116
|
+
layoutCreateResponse: sha256(layoutRaw),
|
|
1117
|
+
startResponse: sha256(startRaw),
|
|
1118
|
+
}),
|
|
1119
|
+
nonAuthorizing: true,
|
|
1120
|
+
authorityCreated: false,
|
|
1121
|
+
};
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
// Once agent start exits successfully, parsing or read-back failures
|
|
1124
|
+
// cannot erase that mutation: report a live-agent partial.
|
|
1125
|
+
return partialResult(error, {
|
|
1126
|
+
partial: agentStartSucceeded ? "agent_started" : "tab_created",
|
|
1127
|
+
paneId: rootPaneId, tabId, role, modelArgv,
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
return errorResult(error);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
882
1135
|
export function registerHerdrLifecycleInterface(pi, options = {}) {
|
|
883
1136
|
if (typeof pi?.registerTool !== "function" || REGISTRATIONS.has(pi)) return;
|
|
884
1137
|
REGISTRATIONS.add(pi);
|
|
@@ -9,7 +9,7 @@ set -euo pipefail
|
|
|
9
9
|
GC_TAXONOMY_VERSION="guest-containment-taxonomy.v1"
|
|
10
10
|
GC_LOG_SCHEMA="agentic-driver.guest-containment.log.v1"
|
|
11
11
|
GC_KILLSWITCH_SCHEMA="agentic-driver.guest-containment.killswitch.v1"
|
|
12
|
-
GC_TAXONOMY_SHA256="
|
|
12
|
+
GC_TAXONOMY_SHA256="0b194cdfd86f23fd68c2685c2ccfc556a0e311e2973b1ff619c6c78791d65fcd"
|
|
13
13
|
# Severity tiers (design section 2.0): CRITICAL/HIGH trip immediately,
|
|
14
14
|
# ELEVATED/ANOMALY accumulate pressure within a sliding time window.
|
|
15
15
|
GC_WINDOW_SECONDS=60
|
|
@@ -32,7 +32,7 @@ gc_embedded_taxonomy() {
|
|
|
32
32
|
{"id":"GC-FSW-003","class":"GC-FSW","severity":"ELEVATED","action":"deny","match":"file-growth-anomaly: unexpected growth in cache/package directories (~/.npm, ~/.cache/pip, /var/cache/*) beyond the size-delta threshold","evidence":"METR evaluation logs: bulk cache staging of payloads","since":"v1","disposition":"detector:fs-sweep size-delta sampling (gc_cache_growth_sample; aggregate ELEVATED, GC_CACHE_GROWTH_KIB per sweep)"},
|
|
33
33
|
{"id":"GC-SHR-001","class":"GC-SHR","severity":"HIGH","action":"deny","match":"shared-cache-write: writes to paths shared with other agents or the coordinator; with host-share absent any such attempt is a deny","evidence":"receipt proves host-share=absent; writes would indicate boundary violation","since":"v1","disposition":"detector:fs-path; share paths auto-arm via gc_share_mounted (/proc/mounts); shared-cache roots classify as SHR when unarmed"},
|
|
34
34
|
{"id":"GC-SHR-002","class":"GC-SHR","severity":"HIGH","action":"deny","match":"cross-agent-channel: creation of or writes to inter-agent coordination paths (maildirs, task-queue files, agent state files)","evidence":"HF incident multi-agent escalation; METR coordination/sabotage threat model","since":"v1","disposition":"detector:fs-path+shim-argv heuristics (Maildir, task-queue, agent-channel, coordination paths)"},
|
|
35
|
-
{"id":"GC-NET-001","class":"GC-NET","severity":"HIGH","action":"deny","match":"socket-open: any entry in /proc/net/tcp, /proc/net/tcp6, /proc/net/udp at any sweep","evidence":"fixture boots with no network device; any socket is exfiltration-capable","since":"v1","disposition":"detector:net-proc-sweep"},
|
|
35
|
+
{"id":"GC-NET-001","class":"GC-NET","severity":"HIGH","action":"deny","match":"socket-open: any entry in /proc/net/tcp, /proc/net/tcp6, /proc/net/udp not present in the pre-arming socket baseline, at any sweep","evidence":"fixture boots with no network device; any socket is exfiltration-capable","since":"v1","disposition":"detector:net-proc-sweep-baselined"},
|
|
36
36
|
{"id":"GC-NET-002","class":"GC-NET","severity":"HIGH","action":"deny","match":"network-tool-invocation: shim-detects wget, curl, nc, ssh, telnet, ping regardless of arguments","evidence":"METR self-replication exfiltration patterns; HF incident remote control","since":"v1","disposition":"detector:shim-argv"},
|
|
37
37
|
{"id":"GC-CRED-001","class":"GC-CRED","severity":"CRITICAL","action":"deny","match":"credential-path-read: opens of ~/.ssh/, ~/.aws/, ~/.netrc, ~/.git-credentials, token files","evidence":"credential theft is step one in every reported agent-escape chain","since":"v1","disposition":"detector:shim-argv"},
|
|
38
38
|
{"id":"GC-CRED-002","class":"GC-CRED","severity":"CRITICAL","action":"deny","match":"env-secret-probing: attempts to dump or scan the environment for secret-shaped keys","evidence":"METR evaluation logs; HF incident postmortem credential harvest attempts","since":"v1","disposition":"detector:shim-argv"},
|
|
@@ -673,7 +673,7 @@ if [ "${1:-}" = "--gc-core-embed" ]; then
|
|
|
673
673
|
GC_TAXONOMY_VERSION="guest-containment-taxonomy.v1"
|
|
674
674
|
GC_LOG_SCHEMA="agentic-driver.guest-containment.log.v1"
|
|
675
675
|
GC_KILLSWITCH_SCHEMA="agentic-driver.guest-containment.killswitch.v1"
|
|
676
|
-
GC_TAXONOMY_SHA256="
|
|
676
|
+
GC_TAXONOMY_SHA256="0b194cdfd86f23fd68c2685c2ccfc556a0e311e2973b1ff619c6c78791d65fcd"
|
|
677
677
|
# Severity tiers (design section 2.0): CRITICAL/HIGH trip immediately,
|
|
678
678
|
# ELEVATED/ANOMALY accumulate pressure within a sliding time window.
|
|
679
679
|
GC_WINDOW_SECONDS=60
|
|
@@ -696,7 +696,7 @@ gc_embedded_taxonomy() {
|
|
|
696
696
|
{"id":"GC-FSW-003","class":"GC-FSW","severity":"ELEVATED","action":"deny","match":"file-growth-anomaly: unexpected growth in cache/package directories (~/.npm, ~/.cache/pip, /var/cache/*) beyond the size-delta threshold","evidence":"METR evaluation logs: bulk cache staging of payloads","since":"v1","disposition":"detector:fs-sweep size-delta sampling (gc_cache_growth_sample; aggregate ELEVATED, GC_CACHE_GROWTH_KIB per sweep)"},
|
|
697
697
|
{"id":"GC-SHR-001","class":"GC-SHR","severity":"HIGH","action":"deny","match":"shared-cache-write: writes to paths shared with other agents or the coordinator; with host-share absent any such attempt is a deny","evidence":"receipt proves host-share=absent; writes would indicate boundary violation","since":"v1","disposition":"detector:fs-path; share paths auto-arm via gc_share_mounted (/proc/mounts); shared-cache roots classify as SHR when unarmed"},
|
|
698
698
|
{"id":"GC-SHR-002","class":"GC-SHR","severity":"HIGH","action":"deny","match":"cross-agent-channel: creation of or writes to inter-agent coordination paths (maildirs, task-queue files, agent state files)","evidence":"HF incident multi-agent escalation; METR coordination/sabotage threat model","since":"v1","disposition":"detector:fs-path+shim-argv heuristics (Maildir, task-queue, agent-channel, coordination paths)"},
|
|
699
|
-
{"id":"GC-NET-001","class":"GC-NET","severity":"HIGH","action":"deny","match":"socket-open: any entry in /proc/net/tcp, /proc/net/tcp6, /proc/net/udp at any sweep","evidence":"fixture boots with no network device; any socket is exfiltration-capable","since":"v1","disposition":"detector:net-proc-sweep"},
|
|
699
|
+
{"id":"GC-NET-001","class":"GC-NET","severity":"HIGH","action":"deny","match":"socket-open: any entry in /proc/net/tcp, /proc/net/tcp6, /proc/net/udp not present in the pre-arming socket baseline, at any sweep","evidence":"fixture boots with no network device; any socket is exfiltration-capable","since":"v1","disposition":"detector:net-proc-sweep-baselined"},
|
|
700
700
|
{"id":"GC-NET-002","class":"GC-NET","severity":"HIGH","action":"deny","match":"network-tool-invocation: shim-detects wget, curl, nc, ssh, telnet, ping regardless of arguments","evidence":"METR self-replication exfiltration patterns; HF incident remote control","since":"v1","disposition":"detector:shim-argv"},
|
|
701
701
|
{"id":"GC-CRED-001","class":"GC-CRED","severity":"CRITICAL","action":"deny","match":"credential-path-read: opens of ~/.ssh/, ~/.aws/, ~/.netrc, ~/.git-credentials, token files","evidence":"credential theft is step one in every reported agent-escape chain","since":"v1","disposition":"detector:shim-argv"},
|
|
702
702
|
{"id":"GC-CRED-002","class":"GC-CRED","severity":"CRITICAL","action":"deny","match":"env-secret-probing: attempts to dump or scan the environment for secret-shaped keys","evidence":"METR evaluation logs; HF incident postmortem credential harvest attempts","since":"v1","disposition":"detector:shim-argv"},
|
|
@@ -1722,7 +1722,7 @@ if ! cat >"$root/gc/core.sh" <<'GC_CORE_EOF'
|
|
|
1722
1722
|
GC_TAXONOMY_VERSION="guest-containment-taxonomy.v1"
|
|
1723
1723
|
GC_LOG_SCHEMA="agentic-driver.guest-containment.log.v1"
|
|
1724
1724
|
GC_KILLSWITCH_SCHEMA="agentic-driver.guest-containment.killswitch.v1"
|
|
1725
|
-
GC_TAXONOMY_SHA256="
|
|
1725
|
+
GC_TAXONOMY_SHA256="0b194cdfd86f23fd68c2685c2ccfc556a0e311e2973b1ff619c6c78791d65fcd"
|
|
1726
1726
|
# Severity tiers (design section 2.0): CRITICAL/HIGH trip immediately,
|
|
1727
1727
|
# ELEVATED/ANOMALY accumulate pressure within a sliding time window.
|
|
1728
1728
|
GC_WINDOW_SECONDS=60
|
|
@@ -1745,7 +1745,7 @@ gc_embedded_taxonomy() {
|
|
|
1745
1745
|
{"id":"GC-FSW-003","class":"GC-FSW","severity":"ELEVATED","action":"deny","match":"file-growth-anomaly: unexpected growth in cache/package directories (~/.npm, ~/.cache/pip, /var/cache/*) beyond the size-delta threshold","evidence":"METR evaluation logs: bulk cache staging of payloads","since":"v1","disposition":"detector:fs-sweep size-delta sampling (gc_cache_growth_sample; aggregate ELEVATED, GC_CACHE_GROWTH_KIB per sweep)"},
|
|
1746
1746
|
{"id":"GC-SHR-001","class":"GC-SHR","severity":"HIGH","action":"deny","match":"shared-cache-write: writes to paths shared with other agents or the coordinator; with host-share absent any such attempt is a deny","evidence":"receipt proves host-share=absent; writes would indicate boundary violation","since":"v1","disposition":"detector:fs-path; share paths auto-arm via gc_share_mounted (/proc/mounts); shared-cache roots classify as SHR when unarmed"},
|
|
1747
1747
|
{"id":"GC-SHR-002","class":"GC-SHR","severity":"HIGH","action":"deny","match":"cross-agent-channel: creation of or writes to inter-agent coordination paths (maildirs, task-queue files, agent state files)","evidence":"HF incident multi-agent escalation; METR coordination/sabotage threat model","since":"v1","disposition":"detector:fs-path+shim-argv heuristics (Maildir, task-queue, agent-channel, coordination paths)"},
|
|
1748
|
-
{"id":"GC-NET-001","class":"GC-NET","severity":"HIGH","action":"deny","match":"socket-open: any entry in /proc/net/tcp, /proc/net/tcp6, /proc/net/udp at any sweep","evidence":"fixture boots with no network device; any socket is exfiltration-capable","since":"v1","disposition":"detector:net-proc-sweep"},
|
|
1748
|
+
{"id":"GC-NET-001","class":"GC-NET","severity":"HIGH","action":"deny","match":"socket-open: any entry in /proc/net/tcp, /proc/net/tcp6, /proc/net/udp not present in the pre-arming socket baseline, at any sweep","evidence":"fixture boots with no network device; any socket is exfiltration-capable","since":"v1","disposition":"detector:net-proc-sweep-baselined"},
|
|
1749
1749
|
{"id":"GC-NET-002","class":"GC-NET","severity":"HIGH","action":"deny","match":"network-tool-invocation: shim-detects wget, curl, nc, ssh, telnet, ping regardless of arguments","evidence":"METR self-replication exfiltration patterns; HF incident remote control","since":"v1","disposition":"detector:shim-argv"},
|
|
1750
1750
|
{"id":"GC-CRED-001","class":"GC-CRED","severity":"CRITICAL","action":"deny","match":"credential-path-read: opens of ~/.ssh/, ~/.aws/, ~/.netrc, ~/.git-credentials, token files","evidence":"credential theft is step one in every reported agent-escape chain","since":"v1","disposition":"detector:shim-argv"},
|
|
1751
1751
|
{"id":"GC-CRED-002","class":"GC-CRED","severity":"CRITICAL","action":"deny","match":"env-secret-probing: attempts to dump or scan the environment for secret-shaped keys","evidence":"METR evaluation logs; HF incident postmortem credential harvest attempts","since":"v1","disposition":"detector:shim-argv"},
|
|
@@ -9,3 +9,19 @@ export function isNativeTuiContext(context) {
|
|
|
9
9
|
&& context?.hasUI === true
|
|
10
10
|
&& typeof context?.ui?.confirm === "function";
|
|
11
11
|
}
|
|
12
|
+
|
|
13
|
+
// Attended Electron main-process context predicate. This is a SEPARATE,
|
|
14
|
+
// explicit context class for a trusted non-TUI host (the Electron main
|
|
15
|
+
// process) that owns a native owner-confirmation capability of its own. It
|
|
16
|
+
// is deliberately not a Pi TUI representation: the mode token differs, so an
|
|
17
|
+
// attended-Electron context NEVER satisfies isNativeTuiContext and a TUI
|
|
18
|
+
// context NEVER satisfies this predicate. Restriction-gated TUI actions
|
|
19
|
+
// continue to deny it; only the explicit attended-Electron driver boundary
|
|
20
|
+
// accepts it.
|
|
21
|
+
export const ATTENDED_ELECTRON_CONTEXT_MODE = "electron-attended";
|
|
22
|
+
|
|
23
|
+
export function isAttendedElectronContext(context) {
|
|
24
|
+
return context?.mode === ATTENDED_ELECTRON_CONTEXT_MODE
|
|
25
|
+
&& context?.hasUI === true
|
|
26
|
+
&& typeof context?.ui?.confirm === "function";
|
|
27
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// NUDGE §2 total detection classifier (SPEC_TASK43_NUDGE.md v3.3.1). Pure
|
|
5
|
+
// module: no I/O, no transport, no authority. #41's poll loop supplies one
|
|
6
|
+
// validated observation per poll and applies this classifier exactly once;
|
|
7
|
+
// the first matching ordered rule wins, so the classification is total and
|
|
8
|
+
// deterministic. Screen text corroborates a block but never changes the
|
|
9
|
+
// classification; malformed identity/readiness/provenance is an observation
|
|
10
|
+
// error owned by #41 and fails closed there, not a worker state here.
|
|
11
|
+
|
|
12
|
+
// The closed stuck-reason enum (NUDGE §5). `gone` (absent role) is
|
|
13
|
+
// deliberately excluded: #41's absent-role taxonomy owns it.
|
|
14
|
+
export const NUDGE_STUCK_REASONS = Object.freeze([
|
|
15
|
+
"blocked-modal",
|
|
16
|
+
"working-sequence-frozen",
|
|
17
|
+
"status-unknown",
|
|
18
|
+
"done-report-unavailable",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
// The seven classifier states (NUDGE §2), in rule order.
|
|
22
|
+
export const NUDGE_CLASSIFIER_STATES = Object.freeze([
|
|
23
|
+
"complete",
|
|
24
|
+
"gone",
|
|
25
|
+
"stuck-on-dialog",
|
|
26
|
+
"working-on-task",
|
|
27
|
+
"stalled-unknown",
|
|
28
|
+
"idle-eligible",
|
|
29
|
+
"done-without-report",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
// Classifier state → stuck reason (NUDGE §5 table). States outside the
|
|
33
|
+
// stuck taxonomy map to null: `complete`, `gone`, `working-on-task`, and
|
|
34
|
+
// `idle-eligible` are not recovery triggers.
|
|
35
|
+
export const NUDGE_STATE_REASON = Object.freeze({
|
|
36
|
+
"stuck-on-dialog": "blocked-modal",
|
|
37
|
+
// Deliberately null: callers must use stalledUnknownReason() to resolve
|
|
38
|
+
// frozen-sequence versus unknown-status evidence.
|
|
39
|
+
"stalled-unknown": null,
|
|
40
|
+
"done-without-report": "done-report-unavailable",
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// `stalled-unknown` splits on evidence: a frozen working sequence is
|
|
44
|
+
// `working-sequence-frozen`; an otherwise-valid `unknown` status is
|
|
45
|
+
// `status-unknown`. This is the one state with two reasons.
|
|
46
|
+
export function stalledUnknownReason({ status = null, sequenceFrozen = false } = {}) {
|
|
47
|
+
if (sequenceFrozen) return "working-sequence-frozen";
|
|
48
|
+
if (status === "unknown") return "status-unknown";
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const VALID_STATUS = Object.freeze(["idle", "working", "blocked", "done", "unknown"]);
|
|
53
|
+
|
|
54
|
+
// Validate one agent observation (NUDGE §1.1 validated fields). Returns
|
|
55
|
+
// {ok, errors} — malformed identity/status/sequence is an observation error,
|
|
56
|
+
// not a worker state, and the caller (the #41 poll loop) fails closed on it.
|
|
57
|
+
// This module never guesses a status to keep classifying.
|
|
58
|
+
export function validateObservation(observation) {
|
|
59
|
+
const errors = [];
|
|
60
|
+
if (observation === null || typeof observation !== "object" || Array.isArray(observation)) {
|
|
61
|
+
return { ok: false, errors: ["observation: must be an object"] };
|
|
62
|
+
}
|
|
63
|
+
if (!VALID_STATUS.includes(observation.agentStatus)) {
|
|
64
|
+
errors.push(`agentStatus: must be one of ${VALID_STATUS.join(", ")} (got ${JSON.stringify(observation.agentStatus ?? null)})`);
|
|
65
|
+
}
|
|
66
|
+
const seq = observation.stateChangeSeq;
|
|
67
|
+
if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) {
|
|
68
|
+
errors.push(`stateChangeSeq: must be a safe integer >= 0 (got ${JSON.stringify(seq ?? null)})`);
|
|
69
|
+
}
|
|
70
|
+
if (typeof observation.role !== "string" || observation.role === "") {
|
|
71
|
+
errors.push("role: must be a non-empty string");
|
|
72
|
+
}
|
|
73
|
+
const revision = observation.revision;
|
|
74
|
+
if (revision !== undefined && revision !== null
|
|
75
|
+
&& (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 0)) {
|
|
76
|
+
errors.push(`revision: when present must be a safe integer >= 0 (got ${JSON.stringify(revision)})`);
|
|
77
|
+
}
|
|
78
|
+
return { ok: errors.length === 0, errors };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The total ordered classifier (NUDGE §2). Input: one validated agent
|
|
82
|
+
// observation plus the report evidence and the frozen-sequence judgment
|
|
83
|
+
// supplied by the #41 poll loop (stallPolls is owned solely by #41's
|
|
84
|
+
// dispatch config; this module only consumes the derived boolean).
|
|
85
|
+
//
|
|
86
|
+
// Parameters:
|
|
87
|
+
// agentStatus — one of idle | working | blocked | done | unknown
|
|
88
|
+
// stateChangeSeq — safe integer >= 0
|
|
89
|
+
// role — non-empty role string (validated identity)
|
|
90
|
+
// reportState — "pending" | "complete" | "unavailable" (#41 §3.2)
|
|
91
|
+
// absentRole — true when #41 mapped the observation to its
|
|
92
|
+
// absent-role class (`gone` is #41 taxonomy scope)
|
|
93
|
+
// sequenceFrozen — boolean: working sequence frozen for >= stallPolls
|
|
94
|
+
// consecutive observations (judged by the poll loop)
|
|
95
|
+
//
|
|
96
|
+
// Output: { state, reasonCode, confidenceBasis } — reasonCode is null for
|
|
97
|
+
// non-stuck states; confidenceBasis names the ordered rule that decided.
|
|
98
|
+
export function classifyObservation({
|
|
99
|
+
agentStatus = null,
|
|
100
|
+
stateChangeSeq = null,
|
|
101
|
+
role = null,
|
|
102
|
+
reportState = null,
|
|
103
|
+
absentRole = false,
|
|
104
|
+
sequenceFrozen = false,
|
|
105
|
+
} = {}) {
|
|
106
|
+
const observation = { agentStatus, stateChangeSeq, role };
|
|
107
|
+
const validity = validateObservation(observation);
|
|
108
|
+
if (!validity.ok) {
|
|
109
|
+
return {
|
|
110
|
+
state: null,
|
|
111
|
+
reasonCode: null,
|
|
112
|
+
confidenceBasis: `observation-error: ${validity.errors.join("; ")}`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (typeof reportState !== "string"
|
|
116
|
+
|| !["pending", "complete", "unavailable"].includes(reportState)) {
|
|
117
|
+
return {
|
|
118
|
+
state: null,
|
|
119
|
+
reasonCode: null,
|
|
120
|
+
confidenceBasis: `observation-error: reportState must be pending | complete | unavailable (got ${JSON.stringify(reportState)})`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Rule 1 — complete: a complete provenance-valid role report exists,
|
|
125
|
+
// regardless of current idle/done status. Complete precedes idle so a
|
|
126
|
+
// prior report can never trigger new work.
|
|
127
|
+
if (reportState === "complete") {
|
|
128
|
+
return { state: "complete", reasonCode: null, confidenceBasis: "rule-1-complete-over-status" };
|
|
129
|
+
}
|
|
130
|
+
// Rule 2 — gone: #41 returns its absent-role class; `gone` stays #41
|
|
131
|
+
// taxonomy scope and is excluded from the stuck-reason enum.
|
|
132
|
+
if (absentRole === true) {
|
|
133
|
+
return { state: "gone", reasonCode: null, confidenceBasis: "rule-2-absent-role" };
|
|
134
|
+
}
|
|
135
|
+
// Rule 3 — stuck-on-dialog: status is `blocked`. herdr classified the
|
|
136
|
+
// worker blocked; screen text does not determine the cause.
|
|
137
|
+
if (agentStatus === "blocked") {
|
|
138
|
+
return { state: "stuck-on-dialog", reasonCode: "blocked-modal", confidenceBasis: "rule-3-blocked-status" };
|
|
139
|
+
}
|
|
140
|
+
// Rule 4 — working-on-task: status is `working` and the sequence advanced,
|
|
141
|
+
// or fewer than stallPolls consecutive frozen observations exist (i.e.
|
|
142
|
+
// the poll loop has not yet judged the sequence frozen).
|
|
143
|
+
if (agentStatus === "working" && !sequenceFrozen) {
|
|
144
|
+
return { state: "working-on-task", reasonCode: null, confidenceBasis: "rule-4-working-progressing" };
|
|
145
|
+
}
|
|
146
|
+
// Rule 5 — stalled-unknown: working sequence frozen for at least
|
|
147
|
+
// stallPolls, or status is otherwise-valid `unknown`.
|
|
148
|
+
if ((agentStatus === "working" && sequenceFrozen) || agentStatus === "unknown") {
|
|
149
|
+
const reasonCode = stalledUnknownReason({ status: agentStatus, sequenceFrozen });
|
|
150
|
+
return {
|
|
151
|
+
state: "stalled-unknown",
|
|
152
|
+
reasonCode,
|
|
153
|
+
confidenceBasis: agentStatus === "working" ? "rule-5-frozen-sequence" : "rule-5-unknown-status",
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
// Rule 6 — idle-eligible: idle with no complete report.
|
|
157
|
+
if (agentStatus === "idle") {
|
|
158
|
+
return { state: "idle-eligible", reasonCode: null, confidenceBasis: "rule-6-idle-no-report" };
|
|
159
|
+
}
|
|
160
|
+
// Rule 7 — done-without-report: done with no extractable complete report;
|
|
161
|
+
// terminal evidence failure `done-report-unavailable`.
|
|
162
|
+
if (agentStatus === "done") {
|
|
163
|
+
return {
|
|
164
|
+
state: "done-without-report",
|
|
165
|
+
reasonCode: "done-report-unavailable",
|
|
166
|
+
confidenceBasis: "rule-7-done-no-report",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
// Unreachable for validated inputs (every validated status is handled
|
|
170
|
+
// above); kept as an explicit fail-closed floor.
|
|
171
|
+
return {
|
|
172
|
+
state: null,
|
|
173
|
+
reasonCode: null,
|
|
174
|
+
confidenceBasis: `observation-error: unclassified status ${JSON.stringify(agentStatus)}`,
|
|
175
|
+
};
|
|
176
|
+
}
|