@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.
Files changed (48) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +132 -291
  3. package/dist/agent-src/commands/analyze/decision.md +7 -0
  4. package/dist/agent-src/commands/optimize/project.md +10 -0
  5. package/dist/agent-src/commands/roadmap/process-full.md +45 -3
  6. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +130 -1
  7. package/dist/agent-src/rules/decision-revisit-gate.md +108 -7
  8. package/dist/agent-src/skills/adr-create/SKILL.md +25 -3
  9. package/dist/agent-src/skills/decision-review/SKILL.md +18 -0
  10. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/dist/cli-delegate/{chunk-ZPSXJTLI.js → chunk-P6LQDIWK.js} +15 -1
  12. package/dist/cli-delegate/cmd_conformance.js +1 -1
  13. package/dist/cli-delegate/cmd_handoff.js +1 -1
  14. package/dist/cli-delegate/cmd_session_recycle.js +1 -1
  15. package/dist/discovery/deprecation-report.md +1 -1
  16. package/dist/discovery/discovery-manifest.json +8 -8
  17. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  18. package/dist/discovery/discovery-manifest.summary.md +1 -1
  19. package/dist/discovery/dormancy-report.md +1 -1
  20. package/dist/discovery/orphan-report.md +1 -1
  21. package/dist/discovery/packs.json +2 -2
  22. package/dist/discovery/trust-report.md +1 -1
  23. package/dist/discovery/workspaces.json +2 -2
  24. package/dist/hooks/dispatch.js +419 -94
  25. package/dist/mcp/registry-manifest.json +2 -2
  26. package/dist/mcp/server.json +2 -2
  27. package/dist/router.json +1 -1
  28. package/docs/guidelines/agent-infra/engineering-memory-data-format.md +49 -0
  29. package/package.json +1 -1
  30. package/src/config/estate-count-budget.json +36 -1
  31. package/src/config/pack-size-budget.json +3 -2
  32. package/src/scripts/_lib/layer_overlap_notice.ts +85 -0
  33. package/src/scripts/_lib/scope_guard.sh +62 -3
  34. package/src/scripts/_lib/subagent_capsule.ts +81 -1
  35. package/src/scripts/adr/regenerate_index.ts +13 -2
  36. package/src/scripts/adr_cite_check.ts +424 -0
  37. package/src/scripts/bench_hook_injection.ts +18 -1
  38. package/src/scripts/check_adr_frontmatter.ts +187 -1
  39. package/src/scripts/check_single_delivery.ts +473 -0
  40. package/src/scripts/hooks/host_semantics.ts +28 -0
  41. package/src/scripts/hooks/injection_budget.ts +152 -16
  42. package/src/scripts/hooks/run_continuation_hook.ts +918 -46
  43. package/src/scripts/install.sh +12 -2
  44. package/src/scripts/memory_consolidation.ts +189 -0
  45. package/src/scripts/memory_eviction.ts +384 -0
  46. package/src/scripts/report_layer_overlap.ts +72 -0
  47. package/src/scripts/report_memory_pointers.ts +622 -0
  48. 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
- export const TURN_SPEND_BASENAME = "injection-turn.json";
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
- export function turnSpendPath(workspaceRoot: string): string {
327
- return path.join(workspaceRoot, "agents", "runtime", "state", TURN_SPEND_BASENAME);
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
- fs.mkdirSync(path.dirname(target), { recursive: true });
371
- fs.writeFileSync(target, JSON.stringify(entry) + "\n", { encoding: "utf-8" });
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.