@event4u/agent-config 16.0.0 → 16.1.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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +297 -137
- package/CONTRIBUTING.md +9 -0
- package/README.md +2 -2
- package/dist/agent-src/commands/analyze/inbox.md +26 -0
- package/dist/agent-src/commands/analyze/roadmap-repos.md +27 -0
- package/dist/agent-src/commands/challenge-me/closure.md +178 -0
- package/dist/agent-src/commands/challenge-me.md +9 -2
- package/dist/agent-src/commands/council/default.md +25 -5
- package/dist/agent-src/commands/feature/plan.md +28 -1
- package/dist/agent-src/commands/feature/roadmap.md +28 -1
- package/dist/agent-src/commands/implement-ticket.md +27 -0
- package/dist/agent-src/commands/jira-ticket.md +27 -0
- package/dist/agent-src/commands/pr/create.md +29 -0
- package/dist/agent-src/commands/pr/merge.md +60 -9
- package/dist/agent-src/commands/roadmap/ai-council.md +7 -1
- package/dist/agent-src/commands/roadmap/create.md +74 -27
- package/dist/agent-src/commands/roadmap/materialize.md +27 -0
- package/dist/agent-src/commands/roadmap/process-full.md +123 -18
- package/dist/agent-src/commands/team.md +22 -0
- package/dist/agent-src/contexts/authority/scope-growth-ownership.md +80 -0
- package/dist/agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +47 -0
- package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +44 -3
- package/dist/agent-src/contexts/execution/autonomy-mechanics.md +15 -15
- package/dist/agent-src/contexts/execution/decision-closure-execution.md +93 -0
- package/dist/agent-src/contexts/execution/fix-loop-ladder.md +108 -0
- package/dist/agent-src/contexts/execution/plan-confidence-gate.md +4 -2
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +75 -6
- package/dist/agent-src/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
- package/dist/agent-src/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
- package/dist/agent-src/guidelines/design-fidelity-routing.md +29 -0
- package/dist/agent-src/rules/autonomous-execution.md +15 -8
- package/dist/agent-src/rules/delegation-policy.md +23 -45
- package/dist/agent-src/rules/design-fidelity.md +32 -37
- package/dist/agent-src/rules/evaluator-independence.md +43 -49
- package/dist/agent-src/rules/test-first.md +41 -0
- package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +3 -3
- package/dist/agent-src/skills/ai-council/SKILL.md +11 -3
- package/dist/agent-src/skills/api-design/data/manifest.json +3 -3
- package/dist/agent-src/skills/database/data/manifest.json +4 -4
- package/dist/agent-src/skills/database/data/query-tuning.csv +3 -3
- package/dist/agent-src/skills/design-review/SKILL.md +50 -0
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +14 -4
- package/dist/agent-src/skills/experiment-loop/SKILL.md +31 -1
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
- package/dist/agent-src/skills/storybook-workshop/SKILL.md +42 -3
- package/dist/agent-src/skills/threat-modeling/SKILL.md +9 -4
- package/dist/agent-src/skills/threat-modeling/data/manifest.json +4 -4
- package/dist/agent-src/skills/verify-repair-loop/SKILL.md +37 -3
- package/dist/agent-src/templates/agent-settings.md +1 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +188 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
- package/dist/cli-delegate/check_update_banner.js +40 -2
- package/dist/cli-delegate/chunk-4E2W63DA.js +335 -0
- package/dist/cli-delegate/chunk-5R7HOLAF.js +99 -0
- package/dist/cli-delegate/{chunk-IVBPRCQO.js → chunk-6KFEC3G7.js} +1 -1
- package/dist/cli-delegate/{chunk-2TAQ2K4V.js → chunk-6KM6ORRY.js} +55 -386
- package/dist/cli-delegate/{chunk-2XY7ILBT.js → chunk-FC75PGJE.js} +42 -2
- package/dist/cli-delegate/{chunk-GYC3VRYN.js → chunk-FXYUYU4N.js} +57 -13
- package/dist/cli-delegate/{chunk-55ACLDUE.js → chunk-GJCBB2UG.js} +168 -10
- package/dist/cli-delegate/{chunk-AK6HODNQ.js → chunk-HDVVGPQW.js} +873 -539
- package/dist/cli-delegate/{chunk-H32DGUJH.js → chunk-P6ZHP5W2.js} +1 -1
- package/dist/cli-delegate/{chunk-57PN4GYJ.js → chunk-RGNIWTEA.js} +1 -1
- package/dist/cli-delegate/chunk-ROYYTEZU.js +39 -0
- package/dist/cli-delegate/{chunk-3FKR64R5.js → chunk-UNCRJBT3.js} +31 -7
- package/dist/cli-delegate/{chunk-2DK2VJWG.js → chunk-WGXTVKCQ.js} +20 -6
- package/dist/cli-delegate/chunk-YDPILCOL.js +152 -0
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +1 -1
- package/dist/cli-delegate/cmd_conformance.js +90 -56
- package/dist/cli-delegate/cmd_doctor.js +7 -5
- package/dist/cli-delegate/cmd_explain.js +2 -2
- package/dist/cli-delegate/cmd_export.js +9 -7
- package/dist/cli-delegate/cmd_fleet.js +4 -2
- package/dist/cli-delegate/cmd_handoff.js +4 -4
- package/dist/cli-delegate/cmd_mcp_available.js +1 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +2 -2
- package/dist/cli-delegate/cmd_preflight.js +4 -2
- package/dist/cli-delegate/cmd_prune.js +10 -8
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_route_explain.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +2 -2
- package/dist/cli-delegate/cmd_settings_get.js +28 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +9 -7
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +10 -8
- package/dist/cli-delegate/cmd_uninstall.js +16 -13
- package/dist/cli-delegate/cmd_update.js +1 -1
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +12 -10
- package/dist/cli-delegate/cmd_versions.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +85 -38
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +10 -8
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +8 -6
- package/dist/hooks/dispatch.js +1832 -1007
- package/dist/install/claudeRuleRewrite.js +14 -3
- package/dist/install/conflict.js +60 -10
- package/dist/install/conflictTracker.js +218 -0
- package/dist/install/fsPrimitives.js +54 -0
- package/dist/install/install.mjs +2007 -1481
- package/dist/install/manifestFiles.js +259 -0
- package/dist/install/preflight.js +14 -2
- package/dist/install/preserve.js +157 -0
- package/dist/install/recordedOwnership.js +132 -0
- package/dist/install/txlog.js +11 -2
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +398 -39
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/agent_settings.js +3 -2
- package/dist/scripts/_lib/fs_atomic.js +167 -0
- package/dist/scripts/_lib/installed_tools.js +460 -0
- package/dist/scripts/_lib/lean_projection_mode.js +3 -2
- package/dist/scripts/_lib/settings_renamed_keys.js +78 -0
- package/dist/server/routes/install.js +8 -0
- package/dist/server/routes/wizard.js +9 -3
- package/dist/server/schemas/settings.js +17 -5
- package/dist/shared/missionExecution.js +97 -0
- package/dist/ui/assets/{index-D0jaCRPM.js → index-DH1xc62v.js} +17 -17
- package/dist/ui/index.html +1 -1
- package/docs/MIGRATION.md +103 -0
- package/docs/contracts/settings-classes.md +26 -7
- package/docs/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
- package/docs/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
- package/docs/guidelines/design-fidelity-routing.md +29 -0
- package/package.json +1 -1
- package/src/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
- package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
- package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
- package/src/config/agent-settings.template.yml +139 -27
- package/src/config/auto-rule-budget.json +16 -0
- package/src/config/ci-local-parity.yml +18 -5
- package/src/config/continuity-surface.json +21 -0
- package/src/config/gate-coverage.yml +146 -13
- package/src/config/gate-violation-baselines.json +6 -6
- package/src/config/hook-token-budget.json +2 -0
- package/src/config/loop-surfaces.yaml +215 -0
- package/src/config/platform-anchor.json +3 -2
- package/src/config/release-gate-locality.yml +11 -0
- package/src/config/routing-coverage-seed.json +18 -4
- package/src/config/rule-enforcement-baseline.json +0 -1
- package/src/install/claudeRuleRewrite.ts +16 -2
- package/src/install/conflict.ts +65 -9
- package/src/install/conflictTracker.ts +299 -0
- package/src/install/fsPrimitives.ts +54 -0
- package/src/install/manifestFiles.ts +312 -0
- package/src/install/preflight.ts +16 -2
- package/src/install/preserve.ts +205 -0
- package/src/install/recordedOwnership.ts +149 -0
- package/src/install/txlog.ts +11 -2
- package/src/install/types.ts +19 -4
- package/src/scripts/_cli/cmd_conformance.ts +88 -15
- package/src/scripts/_cli/cmd_doctor.ts +4 -3
- package/src/scripts/_cli/cmd_route_explain.ts +9 -2
- package/src/scripts/_cli/cmd_settings_get.ts +44 -0
- package/src/scripts/_cli/doctor_execution.ts +90 -0
- package/src/scripts/_lib/activation_payload.ts +247 -0
- package/src/scripts/_lib/agent_settings.ts +4 -2
- package/src/scripts/_lib/authority_path.ts +102 -0
- package/src/scripts/_lib/cascade_base.ts +94 -0
- package/src/scripts/_lib/changelog_eras.ts +7 -2
- package/src/scripts/_lib/continuation_ladder.ts +149 -2
- package/src/scripts/_lib/council_transport.ts +129 -0
- package/src/scripts/_lib/delivery_ready.ts +112 -0
- package/src/scripts/_lib/forge_protection.ts +267 -0
- package/src/scripts/_lib/hook_platform_keys.ts +27 -0
- package/src/scripts/_lib/json_python_parity.ts +127 -0
- package/src/scripts/_lib/lean_projection_mode.ts +14 -3
- package/src/scripts/_lib/loop_surfaces.ts +382 -0
- package/src/scripts/_lib/mission_record.ts +190 -0
- package/src/scripts/_lib/obligation_frequency.ts +217 -0
- package/src/scripts/_lib/obligations.ts +402 -0
- package/src/scripts/_lib/orchestration_record.ts +11 -20
- package/src/scripts/_lib/release_highlights.ts +199 -10
- package/src/scripts/_lib/release_holds.ts +493 -0
- package/src/scripts/_lib/release_migration_gate.ts +184 -0
- package/src/scripts/_lib/rides_along.ts +102 -0
- package/src/scripts/_lib/settings_renamed_keys.ts +82 -0
- package/src/scripts/_lib/test_delta.ts +166 -0
- package/src/scripts/_lib/test_provenance.ts +171 -0
- package/src/scripts/_lib/typed_op_grant.ts +246 -0
- package/src/scripts/_lib/typed_op_watch.ts +185 -0
- package/src/scripts/ai_council/config.ts +8 -11
- package/src/scripts/ai_council/fallback_config.ts +5 -3
- package/src/scripts/ai_council/ownership_classes.ts +118 -0
- package/src/scripts/ask_block_census.ts +285 -15
- package/src/scripts/check_enforcement_matrix.ts +624 -0
- package/src/scripts/check_gate_reachability.ts +173 -10
- package/src/scripts/check_generator_sync.ts +954 -0
- package/src/scripts/check_preamble_payload_budget.ts +75 -2
- package/src/scripts/check_release_highlights.ts +33 -1
- package/src/scripts/check_release_holds.ts +567 -0
- package/src/scripts/check_rule_projection_integrity.ts +22 -12
- package/src/scripts/check_test_delta.ts +294 -0
- package/src/scripts/check_test_weakening.ts +299 -0
- package/src/scripts/closure_scan.ts +333 -0
- package/src/scripts/compile_hook_manifest.ts +43 -3
- package/src/scripts/council_record_shape.ts +209 -0
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +88 -9
- package/src/scripts/hooks/concern_registry.ts +4 -0
- package/src/scripts/hooks/design_pass_hook.ts +128 -3
- package/src/scripts/hooks/evidence_independence.ts +96 -0
- package/src/scripts/hooks/memory_recall_hook.ts +540 -0
- package/src/scripts/hooks/obligation_settle_hook.ts +182 -0
- package/src/scripts/hooks/rule_inject_hook.ts +49 -1
- package/src/scripts/hooks/run_continuation_hook.ts +17 -20
- package/src/scripts/hooks_doctor.ts +85 -0
- package/src/scripts/hooks_status.ts +19 -0
- package/src/scripts/install.ts +106 -206
- package/src/scripts/janitor.ts +7 -0
- package/src/scripts/lint_decision_classes.ts +464 -0
- package/src/scripts/lint_hook_manifest.ts +11 -0
- package/src/scripts/lint_major_migration_sections.ts +450 -0
- package/src/scripts/lint_plan_risk_register.ts +47 -3
- package/src/scripts/lint_roadmap_blockers.ts +61 -0
- package/src/scripts/lint_roadmap_producers.ts +202 -0
- package/src/scripts/measure_rule_budget.ts +101 -5
- package/src/scripts/new_roadmap.ts +37 -0
- package/src/scripts/orchestration_record.ts +0 -5
- package/src/scripts/release.ts +14 -98
- package/src/scripts/release_drill.ts +267 -1
- package/src/scripts/release_publication.ts +51 -0
- package/src/scripts/report_enforcement_drift.ts +566 -0
- package/src/scripts/report_host_injection_effect.ts +3 -11
- package/src/scripts/report_standing_payload_by_host.ts +26 -152
- package/src/scripts/schemas/agent-settings.schema.json +23 -0
- package/src/scripts/schemas/command.schema.json +4 -0
- package/src/scripts/scope_growth.ts +219 -0
- package/src/scripts/trigger_eval_grandfather.json +0 -1
- package/src/scripts/ui_conformance_probe.ts +622 -0
- package/src/server/schemas/settings.ts +31 -7
- package/src/shared/missionExecution.ts +126 -0
- package/dist/cli-delegate/chunk-523HCFYO.js +0 -90
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
*
|
|
44
44
|
* Exit codes: 0 allow · 2 block (stderr carries the reason).
|
|
45
45
|
*/
|
|
46
|
+
import { spawnSync } from "node:child_process";
|
|
46
47
|
import crypto from "node:crypto";
|
|
47
48
|
import fs from "node:fs";
|
|
48
49
|
import path from "node:path";
|
|
@@ -50,6 +51,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
50
51
|
|
|
51
52
|
import { atomic_write_json } from "./state_io.js";
|
|
52
53
|
import { readHookStdin } from "./hook_stdin.js";
|
|
54
|
+
import { isCodePath, isTestPath } from "../_lib/test_delta.js";
|
|
53
55
|
|
|
54
56
|
const EXIT_ALLOW = 0;
|
|
55
57
|
// MUST equal dispatch_hook.EXIT_BLOCK. The dispatcher's internal ladder is
|
|
@@ -324,6 +326,79 @@ export function decide(
|
|
|
324
326
|
};
|
|
325
327
|
}
|
|
326
328
|
|
|
329
|
+
/**
|
|
330
|
+
* The one-commit test-authorship flag —
|
|
331
|
+
* `road-to-adversarial-verification-and-long-runs` 2.4, third clause.
|
|
332
|
+
*
|
|
333
|
+
* A commit staging BOTH a test and code it could cover is the shape of L0 on the
|
|
334
|
+
* independence scale — the implementer wrote the evaluator — and it is the one
|
|
335
|
+
* case nobody sees, because the diff looks exactly like a well-tested change.
|
|
336
|
+
*
|
|
337
|
+
* **It reads the STAGED SET and the command, and nothing else.** It does NOT
|
|
338
|
+
* read a session id, an author or a timestamp, so it cannot distinguish an
|
|
339
|
+
* implementer who wrote both from one committing a test another session authored
|
|
340
|
+
* — an independent review of this function caught an earlier docstring here
|
|
341
|
+
* claiming a session boundary the code never reads, which is exactly the
|
|
342
|
+
* over-claim `evaluator-independence` exists over. The honest reading is: this
|
|
343
|
+
* is the shape L0 takes in a diff, surfaced so the level is stated rather than
|
|
344
|
+
* inferred.
|
|
345
|
+
*
|
|
346
|
+
* **WARN, never block.** L0 is explicitly permitted as a fallback, so refusing it
|
|
347
|
+
* would forbid a legal state; and a signal this coarse may surface, never refuse.
|
|
348
|
+
*
|
|
349
|
+
* Returns `null` when nothing is worth saying — i.e. this is not a commit.
|
|
350
|
+
*/
|
|
351
|
+
export function sameSessionTestFlag(command: string | null): string | null {
|
|
352
|
+
if (command === null || !/\bgit\b[^|;&]*\bcommit\b/.test(command)) return null;
|
|
353
|
+
return SAME_SESSION_TEXT;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const SAME_SESSION_TEXT =
|
|
357
|
+
"This commit stages both a test and code it could cover — the shape L0 takes " +
|
|
358
|
+
"in a diff, where the implementer authored the evaluator. This check reads the " +
|
|
359
|
+
"staged set only: it cannot tell who wrote the test, so it is a prompt to STATE " +
|
|
360
|
+
"the level, not a finding that you are at L0. L0 is a permitted FALLBACK, not " +
|
|
361
|
+
"the default: where a second session, a second model or a second provider was " +
|
|
362
|
+
"available, the test should have come from one of them. Say which in the commit " +
|
|
363
|
+
"or the PR body — a diff looks identical either way. See evaluator-independence " +
|
|
364
|
+
"§ Tests are evaluators.";
|
|
365
|
+
|
|
366
|
+
/** Both a test path and a production path in one changed set. */
|
|
367
|
+
export function touchesTestAndCode(paths: readonly string[]): boolean {
|
|
368
|
+
return paths.some(isTestPath) && paths.some(isCodePath);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** The shell command this envelope is about, across the key names hosts use. */
|
|
372
|
+
function _commandOf(envelope: JsonObject): string | null {
|
|
373
|
+
const payload = _isObject(envelope["payload"]) ? envelope["payload"] : envelope;
|
|
374
|
+
const input = _isObject(payload["tool_input"]) ? payload["tool_input"] : payload;
|
|
375
|
+
for (const key of ["command", "cmd", "shell_command"]) {
|
|
376
|
+
const v = input[key];
|
|
377
|
+
if (typeof v === "string" && v !== "") return v;
|
|
378
|
+
}
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Staged paths, or `null` when git cannot answer.
|
|
384
|
+
*
|
|
385
|
+
* `null` is not "nothing staged" — it is "no reading", and the caller must not
|
|
386
|
+
* turn an unreadable index into a silent clean bill. A hook that cannot measure
|
|
387
|
+
* says nothing rather than implying a measurement.
|
|
388
|
+
*/
|
|
389
|
+
function _stagedPaths(consumer_root: string): string[] | null {
|
|
390
|
+
const r = spawnSync("git", ["diff", "--cached", "--name-only"], {
|
|
391
|
+
cwd: consumer_root,
|
|
392
|
+
encoding: "utf8",
|
|
393
|
+
});
|
|
394
|
+
if (r.status !== 0) return null;
|
|
395
|
+
const out = r.stdout
|
|
396
|
+
.split("\n")
|
|
397
|
+
.map((s) => s.trim())
|
|
398
|
+
.filter((s) => s !== "");
|
|
399
|
+
return out.length === 0 ? null : out;
|
|
400
|
+
}
|
|
401
|
+
|
|
327
402
|
export function run(stdin_text: string, options: { consumer_root: string }): number {
|
|
328
403
|
let envelope: JsonObject = {};
|
|
329
404
|
if (stdin_text.trim()) {
|
|
@@ -355,6 +430,27 @@ export function run(stdin_text: string, options: { consumer_root: string }): num
|
|
|
355
430
|
}
|
|
356
431
|
|
|
357
432
|
const [tool, prompt] = extractDispatch(envelope);
|
|
433
|
+
|
|
434
|
+
// 2.4 — the one-commit test-authorship flag, checked BEFORE the dispatch
|
|
435
|
+
// branch and returning early. A commit envelope carries no evaluation prompt,
|
|
436
|
+
// so `decide` would classify it as not-an-evaluation and allow it silently;
|
|
437
|
+
// running both would mean the commit path fell through to a counter that is
|
|
438
|
+
// about evaluation dispatches and would be wrong to advance here.
|
|
439
|
+
//
|
|
440
|
+
// CHEAP TEST FIRST, and that ordering is the finding rather than a style
|
|
441
|
+
// preference: `_stagedPaths` spawns `git diff --cached`, and this concern is
|
|
442
|
+
// bound on `pre_tool_use`, so reading the index before the regex made every
|
|
443
|
+
// Read, Edit, Grep and Task dispatch in every session pay a subprocess. The
|
|
444
|
+
// regex answers "is this even a commit" for free.
|
|
445
|
+
const flag = sameSessionTestFlag(_commandOf(envelope));
|
|
446
|
+
if (flag !== null) {
|
|
447
|
+
const staged = _stagedPaths(options.consumer_root);
|
|
448
|
+
if (staged !== null && touchesTestAndCode(staged)) {
|
|
449
|
+
process.stdout.write(`${JSON.stringify({ decision: "warn", reason: flag })}\n`);
|
|
450
|
+
return EXIT_ALLOW;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
358
454
|
const decision = decide(tool, prompt, state.evaluations.length);
|
|
359
455
|
|
|
360
456
|
if (decision.evaluations > state.evaluations.length) {
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Memory recall — `pre_tool_use` concern. Delivers a memory the store ALREADY
|
|
4
|
+
* holds, at the moment the tool call it is about would run, instead of after
|
|
5
|
+
* it has gone wrong.
|
|
6
|
+
*
|
|
7
|
+
* The finding it is built from, measured rather than supposed: in one long
|
|
8
|
+
* autonomous run the agent walked into four distinct traps that each already
|
|
9
|
+
* had a memory file, some weeks old. The store's own index records the same
|
|
10
|
+
* shape on an earlier day — "of eleven error classes in one session, nine had
|
|
11
|
+
* a file here already and all nine were read AFTER the failure". So the store
|
|
12
|
+
* is not the gap; retrieval is. `memory_lookup` exists as a CLI and nothing
|
|
13
|
+
* calls it automatically, which makes recall model-carried and therefore
|
|
14
|
+
* weakest exactly when the agent is busy.
|
|
15
|
+
*
|
|
16
|
+
* Per `recurring-criticism`, a criticism that arrives again is evidence about
|
|
17
|
+
* the system. Writing a fifth memory file is the item response; delivering the
|
|
18
|
+
* four that exist, before the action, is the system one.
|
|
19
|
+
*
|
|
20
|
+
* A committed trigger table, never a similarity score. `RECALL_RULES` is a
|
|
21
|
+
* short, hand-authored table. Each row names ONE memory slug and ONE
|
|
22
|
+
* precisely-decidable condition on the tool payload. There is no ranking, no
|
|
23
|
+
* threshold, no lexical match against 467 files: a fuzzy matcher over the
|
|
24
|
+
* whole store would fire on most calls, and the sibling concern that measured
|
|
25
|
+
* this (`skill-route`, silent on ~86% of prompts by design) is why the floor
|
|
26
|
+
* here is a predicate rather than a score. A predicate is also falsifiable —
|
|
27
|
+
* every row is replayed against the real payload shape in
|
|
28
|
+
* `tests/scripts/memory_recall_hook.test.ts`, so a row that cannot fire fails
|
|
29
|
+
* a test instead of degrading quietly into noise.
|
|
30
|
+
*
|
|
31
|
+
* Advisory only — never a block. It binds on the slot that carries the
|
|
32
|
+
* blocking guards and never returns exit 1 on any path. A recall hook that can
|
|
33
|
+
* refuse a tool call is a larger failure mode than the one it closes: a stale
|
|
34
|
+
* trigger row would then deny work over a bookkeeping artefact. Delivery
|
|
35
|
+
* mirrors `reread_guard_hook.ts` exactly — `{decision:"warn", reason,
|
|
36
|
+
* additional_context}` at exit 2, which `host_semantics.emitFor` turns into a
|
|
37
|
+
* host-facing exit 0 unconditionally on the verified `claude` platform.
|
|
38
|
+
*
|
|
39
|
+
* Budget. `pre_tool_use` fires once per tool call, the most expensive slot
|
|
40
|
+
* there is. ONE memory per fire, pointer plus the memory's own one-line
|
|
41
|
+
* description, truncated to `CAP_BYTES` (768 — declared BELOW the 1024 default
|
|
42
|
+
* in `src/config/hook-token-budget.json` deliberately, so a later change that
|
|
43
|
+
* starts injecting memory BODIES fails the build instead of quietly tripling a
|
|
44
|
+
* per-call slot). Injecting a whole memory file would be 1.5-6 kB per fire.
|
|
45
|
+
*
|
|
46
|
+
* Cost of the silent path. The overwhelmingly common case is no match, and it
|
|
47
|
+
* costs a payload parse and a few regex tests — zero filesystem work. An
|
|
48
|
+
* `lstat` runs only after a build-shaped command has already matched; the
|
|
49
|
+
* store is resolved and the latch touched only after a rule has fired.
|
|
50
|
+
*
|
|
51
|
+
* An unreadable store is not the same answer as no match. When a rule matches
|
|
52
|
+
* and the memory directory cannot be resolved, the concern still emits the
|
|
53
|
+
* pointer (the slug is actionable on its own) and writes a one-line diagnostic
|
|
54
|
+
* to stderr naming the directories it tried. Silence there would report
|
|
55
|
+
* "nothing to recall" for a store that was merely unreachable.
|
|
56
|
+
*
|
|
57
|
+
* ROBUSTNESS: malformed payload, any fs error, any throw -> exit 0 silently.
|
|
58
|
+
* No network, no writes outside the session latch under `agents/runtime/`.
|
|
59
|
+
*/
|
|
60
|
+
import * as crypto from 'node:crypto';
|
|
61
|
+
import * as fs from 'node:fs';
|
|
62
|
+
import * as path from 'node:path';
|
|
63
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
64
|
+
|
|
65
|
+
import { DEFAULT_PROJECTS_ROOT, projectStoreSlug } from '../_lib/cc_transcript.js';
|
|
66
|
+
import { EDIT_TOOLS } from '../minimal_safe_diff_hook.js';
|
|
67
|
+
import { COMMAND_TOOLS } from '../before_complete_hook.js';
|
|
68
|
+
import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
|
|
69
|
+
import { readHookStdin } from './hook_stdin.js';
|
|
70
|
+
import { atomic_write_json } from './state_io.js';
|
|
71
|
+
|
|
72
|
+
const EXIT_ALLOW = 0;
|
|
73
|
+
/**
|
|
74
|
+
* Severity comes from the exit code, not from the `decision` field. See the
|
|
75
|
+
* header: this never blocks, and never returns 1 on any path.
|
|
76
|
+
*/
|
|
77
|
+
const EXIT_WARN = 2;
|
|
78
|
+
|
|
79
|
+
/** Hard ceiling on `reason` + `additional_context`, matching the registered row. */
|
|
80
|
+
export const CAP_BYTES = 768;
|
|
81
|
+
|
|
82
|
+
/** How many ancestor directories the store walk inspects before giving up. */
|
|
83
|
+
const MAX_ANCESTOR_WALK = 8;
|
|
84
|
+
|
|
85
|
+
/** One tool call, reduced to the fields a rule may read. */
|
|
86
|
+
export interface ExtractedCall {
|
|
87
|
+
tool: string;
|
|
88
|
+
/** Every target path the payload names, normalized to forward slashes. */
|
|
89
|
+
paths: readonly string[];
|
|
90
|
+
/** The shell command, for command tools; empty string otherwise. */
|
|
91
|
+
command: string;
|
|
92
|
+
/** The session's own working directory, when the host payload carries one. */
|
|
93
|
+
cwd: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Context a rule's environment predicate may inspect. Filesystem reads only.
|
|
98
|
+
*
|
|
99
|
+
* `roots` is a LIST, and that is the whole point. The dispatcher sets
|
|
100
|
+
* `workspace_root` from `--project-dir`, which the Claude hook command fills
|
|
101
|
+
* with `$CLAUDE_PROJECT_DIR` — the PARENT CHECKOUT when the session runs in a
|
|
102
|
+
* git worktree. A parent checkout has a real `node_modules`, so a predicate
|
|
103
|
+
* reading `workspace_root` alone would go silent in exactly the topology the
|
|
104
|
+
* symlink rule exists for. The payload's own `cwd` carries the worktree, so
|
|
105
|
+
* both are offered and a rule fires when EITHER satisfies it.
|
|
106
|
+
*/
|
|
107
|
+
export interface RuleContext {
|
|
108
|
+
roots: readonly string[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface RecallRule {
|
|
112
|
+
/** Stable id, used in diagnostics and in the test roster. */
|
|
113
|
+
id: string;
|
|
114
|
+
/** The memory file's slug — `<slug>.md` in the store. */
|
|
115
|
+
slug: string;
|
|
116
|
+
/** Tool names this row reacts to. */
|
|
117
|
+
tools: ReadonlySet<string>;
|
|
118
|
+
/** Pure predicate over the extracted call. No I/O. */
|
|
119
|
+
test: (call: ExtractedCall) => boolean;
|
|
120
|
+
/**
|
|
121
|
+
* Optional environment condition, evaluated ONLY after `test` passes, so
|
|
122
|
+
* the silent path never pays for it.
|
|
123
|
+
*/
|
|
124
|
+
requires?: (ctx: RuleContext) => boolean;
|
|
125
|
+
/**
|
|
126
|
+
* One clause saying why this memory matters for THIS call. Authored here,
|
|
127
|
+
* never derived from the memory — the memory supplies its own description.
|
|
128
|
+
*/
|
|
129
|
+
why: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Commands that produce a bundle whose module paths record their resolution. */
|
|
133
|
+
const BUILD_COMMAND_RE =
|
|
134
|
+
/\bnpm\s+run\s+build\b|\bbuild:(?:install-bundle|cli|hooks)\b|\besbuild\b/;
|
|
135
|
+
|
|
136
|
+
/** A decision record, in either of the two directories this tree has used. */
|
|
137
|
+
const ADR_PATH_RE = /(?:^|\/)docs\/(?:decisions|adr)\/ADR-[^/]*\.md$/i;
|
|
138
|
+
|
|
139
|
+
/** Sources whose build output is committed twice — tsc per-file and esbuild bundle. */
|
|
140
|
+
const INSTALL_SOURCE_RE = /(?:^|\/)src\/install\/|(?:^|\/)src\/scripts\/install\.ts$/;
|
|
141
|
+
|
|
142
|
+
/** The hook-latency bench and its pre-registered budget. */
|
|
143
|
+
const LATENCY_BENCH_RE = /bench_hook_latency|hook-latency-budget/;
|
|
144
|
+
|
|
145
|
+
/** True when `<root>/node_modules` exists and is a symbolic link. */
|
|
146
|
+
export function nodeModulesIsSymlink(root: string): boolean {
|
|
147
|
+
if (root === '') return false;
|
|
148
|
+
try {
|
|
149
|
+
return fs.lstatSync(path.join(root, 'node_modules')).isSymbolicLink();
|
|
150
|
+
} catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** True when ANY candidate root carries a symlinked `node_modules`. */
|
|
156
|
+
export function anyRootHasSymlinkedNodeModules(ctx: RuleContext): boolean {
|
|
157
|
+
return ctx.roots.some(nodeModulesIsSymlink);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The committed trigger table. Order is priority order: the first matching row
|
|
162
|
+
* wins and at most one memory is ever emitted.
|
|
163
|
+
*
|
|
164
|
+
* Each row is seeded from a real incident in which the memory existed and was
|
|
165
|
+
* read only after the failure. Adding a row is cheap; adding a row that cannot
|
|
166
|
+
* be shown to fire on a real payload is the failure mode this table exists to
|
|
167
|
+
* avoid, so every row carries a replay case in the test file.
|
|
168
|
+
*/
|
|
169
|
+
export const RECALL_RULES: readonly RecallRule[] = [
|
|
170
|
+
{
|
|
171
|
+
id: 'build-in-symlinked-worktree',
|
|
172
|
+
slug: 'worktree-build-poisons-install-bundle',
|
|
173
|
+
tools: COMMAND_TOOLS,
|
|
174
|
+
test: (call) => BUILD_COMMAND_RE.test(call.command),
|
|
175
|
+
requires: anyRootHasSymlinkedNodeModules,
|
|
176
|
+
why:
|
|
177
|
+
'node_modules here is a SYMLINK, so esbuild resolves every dependency ' +
|
|
178
|
+
'outside the repo root and bakes ../../../node_modules/ paths into the ' +
|
|
179
|
+
'TRACKED dist/install/install.mjs',
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
id: 'adr-edit',
|
|
183
|
+
slug: 'adding-an-adr-downstream-surface',
|
|
184
|
+
tools: EDIT_TOOLS,
|
|
185
|
+
test: (call) => call.paths.some((p) => ADR_PATH_RE.test(p)),
|
|
186
|
+
why:
|
|
187
|
+
'an ADR has TWO generated consumers — the INDEX and the evidence census; ' +
|
|
188
|
+
'census staleness reds the Rule-backstops CI job and no local preflight ' +
|
|
189
|
+
'gate catches it',
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
id: 'install-source-edit',
|
|
193
|
+
slug: 'adding-a-src-install-module-needs-two-builds',
|
|
194
|
+
tools: EDIT_TOOLS,
|
|
195
|
+
test: (call) => call.paths.some((p) => INSTALL_SOURCE_RE.test(p)),
|
|
196
|
+
why:
|
|
197
|
+
'this source is committed twice — build:cli AND build:install-bundle are ' +
|
|
198
|
+
'both required, and neither freshness gate is in task preflight',
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: 'hook-latency-bench',
|
|
202
|
+
slug: 'hook-latency-gate-has-runner-variance',
|
|
203
|
+
tools: COMMAND_TOOLS,
|
|
204
|
+
test: (call) => LATENCY_BENCH_RE.test(call.command),
|
|
205
|
+
why:
|
|
206
|
+
"reading main's own most recent run of the same job is step ONE, not the " +
|
|
207
|
+
'fallback — measuring locally first answered nothing and cost a CI cycle',
|
|
208
|
+
},
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
function isObject(v: unknown): v is JsonObject {
|
|
212
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function str(v: JsonValue | undefined): string {
|
|
216
|
+
return typeof v === 'string' ? v : '';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Keys across host spellings that carry the tool's target file path. */
|
|
220
|
+
const PATH_KEYS: readonly string[] = [
|
|
221
|
+
'file_path',
|
|
222
|
+
'path',
|
|
223
|
+
'target_file',
|
|
224
|
+
'filename',
|
|
225
|
+
'filePath',
|
|
226
|
+
'notebook_path',
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
/** Reduce a `pre_tool_use` payload to the fields a rule may read. */
|
|
230
|
+
export function extractCall(payload: JsonObject): ExtractedCall {
|
|
231
|
+
const tool = str(
|
|
232
|
+
(payload['tool_name'] ?? payload['toolName'] ?? payload['tool']) as JsonValue | undefined,
|
|
233
|
+
);
|
|
234
|
+
const tiRaw = payload['tool_input'] ?? payload['toolInput'] ?? payload['input'];
|
|
235
|
+
const ti: JsonObject = isObject(tiRaw) ? tiRaw : {};
|
|
236
|
+
|
|
237
|
+
const paths: string[] = [];
|
|
238
|
+
for (const key of PATH_KEYS) {
|
|
239
|
+
const v = ti[key];
|
|
240
|
+
if (typeof v === 'string' && v !== '') {
|
|
241
|
+
paths.push(v.replace(/\\/g, '/'));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// A payload carrying a `paths` array (batch edit shapes) is read too; the
|
|
245
|
+
// single-file keys above already cover MultiEdit, which names one file
|
|
246
|
+
// plus a list of edits.
|
|
247
|
+
const many = ti['paths'];
|
|
248
|
+
if (Array.isArray(many)) {
|
|
249
|
+
for (const v of many) {
|
|
250
|
+
if (typeof v === 'string' && v !== '') paths.push(v.replace(/\\/g, '/'));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const command = str((ti['command'] ?? payload['command']) as JsonValue | undefined);
|
|
255
|
+
const cwd = str((payload['cwd'] ?? payload['workspace_root']) as JsonValue | undefined);
|
|
256
|
+
return { tool, paths, command, cwd };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Candidate roots for an environment predicate, most specific first, deduped. */
|
|
260
|
+
export function ruleRoots(call: ExtractedCall, workspaceRoot: string): RuleContext {
|
|
261
|
+
const roots: string[] = [];
|
|
262
|
+
for (const r of [call.cwd, workspaceRoot]) {
|
|
263
|
+
if (r !== '' && !roots.includes(r)) roots.push(r);
|
|
264
|
+
}
|
|
265
|
+
return { roots };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* First matching rule, or null. Pure over the call except for a matched row's
|
|
270
|
+
* own `requires` predicate, which is deliberately evaluated last.
|
|
271
|
+
*/
|
|
272
|
+
export function matchRule(
|
|
273
|
+
call: ExtractedCall,
|
|
274
|
+
ctx: RuleContext,
|
|
275
|
+
rules: readonly RecallRule[] = RECALL_RULES,
|
|
276
|
+
): RecallRule | null {
|
|
277
|
+
for (const rule of rules) {
|
|
278
|
+
if (!rule.tools.has(call.tool)) continue;
|
|
279
|
+
if (!rule.test(call)) continue;
|
|
280
|
+
if (rule.requires !== undefined && !rule.requires(ctx)) continue;
|
|
281
|
+
return rule;
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface StoreResolution {
|
|
287
|
+
/** Absolute path to the memory directory, or null when none was found. */
|
|
288
|
+
dir: string | null;
|
|
289
|
+
/** Every candidate inspected, in order — the stderr diagnostic's content. */
|
|
290
|
+
tried: readonly string[];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Resolve the host's memory directory for `startDir`.
|
|
295
|
+
*
|
|
296
|
+
* The store lives at `<projectsRoot>/<slugged cwd>/memory`, and in a git
|
|
297
|
+
* worktree the worktree's OWN slug has a project directory with no `memory/`
|
|
298
|
+
* in it — the store belongs to the parent checkout. So this walks ancestors
|
|
299
|
+
* rather than assuming one slug, which also covers a subdirectory cwd.
|
|
300
|
+
*
|
|
301
|
+
* `AGENT_CONFIG_MEMORY_DIR` overrides everything, for tests and for a user
|
|
302
|
+
* whose store is elsewhere.
|
|
303
|
+
*/
|
|
304
|
+
export function resolveMemoryDir(
|
|
305
|
+
startDir: string,
|
|
306
|
+
projectsRoot: string = DEFAULT_PROJECTS_ROOT,
|
|
307
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
308
|
+
): StoreResolution {
|
|
309
|
+
const override = env['AGENT_CONFIG_MEMORY_DIR'];
|
|
310
|
+
if (typeof override === 'string' && override !== '') {
|
|
311
|
+
return { dir: isDirectory(override) ? override : null, tried: [override] };
|
|
312
|
+
}
|
|
313
|
+
const tried: string[] = [];
|
|
314
|
+
let cur = path.resolve(startDir);
|
|
315
|
+
for (let i = 0; i < MAX_ANCESTOR_WALK; i += 1) {
|
|
316
|
+
const candidate = path.join(projectsRoot, projectStoreSlug(cur), 'memory');
|
|
317
|
+
tried.push(candidate);
|
|
318
|
+
if (isDirectory(candidate)) {
|
|
319
|
+
return { dir: candidate, tried };
|
|
320
|
+
}
|
|
321
|
+
const parent = path.dirname(cur);
|
|
322
|
+
if (parent === cur) break;
|
|
323
|
+
cur = parent;
|
|
324
|
+
}
|
|
325
|
+
return { dir: null, tried };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function isDirectory(p: string): boolean {
|
|
329
|
+
try {
|
|
330
|
+
return fs.statSync(p).isDirectory();
|
|
331
|
+
} catch {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The memory's own `description:` frontmatter line, or null. Reads at most the
|
|
338
|
+
* file's head — the body is never loaded, and never injected.
|
|
339
|
+
*/
|
|
340
|
+
export function readMemoryDescription(memoryDir: string, slug: string): string | null {
|
|
341
|
+
let head: string;
|
|
342
|
+
try {
|
|
343
|
+
const fd = fs.openSync(path.join(memoryDir, `${slug}.md`), 'r');
|
|
344
|
+
try {
|
|
345
|
+
const buf = Buffer.alloc(4096);
|
|
346
|
+
const n = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
347
|
+
head = buf.subarray(0, n).toString('utf8');
|
|
348
|
+
} finally {
|
|
349
|
+
fs.closeSync(fd);
|
|
350
|
+
}
|
|
351
|
+
} catch {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
const m = /^description:\s*(.*)$/m.exec(head);
|
|
355
|
+
if (m === null) return null;
|
|
356
|
+
let value = (m[1] as string).trim();
|
|
357
|
+
if (
|
|
358
|
+
(value.startsWith('"') && value.endsWith('"') && value.length > 1) ||
|
|
359
|
+
(value.startsWith("'") && value.endsWith("'") && value.length > 1)
|
|
360
|
+
) {
|
|
361
|
+
value = value.slice(1, -1);
|
|
362
|
+
}
|
|
363
|
+
return value === '' ? null : value;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Truncate to at most `max` BYTES of UTF-8, on a character boundary. */
|
|
367
|
+
export function truncateBytes(text: string, max: number): string {
|
|
368
|
+
if (Buffer.byteLength(text, 'utf8') <= max) return text;
|
|
369
|
+
const ell = '...';
|
|
370
|
+
const room = Math.max(0, max - Buffer.byteLength(ell, 'utf8'));
|
|
371
|
+
let out = '';
|
|
372
|
+
let used = 0;
|
|
373
|
+
for (const ch of text) {
|
|
374
|
+
const w = Buffer.byteLength(ch, 'utf8');
|
|
375
|
+
if (used + w > room) break;
|
|
376
|
+
out += ch;
|
|
377
|
+
used += w;
|
|
378
|
+
}
|
|
379
|
+
return out + ell;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export interface RecallEmission {
|
|
383
|
+
reason: string;
|
|
384
|
+
additional_context: string;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Build the emission, guaranteed under `CAP_BYTES` for `reason` +
|
|
389
|
+
* `additional_context` combined. The description is the elastic part: the
|
|
390
|
+
* pointer and the why-now are what make the line actionable, so they are
|
|
391
|
+
* reserved first and the description absorbs the truncation.
|
|
392
|
+
*/
|
|
393
|
+
export function buildEmission(
|
|
394
|
+
rule: RecallRule,
|
|
395
|
+
store: StoreResolution,
|
|
396
|
+
description: string | null,
|
|
397
|
+
cap: number = CAP_BYTES,
|
|
398
|
+
): RecallEmission {
|
|
399
|
+
const reason = `memory-recall: ${rule.slug}`;
|
|
400
|
+
const pointer =
|
|
401
|
+
store.dir === null
|
|
402
|
+
? `memory store not resolvable here — the slug is still the key: grep the store for ${rule.slug}`
|
|
403
|
+
: `read: ${path.join(store.dir, `${rule.slug}.md`)}`;
|
|
404
|
+
const head =
|
|
405
|
+
`memory-recall — you have a memory for this, written before today: ${rule.slug}. ` +
|
|
406
|
+
`Why now: ${rule.why}. ${pointer}`;
|
|
407
|
+
const budget = cap - Buffer.byteLength(reason, 'utf8');
|
|
408
|
+
if (description === null) {
|
|
409
|
+
return { reason, additional_context: truncateBytes(head, budget) };
|
|
410
|
+
}
|
|
411
|
+
const room = budget - Buffer.byteLength(`${head} It says: ""`, 'utf8');
|
|
412
|
+
if (room <= 16) {
|
|
413
|
+
return { reason, additional_context: truncateBytes(head, budget) };
|
|
414
|
+
}
|
|
415
|
+
return { reason, additional_context: `${head} It says: "${truncateBytes(description, room)}"` };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Hashed session key — mirrors `reread_guard_hook.deriveSessionKey`. */
|
|
419
|
+
export function deriveSessionKey(envelope: JsonObject, payload: JsonObject): string {
|
|
420
|
+
const raw =
|
|
421
|
+
str(envelope['session_id'] as JsonValue | undefined) ||
|
|
422
|
+
str((payload['session_id'] ?? payload['sessionId']) as JsonValue | undefined);
|
|
423
|
+
return crypto
|
|
424
|
+
.createHash('sha256')
|
|
425
|
+
.update(raw || 'unknown-session')
|
|
426
|
+
.digest('hex');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export function latchFile(workspaceRoot: string, sessionKey: string): string {
|
|
430
|
+
return path.join(
|
|
431
|
+
workspaceRoot,
|
|
432
|
+
'agents',
|
|
433
|
+
'runtime',
|
|
434
|
+
'state',
|
|
435
|
+
'memory-recall',
|
|
436
|
+
`${sessionKey}.json`,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Per-session, per-slug latch. A memory delivered once has been delivered; a
|
|
442
|
+
* second copy on the next build command is the noise that gets a nudge tuned
|
|
443
|
+
* out. Slug keys and a timestamp only — the record type has no field able to
|
|
444
|
+
* hold a path, a command, or any free text.
|
|
445
|
+
*/
|
|
446
|
+
export function readLatch(workspaceRoot: string, sessionKey: string): Record<string, number> {
|
|
447
|
+
try {
|
|
448
|
+
const parsed = JSON.parse(fs.readFileSync(latchFile(workspaceRoot, sessionKey), 'utf8'));
|
|
449
|
+
return isObject(parsed) ? (parsed as unknown as Record<string, number>) : {};
|
|
450
|
+
} catch {
|
|
451
|
+
return {};
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function writeLatch(
|
|
456
|
+
workspaceRoot: string,
|
|
457
|
+
sessionKey: string,
|
|
458
|
+
latch: Record<string, number>,
|
|
459
|
+
): void {
|
|
460
|
+
try {
|
|
461
|
+
atomic_write_json(latchFile(workspaceRoot, sessionKey), latch as unknown as JsonObject);
|
|
462
|
+
} catch {
|
|
463
|
+
// a state-write failure must never affect the tool call
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export function main(): number {
|
|
468
|
+
try {
|
|
469
|
+
const [envelope, payload] = unwrap(readHookStdin(), 'claude');
|
|
470
|
+
|
|
471
|
+
const event = str(envelope['event'] as JsonValue | undefined);
|
|
472
|
+
if (event !== '' && event !== 'pre_tool_use') return EXIT_ALLOW;
|
|
473
|
+
|
|
474
|
+
const call = extractCall(payload);
|
|
475
|
+
if (call.tool === '') return EXIT_ALLOW;
|
|
476
|
+
|
|
477
|
+
const workspaceRoot =
|
|
478
|
+
str(envelope['workspace_root'] as JsonValue | undefined).trim() || process.cwd();
|
|
479
|
+
|
|
480
|
+
const ctx = ruleRoots(call, workspaceRoot);
|
|
481
|
+
const rule = matchRule(call, ctx);
|
|
482
|
+
if (rule === null) return EXIT_ALLOW;
|
|
483
|
+
|
|
484
|
+
const sessionKey = deriveSessionKey(envelope, payload);
|
|
485
|
+
const latch = readLatch(workspaceRoot, sessionKey);
|
|
486
|
+
if (latch[rule.slug] !== undefined) return EXIT_ALLOW;
|
|
487
|
+
|
|
488
|
+
const store = resolveMemoryDir(workspaceRoot);
|
|
489
|
+
if (store.dir === null) {
|
|
490
|
+
process.stderr.write(
|
|
491
|
+
`memory-recall: rule ${rule.id} matched but no memory store was ` +
|
|
492
|
+
`resolvable; tried ${store.tried.join(', ')}\n`,
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
const description = store.dir === null ? null : readMemoryDescription(store.dir, rule.slug);
|
|
496
|
+
if (store.dir !== null && description === null) {
|
|
497
|
+
process.stderr.write(
|
|
498
|
+
`memory-recall: rule ${rule.id} matched but ${rule.slug}.md was ` +
|
|
499
|
+
`unreadable under ${store.dir}\n`,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
latch[rule.slug] = Date.now();
|
|
504
|
+
writeLatch(workspaceRoot, sessionKey, latch);
|
|
505
|
+
|
|
506
|
+
process.stdout.write(
|
|
507
|
+
`${JSON.stringify({ decision: 'warn', ...buildEmission(rule, store, description) })}\n`,
|
|
508
|
+
);
|
|
509
|
+
return EXIT_WARN;
|
|
510
|
+
} catch {
|
|
511
|
+
return EXIT_ALLOW; // malformed payload / any error — never touch the tool call
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Bundle-safety: never auto-run when inlined into an esbuild bundle, where
|
|
516
|
+
// every module shares the bundle's `import.meta.url` (see cmd_migrate.ts).
|
|
517
|
+
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
518
|
+
function _isCliEntry(): boolean {
|
|
519
|
+
if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
if (process.argv[1] === undefined) {
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
526
|
+
if (import.meta.url === argvUrl) {
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
531
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
532
|
+
return here === argv;
|
|
533
|
+
} catch {
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (_isCliEntry()) {
|
|
539
|
+
process.exit(main());
|
|
540
|
+
}
|