@event4u/agent-config 14.4.0 → 14.6.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 +132 -291
- package/dist/agent-src/commands/analyze/decision.md +7 -0
- package/dist/agent-src/commands/optimize/project.md +10 -0
- package/dist/agent-src/commands/roadmap/process-full.md +45 -3
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +130 -1
- package/dist/agent-src/rules/decision-revisit-gate.md +108 -7
- package/dist/agent-src/skills/adr-create/SKILL.md +25 -3
- package/dist/agent-src/skills/decision-review/SKILL.md +18 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/{chunk-ZPSXJTLI.js → chunk-P6LQDIWK.js} +15 -1
- package/dist/cli-delegate/cmd_conformance.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +8 -8
- 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 +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +419 -94
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/router.json +1 -1
- package/docs/guidelines/agent-infra/engineering-memory-data-format.md +49 -0
- package/package.json +1 -1
- package/src/config/estate-count-budget.json +36 -1
- package/src/config/pack-size-budget.json +3 -2
- package/src/scripts/_lib/layer_overlap_notice.ts +85 -0
- package/src/scripts/_lib/scope_guard.sh +62 -3
- package/src/scripts/_lib/subagent_capsule.ts +81 -1
- package/src/scripts/adr/regenerate_index.ts +13 -2
- package/src/scripts/adr_cite_check.ts +424 -0
- package/src/scripts/bench_hook_injection.ts +18 -1
- package/src/scripts/check_adr_frontmatter.ts +187 -1
- package/src/scripts/check_single_delivery.ts +473 -0
- 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 +918 -46
- package/src/scripts/install.sh +12 -2
- package/src/scripts/memory_consolidation.ts +189 -0
- package/src/scripts/memory_eviction.ts +384 -0
- package/src/scripts/report_layer_overlap.ts +72 -0
- package/src/scripts/report_memory_pointers.ts +622 -0
- package/src/scripts/session_register_hook.ts +144 -21
|
@@ -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.
|