@event4u/agent-config 14.4.0 → 14.5.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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +26 -313
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +1 -1
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +1 -1
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +1 -1
- package/dist/hooks/dispatch.js +93 -25
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/mcp/server.json +2 -2
- package/package.json +1 -1
- package/src/config/estate-count-budget.json +8 -1
- package/src/scripts/bench_hook_injection.ts +18 -1
- package/src/scripts/hooks/host_semantics.ts +28 -0
- package/src/scripts/hooks/injection_budget.ts +152 -16
- package/src/scripts/hooks/run_continuation_hook.ts +59 -3
- package/src/scripts/session_register_hook.ts +40 -6
package/dist/hooks/dispatch.js
CHANGED
|
@@ -7765,6 +7765,10 @@ function claudeAdditionalContext(event, text) {
|
|
|
7765
7765
|
})}
|
|
7766
7766
|
`;
|
|
7767
7767
|
}
|
|
7768
|
+
function emissionCarriesReasons(platform, severity) {
|
|
7769
|
+
if (!VERIFIED_PLATFORMS.has(platform)) return false;
|
|
7770
|
+
return severity !== "allow";
|
|
7771
|
+
}
|
|
7768
7772
|
function emitFor(platform, event, severity, reasons, legacyExit) {
|
|
7769
7773
|
if (!VERIFIED_PLATFORMS.has(platform)) {
|
|
7770
7774
|
return { exit: legacyExit, stdout: "", stderr: "" };
|
|
@@ -7790,6 +7794,13 @@ function emitFor(platform, event, severity, reasons, legacyExit) {
|
|
|
7790
7794
|
|
|
7791
7795
|
// src/scripts/hooks/injection_budget.ts
|
|
7792
7796
|
var TURN_START_EVENT = "user_prompt_submit";
|
|
7797
|
+
var RC_ALLOW = 0;
|
|
7798
|
+
var RC_WARN = 2;
|
|
7799
|
+
function _severityForRc(rc) {
|
|
7800
|
+
if (rc === RC_ALLOW) return "allow";
|
|
7801
|
+
if (rc === RC_WARN) return "warn";
|
|
7802
|
+
return "block";
|
|
7803
|
+
}
|
|
7793
7804
|
function isExempt(c) {
|
|
7794
7805
|
if (c.failClosed) return true;
|
|
7795
7806
|
return c.severity.trim().toLowerCase() !== "advisory";
|
|
@@ -7870,13 +7881,43 @@ function shapeEmissions(candidates, opts) {
|
|
|
7870
7881
|
ceilingCause
|
|
7871
7882
|
};
|
|
7872
7883
|
}
|
|
7873
|
-
var
|
|
7874
|
-
|
|
7875
|
-
|
|
7884
|
+
var TURN_SPEND_DIR_REL = path3.join("agents", "runtime", "state", "injection-turn");
|
|
7885
|
+
var TURN_SPEND_MAX_FILES = 64;
|
|
7886
|
+
function turnSpendKey(session) {
|
|
7887
|
+
const safe = session.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
7888
|
+
let h = 2166136261;
|
|
7889
|
+
for (let i = 0; i < session.length; i += 1) {
|
|
7890
|
+
h ^= session.charCodeAt(i);
|
|
7891
|
+
h = Math.imul(h, 16777619) >>> 0;
|
|
7892
|
+
}
|
|
7893
|
+
const digest = h.toString(16).padStart(8, "0");
|
|
7894
|
+
return `${safe.slice(0, 96)}.${digest}`;
|
|
7895
|
+
}
|
|
7896
|
+
function turnSpendPath(workspaceRoot2, session) {
|
|
7897
|
+
return path3.join(workspaceRoot2, TURN_SPEND_DIR_REL, `${turnSpendKey(session)}.json`);
|
|
7898
|
+
}
|
|
7899
|
+
function _pruneTurnSpend(dir) {
|
|
7900
|
+
try {
|
|
7901
|
+
const entries = fs3.readdirSync(dir).filter((n) => n.endsWith(".json") || n.endsWith(".tmp")).map((n) => {
|
|
7902
|
+
const p = path3.join(dir, n);
|
|
7903
|
+
try {
|
|
7904
|
+
return { p, mtime: fs3.statSync(p).mtimeMs };
|
|
7905
|
+
} catch {
|
|
7906
|
+
return { p, mtime: 0 };
|
|
7907
|
+
}
|
|
7908
|
+
}).sort((a, b) => b.mtime - a.mtime);
|
|
7909
|
+
for (const stale of entries.slice(TURN_SPEND_MAX_FILES)) {
|
|
7910
|
+
try {
|
|
7911
|
+
fs3.unlinkSync(stale.p);
|
|
7912
|
+
} catch {
|
|
7913
|
+
}
|
|
7914
|
+
}
|
|
7915
|
+
} catch {
|
|
7916
|
+
}
|
|
7876
7917
|
}
|
|
7877
7918
|
function readTurnSpend(workspaceRoot2, session) {
|
|
7878
7919
|
try {
|
|
7879
|
-
const raw = fs3.readFileSync(turnSpendPath(workspaceRoot2), "utf-8");
|
|
7920
|
+
const raw = fs3.readFileSync(turnSpendPath(workspaceRoot2, session), "utf-8");
|
|
7880
7921
|
const parsed = JSON.parse(raw);
|
|
7881
7922
|
if (parsed.session !== session) return 0;
|
|
7882
7923
|
return typeof parsed.bytes === "number" && Number.isFinite(parsed.bytes) && parsed.bytes >= 0 ? parsed.bytes : 0;
|
|
@@ -7889,9 +7930,13 @@ function recordTurnSpend(workspaceRoot2, session, bytes, opts = {}) {
|
|
|
7889
7930
|
const previous = opts.reset === true ? 0 : readTurnSpend(workspaceRoot2, session);
|
|
7890
7931
|
const entry = { session, bytes: previous + Math.max(0, bytes) };
|
|
7891
7932
|
try {
|
|
7892
|
-
const target = turnSpendPath(workspaceRoot2);
|
|
7893
|
-
|
|
7894
|
-
fs3.
|
|
7933
|
+
const target = turnSpendPath(workspaceRoot2, session);
|
|
7934
|
+
const dir = path3.dirname(target);
|
|
7935
|
+
fs3.mkdirSync(dir, { recursive: true });
|
|
7936
|
+
const tmp = `${target}.${String(process.pid)}.tmp`;
|
|
7937
|
+
fs3.writeFileSync(tmp, JSON.stringify(entry) + "\n", { encoding: "utf-8" });
|
|
7938
|
+
fs3.renameSync(tmp, target);
|
|
7939
|
+
_pruneTurnSpend(dir);
|
|
7895
7940
|
} catch {
|
|
7896
7941
|
}
|
|
7897
7942
|
}
|
|
@@ -7957,12 +8002,15 @@ function shapeAndRecord(ctx, messages, finalRc) {
|
|
|
7957
8002
|
nudgeRank: typeof rank2 === "number" ? rank2 : null
|
|
7958
8003
|
};
|
|
7959
8004
|
});
|
|
7960
|
-
if (!VERIFIED_PLATFORMS.has(ctx.platform)) {
|
|
7961
|
-
return textOf(new Set(candidates.map((c) => c.concern)));
|
|
7962
|
-
}
|
|
7963
8005
|
const workspaceRoot2 = _workspaceRoot(ctx);
|
|
7964
8006
|
const sessionKey = _sessionKey(ctx) ?? "";
|
|
7965
8007
|
const cap = resolveVolumeCap(ctx);
|
|
8008
|
+
if (!emissionCarriesReasons(ctx.platform, _severityForRc(finalRc))) {
|
|
8009
|
+
if (cap !== null && ctx.event === TURN_START_EVENT) {
|
|
8010
|
+
recordTurnSpend(workspaceRoot2, sessionKey, 0, { reset: true });
|
|
8011
|
+
}
|
|
8012
|
+
return textOf(new Set(candidates.map((c) => c.concern)));
|
|
8013
|
+
}
|
|
7966
8014
|
const turnStartsHere = ctx.event === TURN_START_EVENT;
|
|
7967
8015
|
const spentBefore = cap === null || turnStartsHere ? 0 : readTurnSpend(workspaceRoot2, sessionKey);
|
|
7968
8016
|
const shaped = shapeEmissions(candidates, { capBytes: cap, spentBytes: spentBefore });
|
|
@@ -21913,9 +21961,9 @@ function resolveAvailability(cwd, env) {
|
|
|
21913
21961
|
return null;
|
|
21914
21962
|
}
|
|
21915
21963
|
const exists = fs41.existsSync(p);
|
|
21916
|
-
const
|
|
21964
|
+
const provenance2 = env[COUNCIL_CONFIG_ENV] ? "env-override" : "user-global";
|
|
21917
21965
|
if (!exists) {
|
|
21918
|
-
return { configured: false, path: p, exists: false, provenance, membersEnabled: 0, memberNames: [] };
|
|
21966
|
+
return { configured: false, path: p, exists: false, provenance: provenance2, membersEnabled: 0, memberNames: [] };
|
|
21919
21967
|
}
|
|
21920
21968
|
try {
|
|
21921
21969
|
const cfg = load_council_config(p);
|
|
@@ -21924,12 +21972,12 @@ function resolveAvailability(cwd, env) {
|
|
|
21924
21972
|
configured: cfg.enabled && enabled4.length > 0,
|
|
21925
21973
|
path: p,
|
|
21926
21974
|
exists: true,
|
|
21927
|
-
provenance,
|
|
21975
|
+
provenance: provenance2,
|
|
21928
21976
|
membersEnabled: enabled4.length,
|
|
21929
21977
|
memberNames: enabled4
|
|
21930
21978
|
};
|
|
21931
21979
|
} catch {
|
|
21932
|
-
return { configured: false, path: p, exists: true, provenance, membersEnabled: 0, memberNames: [] };
|
|
21980
|
+
return { configured: false, path: p, exists: true, provenance: provenance2, membersEnabled: 0, memberNames: [] };
|
|
21933
21981
|
}
|
|
21934
21982
|
}
|
|
21935
21983
|
function buildFact(a) {
|
|
@@ -23828,20 +23876,24 @@ function _read_claim_file(file) {
|
|
|
23828
23876
|
return null;
|
|
23829
23877
|
}
|
|
23830
23878
|
}
|
|
23831
|
-
function
|
|
23879
|
+
function resolve_claim(workspace_root, session_id) {
|
|
23832
23880
|
const own = roadmap_claim_rel(session_id);
|
|
23833
23881
|
if (own !== ROADMAP_CLAIM_REL) {
|
|
23834
23882
|
for (const p of claim_read_paths(workspace_root, session_id)) {
|
|
23835
23883
|
if (path52.basename(p) === path52.basename(ROADMAP_CLAIM_REL)) continue;
|
|
23836
23884
|
const mine = _read_claim_file(p);
|
|
23837
|
-
if (mine !== null) return mine.slug;
|
|
23885
|
+
if (mine !== null) return { slug: mine.slug, path: p };
|
|
23838
23886
|
}
|
|
23839
23887
|
}
|
|
23840
23888
|
let legacy = null;
|
|
23889
|
+
let legacy_path = "";
|
|
23841
23890
|
for (const p of claim_read_paths(workspace_root, session_id)) {
|
|
23842
23891
|
if (path52.basename(p) !== path52.basename(ROADMAP_CLAIM_REL)) continue;
|
|
23843
23892
|
legacy = _read_claim_file(p);
|
|
23844
|
-
if (legacy !== null)
|
|
23893
|
+
if (legacy !== null) {
|
|
23894
|
+
legacy_path = p;
|
|
23895
|
+
break;
|
|
23896
|
+
}
|
|
23845
23897
|
}
|
|
23846
23898
|
if (legacy === null) {
|
|
23847
23899
|
return null;
|
|
@@ -23850,7 +23902,10 @@ function read_claimed_slug(workspace_root, session_id) {
|
|
|
23850
23902
|
if (id !== "") {
|
|
23851
23903
|
return null;
|
|
23852
23904
|
}
|
|
23853
|
-
return legacy.slug;
|
|
23905
|
+
return { slug: legacy.slug, path: legacy_path };
|
|
23906
|
+
}
|
|
23907
|
+
function read_claimed_slug(workspace_root, session_id) {
|
|
23908
|
+
return resolve_claim(workspace_root, session_id)?.slug ?? null;
|
|
23854
23909
|
}
|
|
23855
23910
|
function claim_is_stale(workspace_root, slug, peer_worktree) {
|
|
23856
23911
|
if (slug === null || slug.trim() === "") {
|
|
@@ -26119,7 +26174,7 @@ import { fileURLToPath as fileURLToPath39, pathToFileURL as pathToFileURL32 } fr
|
|
|
26119
26174
|
|
|
26120
26175
|
// src/install/rtkDetection.ts
|
|
26121
26176
|
import { spawnSync as spawnSync5 } from "node:child_process";
|
|
26122
|
-
import { existsSync as existsSync11, statSync as
|
|
26177
|
+
import { existsSync as existsSync11, statSync as statSync16, readFileSync as readFileSync27, writeFileSync as writeFileSync13, mkdirSync as mkdirSync15 } from "node:fs";
|
|
26123
26178
|
import { homedir as homedir5 } from "node:os";
|
|
26124
26179
|
import { delimiter as delimiter2, join as join26, dirname as dirname16 } from "node:path";
|
|
26125
26180
|
var TOKEN_KILLER_GAIN_SIGNATURE = /RTK Token Savings/;
|
|
@@ -26190,7 +26245,7 @@ function detectRtkCached(options = {}) {
|
|
|
26190
26245
|
if (binPath === null) return { present: false };
|
|
26191
26246
|
let stat = null;
|
|
26192
26247
|
try {
|
|
26193
|
-
const st =
|
|
26248
|
+
const st = statSync16(binPath);
|
|
26194
26249
|
stat = { mtimeMs: st.mtimeMs, size: st.size };
|
|
26195
26250
|
} catch {
|
|
26196
26251
|
stat = null;
|
|
@@ -32092,6 +32147,14 @@ function readState4(file) {
|
|
|
32092
32147
|
return null;
|
|
32093
32148
|
}
|
|
32094
32149
|
}
|
|
32150
|
+
function provenance(workspaceRoot2, claimPath) {
|
|
32151
|
+
return {
|
|
32152
|
+
workspace_root: workspaceRoot2,
|
|
32153
|
+
git_dir: git_dir(workspaceRoot2) ?? "",
|
|
32154
|
+
git_common_dir: git_common_dir(workspaceRoot2) ?? "",
|
|
32155
|
+
claim_path: claimPath
|
|
32156
|
+
};
|
|
32157
|
+
}
|
|
32095
32158
|
function appendEvent2(workspaceRoot2, record) {
|
|
32096
32159
|
try {
|
|
32097
32160
|
const file = path85.join(workspaceRoot2, EVENTS_RELPATH);
|
|
@@ -32125,8 +32188,10 @@ function main55() {
|
|
|
32125
32188
|
}
|
|
32126
32189
|
const workspaceRoot2 = str10(envelope["workspace_root"]) || process.cwd();
|
|
32127
32190
|
const sessionId2 = str10(payload["session_id"]) || str10(envelope["session_id"]);
|
|
32128
|
-
const
|
|
32129
|
-
if (
|
|
32191
|
+
const claim = resolve_claim(workspaceRoot2, sessionId2 || null);
|
|
32192
|
+
if (claim === null) return EXIT_ALLOW27;
|
|
32193
|
+
const slug = claim.slug;
|
|
32194
|
+
const roots = provenance(workspaceRoot2, claim.path);
|
|
32130
32195
|
const roadmapPath2 = path85.join(workspaceRoot2, "agents", "roadmaps", `${slug}.md`);
|
|
32131
32196
|
let roadmapText;
|
|
32132
32197
|
try {
|
|
@@ -32159,7 +32224,8 @@ function main55() {
|
|
|
32159
32224
|
run_id: runId,
|
|
32160
32225
|
roadmap: slug,
|
|
32161
32226
|
turn: turnOrdinal,
|
|
32162
|
-
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
32227
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
32228
|
+
...roots
|
|
32163
32229
|
});
|
|
32164
32230
|
return EXIT_ALLOW27;
|
|
32165
32231
|
}
|
|
@@ -32186,7 +32252,8 @@ function main55() {
|
|
|
32186
32252
|
iterations: state.iterations,
|
|
32187
32253
|
open: scan.open,
|
|
32188
32254
|
blocked: scan.blocked,
|
|
32189
|
-
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
32255
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
32256
|
+
...roots
|
|
32190
32257
|
});
|
|
32191
32258
|
if (action === "complete") {
|
|
32192
32259
|
try {
|
|
@@ -32225,7 +32292,8 @@ function main55() {
|
|
|
32225
32292
|
iteration: state.iterations,
|
|
32226
32293
|
open: scan.open,
|
|
32227
32294
|
blocked: scan.blocked,
|
|
32228
|
-
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
32295
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
32296
|
+
...roots
|
|
32229
32297
|
});
|
|
32230
32298
|
process.stderr.write(_continuationText(slug, scan, state.iterations));
|
|
32231
32299
|
return EXIT_BLOCK5;
|
package/dist/mcp/server.json
CHANGED
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
"transport": {
|
|
10
10
|
"type": "stdio"
|
|
11
11
|
},
|
|
12
|
-
"version": "14.
|
|
12
|
+
"version": "14.5.0"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"repository": {
|
|
16
16
|
"source": "github",
|
|
17
17
|
"url": "https://github.com/event4u-app/agent-config"
|
|
18
18
|
},
|
|
19
|
-
"version": "14.
|
|
19
|
+
"version": "14.5.0"
|
|
20
20
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@event4u/agent-config",
|
|
3
3
|
"mcpName": "io.github.event4u-app/agent-config",
|
|
4
|
-
"version": "14.
|
|
4
|
+
"version": "14.5.0",
|
|
5
5
|
"description": "Governed skills, rules and commands for AI coding tools (Claude Code, Cursor, Windsurf, Copilot) — every claim machine-checked, including \"zero runtime daemon\".",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"private": false,
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"baseline": {
|
|
19
19
|
"active_roadmaps": 33,
|
|
20
20
|
"later_roadmaps": 50,
|
|
21
|
-
"open_blockers":
|
|
21
|
+
"open_blockers": 71
|
|
22
22
|
},
|
|
23
23
|
"baseline_history": [
|
|
24
24
|
{
|
|
@@ -104,6 +104,13 @@
|
|
|
104
104
|
"later_roadmaps": 50,
|
|
105
105
|
"open_blockers": 70,
|
|
106
106
|
"why": "WALKED DOWN by 1 blocker: worktree-claim-root-split resolved in the change that found it. The blocker recorded a measured defect \u2014 the roadmap claim and the stop-slot concern resolved different roots, so run-continuation was inert in every worktree session \u2014 and the claim now lives in the git common dir beside the session register. Active and later are unchanged and net zero by construction: road-to-long-horizon-execution archived and road-to-run-continuation-observation was created in the same change to carry its last deferred criterion, per the preservation test the same change added to roadmap-progress-sync. Walked here rather than banked because the gate reported the slack itself, and a baseline above the measurement is headroom a later change spends with nothing objecting."
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"at": "2026-08-19",
|
|
110
|
+
"active_roadmaps": 33,
|
|
111
|
+
"later_roadmaps": 50,
|
|
112
|
+
"open_blockers": 71,
|
|
113
|
+
"why": "RAISED by 1 blocker, and the raise is the alternative to a silent drop rather than room made for new growth. road-to-run-continuation-observation step 0.1 carries the parent criterion \"a process-full contract run finishes a 3-phase roadmap with zero synchronous contacts, re-engaging across turns, and opens the PR\". Its sibling step 0.0 closed on a real engagement observed 2026-08-19, and 0.1 did NOT: this roadmap has one phase, so the three-phase half cannot be read off that run however the rest of it went, and closing it there would be the attribution error the step's own text warns against. The choice was therefore between recording the gap as three-phase-contract-run, where the dashboard and this ratchet both see it, and leaving it as prose in a step nobody counts \u2014 which is the invisible-gate failure road-to-solution-minimalism recorded when its own halt lived in prose and the generated dashboard published zero blockers for a roadmap stopped for four days. Active and later are unchanged, which is the check that this is not a park wearing a tightening's clothes. The entry is class 3 rather than 0 on purpose and that bounds what the raise buys: an agent does the work, but choosing which roadmap gets worked next is the operator's, so no command produces a qualifying run and this number walks back down on the first three-phase autonomous roadmap anyone finishes from a worktree \u2014 as a side effect, with no dedicated effort."
|
|
107
114
|
}
|
|
108
115
|
],
|
|
109
116
|
"target": {
|
|
@@ -17,10 +17,27 @@
|
|
|
17
17
|
* the fix is a registered decision, not a silent default bump
|
|
18
18
|
* - per-slot SUM over all bound concerns > the slot cap → RED
|
|
19
19
|
*
|
|
20
|
+
* NOT FULLY DETERMINISTIC — this paragraph replaces a claim that said it was.
|
|
21
|
+
* The fixture path was described as "deterministic, CI-reproducible". For most
|
|
22
|
+
* concerns it is; they read only the committed envelope. But `end-review-nudge`
|
|
23
|
+
* decides whether to fire from the LIVE working-tree diff plus a once-per-session
|
|
24
|
+
* latch, so one commit reads 1,140 B with uncommitted changes present and 922 B
|
|
25
|
+
* without them. Two values, observed three times in one session as the tree went
|
|
26
|
+
* dirty, clean and dirty again — the earlier wording here said "three different
|
|
27
|
+
* values", which counted the readings and not the values, and an R2 review was
|
|
28
|
+
* right to call that an unbacked number inside the paragraph that exists to
|
|
29
|
+
* replace an unbacked claim.
|
|
30
|
+
* Two consequences. A single run is a reading of THIS checkout right now, so its
|
|
31
|
+
* number must never be pinned as acceptance evidence (a roadmap note tried, and
|
|
32
|
+
* had to be corrected). And the BREACH verdict is unaffected regardless, because
|
|
33
|
+
* a state-dependent concern can only push the sum UP: a green run is still a
|
|
34
|
+
* green run. Documented rather than fixed, because making this hermetic means
|
|
35
|
+
* changing what the concern reads — a change to the concern, not to its bench.
|
|
36
|
+
*
|
|
20
37
|
* HONEST SCOPE, stated up front: concerns are conditional-silence by design —
|
|
21
38
|
* a generic fixture triggers few of them, so most measure 0 bytes here. The
|
|
22
39
|
* gate therefore enforces the cap on whatever DOES fire under the committed
|
|
23
|
-
* fixtures
|
|
40
|
+
* fixtures, while the census mode
|
|
24
41
|
* (`--record`) measures REAL sessions: it appends counts-only lines
|
|
25
42
|
* ({ts, slot, concern, bytes}) to agents/runtime/state/injection-census.jsonl
|
|
26
43
|
* — the repeat-injection-census blocker's instrument. Claiming the fixture
|
|
@@ -97,6 +97,34 @@ export function claudeAdditionalContext(event: string, text: string): string {
|
|
|
97
97
|
})}\n`;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Will `emitFor` actually put the reasons on a stream?
|
|
102
|
+
*
|
|
103
|
+
* The per-turn injection ceiling (`injection_budget.ts`) may only charge bytes
|
|
104
|
+
* the host receives. Two branches of `emitFor` below emit nothing REGARDLESS of
|
|
105
|
+
* the reasons, and those are the two this predicate answers for: an unverified
|
|
106
|
+
* platform (legacy pass-through) and `severity: allow`. The budget module
|
|
107
|
+
* originally covered only the first, which let an `allow` dispatch spend the turn
|
|
108
|
+
* on output nobody got — and a crashed non-`fail_closed` concern is fail-opened
|
|
109
|
+
* to exactly that verdict with its stack trace as the largest candidate.
|
|
110
|
+
*
|
|
111
|
+
* A THIRD non-emitting case exists and this predicate deliberately does not claim
|
|
112
|
+
* it: a `warn` whose joined reason comes out empty, because `_joinReasons` trims
|
|
113
|
+
* and drops blanks, so a whitespace-only advisory text emits nothing either. It
|
|
114
|
+
* is content-dependent rather than a property of `(platform, severity)`, so a
|
|
115
|
+
* predicate with this signature cannot decide it — stated here rather than left
|
|
116
|
+
* to be discovered, since an R2 review found the earlier "TWO branches" wording
|
|
117
|
+
* asserting a completeness it did not have.
|
|
118
|
+
*
|
|
119
|
+
* Kept beside `emitFor` deliberately rather than re-derived at the call site:
|
|
120
|
+
* the predicate is only correct while it mirrors the function below it, and a
|
|
121
|
+
* copy one module away is a copy that drifts.
|
|
122
|
+
*/
|
|
123
|
+
export function emissionCarriesReasons(platform: string, severity: Severity): boolean {
|
|
124
|
+
if (!VERIFIED_PLATFORMS.has(platform)) return false;
|
|
125
|
+
return severity !== "allow";
|
|
126
|
+
}
|
|
127
|
+
|
|
100
128
|
/**
|
|
101
129
|
* Translate (platform, event, severity, reasons) into a native emission.
|
|
102
130
|
*
|
|
@@ -67,7 +67,7 @@ import * as fs from "node:fs";
|
|
|
67
67
|
import * as path from "node:path";
|
|
68
68
|
|
|
69
69
|
import { log_dispatch_issue } from "./dispatch_issues.js";
|
|
70
|
-
import { VERIFIED_PLATFORMS } from "./host_semantics.js";
|
|
70
|
+
import { VERIFIED_PLATFORMS, emissionCarriesReasons, type Severity } from "./host_semantics.js";
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
73
|
* The event that STARTS a turn, and therefore the one that resets the per-turn
|
|
@@ -81,6 +81,20 @@ export const RC_ALLOW = 0;
|
|
|
81
81
|
export const RC_BLOCK = 1;
|
|
82
82
|
export const RC_WARN = 2;
|
|
83
83
|
|
|
84
|
+
/**
|
|
85
|
+
* The host-facing severity for a reduced exit code, mirroring
|
|
86
|
+
* `dispatch_hook._severity_for`. Local rather than imported: this module is
|
|
87
|
+
* imported BY the dispatcher, so reaching back would close an import cycle.
|
|
88
|
+
* Anything outside the ladder reads as `block`, the conservative direction —
|
|
89
|
+
* an unknown verdict must never be treated as "emits nothing" and silently
|
|
90
|
+
* skip the ceiling.
|
|
91
|
+
*/
|
|
92
|
+
function _severityForRc(rc: number): Severity {
|
|
93
|
+
if (rc === RC_ALLOW) return "allow";
|
|
94
|
+
if (rc === RC_WARN) return "warn";
|
|
95
|
+
return "block";
|
|
96
|
+
}
|
|
97
|
+
|
|
84
98
|
/** Why a candidate did not survive shaping. */
|
|
85
99
|
export type DropReason = "nudge_interference" | "injection_budget";
|
|
86
100
|
|
|
@@ -321,10 +335,91 @@ export interface TurnSpend {
|
|
|
321
335
|
readonly bytes: number;
|
|
322
336
|
}
|
|
323
337
|
|
|
324
|
-
|
|
338
|
+
/** The per-session directory the counters live in. */
|
|
339
|
+
export const TURN_SPEND_DIR_REL = path.join("agents", "runtime", "state", "injection-turn");
|
|
340
|
+
|
|
341
|
+
/** Keep the directory bounded; oldest files are pruned past this many. */
|
|
342
|
+
export const TURN_SPEND_MAX_FILES = 64;
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* A session id reduced to ONE safe path segment.
|
|
346
|
+
*
|
|
347
|
+
* Two jobs, and the second is why a plain `replace` is not enough: the id is
|
|
348
|
+
* host-supplied, so it must not escape the directory (`../`), and it must not
|
|
349
|
+
* blow the filesystem's name limit. Anything outside a conservative charset is
|
|
350
|
+
* replaced, and the basename carries a short digest of the ORIGINAL id so two
|
|
351
|
+
* ids that sanitise to the same characters still land on different counters.
|
|
352
|
+
*
|
|
353
|
+
* The digest is a 32-bit FNV-1a-style mix over the id's UTF-16 code units — not
|
|
354
|
+
* over its encoded bytes, and not a cryptographic hash. So the collision claim
|
|
355
|
+
* is a PROBABILITY, not an absolute: ~2^-32 per pair, and the consequence of one
|
|
356
|
+
* is exactly the shared-file defect this layout removes (the foreign read returns
|
|
357
|
+
* 0, then the write clobbers). That is the honest bound; "can never collide" was
|
|
358
|
+
* the first version of this sentence and an R2 review was right to reject it.
|
|
359
|
+
*/
|
|
360
|
+
export function turnSpendKey(session: string): string {
|
|
361
|
+
const safe = session.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
362
|
+
let h = 0x811c9dc5;
|
|
363
|
+
for (let i = 0; i < session.length; i += 1) {
|
|
364
|
+
h ^= session.charCodeAt(i);
|
|
365
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
366
|
+
}
|
|
367
|
+
const digest = h.toString(16).padStart(8, "0");
|
|
368
|
+
// The digest is unconditional rather than only-when-needed: a conditional
|
|
369
|
+
// suffix means two code paths can produce the same basename for different
|
|
370
|
+
// ids, which is the collision it exists to prevent.
|
|
371
|
+
return `${safe.slice(0, 96)}.${digest}`;
|
|
372
|
+
}
|
|
325
373
|
|
|
326
|
-
|
|
327
|
-
|
|
374
|
+
/**
|
|
375
|
+
* Path of ONE session's counter.
|
|
376
|
+
*
|
|
377
|
+
* Per session, not per workspace, and that was a defect rather than a
|
|
378
|
+
* refinement: with a single shared file, two concurrent sessions in one
|
|
379
|
+
* workspace read each other's record, saw a foreign id, resolved to "nothing
|
|
380
|
+
* spent", and then overwrote it — so the ceiling was effectively unenforced for
|
|
381
|
+
* both, every fire. The estate's existing pattern for per-session state is a
|
|
382
|
+
* directory (`agents/runtime/state/end-review-nudge/`); this follows it.
|
|
383
|
+
*/
|
|
384
|
+
export function turnSpendPath(workspaceRoot: string, session: string): string {
|
|
385
|
+
return path.join(workspaceRoot, TURN_SPEND_DIR_REL, `${turnSpendKey(session)}.json`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Drop the oldest counters once the directory passes its cap.
|
|
390
|
+
*
|
|
391
|
+
* A per-session file trades one unbounded counter for unbounded FILES, so the
|
|
392
|
+
* cap is part of the same change rather than a follow-up. Best-effort and
|
|
393
|
+
* silent: failing to prune must never affect a dispatch.
|
|
394
|
+
*/
|
|
395
|
+
function _pruneTurnSpend(dir: string): void {
|
|
396
|
+
try {
|
|
397
|
+
// `.tmp` is included deliberately: the temp file is `<name>.json.<pid>.tmp`,
|
|
398
|
+
// which does NOT end in `.json`, so a `.json`-only filter left every temp
|
|
399
|
+
// leaked by a crash between the write and the rename to accumulate forever
|
|
400
|
+
// — unbounded growth in the directory this cap exists to bound.
|
|
401
|
+
const entries = fs
|
|
402
|
+
.readdirSync(dir)
|
|
403
|
+
.filter((n) => n.endsWith(".json") || n.endsWith(".tmp"))
|
|
404
|
+
.map((n) => {
|
|
405
|
+
const p = path.join(dir, n);
|
|
406
|
+
try {
|
|
407
|
+
return { p, mtime: fs.statSync(p).mtimeMs };
|
|
408
|
+
} catch {
|
|
409
|
+
return { p, mtime: 0 };
|
|
410
|
+
}
|
|
411
|
+
})
|
|
412
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
413
|
+
for (const stale of entries.slice(TURN_SPEND_MAX_FILES)) {
|
|
414
|
+
try {
|
|
415
|
+
fs.unlinkSync(stale.p);
|
|
416
|
+
} catch {
|
|
417
|
+
/* a file another process already removed is the desired state */
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
} catch {
|
|
421
|
+
/* unreadable directory — nothing to prune, nothing to report */
|
|
422
|
+
}
|
|
328
423
|
}
|
|
329
424
|
|
|
330
425
|
/**
|
|
@@ -335,7 +430,7 @@ export function turnSpendPath(workspaceRoot: string): string {
|
|
|
335
430
|
*/
|
|
336
431
|
export function readTurnSpend(workspaceRoot: string, session: string): number {
|
|
337
432
|
try {
|
|
338
|
-
const raw = fs.readFileSync(turnSpendPath(workspaceRoot), "utf-8");
|
|
433
|
+
const raw = fs.readFileSync(turnSpendPath(workspaceRoot, session), "utf-8");
|
|
339
434
|
const parsed = JSON.parse(raw) as Partial<TurnSpend>;
|
|
340
435
|
if (parsed.session !== session) return 0;
|
|
341
436
|
return typeof parsed.bytes === "number" &&
|
|
@@ -355,6 +450,16 @@ export function readTurnSpend(workspaceRoot: string, session: string): number {
|
|
|
355
450
|
*
|
|
356
451
|
* `reset` is passed on the slot that STARTS a turn (`user_prompt_submit`), so
|
|
357
452
|
* the count is bounded by one turn rather than growing across a session.
|
|
453
|
+
*
|
|
454
|
+
* The write is a temp file plus a rename, which buys exactly one thing: no reader
|
|
455
|
+
* ever sees a TORN file. It does NOT serialise the read-modify-write — the read
|
|
456
|
+
* at the top of this function and the write below it are still two steps, so two
|
|
457
|
+
* interleaved dispatches of the same session can still lose an update. Claiming
|
|
458
|
+
* otherwise was an R2 finding on the first version of this docblock, and the
|
|
459
|
+
* narrower claim is the true one. The residual failure is under-counting, which
|
|
460
|
+
* is the safe direction: an accounting error must never be the reason an advisory
|
|
461
|
+
* disappears. The sibling counter in this estate (`rule-trips.json`) uses the
|
|
462
|
+
* same primitive for the same class of artefact in the same directory.
|
|
358
463
|
*/
|
|
359
464
|
export function recordTurnSpend(
|
|
360
465
|
workspaceRoot: string,
|
|
@@ -366,9 +471,13 @@ export function recordTurnSpend(
|
|
|
366
471
|
const previous = opts.reset === true ? 0 : readTurnSpend(workspaceRoot, session);
|
|
367
472
|
const entry: TurnSpend = { session, bytes: previous + Math.max(0, bytes) };
|
|
368
473
|
try {
|
|
369
|
-
const target = turnSpendPath(workspaceRoot);
|
|
370
|
-
|
|
371
|
-
fs.
|
|
474
|
+
const target = turnSpendPath(workspaceRoot, session);
|
|
475
|
+
const dir = path.dirname(target);
|
|
476
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
477
|
+
const tmp = `${target}.${String(process.pid)}.tmp`;
|
|
478
|
+
fs.writeFileSync(tmp, JSON.stringify(entry) + "\n", { encoding: "utf-8" });
|
|
479
|
+
fs.renameSync(tmp, target);
|
|
480
|
+
_pruneTurnSpend(dir);
|
|
372
481
|
} catch {
|
|
373
482
|
/* observability-adjacent bookkeeping never breaks the agent loop */
|
|
374
483
|
}
|
|
@@ -536,17 +645,44 @@ export function shapeAndRecord(
|
|
|
536
645
|
nudgeRank: typeof rank === "number" ? rank : null,
|
|
537
646
|
};
|
|
538
647
|
});
|
|
539
|
-
// Skipped wholesale where the emission carries nothing: on an unverified
|
|
540
|
-
// platform `emitFor` returns empty stdout AND stderr, so choosing between two
|
|
541
|
-
// messages there would write advisory-suppression records into the consumer's
|
|
542
|
-
// log for output that never left the process. Nothing to shape is not the
|
|
543
|
-
// same as shaping to nothing.
|
|
544
|
-
if (!VERIFIED_PLATFORMS.has(ctx.platform)) {
|
|
545
|
-
return textOf(new Set(candidates.map((c) => c.concern)));
|
|
546
|
-
}
|
|
547
648
|
const workspaceRoot = _workspaceRoot(ctx);
|
|
548
649
|
const sessionKey = _sessionKey(ctx) ?? "";
|
|
549
650
|
const cap = resolveVolumeCap(ctx);
|
|
651
|
+
// Where the emission carries nothing, skip the CHARGE — never the reset.
|
|
652
|
+
// Two such cases, and one reason for both: this is an INJECTION ceiling, so
|
|
653
|
+
// it may only charge bytes the host actually receives. Shaping output that
|
|
654
|
+
// never left would write advisory-suppression records for nothing and —
|
|
655
|
+
// worse — spend the turn's budget, so a LATER dispatch whose advisory WOULD
|
|
656
|
+
// have been delivered gets dropped instead.
|
|
657
|
+
//
|
|
658
|
+
// 1. an unverified platform — `emitFor` returns the legacy pass-through,
|
|
659
|
+
// empty stdout AND stderr;
|
|
660
|
+
// 2. a reduced verdict of ALLOW — `emitFor` returns
|
|
661
|
+
// `{exit: 0, stdout: "", stderr: ""}` for `severity: allow`, on every
|
|
662
|
+
// platform including the verified one.
|
|
663
|
+
//
|
|
664
|
+
// Case 2 was missed when this module landed, and it is not theoretical. A
|
|
665
|
+
// non-`fail_closed` concern that CRASHES is fail-opened to `EXIT_ALLOW` by
|
|
666
|
+
// the dispatcher and its stderr then becomes the deciding message — usually
|
|
667
|
+
// the largest candidate in the set. Measured against this module before the
|
|
668
|
+
// fix: a 9,000-byte rc-0 message emitted 0 bytes and charged 9,000, so about
|
|
669
|
+
// five such dispatches exhausted a 47,104-byte turn ceiling on output nobody
|
|
670
|
+
// received. `emissionCarriesReasons` is the shared predicate, kept beside
|
|
671
|
+
// `emitFor` so it cannot drift from the function whose behaviour it reports.
|
|
672
|
+
//
|
|
673
|
+
// THE RESET STILL HAS TO RUN, and getting that wrong was an R2 finding on the
|
|
674
|
+
// first version of this fix. `_reduce([])` is `EXIT_ALLOW`, so a
|
|
675
|
+
// `user_prompt_submit` where no concern fires — the common case for
|
|
676
|
+
// conditional-silence concerns — takes this branch. Returning here without
|
|
677
|
+
// resetting left the PREVIOUS turn's total on disk, and later dispatches in
|
|
678
|
+
// the new turn then dropped advisories to pay for bytes belonging to an
|
|
679
|
+
// earlier one. Skipping a charge and skipping a boundary are different acts.
|
|
680
|
+
if (!emissionCarriesReasons(ctx.platform, _severityForRc(finalRc))) {
|
|
681
|
+
if (cap !== null && ctx.event === TURN_START_EVENT) {
|
|
682
|
+
recordTurnSpend(workspaceRoot, sessionKey, 0, { reset: true });
|
|
683
|
+
}
|
|
684
|
+
return textOf(new Set(candidates.map((c) => c.concern)));
|
|
685
|
+
}
|
|
550
686
|
// On the turn-START slot the carried total is zero by definition: this is the
|
|
551
687
|
// dispatch that begins the turn. Reading the stored value here budgeted the
|
|
552
688
|
// new turn against the one that just ended.
|