@kontourai/flow-agents 4.1.0 → 4.2.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 +16 -0
- package/build/src/builder-flow-runtime.js +10 -5
- package/context/contracts/verification-contract.md +6 -0
- package/context/scripts/telemetry/lib/config.sh +29 -0
- package/dist/base/build/package.json +1 -1
- package/dist/base/build/src/builder-flow-runtime.js +10 -5
- package/dist/base/context/contracts/verification-contract.md +6 -0
- package/dist/base/context/scripts/telemetry/lib/config.sh +29 -0
- package/dist/base/evals/acceptance/prove-capture-teeth.sh +24 -0
- package/dist/base/evals/integration/test_effective_backlog_settings.sh +118 -15
- package/dist/base/evals/integration/test_liveness_console_relay.sh +91 -1
- package/dist/base/evals/integration/test_liveness_heartbeat.sh +66 -0
- package/dist/base/install.sh +1 -1
- package/dist/base/scripts/hooks/claude-telemetry-hook.js +6 -0
- package/dist/base/scripts/hooks/lib/codex-exit-code.js +75 -23
- package/dist/base/scripts/hooks/lib/liveness-heartbeat.js +42 -1
- package/dist/base/scripts/hooks/lib/liveness-write.js +109 -9
- package/dist/base/scripts/liveness/relay.sh +23 -11
- package/dist/base/scripts/telemetry/lib/config.sh +29 -0
- package/dist/claude-code/build/package.json +1 -1
- package/dist/claude-code/build/src/builder-flow-runtime.js +10 -5
- package/dist/claude-code/context/contracts/verification-contract.md +6 -0
- package/dist/claude-code/context/scripts/telemetry/lib/config.sh +29 -0
- package/dist/claude-code/evals/acceptance/prove-capture-teeth.sh +24 -0
- package/dist/claude-code/evals/integration/test_effective_backlog_settings.sh +118 -15
- package/dist/claude-code/evals/integration/test_liveness_console_relay.sh +91 -1
- package/dist/claude-code/evals/integration/test_liveness_heartbeat.sh +66 -0
- package/dist/claude-code/install.sh +1 -1
- package/dist/claude-code/scripts/hooks/claude-telemetry-hook.js +6 -0
- package/dist/claude-code/scripts/hooks/lib/codex-exit-code.js +75 -23
- package/dist/claude-code/scripts/hooks/lib/liveness-heartbeat.js +42 -1
- package/dist/claude-code/scripts/hooks/lib/liveness-write.js +109 -9
- package/dist/claude-code/scripts/liveness/relay.sh +23 -11
- package/dist/claude-code/scripts/telemetry/lib/config.sh +29 -0
- package/dist/codex/build/package.json +1 -1
- package/dist/codex/build/src/builder-flow-runtime.js +10 -5
- package/dist/codex/context/contracts/verification-contract.md +6 -0
- package/dist/codex/context/scripts/telemetry/lib/config.sh +29 -0
- package/dist/codex/evals/acceptance/prove-capture-teeth.sh +24 -0
- package/dist/codex/evals/integration/test_effective_backlog_settings.sh +118 -15
- package/dist/codex/evals/integration/test_liveness_console_relay.sh +91 -1
- package/dist/codex/evals/integration/test_liveness_heartbeat.sh +66 -0
- package/dist/codex/install.sh +1 -1
- package/dist/codex/scripts/hooks/claude-telemetry-hook.js +6 -0
- package/dist/codex/scripts/hooks/lib/codex-exit-code.js +75 -23
- package/dist/codex/scripts/hooks/lib/liveness-heartbeat.js +42 -1
- package/dist/codex/scripts/hooks/lib/liveness-write.js +109 -9
- package/dist/codex/scripts/liveness/relay.sh +23 -11
- package/dist/codex/scripts/telemetry/lib/config.sh +29 -0
- package/dist/kiro/build/package.json +1 -1
- package/dist/kiro/build/src/builder-flow-runtime.js +10 -5
- package/dist/kiro/context/contracts/verification-contract.md +6 -0
- package/dist/kiro/context/scripts/telemetry/lib/config.sh +29 -0
- package/dist/kiro/evals/acceptance/prove-capture-teeth.sh +24 -0
- package/dist/kiro/evals/integration/test_effective_backlog_settings.sh +118 -15
- package/dist/kiro/evals/integration/test_liveness_console_relay.sh +91 -1
- package/dist/kiro/evals/integration/test_liveness_heartbeat.sh +66 -0
- package/dist/kiro/install.sh +1 -1
- package/dist/kiro/scripts/hooks/claude-telemetry-hook.js +6 -0
- package/dist/kiro/scripts/hooks/lib/codex-exit-code.js +75 -23
- package/dist/kiro/scripts/hooks/lib/liveness-heartbeat.js +42 -1
- package/dist/kiro/scripts/hooks/lib/liveness-write.js +109 -9
- package/dist/kiro/scripts/liveness/relay.sh +23 -11
- package/dist/kiro/scripts/telemetry/lib/config.sh +29 -0
- package/dist/opencode/build/package.json +1 -1
- package/dist/opencode/build/src/builder-flow-runtime.js +10 -5
- package/dist/opencode/context/contracts/verification-contract.md +6 -0
- package/dist/opencode/context/scripts/telemetry/lib/config.sh +29 -0
- package/dist/opencode/evals/acceptance/prove-capture-teeth.sh +24 -0
- package/dist/opencode/evals/integration/test_effective_backlog_settings.sh +118 -15
- package/dist/opencode/evals/integration/test_liveness_console_relay.sh +91 -1
- package/dist/opencode/evals/integration/test_liveness_heartbeat.sh +66 -0
- package/dist/opencode/install.sh +1 -1
- package/dist/opencode/scripts/hooks/claude-telemetry-hook.js +6 -0
- package/dist/opencode/scripts/hooks/lib/codex-exit-code.js +75 -23
- package/dist/opencode/scripts/hooks/lib/liveness-heartbeat.js +42 -1
- package/dist/opencode/scripts/hooks/lib/liveness-write.js +109 -9
- package/dist/opencode/scripts/liveness/relay.sh +23 -11
- package/dist/opencode/scripts/telemetry/lib/config.sh +29 -0
- package/dist/pi/build/package.json +1 -1
- package/dist/pi/build/src/builder-flow-runtime.js +10 -5
- package/dist/pi/context/contracts/verification-contract.md +6 -0
- package/dist/pi/context/scripts/telemetry/lib/config.sh +29 -0
- package/dist/pi/evals/acceptance/prove-capture-teeth.sh +24 -0
- package/dist/pi/evals/integration/test_effective_backlog_settings.sh +118 -15
- package/dist/pi/evals/integration/test_liveness_console_relay.sh +91 -1
- package/dist/pi/evals/integration/test_liveness_heartbeat.sh +66 -0
- package/dist/pi/install.sh +1 -1
- package/dist/pi/scripts/hooks/claude-telemetry-hook.js +6 -0
- package/dist/pi/scripts/hooks/lib/codex-exit-code.js +75 -23
- package/dist/pi/scripts/hooks/lib/liveness-heartbeat.js +42 -1
- package/dist/pi/scripts/hooks/lib/liveness-write.js +109 -9
- package/dist/pi/scripts/liveness/relay.sh +23 -11
- package/dist/pi/scripts/telemetry/lib/config.sh +29 -0
- package/evals/acceptance/prove-capture-teeth.sh +24 -0
- package/evals/integration/test_effective_backlog_settings.sh +118 -15
- package/evals/integration/test_liveness_console_relay.sh +91 -1
- package/evals/integration/test_liveness_heartbeat.sh +66 -0
- package/package.json +2 -1
- package/scripts/hooks/claude-telemetry-hook.js +6 -0
- package/scripts/hooks/lib/codex-exit-code.js +75 -23
- package/scripts/hooks/lib/liveness-heartbeat.js +42 -1
- package/scripts/hooks/lib/liveness-write.js +109 -9
- package/scripts/liveness/relay.sh +23 -11
- package/scripts/telemetry/lib/config.sh +29 -0
- package/src/builder-flow-runtime.ts +10 -5
- package/src/cli/builder-flow-runtime.test.mjs +55 -2
- package/src/cli/codex-exit-code.test.mjs +217 -2
|
@@ -704,6 +704,72 @@ else
|
|
|
704
704
|
_fail "maybeEmitHeartbeat did not neutralize an orphan heartbeat as expected: $J1_OUT"
|
|
705
705
|
fi
|
|
706
706
|
|
|
707
|
+
# ─── K. Richer "real liveness": activity descriptor on the emitted heartbeat ────────────────
|
|
708
|
+
echo "--- K. Activity enrichment (tool name on the pulse) ---"
|
|
709
|
+
|
|
710
|
+
# call_heartbeat_activity <scratch_root> <env_json> <now_iso> <activity_json>
|
|
711
|
+
# Like call_heartbeat, but passes an `activity` descriptor through opts so we can prove the emitted
|
|
712
|
+
# heartbeat carries a sanitized activity field (the "what is this agent doing right now" signal).
|
|
713
|
+
call_heartbeat_activity() {
|
|
714
|
+
local root="$1" env_json="$2" now="$3" activity_json="$4"
|
|
715
|
+
MODULE_ARG="$HEARTBEAT_MODULE" ROOT_ARG="$root" ENV_JSON_ARG="$env_json" NOW_ARG="$now" ACTIVITY_ARG="$activity_json" \
|
|
716
|
+
node - <<'NODE'
|
|
717
|
+
const { maybeEmitHeartbeat } = require(process.env.MODULE_ARG);
|
|
718
|
+
const env = JSON.parse(process.env.ENV_JSON_ARG);
|
|
719
|
+
const opts = { cwd: process.env.ROOT_ARG, env, now: process.env.NOW_ARG, activity: JSON.parse(process.env.ACTIVITY_ARG) };
|
|
720
|
+
process.stdout.write(JSON.stringify(maybeEmitHeartbeat(opts)));
|
|
721
|
+
NODE
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
# K1: a clean tool name rides through onto the emitted heartbeat as activity.tool.
|
|
725
|
+
K1_ROOT="$(new_scratch)"
|
|
726
|
+
seed_claim "$K1_ROOT" "k1-subj" "agent-k1" "2026-06-25T12:00:00.000Z" 1800
|
|
727
|
+
K1_RESULT="$(call_heartbeat_activity "$K1_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-k1"}' "2026-06-25T12:01:00.000Z" '{"tool":"Bash"}')"
|
|
728
|
+
K1_LINE="$(tail -n 1 "$(stream_file "$K1_ROOT")" 2>/dev/null)"
|
|
729
|
+
if [[ "$K1_RESULT" == '{"emitted":true}' ]] \
|
|
730
|
+
&& grep -q '"type":"heartbeat"' <<<"$K1_LINE" \
|
|
731
|
+
&& grep -q '"activity":{"tool":"Bash"}' <<<"$K1_LINE"; then
|
|
732
|
+
_pass "the emitted heartbeat carries a sanitized activity.tool (what the agent is doing right now)"
|
|
733
|
+
else
|
|
734
|
+
_fail "the heartbeat did not carry the expected activity.tool: result=$K1_RESULT line=$K1_LINE"
|
|
735
|
+
fi
|
|
736
|
+
|
|
737
|
+
# K2: an MCP-style tool name (double underscores + hyphens) survives sanitizeSegment intact.
|
|
738
|
+
K2_ROOT="$(new_scratch)"
|
|
739
|
+
seed_claim "$K2_ROOT" "k2-subj" "agent-k2" "2026-06-25T12:00:00.000Z" 1800
|
|
740
|
+
call_heartbeat_activity "$K2_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-k2"}' "2026-06-25T12:01:00.000Z" '{"tool":"mcp__claude-in-chrome__navigate"}' >/dev/null
|
|
741
|
+
K2_LINE="$(tail -n 1 "$(stream_file "$K2_ROOT")" 2>/dev/null)"
|
|
742
|
+
if grep -q '"activity":{"tool":"mcp__claude-in-chrome__navigate"}' <<<"$K2_LINE"; then
|
|
743
|
+
_pass "an MCP tool name (double underscores + hyphens) rides through the allowlist sanitizer intact"
|
|
744
|
+
else
|
|
745
|
+
_fail "the MCP tool name was not preserved on the activity descriptor: line=$K2_LINE"
|
|
746
|
+
fi
|
|
747
|
+
|
|
748
|
+
# K3: a hostile tool name (control bytes / injection payload) is stripped to the allowlist charset —
|
|
749
|
+
# never emitted raw into the append-only stream.
|
|
750
|
+
K3_ROOT="$(new_scratch)"
|
|
751
|
+
seed_claim "$K3_ROOT" "k3-subj" "agent-k3" "2026-06-25T12:00:00.000Z" 1800
|
|
752
|
+
call_heartbeat_activity "$K3_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-k3"}' "2026-06-25T12:01:00.000Z" '{"tool":"Ba\"sh\n{\"x\":1}"}' >/dev/null
|
|
753
|
+
K3_LINE="$(tail -n 1 "$(stream_file "$K3_ROOT")" 2>/dev/null)"
|
|
754
|
+
# The allowlist keeps only [A-Za-z0-9_.-], so the payload collapses to "Bashx1".
|
|
755
|
+
if grep -q '"activity":{"tool":"Bashx1"}' <<<"$K3_LINE" && ! grep -q 'Ba"sh' <<<"$K3_LINE"; then
|
|
756
|
+
_pass "a hostile tool name is stripped to the allowlist charset, never emitted raw (injection discipline)"
|
|
757
|
+
else
|
|
758
|
+
_fail "the hostile tool name was not sanitized as expected: line=$K3_LINE"
|
|
759
|
+
fi
|
|
760
|
+
|
|
761
|
+
# K4: no activity supplied → the heartbeat is a plain aliveness pulse with NO activity key (the field
|
|
762
|
+
# is omitted, not emitted empty).
|
|
763
|
+
K4_ROOT="$(new_scratch)"
|
|
764
|
+
seed_claim "$K4_ROOT" "k4-subj" "agent-k4" "2026-06-25T12:00:00.000Z" 1800
|
|
765
|
+
call_heartbeat "$K4_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-k4"}' "2026-06-25T12:01:00.000Z" >/dev/null
|
|
766
|
+
K4_LINE="$(tail -n 1 "$(stream_file "$K4_ROOT")" 2>/dev/null)"
|
|
767
|
+
if grep -q '"type":"heartbeat"' <<<"$K4_LINE" && ! grep -q '"activity"' <<<"$K4_LINE"; then
|
|
768
|
+
_pass "with no activity supplied the heartbeat omits the activity field entirely (no empty-object noise)"
|
|
769
|
+
else
|
|
770
|
+
_fail "the no-activity heartbeat unexpectedly carried an activity field: line=$K4_LINE"
|
|
771
|
+
fi
|
|
772
|
+
|
|
707
773
|
echo ""
|
|
708
774
|
if [[ "$errors" -eq 0 ]]; then
|
|
709
775
|
echo "Liveness heartbeat integration passed."
|
package/dist/base/install.sh
CHANGED
|
@@ -72,7 +72,7 @@ if [[ ! -e "$DEST/AGENTS.md" && ! -L "$DEST/AGENTS.md" ]]; then
|
|
|
72
72
|
rm -f "$instruction_tmp"
|
|
73
73
|
fi
|
|
74
74
|
if command -v node >/dev/null 2>&1; then
|
|
75
|
-
node "$DEST/scripts/install-merge.js" --stamp-only --version "4.1
|
|
75
|
+
node "$DEST/scripts/install-merge.js" --stamp-only --version "4.2.1" --install-record "$DEST/.flow-agents/install.json" --runtime "base" || true
|
|
76
76
|
fi
|
|
77
77
|
if [[ ${#CONSOLE_CONFIG_ARGS[@]} -gt 0 || -n "${FLOW_AGENTS_TELEMETRY_SINK:-}" || -n "${FLOW_AGENTS_TELEMETRY_SINKS:-}" || -n "${FLOW_AGENTS_CONSOLE_URL:-}" || -n "${CONSOLE_TELEMETRY_URL:-}" || -n "${CONSOLE_URL:-}" || -n "${FLOW_AGENTS_CONSOLE_TOKEN_FILE:-}" || -n "${CONSOLE_TELEMETRY_TOKEN_FILE:-}" ]]; then
|
|
78
78
|
bash "$DEST/scripts/telemetry/install-console-config.sh" "$DEST/scripts/telemetry/telemetry.conf" "${CONSOLE_CONFIG_ARGS[@]}"
|
|
@@ -105,9 +105,15 @@ async function main() {
|
|
|
105
105
|
let conflict;
|
|
106
106
|
if (canonical === 'postToolUse') {
|
|
107
107
|
try {
|
|
108
|
+
// Richer "real liveness": name the tool this pulse rode in on so the heartbeat says WHAT the
|
|
109
|
+
// agent is doing, not just that it is alive. Claude Code's PostToolUse payload carries the
|
|
110
|
+
// tool name as `tool_name` (fall back to `toolName`); the heartbeat sanitizes it, so a
|
|
111
|
+
// missing/odd value degrades to a plain aliveness pulse rather than an error.
|
|
112
|
+
const toolName = payload.tool_name || payload.toolName;
|
|
108
113
|
const heartbeatResult = require('./lib/liveness-heartbeat').maybeEmitHeartbeat({
|
|
109
114
|
cwd: process.cwd(),
|
|
110
115
|
env: process.env,
|
|
116
|
+
activity: toolName ? { tool: toolName } : undefined,
|
|
111
117
|
});
|
|
112
118
|
conflict = heartbeatResult && heartbeatResult.conflict;
|
|
113
119
|
} catch (err) {
|
|
@@ -113,10 +113,13 @@ function normalizeCallArguments(argumentsField) {
|
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
115
|
if (!parsed || typeof parsed !== 'object') return null;
|
|
116
|
-
|
|
116
|
+
// Codex records exec_command arguments under `cmd`; older shell fixtures
|
|
117
|
+
// and some adapters use `command`.
|
|
118
|
+
return normalizeCommandText(parsed.cmd) || normalizeCommandText(parsed.command);
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
const OUTPUT_FIELD_NEEDLE = '"output":"';
|
|
122
|
+
const COMMAND_FUNCTION_NAMES = new Set(['exec_command', 'shell']);
|
|
120
123
|
|
|
121
124
|
/**
|
|
122
125
|
* parseCandidateLine(line, maxLineHeadBytes) → candidate | null
|
|
@@ -149,7 +152,15 @@ function parseCandidateLine(line, maxLineHeadBytes) {
|
|
|
149
152
|
return { type: 'function_call_output', callId, output: payload.output };
|
|
150
153
|
}
|
|
151
154
|
if (payload.type === 'function_call') {
|
|
152
|
-
|
|
155
|
+
const name = typeof payload.name === 'string' && payload.name.trim() ? payload.name.trim() : null;
|
|
156
|
+
const command = normalizeCallArguments(payload.arguments);
|
|
157
|
+
const commandCapable = name === null || COMMAND_FUNCTION_NAMES.has(name) || command !== null;
|
|
158
|
+
return {
|
|
159
|
+
type: 'function_call',
|
|
160
|
+
callId,
|
|
161
|
+
commandCapable,
|
|
162
|
+
command,
|
|
163
|
+
};
|
|
153
164
|
}
|
|
154
165
|
return null;
|
|
155
166
|
}
|
|
@@ -214,13 +225,12 @@ function resolveContainedRealPath(transcriptPath) {
|
|
|
214
225
|
* Correlation policy (Decision B, #470 iteration 2, HIGH finding #4), in
|
|
215
226
|
* priority order:
|
|
216
227
|
* 1. call_id match wins — authoritative.
|
|
217
|
-
* 2. Absent a call_id match:
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
* back to the newest `function_call_output` banner.
|
|
228
|
+
* 2. Absent a call_id match: pair outputs with calls by rollout call_id and
|
|
229
|
+
* select the only pair whose normalized arguments match `command`. Zero
|
|
230
|
+
* matches or multiple matches DECLINE (`null`) rather than attribute a
|
|
231
|
+
* neighboring or repeated command's exit code.
|
|
232
|
+
* 3. If no pairing is resolvable at all and the rollout contains exactly one
|
|
233
|
+
* output with no function call, use that genuinely unpaired legacy output.
|
|
224
234
|
*
|
|
225
235
|
* Any failure (missing/unreadable/non-regular file, containment violation,
|
|
226
236
|
* malformed JSON lines, no candidate found) yields `null` — never throws.
|
|
@@ -271,41 +281,83 @@ function readExitCodeFromRollout(transcriptPath, options) {
|
|
|
271
281
|
}
|
|
272
282
|
if (truncated && lines.length > 1) lines.shift();
|
|
273
283
|
|
|
274
|
-
|
|
284
|
+
const outputsByRequestedCallId = [];
|
|
275
285
|
let newestOutputEntry = null;
|
|
276
|
-
|
|
286
|
+
const outputEntries = [];
|
|
287
|
+
const commandByCallId = new Map();
|
|
288
|
+
const seenFunctionCallIds = new Set();
|
|
289
|
+
const ambiguousCallIds = new Set();
|
|
290
|
+
let functionCallCount = 0;
|
|
291
|
+
let sawUnclassifiableLine = false;
|
|
292
|
+
let sawUnresolvableFunctionCall = false;
|
|
277
293
|
|
|
278
294
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
279
295
|
const line = lines[i].trim();
|
|
280
296
|
if (!line) continue;
|
|
281
297
|
const candidate = parseCandidateLine(line, maxLineHeadBytes);
|
|
282
|
-
if (!candidate)
|
|
298
|
+
if (!candidate) {
|
|
299
|
+
if (Buffer.byteLength(line, 'utf8') > maxLineHeadBytes) {
|
|
300
|
+
sawUnclassifiableLine = true;
|
|
301
|
+
} else {
|
|
302
|
+
try {
|
|
303
|
+
JSON.parse(line); // valid non-candidate events do not poison correlation
|
|
304
|
+
} catch {
|
|
305
|
+
sawUnclassifiableLine = true;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
continue; // malformed/partial/unrecognized line — skip, keep scanning
|
|
309
|
+
}
|
|
283
310
|
|
|
284
311
|
if (candidate.type === 'function_call_output') {
|
|
312
|
+
outputEntries.push(candidate); // scan order is newest to oldest
|
|
285
313
|
if (newestOutputEntry === null) newestOutputEntry = candidate; // first seen scanning backward = newest
|
|
286
314
|
if (callId && candidate.callId === callId) {
|
|
287
|
-
|
|
288
|
-
break; // call_id match is authoritative — stop scanning
|
|
315
|
+
outputsByRequestedCallId.push(candidate);
|
|
289
316
|
}
|
|
290
317
|
} else if (candidate.type === 'function_call') {
|
|
291
|
-
|
|
292
|
-
|
|
318
|
+
functionCallCount += 1;
|
|
319
|
+
if (candidate.callId) {
|
|
320
|
+
if (seenFunctionCallIds.has(candidate.callId)) ambiguousCallIds.add(candidate.callId);
|
|
321
|
+
else seenFunctionCallIds.add(candidate.callId);
|
|
322
|
+
}
|
|
323
|
+
if (candidate.commandCapable && candidate.callId && candidate.command !== null) {
|
|
324
|
+
if (!commandByCallId.has(candidate.callId)) commandByCallId.set(candidate.callId, candidate.command);
|
|
325
|
+
} else if (candidate.commandCapable) {
|
|
326
|
+
sawUnresolvableFunctionCall = true;
|
|
293
327
|
}
|
|
294
328
|
}
|
|
295
329
|
}
|
|
296
330
|
|
|
297
331
|
let chosenOutput = null;
|
|
298
|
-
if (
|
|
299
|
-
|
|
332
|
+
if ((callId || command) && sawUnclassifiableLine) return null;
|
|
333
|
+
if (callId) {
|
|
334
|
+
if (outputsByRequestedCallId.length === 1 && !ambiguousCallIds.has(callId)) {
|
|
335
|
+
chosenOutput = outputsByRequestedCallId[0].output;
|
|
336
|
+
} else {
|
|
337
|
+
return null; // explicit call ID is unresolved, reused, or has duplicate outputs
|
|
338
|
+
}
|
|
300
339
|
} else if (newestOutputEntry) {
|
|
301
|
-
if (
|
|
302
|
-
if (
|
|
303
|
-
|
|
340
|
+
if (command) {
|
|
341
|
+
if (sawUnresolvableFunctionCall) return null;
|
|
342
|
+
const matchingCallIds = [...commandByCallId.entries()]
|
|
343
|
+
.filter(([, candidateCommand]) => candidateCommand === command)
|
|
344
|
+
.map(([candidateCallId]) => candidateCallId);
|
|
345
|
+
const matches = outputEntries.filter((entry) => commandByCallId.get(entry.callId) === command);
|
|
346
|
+
if (matchingCallIds.length === 1
|
|
347
|
+
&& !ambiguousCallIds.has(matchingCallIds[0])
|
|
348
|
+
&& matches.length === 1) {
|
|
349
|
+
chosenOutput = matches[0].output;
|
|
350
|
+
} else if (matchingCallIds.length > 0 || matches.length > 0 || commandByCallId.size > 0) {
|
|
351
|
+
return null; // no unique command correlation
|
|
352
|
+
} else if (!truncated && outputEntries.length === 1 && functionCallCount === 0) {
|
|
353
|
+
chosenOutput = newestOutputEntry.output; // genuinely unpaired legacy fallback
|
|
304
354
|
} else {
|
|
305
|
-
return null; //
|
|
355
|
+
return null; // multi-call or partially paired tail
|
|
306
356
|
}
|
|
357
|
+
} else if (!truncated && outputEntries.length === 1 && functionCallCount === 0) {
|
|
358
|
+
chosenOutput = newestOutputEntry.output; // genuinely unpaired legacy fallback
|
|
307
359
|
} else {
|
|
308
|
-
|
|
360
|
+
return null; // no correlation signal for a paired or multi-call tail
|
|
309
361
|
}
|
|
310
362
|
}
|
|
311
363
|
|
|
@@ -164,6 +164,39 @@ function readActiveSlug(root, actorKey) {
|
|
|
164
164
|
return sanitized === 'unknown' ? '' : sanitized;
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Sanitize an optional caller-supplied `activity` descriptor into a clean, bounded shape suitable
|
|
169
|
+
* for embedding in the emitted heartbeat event (issue: richer "real liveness" signals). The point
|
|
170
|
+
* is to make each pulse say WHAT the agent is doing right now — the tool it just ran — not merely
|
|
171
|
+
* that it is alive. Every field flows both into the local append-only JSONL and (via relay.sh) into
|
|
172
|
+
* the hosted Console record, so each value is passed through the same allowlist+64-cap
|
|
173
|
+
* `sanitizeSegment` the write side already applies to actors/slugs: a hostile or oversized tool
|
|
174
|
+
* name (a custom MCP tool string, a hand-edited hook payload) can never inject control bytes or
|
|
175
|
+
* unbounded text downstream. Returns `undefined` when there is no usable activity, so the caller
|
|
176
|
+
* omits the field entirely rather than emitting `activity: {}` noise.
|
|
177
|
+
*
|
|
178
|
+
* @param {{tool?: string}|undefined} activity
|
|
179
|
+
* @returns {{tool: string}|undefined}
|
|
180
|
+
*/
|
|
181
|
+
function sanitizeActivity(activity) {
|
|
182
|
+
// Fully self-contained fail-safe: this runs on the hot path just BEFORE the durable local append
|
|
183
|
+
// in maybeEmitHeartbeat, and local-first is sacred (ADR 0012 §5) — enriching the pulse must never
|
|
184
|
+
// be able to prevent the write. `String(activity.tool)` could in principle throw for an exotic
|
|
185
|
+
// caller value (a throwing toString/valueOf); swallow it here and degrade to a plain aliveness
|
|
186
|
+
// pulse rather than letting the durable write be skipped.
|
|
187
|
+
try {
|
|
188
|
+
if (!activity || typeof activity !== 'object') return undefined;
|
|
189
|
+
const out = {};
|
|
190
|
+
if (activity.tool != null && String(activity.tool).trim()) {
|
|
191
|
+
const tool = sanitizeSegment(activity.tool);
|
|
192
|
+
if (tool && tool !== 'unknown') out.tool = tool;
|
|
193
|
+
}
|
|
194
|
+
return Object.keys(out).length ? out : undefined;
|
|
195
|
+
} catch {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
167
200
|
/**
|
|
168
201
|
* Filter a list of parsed liveness events down to the ones matching a given
|
|
169
202
|
* (subjectId, actor) pair. Shared by the bounded tail read and the full-read
|
|
@@ -270,7 +303,11 @@ function attachConflictField(result, conflict) {
|
|
|
270
303
|
* wall-clock timer), so it fires once per genuinely new conflicting event, independent of
|
|
271
304
|
* whether our own heartbeat emits or throttles this call.
|
|
272
305
|
*
|
|
273
|
-
* @param {{cwd?: string, env?: NodeJS.ProcessEnv, now?: Date|string}} [opts]
|
|
306
|
+
* @param {{cwd?: string, env?: NodeJS.ProcessEnv, now?: Date|string, activity?: {tool?: string}}} [opts]
|
|
307
|
+
* `activity` is an optional descriptor of what the agent is doing right now (e.g. the tool this
|
|
308
|
+
* postToolUse pulse rode in on); when present + usable it is sanitized and embedded in the emitted
|
|
309
|
+
* heartbeat so the fleet view shows WHAT, not just aliveness. Purely additive — never affects the
|
|
310
|
+
* throttle/emit/conflict decisions.
|
|
274
311
|
* @returns {{emitted: boolean, reason?: string, conflict?: {actor: string, lastAt: string, ttlSeconds: number}}}
|
|
275
312
|
*/
|
|
276
313
|
function maybeEmitHeartbeat(opts = {}) {
|
|
@@ -383,12 +420,16 @@ function maybeEmitHeartbeat(opts = {}) {
|
|
|
383
420
|
const conflict = computeConflict(holderEvents, slug, actor, nowMs, last.at);
|
|
384
421
|
|
|
385
422
|
const nowIso = new Date(nowMs).toISOString();
|
|
423
|
+
const activity = sanitizeActivity(opts.activity);
|
|
386
424
|
appendLivenessEvent(root, {
|
|
387
425
|
type: 'heartbeat',
|
|
388
426
|
subjectId: slug,
|
|
389
427
|
actor,
|
|
390
428
|
at: nowIso,
|
|
391
429
|
source: 'tool-activity',
|
|
430
|
+
// Richer "real liveness": name the tool this pulse rode in on, so the fleet view shows WHAT
|
|
431
|
+
// each agent is doing, not just that it is alive. Omitted entirely when no usable activity.
|
|
432
|
+
...(activity ? { activity } : {}),
|
|
392
433
|
});
|
|
393
434
|
return attachConflictField({ emitted: true }, conflict);
|
|
394
435
|
} catch (err) {
|
|
@@ -18,27 +18,127 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
const fs = require('fs');
|
|
21
|
+
const os = require('os');
|
|
21
22
|
const path = require('path');
|
|
22
23
|
const { spawn } = require('child_process');
|
|
23
24
|
|
|
25
|
+
const TRUTHY = new Set(['1', 'true', 'yes', 'on']);
|
|
26
|
+
const FALSY = new Set(['0', 'false', 'no', 'off']);
|
|
27
|
+
// Cheap per-process memo keyed by the exact env inputs, so a throttled hot path does at most one
|
|
28
|
+
// conf read per (env flag, conf path) signature. Keyed inputs make unit tests that vary
|
|
29
|
+
// TELEMETRY_CONFIG_FILE independent.
|
|
30
|
+
const relayEnabledCache = new Map();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The console-conf resolution chain, mirroring scripts/telemetry/lib/config.sh's slot order:
|
|
34
|
+
* 1. TELEMETRY_CONFIG_FILE (env) if set + readable
|
|
35
|
+
* 2. per-workspace override: <workspace-root>/.kontourai/telemetry-console.conf
|
|
36
|
+
* 3. user-global durable override: <bundle-root>/telemetry-console.conf
|
|
37
|
+
* 4. the shipped default: scripts/telemetry/telemetry.conf (relative to this file)
|
|
38
|
+
* Slot 4's path is <this file>/../../telemetry/telemetry.conf; the workspace root for slot 2 is
|
|
39
|
+
* three levels up from scripts/hooks/lib. This resolver is only the SPAWN pre-gate — it is
|
|
40
|
+
* intentionally lenient (no mode-600 trust gate). relay.sh re-sources config.sh, which applies the
|
|
41
|
+
* trust gate and is the authoritative decision for both enablement and the POST endpoint, so a
|
|
42
|
+
* lenient "enabled" here at worst spawns a relay.sh that then trust-gates itself to a no-op.
|
|
43
|
+
*
|
|
44
|
+
* ACCEPTED, BY DESIGN (security review LOW, A04): because this pre-gate does NOT re-apply
|
|
45
|
+
* config.sh's mode-600/owner gate, it can read an explicit key from an UNtrusted default-path conf
|
|
46
|
+
* (a mode-644 .kontourai/telemetry-console.conf a local tool dropped) that config.sh would skip.
|
|
47
|
+
* This is fail-toward-no-op, never fail-toward-exfil: the only divergence is (a) we spawn a
|
|
48
|
+
* relay.sh that config.sh then no-ops (harmless), or (b) an untrusted `=0` here suppresses a spawn
|
|
49
|
+
* that a trusted conf would have enabled (a missed pulse — availability only, requiring local FS
|
|
50
|
+
* write access). The pre-gate deliberately does not guarantee relay AVAILABILITY — only that we
|
|
51
|
+
* avoid spawning when nothing plausibly enables it. It NEVER weakens the exfil defense, which lives
|
|
52
|
+
* entirely in relay.sh's authoritative, trust-gated re-resolution.
|
|
53
|
+
* @returns {string|null} first readable conf path, or null
|
|
54
|
+
*/
|
|
55
|
+
function resolveConsoleConfPath(env) {
|
|
56
|
+
const candidates = [
|
|
57
|
+
env.TELEMETRY_CONFIG_FILE,
|
|
58
|
+
path.join(__dirname, '..', '..', '..', '.kontourai', 'telemetry-console.conf'),
|
|
59
|
+
path.join(os.homedir(), '.flow-agents', 'telemetry-console.conf'),
|
|
60
|
+
path.join(__dirname, '..', '..', 'telemetry', 'telemetry.conf'),
|
|
61
|
+
];
|
|
62
|
+
for (const c of candidates) {
|
|
63
|
+
try {
|
|
64
|
+
if (c && fs.existsSync(c) && fs.statSync(c).isFile()) return c;
|
|
65
|
+
} catch {
|
|
66
|
+
/* keep scanning */
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Conf-driven liveness relay enablement (#567 — parity with the economics relay #469, so an
|
|
74
|
+
* operator enables via `console_liveness_relay=1` in the console conf, NOT an env var/.profile).
|
|
75
|
+
* Precedence mirrors config.sh's economics rule EXACTLY (config.sh:182-204): an explicit
|
|
76
|
+
* `console_liveness_relay` conf key wins over the env var; absent a key, a pre-set env var is
|
|
77
|
+
* honored; absent both, default ON once a console sink is configured (any console_*url key present)
|
|
78
|
+
* — opt-out, not silent-off. Best-effort and cheap: any FS/parse failure falls back to the env-only
|
|
79
|
+
* decision; never throws. This is the spawn pre-gate; relay.sh+config.sh is authoritative.
|
|
80
|
+
*/
|
|
81
|
+
function resolveLivenessRelayEnabled(env) {
|
|
82
|
+
const envFlag = String(env.FLOW_AGENTS_CONSOLE_LIVENESS_RELAY || '').toLowerCase();
|
|
83
|
+
const envDecision = TRUTHY.has(envFlag) ? true : FALSY.has(envFlag) ? false : null;
|
|
84
|
+
const cacheKey = `${envFlag}|${env.TELEMETRY_CONFIG_FILE || ''}`;
|
|
85
|
+
if (relayEnabledCache.has(cacheKey)) return relayEnabledCache.get(cacheKey);
|
|
86
|
+
let enabled;
|
|
87
|
+
try {
|
|
88
|
+
let explicit = null;
|
|
89
|
+
let hasConsoleUrl = false;
|
|
90
|
+
const confPath = resolveConsoleConfPath(env);
|
|
91
|
+
if (confPath) {
|
|
92
|
+
for (const raw of fs.readFileSync(confPath, 'utf8').split('\n')) {
|
|
93
|
+
const line = raw.trim();
|
|
94
|
+
if (!line || line.startsWith('#')) continue;
|
|
95
|
+
const eq = line.indexOf('=');
|
|
96
|
+
if (eq < 0) continue;
|
|
97
|
+
const key = line.slice(0, eq).trim();
|
|
98
|
+
const value = line.slice(eq + 1).trim().toLowerCase();
|
|
99
|
+
if (key === 'console_liveness_relay') {
|
|
100
|
+
if (TRUTHY.has(value)) explicit = true;
|
|
101
|
+
else if (FALSY.has(value)) explicit = false;
|
|
102
|
+
} else if (key === 'console_telemetry_url' || key === 'console_url' || key === 'console_telemetry_endpoint_url') {
|
|
103
|
+
if (value) hasConsoleUrl = true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// config.sh precedence: explicit conf key > pre-set env var > default-on when a console url resolves.
|
|
108
|
+
enabled = explicit !== null ? explicit : envDecision !== null ? envDecision : hasConsoleUrl;
|
|
109
|
+
} catch {
|
|
110
|
+
enabled = envDecision === true; // best-effort: honor only an explicit env-on if the conf read failed
|
|
111
|
+
}
|
|
112
|
+
relayEnabledCache.set(cacheKey, enabled);
|
|
113
|
+
return enabled;
|
|
114
|
+
}
|
|
115
|
+
|
|
24
116
|
/**
|
|
25
|
-
* OPTIONAL console liveness relay (#295, ADR 0021 §4/§7). Best-effort, FULLY
|
|
26
|
-
* liveness event to the hosted Console via `scripts/liveness/relay.sh`.
|
|
27
|
-
* runs AFTER the durable local append and can never block, throw, or
|
|
28
|
-
* `
|
|
29
|
-
* spawn error) is swallowed.
|
|
117
|
+
* OPTIONAL console liveness relay (#295, ADR 0021 §4/§7; conf-driven per #567). Best-effort, FULLY
|
|
118
|
+
* detached mirror of a liveness event to the hosted Console via `scripts/liveness/relay.sh`.
|
|
119
|
+
* Local-first is sacred: this runs AFTER the durable local append and can never block, throw, or
|
|
120
|
+
* affect it — gated on `resolveLivenessRelayEnabled`, the whole thing wrapped so any failure
|
|
121
|
+
* (missing script, spawn error) is swallowed. Not enabled ⇒ a cheap resolve and return (true no-op).
|
|
30
122
|
*
|
|
31
123
|
* @param {object} evt The liveness event just written locally.
|
|
32
124
|
* @returns {void}
|
|
33
125
|
*/
|
|
34
126
|
function relayLivenessEvent(evt) {
|
|
35
127
|
try {
|
|
36
|
-
|
|
37
|
-
if (flag !== '1' && flag !== 'true' && flag !== 'yes' && flag !== 'on') return;
|
|
128
|
+
if (!resolveLivenessRelayEnabled(process.env)) return;
|
|
38
129
|
// scripts/hooks/lib/ -> scripts/liveness/relay.sh (same relative layout in dist/* bundles).
|
|
39
130
|
const relay = path.join(__dirname, '..', '..', 'liveness', 'relay.sh');
|
|
40
131
|
if (!fs.existsSync(relay)) return;
|
|
41
|
-
|
|
132
|
+
// Pass the environment through UNCHANGED — do not force the enable flag. relay.sh re-sources
|
|
133
|
+
// config.sh, which is the authoritative, trust-gated decision for both enablement (conf key >
|
|
134
|
+
// env > default-on) and the POST endpoint. Our resolve above is only a lenient spawn pre-gate:
|
|
135
|
+
// if we enabled from an untrusted default-path conf, config.sh trust-gates relay.sh to a no-op
|
|
136
|
+
// rather than us bypassing it by forcing the flag on.
|
|
137
|
+
const child = spawn('bash', [relay, JSON.stringify(evt)], {
|
|
138
|
+
detached: true,
|
|
139
|
+
stdio: 'ignore',
|
|
140
|
+
env: process.env,
|
|
141
|
+
});
|
|
42
142
|
child.on('error', () => {}); // never surface a spawn failure
|
|
43
143
|
child.unref(); // fully detach — the parent never waits on the relay
|
|
44
144
|
} catch {
|
|
@@ -70,4 +170,4 @@ function appendLivenessEvent(root, evt) {
|
|
|
70
170
|
relayLivenessEvent(evt); // then optionally mirror to the Console — best-effort, detached, off by default
|
|
71
171
|
}
|
|
72
172
|
|
|
73
|
-
module.exports = { livenessStreamFile, appendLivenessEvent };
|
|
173
|
+
module.exports = { livenessStreamFile, appendLivenessEvent, resolveLivenessRelayEnabled };
|
|
@@ -7,26 +7,36 @@
|
|
|
7
7
|
# never forked). The Console side ingests this record type and projects the fleet view + runs the
|
|
8
8
|
# janitor (console repo #125); this script is only the flow-agents EMIT half.
|
|
9
9
|
#
|
|
10
|
-
# STRICTLY OPTIONAL and local-first (ADR 0012 §5): a no-op unless
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
# is
|
|
10
|
+
# STRICTLY OPTIONAL and local-first (ADR 0012 §5): a no-op unless the liveness relay is enabled AND a
|
|
11
|
+
# console endpoint is configured. Enablement is conf-driven (#567, parity with economics #469): an
|
|
12
|
+
# operator sets `console_liveness_relay=1` in the console conf (or it defaults on once a console url
|
|
13
|
+
# resolves) — NOT an env var. config.sh (sourced below) is the authoritative, trust-gated decision
|
|
14
|
+
# and also supplies the endpoint/token/tenant from the conf. Best-effort throughout — it must NEVER
|
|
15
|
+
# block, slow, or fail the local liveness write that already happened before this was invoked. Every
|
|
16
|
+
# failure path is a quiet `exit 0`.
|
|
14
17
|
#
|
|
15
18
|
# Invoked (fully detached, best-effort) from scripts/hooks/lib/liveness-write.js after the durable
|
|
16
19
|
# local append. Usage: relay.sh '<liveness-event-json>'
|
|
17
20
|
set -uo pipefail
|
|
18
21
|
|
|
19
|
-
# Opt-in gate — off by default.
|
|
20
|
-
case "${FLOW_AGENTS_CONSOLE_LIVENESS_RELAY:-}" in
|
|
21
|
-
1 | true | TRUE | yes | on) ;;
|
|
22
|
-
*) exit 0 ;;
|
|
23
|
-
esac
|
|
24
|
-
|
|
25
22
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" || exit 0
|
|
26
23
|
|
|
27
24
|
# transport.sh sources ${TELEMETRY_DIR}/lib/redact.sh at load — point it at the telemetry lib dir,
|
|
28
25
|
# then reuse the SAME console POST core the telemetry mirror uses.
|
|
29
26
|
export TELEMETRY_DIR="${TELEMETRY_DIR:-$SCRIPT_DIR/../telemetry}"
|
|
27
|
+
|
|
28
|
+
# Source config.sh FIRST (best-effort, like economics-record.sh) so the conf resolves enablement
|
|
29
|
+
# (FLOW_AGENTS_CONSOLE_LIVENESS_RELAY) and the endpoint/token/tenant — trust-gated for default-path
|
|
30
|
+
# confs. This is what makes conf-only operation work with NO env var set.
|
|
31
|
+
# shellcheck source=/dev/null
|
|
32
|
+
[[ -f "$TELEMETRY_DIR/lib/config.sh" ]] && source "$TELEMETRY_DIR/lib/config.sh" 2>/dev/null || true
|
|
33
|
+
|
|
34
|
+
# Opt-in gate — off by default, now honoring the conf-derived value config.sh just resolved.
|
|
35
|
+
case "${FLOW_AGENTS_CONSOLE_LIVENESS_RELAY:-}" in
|
|
36
|
+
1 | true | TRUE | yes | on) ;;
|
|
37
|
+
*) exit 0 ;;
|
|
38
|
+
esac
|
|
39
|
+
|
|
30
40
|
[[ -f "$TELEMETRY_DIR/lib/transport.sh" ]] || exit 0
|
|
31
41
|
# shellcheck source=/dev/null
|
|
32
42
|
source "$TELEMETRY_DIR/lib/transport.sh" 2>/dev/null || exit 0
|
|
@@ -73,7 +83,9 @@ record="$(printf '%s' "$event_json" | jq -c '{
|
|
|
73
83
|
ttlSeconds: (.ttlSeconds // null),
|
|
74
84
|
host: (.host // null),
|
|
75
85
|
branch: (.branch // null),
|
|
76
|
-
artifact_dir: (.artifact_dir // .artifactDir // null)
|
|
86
|
+
artifact_dir: (.artifact_dir // .artifactDir // null),
|
|
87
|
+
source: (.source // null),
|
|
88
|
+
activity: (.activity // null)
|
|
77
89
|
}' 2>/dev/null)" || exit 0
|
|
78
90
|
[[ -z "$record" || "$record" == "null" ]] && exit 0
|
|
79
91
|
|
|
@@ -109,6 +109,13 @@ FLOW_AGENTS_CONSOLE_ECONOMICS_ENDPOINT_URL="${FLOW_AGENTS_CONSOLE_ECONOMICS_ENDP
|
|
|
109
109
|
# Set (non-empty) only when the config file carries an explicit console_economics_relay key —
|
|
110
110
|
# distinguishes "operator said 0/1" from "key absent" for the default-on rule below.
|
|
111
111
|
console_economics_relay_raw=""
|
|
112
|
+
# Liveness relay (#567): same conf-driven, opt-out-not-silent-off shape as economics (#469) so an
|
|
113
|
+
# operator enables the hosted liveness mirror via console_liveness_relay=1 in the conf, NOT an env
|
|
114
|
+
# var/.profile. A caller-pre-set env var is the starting point; the config key overrides it; absent
|
|
115
|
+
# any key the default-on rule below turns it on once a console telemetry sink resolves.
|
|
116
|
+
FLOW_AGENTS_CONSOLE_LIVENESS_RELAY="${FLOW_AGENTS_CONSOLE_LIVENESS_RELAY:-}"
|
|
117
|
+
FLOW_AGENTS_CONSOLE_LIVENESS_ENDPOINT_URL="${FLOW_AGENTS_CONSOLE_LIVENESS_ENDPOINT_URL:-}"
|
|
118
|
+
console_liveness_relay_raw=""
|
|
112
119
|
# Pricing registry source (consumed by lib/pricing.sh). Explicit file/URL win;
|
|
113
120
|
# otherwise lib/pricing.sh uses the bundled pricing.json offline.
|
|
114
121
|
TELEMETRY_PRICING_FILE="${TELEMETRY_PRICING_FILE:-${FLOW_AGENTS_PRICING_FILE:-}}"
|
|
@@ -164,6 +171,17 @@ if [[ -f "$TELEMETRY_CONFIG_FILE" ]]; then
|
|
|
164
171
|
esac
|
|
165
172
|
;;
|
|
166
173
|
console_economics_endpoint_url) FLOW_AGENTS_CONSOLE_ECONOMICS_ENDPOINT_URL="$value" ;;
|
|
174
|
+
console_liveness_relay)
|
|
175
|
+
case "$(echo "$value" | tr '[:upper:]' '[:lower:]')" in
|
|
176
|
+
1|true|yes|on) console_liveness_relay_raw="1" ;;
|
|
177
|
+
0|false|no|off) console_liveness_relay_raw="0" ;;
|
|
178
|
+
*)
|
|
179
|
+
printf 'warning: config.sh: unrecognized console_liveness_relay value %q; treating as off\n' "$value" >&2
|
|
180
|
+
console_liveness_relay_raw="$value"
|
|
181
|
+
;;
|
|
182
|
+
esac
|
|
183
|
+
;;
|
|
184
|
+
console_liveness_endpoint_url) FLOW_AGENTS_CONSOLE_LIVENESS_ENDPOINT_URL="$value" ;;
|
|
167
185
|
console_pricing_url) TELEMETRY_PRICING_URL="$value" ;;
|
|
168
186
|
pricing_url) TELEMETRY_PRICING_URL="$value" ;;
|
|
169
187
|
pricing_file) TELEMETRY_PRICING_FILE="$value" ;;
|
|
@@ -186,6 +204,17 @@ elif [[ -z "$FLOW_AGENTS_CONSOLE_ECONOMICS_RELAY" \
|
|
|
186
204
|
FLOW_AGENTS_CONSOLE_ECONOMICS_RELAY=1
|
|
187
205
|
fi
|
|
188
206
|
|
|
207
|
+
# Liveness relay default-on rule (#567), identical shape to economics above: an explicit
|
|
208
|
+
# console_liveness_relay config key always wins; otherwise, once a console telemetry sink resolves,
|
|
209
|
+
# the relay defaults ON unless a caller already pre-set FLOW_AGENTS_CONSOLE_LIVENESS_RELAY in the
|
|
210
|
+
# environment. relay.sh's own opt-in gate reads this exact variable.
|
|
211
|
+
if [[ -n "$console_liveness_relay_raw" ]]; then
|
|
212
|
+
FLOW_AGENTS_CONSOLE_LIVENESS_RELAY="$console_liveness_relay_raw"
|
|
213
|
+
elif [[ -z "$FLOW_AGENTS_CONSOLE_LIVENESS_RELAY" \
|
|
214
|
+
&& ( -n "${CONSOLE_TELEMETRY_URL:-}" || -n "${CONSOLE_TELEMETRY_ENDPOINT_URL:-}" ) ]]; then
|
|
215
|
+
FLOW_AGENTS_CONSOLE_LIVENESS_RELAY=1
|
|
216
|
+
fi
|
|
217
|
+
|
|
189
218
|
# Pricing URL is explicit-only (env or config). Do not derive the console
|
|
190
219
|
# pricing endpoint by default; the bundled registry is the reliable offline floor.
|
|
191
220
|
|
|
@@ -572,7 +572,7 @@ async function bundleGateEvidence(bundle, gate, state, subject, projectRoot, man
|
|
|
572
572
|
throw new BuilderBuildRunInputError("evidence.claims.metadata.gate_claim.route_reason", `is not declared by gate ${String(gate.id ?? "<unknown>")}`);
|
|
573
573
|
}
|
|
574
574
|
if (String(gate.id) === "verify-gate" && relevant.some((claim) => claim.claimType === "builder.verify.tests" && claim.value === "pass")) {
|
|
575
|
-
await assertVerifiedTestsTrust(
|
|
575
|
+
await assertVerifiedTestsTrust(relevant, projectRoot);
|
|
576
576
|
}
|
|
577
577
|
return { failed, routeReason, expectationIds, visitEnteredAt: enteredAt };
|
|
578
578
|
}
|
|
@@ -602,8 +602,8 @@ function timestampAtOrAfter(value, boundary) {
|
|
|
602
602
|
const parsed = parseTimestamp(value);
|
|
603
603
|
return parsed !== null && parsed >= boundary;
|
|
604
604
|
}
|
|
605
|
-
async function assertVerifiedTestsTrust(
|
|
606
|
-
const testClaims =
|
|
605
|
+
async function assertVerifiedTestsTrust(currentGateClaims, projectRoot) {
|
|
606
|
+
const testClaims = currentGateClaims.filter((claim) => isRecord(claim)
|
|
607
607
|
&& claim.claimType === "builder.verify.tests"
|
|
608
608
|
&& claim.value === "pass"
|
|
609
609
|
&& isRecord(claim.metadata)
|
|
@@ -611,7 +611,12 @@ async function assertVerifiedTestsTrust(claims, projectRoot) {
|
|
|
611
611
|
&& claim.metadata.gate_claim.expectation_id === "tests-evidence");
|
|
612
612
|
if (testClaims.length === 0)
|
|
613
613
|
throw new BuilderBuildRunInputError("evidence.tests", "is missing a passing tests-evidence claim");
|
|
614
|
-
|
|
614
|
+
// A route-back starts a new gate visit and therefore a new critique generation. Historical
|
|
615
|
+
// reviewer slices remain in the bundle and manifest for audit, but only critiques acquired
|
|
616
|
+
// during this visit describe the implementation snapshot currently being verified. Within a
|
|
617
|
+
// visit every live reviewer slice still participates, so changing reviewers cannot bury a
|
|
618
|
+
// disputed finding.
|
|
619
|
+
const liveCritiques = currentGateClaims.filter((claim) => isRecord(claim)
|
|
615
620
|
&& isRecord(claim.metadata)
|
|
616
621
|
&& claim.metadata.origin === "critique"
|
|
617
622
|
&& typeof claim.metadata.superseded_by !== "string");
|
|
@@ -627,7 +632,7 @@ async function assertVerifiedTestsTrust(claims, projectRoot) {
|
|
|
627
632
|
await Promise.all(artifacts.map((artifact) => assertReviewedArtifactDigest(artifact, projectRoot)));
|
|
628
633
|
assertReviewedWorkspaceSnapshot(claim, artifacts, projectRoot);
|
|
629
634
|
}));
|
|
630
|
-
const criteria =
|
|
635
|
+
const criteria = currentGateClaims.filter((claim) => isRecord(claim) && isRecord(claim.metadata) && claim.metadata.origin === "acceptance");
|
|
631
636
|
if (criteria.length === 0 || criteria.some((claim) => {
|
|
632
637
|
const criterion = isRecord(claim.metadata.criterion) ? claim.metadata.criterion : null;
|
|
633
638
|
return claim.value !== "pass" || !criterion || !Array.isArray(criterion.evidence_refs) || criterion.evidence_refs.length === 0;
|
|
@@ -20,6 +20,12 @@ Verifiers and reviewers do not modify source code. They may run commands, inspec
|
|
|
20
20
|
|
|
21
21
|
Verifiers write evidence and acceptance status **through** `record-evidence` (directly, or via the orchestrating skill), never by hand-authoring `evidence.json`, `acceptance.json`, or `trust.bundle`. Those bespoke sidecars were retired as the source of truth by ADR 0010 Phase 4c; the `trust.bundle` is the sole verification artifact, and only the sidecar writer performs the evidence classification (`evidenceType`/`method` derivation and manifest reconciliation) the CI trust anchor depends on (ADR 0020). `config-protection.js` blocks direct tool writes to these gate files by design. If the sidecar writer is unavailable, record the exact gap instead of writing the files by hand.
|
|
22
22
|
|
|
23
|
+
### Critique Generations Across Route-Backs
|
|
24
|
+
|
|
25
|
+
Each visit to a verification gate establishes a critique generation. Critiques acquired during that visit must target the current implementation snapshot, and every live reviewer slice in the generation participates in the gate decision. A disputed finding therefore cannot be hidden by adding a different reviewer.
|
|
26
|
+
|
|
27
|
+
When Flow routes back and later re-enters verification, a fresh critique generation is required. Older reviewer slices remain immutable audit history in the trust bundle and evidence manifest, but their prior snapshot hashes are not reinterpreted as reviews of the new implementation. A reviewer handoff must use the public critique writer; it must never require actor impersonation or direct trust-bundle edits.
|
|
28
|
+
|
|
23
29
|
## Mutation Testing Runs In A Scratch Copy
|
|
24
30
|
|
|
25
31
|
Mutation-testing tools (Stryker or equivalent) **must** run against a scratch/throwaway copy of the working tree, never the live working tree. They deliberately introduce defects to measure test-suite sensitivity; running them in place risks leaving mutated source, corrupting the checkout, or tripping the gate/anchor on injected failures. Copy the tree to a temporary directory (or a git worktree/clone) and run the mutation tool there; discard it afterward.
|