@event4u/agent-config 14.7.0 → 14.8.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 (138) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +245 -388
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/evals/git-pr-merge.json +13 -0
  5. package/dist/agent-src/commands/evals/roadmap.json +17 -1
  6. package/dist/agent-src/commands/pr/create.md +14 -2
  7. package/dist/agent-src/commands/pr/merge.md +379 -0
  8. package/dist/agent-src/commands/roadmap/process-full.md +157 -3
  9. package/dist/agent-src/commands/roadmap.md +1 -1
  10. package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +46 -36
  11. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +37 -2
  12. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +64 -2
  13. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +154 -2
  14. package/dist/agent-src/rules/artifact-drafting-protocol.md +5 -1
  15. package/dist/agent-src/rules/decision-revisit-gate.md +65 -33
  16. package/dist/agent-src/rules/recurring-criticism.md +1 -1
  17. package/dist/agent-src/rules/roadmap-progress-sync.md +19 -0
  18. package/dist/agent-src/rules/source-confidentiality.md +1 -1
  19. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
  20. package/dist/agent-src/scripts/dashboard_mode.ts +212 -0
  21. package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -48
  22. package/dist/agent-src/skills/adr-create/SKILL.md +124 -5
  23. package/dist/agent-src/skills/decision-record/SKILL.md +61 -3
  24. package/dist/agent-src/skills/decision-review/SKILL.md +65 -0
  25. package/dist/agent-src/skills/merge-conflicts/SKILL.md +26 -0
  26. package/dist/agent-src/skills/security/SKILL.md +8 -0
  27. package/dist/agent-src/skills/security-audit/SKILL.md +12 -1
  28. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  29. package/dist/agent-src/templates/roadmaps.md +20 -2
  30. package/dist/cli/registry.js +1 -0
  31. package/dist/cli/registry.js.map +1 -1
  32. package/dist/cli-delegate/{chunk-JZ4NR2TW.js → chunk-7PRE6ZV2.js} +1 -1
  33. package/dist/cli-delegate/{chunk-7F3VYSM2.js → chunk-EEJUMCDZ.js} +14 -14
  34. package/dist/cli-delegate/{chunk-74E3TKWV.js → chunk-H4VIVD4C.js} +5 -5
  35. package/dist/cli-delegate/chunk-I3EIN2YG.js +36 -0
  36. package/dist/cli-delegate/{chunk-4VR4ORQ6.js → chunk-LPPWO5CE.js} +4 -3
  37. package/dist/cli-delegate/{chunk-UEZ7D35A.js → chunk-N7CDDGI3.js} +4 -23
  38. package/dist/cli-delegate/cmd_adr_effective.js +1162 -0
  39. package/dist/cli-delegate/cmd_conformance.js +14 -14
  40. package/dist/cli-delegate/cmd_converge.js +3 -3
  41. package/dist/cli-delegate/cmd_doctor.js +7 -7
  42. package/dist/cli-delegate/cmd_export.js +6 -6
  43. package/dist/cli-delegate/cmd_handoff.js +7 -6
  44. package/dist/cli-delegate/cmd_memory_get.js +2 -1
  45. package/dist/cli-delegate/cmd_migrate.js +2 -2
  46. package/dist/cli-delegate/cmd_preflight.js +5 -5
  47. package/dist/cli-delegate/cmd_prune.js +6 -6
  48. package/dist/cli-delegate/cmd_route_audit.js +6 -5
  49. package/dist/cli-delegate/cmd_route_explain.js +3 -3
  50. package/dist/cli-delegate/cmd_session_recycle.js +3 -3
  51. package/dist/cli-delegate/cmd_settings_get.js +3 -3
  52. package/dist/cli-delegate/cmd_settings_migrate.js +6 -6
  53. package/dist/cli-delegate/cmd_sync.js +6 -6
  54. package/dist/cli-delegate/cmd_uninstall.js +6 -6
  55. package/dist/cli-delegate/cmd_update.js +3 -3
  56. package/dist/cli-delegate/cmd_upgrade.js +3 -3
  57. package/dist/cli-delegate/cmd_validate.js +6 -6
  58. package/dist/cli-delegate/cmd_versions.js +3 -3
  59. package/dist/discovery/deprecation-report.md +1 -1
  60. package/dist/discovery/discovery-manifest.json +53 -22
  61. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  62. package/dist/discovery/discovery-manifest.summary.md +3 -3
  63. package/dist/discovery/dormancy-report.md +1 -1
  64. package/dist/discovery/orphan-report.md +1 -1
  65. package/dist/discovery/packs.json +7 -6
  66. package/dist/discovery/trust-report.md +2 -2
  67. package/dist/discovery/workspaces.json +6 -5
  68. package/dist/hooks/dispatch.js +806 -681
  69. package/dist/install/globalRuleLayers.js +169 -0
  70. package/dist/install/globalRuleLayers.js.map +1 -0
  71. package/dist/install/partitionEligibility.js +156 -0
  72. package/dist/install/partitionEligibility.js.map +1 -1
  73. package/dist/install/ruleLayerPartition.js +157 -0
  74. package/dist/install/ruleLayerPartition.js.map +1 -0
  75. package/dist/mcp/registry-manifest.json +3 -3
  76. package/dist/mcp/server.json +2 -2
  77. package/dist/mcp/server.mjs +654 -578
  78. package/dist/scripts/_lib/prune_empty_dirs.js +85 -0
  79. package/dist/scripts/_lib/prune_empty_dirs.js.map +1 -0
  80. package/dist/scripts/condense.js +52 -89
  81. package/dist/scripts/condense.js.map +1 -1
  82. package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +45 -1
  83. package/docs/guidelines/agent-infra/false-green.md +47 -1
  84. package/package.json +1 -1
  85. package/src/agent-src/scripts/archive_completed_roadmaps.ts +7 -4
  86. package/src/agent-src/scripts/dashboard_mode.ts +212 -0
  87. package/src/agent-src/scripts/update_roadmap_progress.ts +47 -48
  88. package/src/config/ci-local-parity.yml +21 -0
  89. package/src/config/estate-count-budget.json +275 -2
  90. package/src/config/evaluator-budgets.json +3 -3
  91. package/src/config/gate-coverage.yml +220 -12
  92. package/src/config/gate-violation-baselines.json +73 -63
  93. package/src/config/gitignore-block.txt +15 -2
  94. package/src/config/hook-latency-budget.json +5 -0
  95. package/src/config/rule-stub-ceilings.json +17 -3
  96. package/src/install/globalRuleLayers.ts +197 -0
  97. package/src/install/partitionEligibility.ts +177 -0
  98. package/src/install/ruleLayerPartition.ts +212 -0
  99. package/src/scripts/_cli/cmd_adr_effective.ts +484 -0
  100. package/src/scripts/_dispatch.bash +11 -0
  101. package/src/scripts/_lib/adr_frontmatter.test.ts +365 -0
  102. package/src/scripts/_lib/adr_frontmatter.ts +459 -0
  103. package/src/scripts/_lib/envelope_grounding.ts +11 -1
  104. package/src/scripts/_lib/layer_overlap_notice.ts +11 -2
  105. package/src/scripts/_lib/prune_empty_dirs.ts +80 -0
  106. package/src/scripts/adr/evidence_census.ts +899 -0
  107. package/src/scripts/adr/regenerate_index.ts +12 -32
  108. package/src/scripts/adr_cite_check.ts +540 -39
  109. package/src/scripts/audit_adr_coverage.ts +103 -17
  110. package/src/scripts/bench_hook_latency.ts +204 -0
  111. package/src/scripts/build_archive_index.ts +34 -0
  112. package/src/scripts/check_adr_frontmatter.ts +574 -39
  113. package/src/scripts/check_hook_bundle_content.ts +219 -0
  114. package/src/scripts/check_new_adr_evidence.ts +529 -0
  115. package/src/scripts/check_references.ts +18 -0
  116. package/src/scripts/check_rule_layer_partition.ts +531 -0
  117. package/src/scripts/check_single_delivery.ts +137 -4
  118. package/src/scripts/check_standing_rule_delivery.ts +8 -2
  119. package/src/scripts/condense.ts +61 -91
  120. package/src/scripts/external_sources_denylist.json +4 -1
  121. package/src/scripts/hooks/block_unauthorized_git.ts +22 -2
  122. package/src/scripts/hooks/dispatch_hook.ts +154 -187
  123. package/src/scripts/hooks/dispatch_issues.ts +38 -19
  124. package/src/scripts/hooks/fallback_yaml.ts +66 -0
  125. package/src/scripts/hooks/host_semantics.ts +21 -0
  126. package/src/scripts/hooks/injection_budget.ts +13 -3
  127. package/src/scripts/hooks/py_json_dumps.ts +103 -0
  128. package/src/scripts/hooks/state_io.ts +88 -1
  129. package/src/scripts/hooks/stdin_failure_policy.ts +96 -0
  130. package/src/scripts/injection_scan_hook.ts +137 -5
  131. package/src/scripts/lint_hook_manifest.ts +49 -0
  132. package/src/scripts/lint_provenance_vocabulary.ts +472 -4
  133. package/src/scripts/routing_doctor.ts +5 -1
  134. package/src/scripts/sync_pr_branch.ts +163 -20
  135. package/dist/cli-delegate/{chunk-PPQO4NGR.js → chunk-6RJA2F5A.js} +3 -3
  136. package/dist/cli-delegate/{chunk-6EOCNQAM.js → chunk-E4XIZDIB.js} +0 -0
  137. package/dist/cli-delegate/{chunk-FBKL6JWT.js → chunk-MMSGVZKV.js} +3 -3
  138. package/dist/cli-delegate/{chunk-GJ5BVHXM.js → chunk-PMEKGDED.js} +3 -3
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `json.dumps(obj, indent=n)` byte-for-byte, extracted from `dispatch_hook.ts`.
3
+ *
4
+ * Pure and self-contained: it recurses into itself and calls nothing else, which
5
+ * is what made it safe to move out of a dispatcher that runs on every tool call.
6
+ * Extracted because dispatch_hook.ts sits at the 1500-line source ceiling.
7
+ *
8
+ * For anyone tempted to share it: mcp_telemetry_store, mcp_telemetry_query and
9
+ * mcp_telemetry_health each define their OWN `_py_json_dumps`. They were left
10
+ * alone — unifying four copies is a change with its own risk, not a merge-time
11
+ * side effect.
12
+ */
13
+ // Python json.dumps(record, indent=2) byte-for-byte (ensure_ascii=True).
14
+ export function _py_json_dumps(value: unknown, indent: number): string {
15
+ const pad = " ".repeat(indent);
16
+ const escapeString = (s: string): string => {
17
+ let out = '"';
18
+ for (const ch of s) {
19
+ const code = ch.codePointAt(0) as number;
20
+ switch (ch) {
21
+ case '"':
22
+ out += '\\"';
23
+ break;
24
+ case "\\":
25
+ out += "\\\\";
26
+ break;
27
+ case "\n":
28
+ out += "\\n";
29
+ break;
30
+ case "\r":
31
+ out += "\\r";
32
+ break;
33
+ case "\t":
34
+ out += "\\t";
35
+ break;
36
+ case "\b":
37
+ out += "\\b";
38
+ break;
39
+ case "\f":
40
+ out += "\\f";
41
+ break;
42
+ default:
43
+ if (code < 0x20 || code > 0x7e) {
44
+ if (code > 0xffff) {
45
+ const c = code - 0x10000;
46
+ const hi = 0xd800 + (c >> 10);
47
+ const lo = 0xdc00 + (c & 0x3ff);
48
+ out +=
49
+ "\\u" +
50
+ hi.toString(16).padStart(4, "0") +
51
+ "\\u" +
52
+ lo.toString(16).padStart(4, "0");
53
+ } else {
54
+ out += "\\u" + code.toString(16).padStart(4, "0");
55
+ }
56
+ } else {
57
+ out += ch;
58
+ }
59
+ }
60
+ }
61
+ return out + '"';
62
+ };
63
+ const render = (v: unknown, depth: number): string => {
64
+ if (v === null || v === undefined) return "null";
65
+ if (typeof v === "boolean") return v ? "true" : "false";
66
+ if (typeof v === "number") {
67
+ if (!Number.isFinite(v)) {
68
+ if (Number.isNaN(v)) return "NaN";
69
+ return v > 0 ? "Infinity" : "-Infinity";
70
+ }
71
+ return String(v);
72
+ }
73
+ if (typeof v === "string") return escapeString(v);
74
+ const curPad = pad.repeat(depth + 1);
75
+ const closePad = pad.repeat(depth);
76
+ if (Array.isArray(v)) {
77
+ if (v.length === 0) return "[]";
78
+ return (
79
+ "[\n" +
80
+ v.map((item) => curPad + render(item, depth + 1)).join(",\n") +
81
+ "\n" +
82
+ closePad +
83
+ "]"
84
+ );
85
+ }
86
+ if (typeof v === "object") {
87
+ const obj = v as Record<string, unknown>;
88
+ const keys = Object.keys(obj);
89
+ if (keys.length === 0) return "{}";
90
+ return (
91
+ "{\n" +
92
+ keys
93
+ .map((k) => curPad + escapeString(k) + ": " + render(obj[k], depth + 1))
94
+ .join(",\n") +
95
+ "\n" +
96
+ closePad +
97
+ "}"
98
+ );
99
+ }
100
+ throw new TypeError(`Object of type ${typeof v} is not JSON serializable`);
101
+ };
102
+ return render(value, 0);
103
+ }
@@ -540,7 +540,9 @@ function _atomic_write_text(target: string, text: string): void {
540
540
  * Layout:
541
541
  * <state_root>/.dispatcher/<session_id>/
542
542
  * <concern>.json — one per concern that ran
543
- * summary.json — rollup written by the dispatcher
543
+ * summary.json — capped list of per-invocation rollups
544
+ * (schema 2; see hook-architecture-v1 for why
545
+ * a single rollup object lost one per overlap)
544
546
  *
545
547
  * Per Council Round 2 (2026-05-04): exit-code reduction collapses
546
548
  * multiple concern signals into a single platform-native code; the
@@ -1003,6 +1005,91 @@ export function owns_session_state(state: unknown, session_id: string): boolean
1003
1005
  * sites at the time of the change and both were converted; a new caller must
1004
1006
  * compare against a member of the union, never coerce to a boolean.
1005
1007
  */
1008
+ /**
1009
+ * Read a TEXT file, transform it, and write it back — the WHOLE sequence under
1010
+ * one lock.
1011
+ *
1012
+ * The text sibling of {@link update_json_under_lock}, added for P3 of
1013
+ * `b-stop-async-split-prerequisites` (council 2026-08-20, option (a),
1014
+ * "P3 before anything else"). The concrete case is
1015
+ * `agents/runtime/state/dispatch-issues.jsonl`, which had NO lock and NO
1016
+ * tmp+rename: `log_dispatch_issue` read the whole capped log, appended a line,
1017
+ * and `writeFileSync`'d the target directly.
1018
+ *
1019
+ * That is corruption-capable rather than merely lossy, and the distinction is
1020
+ * why this needed its own primitive instead of a comment. `writeFileSync`
1021
+ * truncates and then writes, so a concurrent reader can observe a half-written
1022
+ * log and a concurrent writer can interleave into one — and the file is written
1023
+ * precisely when something has ALREADY gone wrong, so the evidence destroyed is
1024
+ * the evidence of the failure being reported. Two concurrent dispatchers in one
1025
+ * workspace is a supported configuration (two platforms installed side by side),
1026
+ * and a host that runs tool calls in parallel produces it without any second
1027
+ * platform at all.
1028
+ *
1029
+ * `transform` receives the file's content as read INSIDE the lock — `null` when
1030
+ * the file does not exist, so a caller can tell "absent" from "empty" — and
1031
+ * returns what to write. Returning `null` writes nothing and still counts as
1032
+ * success.
1033
+ *
1034
+ * Returns `written` / `skipped` / `failed`, the same three-state contract
1035
+ * `update_json_under_lock` carries since `bcbb0380b` — a boolean could not tell
1036
+ * a deliberate decline from a failure, and every member of the union is a truthy
1037
+ * string, so `if (!result)` is a silent no-op rather than a compile error. Under
1038
+ * `AGENT_CONFIG_REPLAY=1` this reports `failed` without touching the tree.
1039
+ */
1040
+ export function update_text_under_lock(
1041
+ target: string,
1042
+ transform: (loaded: string | null) => string | null,
1043
+ options: { blocking?: boolean } = {},
1044
+ ): LockedUpdateResult {
1045
+ if (is_replay_mode()) return "failed";
1046
+ const targetPath = path.resolve(target);
1047
+ const state_dir = path.dirname(targetPath);
1048
+ try {
1049
+ fs.mkdirSync(state_dir, { recursive: true });
1050
+ } catch {
1051
+ return "failed";
1052
+ }
1053
+ // FILE-keyed, matching `update_json_under_lock` rather than
1054
+ // `_atomic_write_text`. Converged onto the sibling deliberately after
1055
+ // `bcbb0380b` landed the measured argument: a read-modify-write needs mutual
1056
+ // exclusion against writers of THIS file and nothing else, and the shared
1057
+ // directory lock was measured paying the full cost of exclusion for writes
1058
+ // that require none (8 workers: slowest 138-267 ms shared vs 83-95 ms
1059
+ // unshared). This function was written against the directory-keyed primitive
1060
+ // one commit earlier; leaving it there would have made
1061
+ // `dispatch-issues.jsonl` serialise against every unrelated
1062
+ // `atomic_write_text` in the state dir.
1063
+ const lock_path = _target_lock_path(targetPath);
1064
+ let fd: number | null;
1065
+ try {
1066
+ fd = _acquire_lock(lock_path, { blocking: options.blocking ?? true });
1067
+ } catch {
1068
+ return "failed";
1069
+ }
1070
+ // FAIL CLOSED, for the same reason the JSON sibling does: an append without
1071
+ // exclusion is the truncating write this function exists to prevent.
1072
+ if (fd === null) return "failed";
1073
+ try {
1074
+ let loaded: string | null = null;
1075
+ try {
1076
+ loaded = fs.readFileSync(targetPath, "utf-8");
1077
+ } catch {
1078
+ // Absent or unreadable. `null` lets the transform distinguish the two
1079
+ // from an empty file, which matters for an append-only log: an
1080
+ // unreadable existing log must not be silently replaced by one line.
1081
+ }
1082
+ const next = transform(loaded);
1083
+ if (next === null) return "skipped"; // deliberate no-write, not a failure
1084
+ _publish_text_locked(targetPath, next);
1085
+ return "written";
1086
+ } catch {
1087
+ return "failed";
1088
+ } finally {
1089
+ _release_lock(fd, lock_path);
1090
+ }
1091
+ }
1092
+
1006
1093
  export function update_json_under_lock<T>(
1007
1094
  target: string,
1008
1095
  mutate: (loaded: Partial<T>) => T | null,
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The stdin-read-failure policy, extracted from `dispatch_hook.ts`.
3
+ *
4
+ * These functions are pure with respect to dispatch_hook's own state: they take
5
+ * what they need as parameters and return a verdict. They do call two imported
6
+ * helpers (`readFd0ToEnd`, `isBlockCapable`), which move with them as imports. The module-level flag they feed, `_stdin_read_failed`, stays in
7
+ * dispatch_hook.ts, written at exactly one call site and read at three. That
8
+ * asymmetry is what makes moving the functions safe and the state untouched.
9
+ *
10
+ * Extracted because dispatch_hook.ts sits at the 1500-line source ceiling.
11
+ */
12
+ import type { JsonObject } from './dispatch_hook.js';
13
+ import { readFd0ToEnd } from './hook_stdin.js';
14
+ import { isBlockCapable } from './host_semantics.js';
15
+
16
+ /**
17
+ * The seam where a FAILED read becomes an empty string — isolated so the three
18
+ * failure classes the policy names can be driven with real errno values.
19
+ *
20
+ * Returns the failure message, or `null` when the read succeeded. The read
21
+ * itself is injectable for exactly one reason: `EAGAIN` exhaustion, `EIO` and
22
+ * `EBADF` cannot be staged against a live fd 0 portably, and a policy whose
23
+ * trigger is untestable is a policy nobody can show works. What the injection
24
+ * does NOT buy is proof that the production path reaches here — that is the
25
+ * wiring, pinned separately by driving `main()`.
26
+ */
27
+ export function stdinReadFailure(read: () => string = readFd0ToEnd): {
28
+ text: string;
29
+ failure: string | null;
30
+ } {
31
+ try {
32
+ // Reads to EOF and retries on EAGAIN rather than treating it as empty
33
+ // input; shared with the concern-side reader so both paths cannot drift.
34
+ return { text: read(), failure: null };
35
+ } catch (exc) {
36
+ return { text: "", failure: exc instanceof Error ? exc.message : String(exc) };
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Is this concern one whose verdict a failed read silently converted into an
42
+ * allow?
43
+ *
44
+ * Both halves are required and neither is redundant. `fail_closed: true` is the
45
+ * manifest's declaration that this concern's own crash must not become a pass;
46
+ * `severity: blocking` is the declaration that it can refuse at all. An
47
+ * advisory concern that happens to be `fail_closed` has no verdict to lose, and
48
+ * a blocking concern that is not `fail_closed` has already declared the opposite
49
+ * intent.
50
+ */
51
+ export function _is_fail_closed_blocking(concern: JsonObject): boolean {
52
+ const severity = String(concern["severity"] ?? "").trim().toLowerCase();
53
+ return concern["fail_closed"] === true && severity === "blocking";
54
+ }
55
+
56
+ /**
57
+ * `b-stdin-read-failure-policy`, option (c) — deny a failed read ONLY where a
58
+ * deny is honoured AND something was actually silenced.
59
+ *
60
+ * Decided by council 2026-08-20, 2/2 quorum, both seats on option (c):
61
+ * "on a read failure, deny only when the slot is block-capable and at least one
62
+ * selected concern is both blocking and `fail_closed: true`".
63
+ *
64
+ * The two rejected options are worth keeping visible, because each is the
65
+ * obvious move. Denying on EVERY slot (option (a)) refuses nothing on
66
+ * `post_tool_use` — the tool already ran — and on `stop` it breaks a turn end
67
+ * to protect a guard that was never there; it buys availability risk for no
68
+ * enforcement. Keeping the allow (option (b), the status quo) leaves a
69
+ * documented allow-on-failure on a security path: F-1 measured a
70
+ * `git commit --no-verify` DENIED at small payload size and ALLOWED at 300 KB,
71
+ * and the loud stderr line that now ships makes that visible without making it
72
+ * stop.
73
+ *
74
+ * The cost is stated rather than hidden: a transient I/O error on
75
+ * `pre_tool_use` now refuses a tool call the user must retry. The retry budget
76
+ * already survived ~10 s of `EAGAIN` before this point, so the class of error
77
+ * that reaches here is not "the writer was slow".
78
+ *
79
+ * Returns `null` when the dispatch proceeds normally.
80
+ */
81
+ export function denyOnStdinFailure(
82
+ platform: string,
83
+ event: string,
84
+ concerns: readonly JsonObject[],
85
+ failure: string,
86
+ ): { reason: string } | null {
87
+ if (!isBlockCapable(platform, event)) return null;
88
+ const silenced = concerns.filter(_is_fail_closed_blocking).map((c) => String(c["name"]));
89
+ if (silenced.length === 0) return null;
90
+ return {
91
+ reason:
92
+ `agent-config: refusing this action — the hook payload could not be read ` +
93
+ `(${failure}), so ${silenced.join(", ")} evaluated nothing. ` +
94
+ `A fail-closed guard on a block-capable slot must not pass by default. Retry.`,
95
+ };
96
+ }
@@ -141,10 +141,114 @@ function _enabled(root: string): boolean {
141
141
  return false;
142
142
  }
143
143
 
144
- /** Best-effort extraction of the tool-output text from the envelope. */
144
+ /**
145
+ * ── The output-envelope contract ─────────────────────────────────────────────
146
+ *
147
+ * `b-injection-scan-unwrap-security`, option (a), council 2026-08-20 (2/2
148
+ * quorum): "specify the envelope contract and its fixtures BEFORE narrowing the
149
+ * scanner input". This block is that contract; the fixtures are in
150
+ * `tests/hooks/injection_scan_output_contract.test.ts`, and they are the
151
+ * deliverable — the code change below is one descent and one deletion.
152
+ *
153
+ * WHAT THIS CONCERN READS, exhaustively:
154
+ *
155
+ * 1. **The tool output, and only the tool output.** `injection-scan` is bound
156
+ * to `post_tool_use` on every platform that carries it, so a payload
157
+ * reaching here is expected to hold a tool result.
158
+ * 2. **Where it lives.** Under a dispatcher envelope the host-shaped fields are
159
+ * nested under `payload` (`dispatch_hook._build_envelope`); under a bare-host
160
+ * or direct invocation they sit at the root. Both are supported.
161
+ * 3. **Which keys.** {@link OUTPUT_KEYS}, in order. The first four mirror
162
+ * `payload_stub.BODY_KEYS.result` — the set the dispatcher stubs, and
163
+ * therefore the set it knows how to serve whole. `output` and `result` are
164
+ * the two generic spellings this concern has accepted since it shipped and
165
+ * are kept: dropping them would narrow coverage on the same change that
166
+ * narrows the fallback, and only one of those is decided here.
167
+ * 4. **How a value is read.** A string is scanned verbatim. An object or array
168
+ * is scanned as its compact JSON serialisation — unchanged, deliberately,
169
+ * including the consequence that a hidden codepoint inside a nested string
170
+ * becomes the literal text `\u200b` and does NOT trip `_HIDDEN`. That is a
171
+ * detection property of the serialiser, not of this contract, and changing
172
+ * it is a separate question.
173
+ * 5. **Missing output → nothing to scan → allow.** THIS IS THE NARROWING. The
174
+ * previous code fell through to serialising the WHOLE envelope, so what it
175
+ * actually scanned in production was everything: the tool input echo, the
176
+ * cwd, the session id, the settings block. It worked by accident and nothing
177
+ * tested the accident.
178
+ * 6. **Malformed envelope → allow.** Non-JSON, a list, a scalar: unchanged.
179
+ * 7. **A stubbed result body → allow, LOUDLY.** The manifest declares
180
+ * `needs_payload_bodies: [input, result]`, which is what keeps the result
181
+ * served whole. If that declaration is ever dropped the body arrives as a
182
+ * `PayloadStub` and a plain read finds no string — indistinguishable from
183
+ * "clean output", which is a scanner that silently stops scanning. Say so on
184
+ * stderr instead. Same second-silent-death route `ship-diff-volume` closes.
185
+ *
186
+ * BOTH DIRECTIONS OF THE COST, stated because the narrowing is on a security
187
+ * surface and only one direction is an improvement:
188
+ *
189
+ * - **Removed false positives.** The old fallback could raise a hit on text
190
+ * that is not tool output at all — a `cwd` under a path containing an exfil
191
+ * signature, an injection phrase echoed in the tool INPUT the user typed.
192
+ * - **Accepted false-negative risk.** A host that puts its tool output under a
193
+ * key not in {@link OUTPUT_KEYS} is now unscanned where the fallback would
194
+ * have caught it. This is mitigated, not dismissed: an unrecognised payload
195
+ * shape emits one stderr line naming the payload's top-level KEY NAMES (never
196
+ * values), so a new host spelling surfaces on first contact instead of going
197
+ * dark. The list is one exported constant; adding a spelling is one line.
198
+ *
199
+ * The descent below is LOCAL and is deliberately NOT `envelope.ts`'s shared
200
+ * `unwrap`, which descends only when all four `ENVELOPE_KEYS` are present: a
201
+ * producer emitting a partial envelope would leave this concern reading the root
202
+ * again with every test still green. `ship-diff-volume`, `design-slop` and
203
+ * `ui-route-nudge` each carry their own descent for the same reason.
204
+ */
205
+ export const OUTPUT_KEYS: readonly string[] = [
206
+ // payload_stub.BODY_KEYS.result — the spellings the dispatcher serves.
207
+ "tool_response",
208
+ "toolResponse",
209
+ "tool_result",
210
+ "toolUseResult",
211
+ // Generic spellings accepted since this concern shipped.
212
+ "output",
213
+ "result",
214
+ ];
215
+
216
+ /** The dispatcher's stub marker (payload_stub.STUB_MARKER), inlined so this
217
+ * concern keeps its no-hard-import-dependency property in a consumer repo. */
218
+ const STUB_MARKER = "_agent_config_body_omitted";
219
+
220
+ function _isStub(v: unknown): boolean {
221
+ return _isObject(v as JsonValue) && (v as JsonObject)[STUB_MARKER] === true;
222
+ }
223
+
224
+ /**
225
+ * Descend to the platform payload. See the contract block above, point 2.
226
+ */
227
+ function _payload_of(root: JsonObject): JsonObject {
228
+ const inner = root["payload"];
229
+ return _isObject(inner) ? inner : root;
230
+ }
231
+
232
+ /**
233
+ * The tool-output text, per the contract above. `''` means "nothing to scan",
234
+ * which is a real answer and not a failure.
235
+ */
145
236
  function _tool_output(envelope: JsonObject): string {
146
- for (const key of ["tool_response", "tool_result", "toolResponse", "output", "result"]) {
147
- const v = envelope[key];
237
+ const payload = _payload_of(envelope);
238
+ for (const key of OUTPUT_KEYS) {
239
+ const v = payload[key];
240
+ if (v === undefined || v === null) {
241
+ continue;
242
+ }
243
+ if (_isStub(v)) {
244
+ // Contract point 7: the declaration that serves this body was dropped.
245
+ process.stderr.write(
246
+ `injection-scan: ${key} arrived stubbed; the concern declares ` +
247
+ "needs_payload_bodies: [input, result] and cannot scan tool output " +
248
+ "without it. Nothing was scanned on this event.\n",
249
+ );
250
+ return "";
251
+ }
148
252
  if (typeof v === "string") {
149
253
  return v;
150
254
  }
@@ -152,8 +256,36 @@ function _tool_output(envelope: JsonObject): string {
152
256
  return _pyJsonDumpsCompact(v);
153
257
  }
154
258
  }
155
- // fall back to the whole payload (minus obvious input echoes)
156
- return _pyJsonDumpsCompact(envelope);
259
+ // Contract point 5 + the accepted false-negative risk. Key NAMES only — a
260
+ // value here could be tool output, which is exactly what must not be logged.
261
+ const names = Object.keys(payload);
262
+ if (names.length > 0) {
263
+ process.stderr.write(
264
+ "injection-scan: no recognised tool-output key in this payload; nothing " +
265
+ `scanned. Present keys: ${names.sort().join(", ")}. Expected one of: ` +
266
+ `${OUTPUT_KEYS.join(", ")}.\n`,
267
+ );
268
+ }
269
+ return "";
270
+ }
271
+
272
+ /**
273
+ * The stdin boundary: raw concern stdin → the text this concern scans.
274
+ *
275
+ * Exported so the regression drives the BOUNDARY rather than the extractor —
276
+ * both council seats required that, and it is what stops a hand-written fixture
277
+ * from drifting into agreeing with the bug. Never throws.
278
+ */
279
+ export function toolOutputFromStdin(raw: string): string {
280
+ if (!raw.trim()) return "";
281
+ let root: unknown;
282
+ try {
283
+ root = JSON.parse(raw);
284
+ } catch {
285
+ return "";
286
+ }
287
+ if (!_isObject(root as JsonValue)) return "";
288
+ return _tool_output(root as JsonObject);
157
289
  }
158
290
 
159
291
  function _scan(text: string): string[] {
@@ -135,6 +135,7 @@ function _check_concerns(manifest: YamlObject, errors: string[]): Set<string> {
135
135
  return new Set();
136
136
  }
137
137
  const names = new Set<string>();
138
+ const nudgeRanks = new Map<number, string[]>();
138
139
  for (const [name, spec] of Object.entries(concernsRaw)) {
139
140
  if (!isObject(spec)) {
140
141
  errors.push(
@@ -213,8 +214,56 @@ function _check_concerns(manifest: YamlObject, errors: string[]): Set<string> {
213
214
  }
214
215
  }
215
216
  }
217
+ // `nudge_rank:` — the nudge-exclusivity ordering `injection_budget`
218
+ // arbitrates on. Validated here because `injection_budget._selectNudge`
219
+ // says so in as many words: "a tie is a manifest defect rather than a
220
+ // runtime one, and `lint_hook_manifest` is where it should eventually be
221
+ // caught — that check does not exist yet and this comment is the honest
222
+ // statement of the gap". This is that check.
223
+ //
224
+ // A tie is not a crash. `_selectNudge` keeps the lowest rank and breaks
225
+ // ties on concern name, so a collision resolves stably and arbitrarily:
226
+ // one nudge silently wins forever, and the author who added the second one
227
+ // gets no signal that their concern can never emit. That is the failure
228
+ // mode — silent, not loud.
229
+ if ("nudge_rank" in spec) {
230
+ const rank = spec["nudge_rank"];
231
+ if (typeof rank !== "number" || !Number.isInteger(rank) || rank < 1) {
232
+ errors.push(
233
+ `concerns.${name}: 'nudge_rank' must be a positive integer ` +
234
+ `(got ${pyTypeName(rank)}) — the dispatcher keeps the LOWEST rank, ` +
235
+ `so a malformed value reads as "never wins" rather than as an error`,
236
+ );
237
+ } else {
238
+ const seen = nudgeRanks.get(rank);
239
+ if (seen) {
240
+ seen.push(name);
241
+ } else {
242
+ nudgeRanks.set(rank, [name]);
243
+ }
244
+ }
245
+ }
216
246
  names.add(name);
217
247
  }
248
+ // Uniqueness is enforced GLOBALLY, not per event, and that is a deliberate
249
+ // choice rather than an accident of what is easy to compute. `_selectNudge`
250
+ // compares candidates within one dispatch, so two concerns bound to disjoint
251
+ // events could share a rank harmlessly and the accurate invariant is
252
+ // per-event. Global uniqueness is the strictly-safe superset: it costs
253
+ // nothing at today's two declared ranks, and an author reusing a rank across
254
+ // events is exactly the confusion worth refusing. If a legitimate design
255
+ // ever needs the same rank on two events, NARROW this to concerns sharing at
256
+ // least one bound event — do not delete it.
257
+ for (const [rank, holders] of nudgeRanks) {
258
+ if (holders.length > 1) {
259
+ errors.push(
260
+ `concerns: nudge_rank ${rank} is declared by ${holders.length} concerns ` +
261
+ `(${holders.slice().sort().join(", ")}) — ranks must be unique, because ` +
262
+ `the dispatcher breaks a tie on concern name and the loser can then ` +
263
+ `never emit`,
264
+ );
265
+ }
266
+ }
218
267
  return names;
219
268
  }
220
269