@event4u/agent-config 14.19.0 → 14.20.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 (101) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +108 -156
  5. package/dist/agent-src/commands/agent-handoff.md +42 -41
  6. package/dist/agent-src/contexts/execution/host-capability-manifest.md +7 -1
  7. package/dist/agent-src/rules/untrusted-input-defense.md +0 -2
  8. package/dist/agent-src/skills/evaluate-llm-feature/SKILL.md +1 -1
  9. package/dist/agent-src/skills/markitdown/SKILL.md +1 -1
  10. package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +28 -6
  11. package/dist/agent-src/skills/test-driven-development/SKILL.md +1 -3
  12. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  13. package/dist/agent-src/templates/roadmaps.md +14 -1
  14. package/dist/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
  15. package/dist/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
  16. package/dist/agent-src/templates/scripts/telemetry_report.ts +22 -2
  17. package/dist/cli/mcp/dispatch.js +10 -0
  18. package/dist/cli/mcp/stdio.js +33 -2
  19. package/dist/cli/mcp/telemetry.js +227 -0
  20. package/dist/cli-delegate/{chunk-GZTMRYCN.js → chunk-C6IPZRDA.js} +79 -12
  21. package/dist/cli-delegate/{chunk-CYH5SP6M.js → chunk-I6RHFQEG.js} +151 -16
  22. package/dist/cli-delegate/{chunk-IDHUQIXP.js → chunk-NUMQHLS5.js} +21 -4
  23. package/dist/cli-delegate/{chunk-PMEKGDED.js → chunk-PI2GU3DP.js} +163 -1
  24. package/dist/cli-delegate/{chunk-7PRE6ZV2.js → chunk-YS6XTLEK.js} +199 -4
  25. package/dist/cli-delegate/chunk-ZVBOHSVF.js +1109 -0
  26. package/dist/cli-delegate/cmd_conformance.js +73 -109
  27. package/dist/cli-delegate/cmd_doctor.js +2 -2
  28. package/dist/cli-delegate/cmd_export.js +2 -2
  29. package/dist/cli-delegate/cmd_handoff.js +41 -11
  30. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  31. package/dist/cli-delegate/cmd_prune.js +2 -2
  32. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  33. package/dist/cli-delegate/cmd_session_recycle.js +59 -902
  34. package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
  35. package/dist/cli-delegate/cmd_sync.js +2 -2
  36. package/dist/cli-delegate/cmd_uninstall.js +2 -2
  37. package/dist/cli-delegate/cmd_validate.js +2 -2
  38. package/dist/discovery/deprecation-report.md +1 -1
  39. package/dist/discovery/discovery-manifest.json +9 -9
  40. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  41. package/dist/discovery/discovery-manifest.summary.md +1 -1
  42. package/dist/discovery/dormancy-report.md +1 -1
  43. package/dist/discovery/orphan-report.md +1 -1
  44. package/dist/discovery/packs.json +2 -2
  45. package/dist/discovery/trust-report.md +1 -1
  46. package/dist/discovery/workspaces.json +2 -2
  47. package/dist/hooks/dispatch.js +7257 -6497
  48. package/dist/install/install.mjs +393 -168
  49. package/dist/mcp/registry-manifest.json +2 -2
  50. package/dist/mcp/server.json +2 -2
  51. package/dist/mcp/server.mjs +497 -118
  52. package/dist/scripts/mcp_server/telemetry.js +148 -0
  53. package/docs/guidelines/agent-infra/naming.md +1 -1
  54. package/docs/guidelines/agent-infra/security-lint-containment.md +46 -6
  55. package/docs/guidelines/agent-infra/self-improvement-pipeline.md +4 -4
  56. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +1 -1
  57. package/docs/guidelines/redundancy-taxonomy.md +28 -0
  58. package/package.json +1 -1
  59. package/src/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
  60. package/src/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
  61. package/src/agent-src/templates/scripts/telemetry_report.ts +22 -2
  62. package/src/config/assurance-capability-registry.json +9 -0
  63. package/src/config/gate-coverage.yml +55 -0
  64. package/src/config/gate-violation-baselines.json +15 -3
  65. package/src/scripts/_cli/cmd_session_recycle.ts +51 -3
  66. package/src/scripts/_lib/audit_field_provenance.ts +44 -3
  67. package/src/scripts/_lib/host_capability.ts +38 -0
  68. package/src/scripts/_lib/install_reach_checks.ts +33 -1
  69. package/src/scripts/_lib/mcp_bridge.ts +222 -10
  70. package/src/scripts/_lib/mcp_consent_residual.ts +173 -0
  71. package/src/scripts/_lib/orchestration_record.ts +23 -1
  72. package/src/scripts/_lib/recycle_envelope_paths.ts +190 -0
  73. package/src/scripts/_lib/release_position.ts +96 -0
  74. package/src/scripts/_lib/review_skipped_record.ts +17 -1
  75. package/src/scripts/_lib/security_lint.ts +179 -7
  76. package/src/scripts/_lib/self_repair.ts +141 -6
  77. package/src/scripts/_lib/self_repair_store.ts +273 -7
  78. package/src/scripts/_lib/subagent_capsule.ts +155 -3
  79. package/src/scripts/build_rule_trigger_matrix.ts +5 -0
  80. package/src/scripts/check_mcp_doc_drift.ts +217 -0
  81. package/src/scripts/check_pack_size.ts +321 -3
  82. package/src/scripts/handoff_context_hook.ts +54 -7
  83. package/src/scripts/hooks/session_eol_hook.ts +18 -7
  84. package/src/scripts/install.ts +4 -4
  85. package/src/scripts/lint_agent_security.ts +316 -25
  86. package/src/scripts/lint_confusables.ts +42 -6
  87. package/src/scripts/lint_handoffs.ts +0 -132
  88. package/src/scripts/lint_hidden_unicode.ts +49 -7
  89. package/src/scripts/lint_instruction_smuggling.ts +42 -6
  90. package/src/scripts/lint_mcp_config_security.ts +42 -6
  91. package/src/scripts/lint_roadmap_later_disposition.ts +217 -27
  92. package/src/scripts/lint_skill_frontmatter_safety.ts +43 -7
  93. package/src/scripts/mcp_server/resources.ts +41 -0
  94. package/src/scripts/mcp_server/telemetry.ts +19 -4
  95. package/src/scripts/orchestration_record.ts +12 -1
  96. package/src/scripts/release.ts +24 -24
  97. package/src/scripts/release_publication.ts +6 -2
  98. package/src/scripts/self_repair_cli.ts +31 -1
  99. package/src/scripts/skill_scout.ts +69 -0
  100. package/dist/cli-delegate/chunk-2GNWGMAL.js +0 -172
  101. package/dist/cli-delegate/chunk-LPPWO5CE.js +0 -161
@@ -0,0 +1,148 @@
1
+ // MCP telemetry sink — Phase 1 J4 instrumentation.
2
+ //
3
+ // Per `agents/roadmaps/archive/road-to-mcp-full-coverage.md` §Phase 1 J4 +
4
+ // `docs/contracts/mcp-tool-stub-envelope.md`, both transports log every
5
+ // `tools/call` with `{tool_name, client_id_hash, ts, transport,
6
+ // outcome}`. The stdio-lite surface (`src/cli/mcp/`) appends a sixth,
7
+ // optional `host` field; callers that do not know their client omit it and
8
+ // their records stay byte-identical to the frozen five-field envelope. Payload bodies are never logged; the client identifier is
9
+ // hashed at the server boundary so the queryable store never sees raw
10
+ // identity.
11
+ //
12
+ // Outcomes:
13
+ //
14
+ // - `implemented` — real handler ran (no envelope returned).
15
+ // - `stub` — catalog entry missing this transport; `not_implemented`
16
+ // envelope returned.
17
+ // - `latent_demand` — caller asked for a tool not in the catalog.
18
+ //
19
+ // The sink writes JSONL to `agents/runtime/mcp-telemetry/calls.jsonl` under the
20
+ // consumer root. Failure to write must not break the wire surface: the
21
+ // `record_call` helper swallows OSError + ValueError and emits a single
22
+ // warning to stderr.
23
+ //
24
+ // TS port of the retired Python telemetry.py (ADR-200). Preserves the full public surface:
25
+ // - Outcome type, TELEMETRY_REL_DIR, TELEMETRY_FILENAME, hash_client_id,
26
+ // build_record, record_call.
27
+ import * as crypto from 'node:crypto';
28
+ import * as fs from 'node:fs';
29
+ import * as os from 'node:os';
30
+ import * as path from 'node:path';
31
+ // Stable file location relative to consumer_root. Phase 2 K1 routes
32
+ // this into a queryable store; Phase 1 only needs the file to exist.
33
+ export const TELEMETRY_REL_DIR = 'agents/runtime/mcp-telemetry';
34
+ export const TELEMETRY_FILENAME = 'calls.jsonl';
35
+ // Truncation length for the client_id hash. 12 hex chars = 48 bits of
36
+ // entropy — enough to distinguish hundreds of consumers without
37
+ // becoming a re-identification vector.
38
+ const _HASH_LEN = 12;
39
+ /**
40
+ * Identity components that together pin a consumer install.
41
+ *
42
+ * USER + machine hostname + repo path is a stable triple that survives
43
+ * sessions without leaking PII into the log. The hash never reverses.
44
+ */
45
+ function _client_id_seed() {
46
+ const user = process.env.USER || process.env.USERNAME || 'unknown';
47
+ let host = process.env.HOSTNAME;
48
+ if (!host) {
49
+ // Mirrors Python `os.uname().nodename` (POSIX) — Node exposes
50
+ // `os.hostname()`. Guarded so an empty value falls through to "unknown".
51
+ try {
52
+ host = os.hostname();
53
+ }
54
+ catch {
55
+ host = undefined;
56
+ }
57
+ }
58
+ host = host || 'unknown';
59
+ const cwd = _resolvePath(process.cwd());
60
+ return `${user}|${host}|${cwd}`;
61
+ }
62
+ /** SHA-256(seed) truncated to 12 hex chars. Boundary-only call. */
63
+ export function hash_client_id(seed) {
64
+ const raw = seed !== undefined ? seed : _client_id_seed();
65
+ const digest = crypto.createHash('sha256').update(Buffer.from(raw, 'utf-8')).digest('hex');
66
+ return digest.slice(0, _HASH_LEN);
67
+ }
68
+ /**
69
+ * Resolve a path like Python `Path(...).resolve()` — absolutize + realpath
70
+ * the existing prefix, tolerating a non-existent tail (Python resolve does
71
+ * not require the path to exist; `fs.realpathSync` does, so fall back to a
72
+ * plain absolute path on ENOENT).
73
+ */
74
+ function _resolvePath(p) {
75
+ try {
76
+ return fs.realpathSync(p);
77
+ }
78
+ catch {
79
+ return path.resolve(p);
80
+ }
81
+ }
82
+ /** Pick the JSONL location. Defaults to CWD when no override given. */
83
+ function _resolve_log_path(consumer_root) {
84
+ const root = _resolvePath(consumer_root ?? process.cwd());
85
+ return path.join(root, TELEMETRY_REL_DIR, TELEMETRY_FILENAME);
86
+ }
87
+ /** ISO-8601 UTC timestamp, seconds precision. */
88
+ function _now_iso() {
89
+ // Mirrors Python `time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())`.
90
+ return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
91
+ }
92
+ /**
93
+ * Pure helper — assemble the record without touching the filesystem.
94
+ *
95
+ * Field insertion order matches the Python dict literal so the
96
+ * compact-JSON serialization is byte-identical.
97
+ */
98
+ export function build_record(options) {
99
+ const { tool_name, outcome, transport, client_id_hash_value, ts, host } = options;
100
+ const record = {
101
+ tool_name,
102
+ client_id_hash: client_id_hash_value || hash_client_id(),
103
+ ts: ts || _now_iso(),
104
+ transport,
105
+ outcome,
106
+ };
107
+ // `host` is APPENDED, and only when a caller supplies one, so a record
108
+ // written by a caller that does not know its client stays byte-identical
109
+ // to the five-field envelope the stub contract froze. It is a closed
110
+ // vocabulary resolved at the boundary (see `src/cli/mcp/telemetry.ts`),
111
+ // never a free-form string: a record type that cannot hold arbitrary text
112
+ // has no scrubber to fail.
113
+ if (typeof host === 'string' && host !== '') {
114
+ record.host = host;
115
+ }
116
+ return record;
117
+ }
118
+ /**
119
+ * Append one JSONL record. Returns the record or null on failure.
120
+ *
121
+ * Failures are swallowed: telemetry must never break the wire surface.
122
+ * A single `mcp-server: warn: telemetry` line is emitted to stderr
123
+ * so silent-failure windows show up in the boot log and the J6
124
+ * healthcheck can detect them.
125
+ */
126
+ export function record_call(options) {
127
+ const { tool_name, outcome, transport, consumer_root, client_id_hash_value, host } = options;
128
+ const record = build_record({
129
+ tool_name,
130
+ outcome,
131
+ transport,
132
+ client_id_hash_value,
133
+ host,
134
+ });
135
+ const target = _resolve_log_path(consumer_root ?? undefined);
136
+ try {
137
+ fs.mkdirSync(path.dirname(target), { recursive: true });
138
+ // json.dumps(record, separators=(",", ":")) — compact, no spaces.
139
+ fs.appendFileSync(target, JSON.stringify(record) + '\n', { encoding: 'utf-8' });
140
+ }
141
+ catch (exc) {
142
+ const message = exc instanceof Error ? exc.message : String(exc);
143
+ process.stderr.write(`mcp-server: warn: telemetry write failed: ${message}\n`);
144
+ return null;
145
+ }
146
+ return record;
147
+ }
148
+ //# sourceMappingURL=telemetry.js.map
@@ -34,7 +34,7 @@ source: package
34
34
  |---|---|---|
35
35
  | `{concern}` | Behavioral constraint | `scope-control`, `ask-when-uncertain` |
36
36
  | `{domain}-{concern}` | Domain-specific rule | `php-coding`, `e2e-testing` |
37
- | `{action}-{target}` | Trigger-based auto rule | `capture-learnings`, `downstream-changes` |
37
+ | `{action}-{target}` | Trigger-based auto rule | `invite-challenge`, `downstream-changes` |
38
38
 
39
39
  ### Commands
40
40
 
@@ -45,18 +45,51 @@ are *expected* to contain illustrative patterns. Only a full-weight (1.0×)
45
45
  **HIGH** finding in a real artifact fails the build. (`is_example_path()` in the
46
46
  shared lib defines the path set.)
47
47
 
48
- ### 3. Per-file pragma
48
+ ### 3. Bound pragma
49
49
 
50
- A single check can be suppressed for one file with an auditable, reasoned
51
- marker placed anywhere in the file:
50
+ A single check can be suppressed for **specific evidence** in one file with an
51
+ auditable, reasoned, content-bound marker placed anywhere in the file:
52
52
 
53
53
  ```
54
- <!-- security-lint: allow instruction-smuggling "teaching example: quotes a prompt-injection string" -->
54
+ <!-- security-lint: allow instruction-smuggling "teaching example: quotes a prompt-injection string" sha256:<hex> -->
55
55
  ```
56
56
 
57
57
  - The `<check>` token is the linter's check id (`hidden-unicode`,
58
- `instruction-smuggling`, `mcp-config-security`, `dangerous-frontmatter`).
58
+ `mixed-script-confusable`, `instruction-smuggling`, `mcp-config-security`,
59
+ `dangerous-frontmatter`).
59
60
  - The `"<reason>"` is **mandatory** — an empty reason does not parse.
61
+ - Each `sha256:<hex>` **binds the suppression to one accepted match**. Repeat the
62
+ token once per accepted match; a file with three accepted matches carries three
63
+ hashes. Anything the check finds that is not in that set is still reported.
64
+ - The fingerprint covers the **check id**, the artifact's **source identity**, and
65
+ the **normalized text of the matched line** — ASCII whitespace runs collapse so
66
+ a reflow does not break the binding, and nothing else is stripped, because the
67
+ zero-width characters this suite hunts are exactly what a wider normalizer
68
+ would erase. It is deliberately NOT the line number (an unrelated insert above
69
+ would break every pragma in the file) and NOT the whole file (every unrelated
70
+ edit would re-fire it, which is how blanket suppressions get written).
71
+ A `dist/agent-src/` path folds to its `src/` original first: the projection is
72
+ byte-exact by contract, so an artifact and its copy are one identity.
73
+ - **A pair pinned equal by a parity test carries ONE HASH PER IDENTITY.** The
74
+ fold above covers `dist/agent-src/` and nothing else, so where a second
75
+ hand-maintained copy exists and a test asserts the two are byte-identical —
76
+ `src/subagents/<stem>.md` and `docs/wedge/<stem>/<stem>.md`, pinned by
77
+ `tests/scripts/subagent_distribution.test.ts` — one hash cannot satisfy both:
78
+ giving each file its own makes the lines differ and reds the parity test,
79
+ which is exactly how this was found. Put BOTH hashes in BOTH files. That is
80
+ what the repeatable token is for, and it is why the grammar has one. A purely
81
+ GENERATED third copy (`.claude/agents/<stem>.md`) needs no hash of its own:
82
+ nothing scans it and nobody edits it, so binding it would be a fingerprint
83
+ for an identity no finding can arise at.
84
+ - Compute one with the linter itself, never by hand — a hash typed from memory
85
+ is a suppression nobody audited.
86
+
87
+ **The unbound form still parses**, suppresses the whole file as it always did,
88
+ and reports itself as a `legacy-pragma` LOW finding. That is a migration signal,
89
+ not a mode to choose: a key lookup with no content binding accepts whatever the
90
+ matched text is later changed to say, which is the defect the hash exists to
91
+ close. There are zero unbound pragmas in the tree.
92
+
60
93
  - Pragmas are **counted and capped**: crossing **20** across the repo means the
61
94
  linter is mis-scoped. Stop adding pragmas; redesign or narrow the check
62
95
  (escalate per `autonomous-execution` — the allowlist-growth antipattern).
@@ -72,7 +105,14 @@ marker placed anywhere in the file:
72
105
 
73
106
  `security-example` fence → confidence weight → pragma. A HIGH finding survives
74
107
  to fail the build only when it is full-weight (not in an example path), not
75
- inside a `security-example` fence, and not covered by a matching pragma.
108
+ inside a `security-example` fence, and not covered by a pragma whose fingerprint
109
+ set contains that match.
110
+
111
+ One consumer deliberately departs from the weighting rung: `skill_scout`'s
112
+ `security_licence` gate refuses a quarantined candidate on **any** HIGH finding,
113
+ whatever its weight. Path-based confidence is a false-positive containment device
114
+ for a corpus whose provenance is known; a third-party candidate has none, and a
115
+ directory named `examples/` in it is the submitter's word for it.
76
116
 
77
117
  ## See also
78
118
 
@@ -4,10 +4,10 @@ Five-stage flow that turns real project experience into reviewed
4
4
  rule / skill / command / guideline changes. No autonomous self-edits;
5
5
  every upstream change ships via a human-reviewed PR.
6
6
 
7
- Referenced by `road-to-curated-self-improvement.md`. Consumed by the
8
- existing skills `capture-learnings`, `learning-to-rule-or-skill`,
9
- `skill-improvement-pipeline`, and `upstream-contribute` — each becomes
10
- a stage, not a standalone invocation.
7
+ Referenced by `road-to-curated-self-improvement.md`. Consumed by the live
8
+ artifacts — the `skill-improvement-trigger` rule and the existing skills
9
+ `learning-to-rule-or-skill`, `skill-improvement-pipeline`, and
10
+ `upstream-contribute` — each becomes a stage, not a standalone invocation.
11
11
 
12
12
  ## The five stages
13
13
 
@@ -1,4 +1,4 @@
1
- <!-- security-lint: allow instruction-smuggling "defense guideline: quotes role-takeover phrases (ignore previous instructions, you are now, <IMPORTANT>) to teach refusal" -->
1
+ <!-- security-lint: allow instruction-smuggling "defense guideline: quotes role-takeover phrases (ignore previous instructions, you are now, <IMPORTANT>) to teach refusal" sha256:d84759534e0fd3ebc5a66533b4a9f52364ef5286eaf3674a611c9c2adf89b969 sha256:f08c68182232149c7ecd881e88ca81dc98a9313c06ba6d010b065f9feef2c149 sha256:4f5936bb0a57de8ff3ce590fb12607cdcb046a31262b5e4d40377c6e6184f494 -->
2
2
 
3
3
  # untrusted-input spotlighting + least-agency mapping
4
4
 
@@ -86,6 +86,34 @@ translation frequently denote different mechanisms — here `route`, `dispatch`,
86
86
  five ways — so a sweep over them destroys information the echo classes exist to
87
87
  protect.
88
88
 
89
+ ## The avoid-list — one term, one concept
90
+
91
+ A homonym collision is only half-diagnosed when it is classified. The other
92
+ half is the **decision**: which concept keeps the word, and what every other
93
+ sense is called instead. This list carries the decided ones. A word is added
94
+ here when the same term has been observed denoting more than one shipped
95
+ mechanism; it is removed only when the collision is gone from the tree, never
96
+ because the entry became inconvenient.
97
+
98
+ Read the columns as a rename instruction for **prose you write from now on**.
99
+ The identifier layer is decided separately and per cluster, exactly as the
100
+ prose/identifier split above requires — a term may be `canonicalize-term` in
101
+ prose and `keep-distinct` in identifiers, and both verdicts are recorded.
102
+
103
+ | Term | Concept that keeps the word | Other senses, and what to call them | Identifier verdict |
104
+ |---|---|---|---|
105
+ | `envelope` | The **transport wrapper** around a payload — `OutcomeEnvelope` (a run's terminal outcome, `src/scripts/_lib/outcome_envelope.ts`) and the `CHECKPOINT` envelope a worker capsule rides in. | The **session continuity record** is a **capsule**. The word is not chosen for readability — it is the shipped discriminator (`CAPSULE_SCHEMA_VERSION`, `CAPSULE_VARIANTS = ['worker','main_session']`, `src/scripts/_lib/subagent_capsule.ts`), so the surviving term is read off what ships rather than off what reads best. | `keep-distinct` for now. `MainSessionRecycleEnvelope` and `RECYCLE_ENVELOPE_*` keep their names until a rename is costed on its own; the prose rule binds immediately and the symbols are a separate blast radius. |
106
+ | `handoff` | The **session-to-session continuity handover** a developer invokes — the `handoff` verb and the `/agent-handoff` command. | The lint over senior-tier skills' `## Related Skills` edges is a **cross-wing skill link**, never "a handoff". | `keep-distinct`, recorded reason: `lint_handoffs.ts` mirrors a ported CLI contract (ADR-200) whose name is an external surface. Its module docstring already says "cross-wing", which is the disambiguation the prose rule now requires everywhere else. |
107
+
108
+ **Why a list and not a linter.** `lint_canonical_terms` decides *spelling*
109
+ pairs, where one side is mechanically wrong. A homonym is not mechanically
110
+ wrong anywhere — every occurrence is a legitimate word for a legitimate
111
+ concept, and only the reader's inability to tell them apart is the defect. A
112
+ gate that flagged `envelope` would flag the entry that is supposed to survive.
113
+ So this list is enforced at review, by citation, and its cost of being ignored
114
+ is the cost the taxonomy already names: the model telling you it has not
115
+ decided.
116
+
89
117
  ## Why any of this is worth a gate at all
90
118
 
91
119
  Generated code trends toward duplication rather than reuse: measured
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
3
  "mcpName": "io.github.event4u-app/agent-config",
4
- "version": "14.19.0",
4
+ "version": "14.20.0",
5
5
  "description": "Governed skills, rules and commands for AI coding tools (Claude Code, Cursor, Windsurf, Copilot) — every claim machine-checked, including the counts in these badges.",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -0,0 +1,213 @@
1
+ /**
2
+ * MCP-lite call reading for `telemetry:report` (roadmap step 4.2).
3
+ *
4
+ * Reads the sink the stdio-lite MCP server writes — the same
5
+ * `agents/runtime/mcp-telemetry/calls.jsonl` the kernel MCP server already
6
+ * used, not a second store — and aggregates it per tool and per host.
7
+ *
8
+ * WHY THIS IS A SEPARATE MODULE. The engagement aggregator models artefacts
9
+ * with a consulted/applied ratio; a `tools/call` has neither. Forcing MCP rows
10
+ * through it would have meant either widening the engagement schema or
11
+ * mislabelling a tool call as a "skill consulted", and both would have made the
12
+ * quartile buckets mean two different things at once.
13
+ *
14
+ * PUBLISH THE ZERO. `render_mcp_lite_markdown` emits the section even when the
15
+ * count is zero, and says so in words. The adjacent archived roadmaps in this
16
+ * family closed at `measured-null` precisely because nulls were published
17
+ * rather than buried; a section that disappears when empty would make "nobody
18
+ * used it" and "nobody looked" indistinguishable.
19
+ *
20
+ * WHAT A ZERO DOES NOT MEAN. The emitter that fills this sink did not exist
21
+ * before this roadmap, and it is gated off by default. A zero is therefore a
22
+ * statement about the age and the default of the instrument first, and about
23
+ * consumer behaviour only once a machine has run with the gate on. The rendered
24
+ * section says this itself, so a reader cannot lift the number away from it.
25
+ */
26
+
27
+ import * as fs from 'node:fs';
28
+ import { check_id_redaction } from './engagement.js';
29
+
30
+ /** Where the stdio-lite server writes, relative to the consumer root. */
31
+ export const MCP_LITE_LOG_REL = 'agents/runtime/mcp-telemetry/calls.jsonl';
32
+
33
+ /** The transport label the stdio-lite surface stamps on every row it writes. */
34
+ export const MCP_LITE_TRANSPORT = 'stdio-lite';
35
+
36
+ export interface McpLiteAggregate {
37
+ /** The file that was read, whether or not it existed. */
38
+ log_path: string;
39
+ /** Rows on this transport that parsed and fell inside the window. */
40
+ calls: number;
41
+ /** Lines that did not parse as JSON, or carried no usable tool name. */
42
+ skipped_lines: number;
43
+ /** Rows that parsed but belong to another transport (the kernel server). */
44
+ other_transport: number;
45
+ by_tool: Map<string, number>;
46
+ by_host: Map<string, number>;
47
+ by_outcome: Map<string, number>;
48
+ earliest_ts: string | null;
49
+ latest_ts: string | null;
50
+ }
51
+
52
+ function _empty(log_path: string): McpLiteAggregate {
53
+ return {
54
+ log_path,
55
+ calls: 0,
56
+ skipped_lines: 0,
57
+ other_transport: 0,
58
+ by_tool: new Map(),
59
+ by_host: new Map(),
60
+ by_outcome: new Map(),
61
+ earliest_ts: null,
62
+ latest_ts: null,
63
+ };
64
+ }
65
+
66
+ function _bump(m: Map<string, number>, k: string): void {
67
+ m.set(k, (m.get(k) ?? 0) + 1);
68
+ }
69
+
70
+ /** Epoch ms for an ISO-8601 stamp, or `null` when it does not parse. */
71
+ function _ms(ts: unknown): number | null {
72
+ if (typeof ts !== 'string') return null;
73
+ const v = Date.parse(ts);
74
+ return Number.isNaN(v) ? null : v;
75
+ }
76
+
77
+ /**
78
+ * Aggregate the lite surface's calls.
79
+ *
80
+ * A missing file is an empty reading, never an error: the overwhelmingly common
81
+ * case is a machine that has simply never run the server, and that is a zero,
82
+ * not a failure. `since` is an epoch-ms lower bound, matching the engagement
83
+ * aggregator's own window semantics; a row with an unreadable timestamp is
84
+ * kept when there is no window and dropped when there is, because it cannot be
85
+ * shown to fall inside one.
86
+ */
87
+ export function aggregate_mcp_lite(
88
+ log_path: string,
89
+ opts: { since?: number | null } = {},
90
+ ): McpLiteAggregate {
91
+ const since = opts.since ?? null;
92
+ const out = _empty(log_path);
93
+ let text: string;
94
+ try {
95
+ text = fs.readFileSync(log_path, 'utf-8');
96
+ } catch {
97
+ return out;
98
+ }
99
+ for (const raw of text.split(/\r\n|\r|\n/)) {
100
+ if (raw.trim() === '') continue;
101
+ let rec: Record<string, unknown>;
102
+ try {
103
+ const parsed: unknown = JSON.parse(raw);
104
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
105
+ out.skipped_lines += 1;
106
+ continue;
107
+ }
108
+ rec = parsed as Record<string, unknown>;
109
+ } catch {
110
+ out.skipped_lines += 1;
111
+ continue;
112
+ }
113
+ if (rec.transport !== MCP_LITE_TRANSPORT) {
114
+ out.other_transport += 1;
115
+ continue;
116
+ }
117
+ const tool = typeof rec.tool_name === 'string' ? rec.tool_name : '';
118
+ if (tool === '') {
119
+ out.skipped_lines += 1;
120
+ continue;
121
+ }
122
+ const ms = _ms(rec.ts);
123
+ if (since !== null && (ms === null || ms < since)) continue;
124
+
125
+ out.calls += 1;
126
+ _bump(out.by_tool, tool);
127
+ _bump(out.by_host, typeof rec.host === 'string' && rec.host !== '' ? rec.host : 'unknown');
128
+ _bump(out.by_outcome, typeof rec.outcome === 'string' ? rec.outcome : 'unknown');
129
+ const ts = typeof rec.ts === 'string' ? rec.ts : null;
130
+ if (ts !== null) {
131
+ if (out.earliest_ts === null || ts < out.earliest_ts) out.earliest_ts = ts;
132
+ if (out.latest_ts === null || ts > out.latest_ts) out.latest_ts = ts;
133
+ }
134
+ }
135
+ return out;
136
+ }
137
+
138
+ /** Descending by count, then by name, so the rendering is deterministic. */
139
+ function _sorted(m: Map<string, number>): [string, number][] {
140
+ return [...m.entries()].sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
141
+ }
142
+
143
+ const ZERO_NOTE =
144
+ '_No calls recorded. The lite-surface emitter is default-off '
145
+ + '(`telemetry.artifact_engagement.enabled`), so a zero here says the '
146
+ + 'instrument was off or unused on this machine — it is not a measurement '
147
+ + 'of what consumers do._';
148
+
149
+ /**
150
+ * The markdown section. Always renders, zero included.
151
+ *
152
+ * Ids are re-validated through the engagement redaction floor even though the
153
+ * emitter already clamps them: this renderer prints whatever is in the file,
154
+ * and a file is not a boundary the emitter controls.
155
+ */
156
+ export function render_mcp_lite_markdown(agg: McpLiteAggregate): string {
157
+ const lines: string[] = [];
158
+ lines.push('## MCP lite surface');
159
+ lines.push('');
160
+ lines.push(`- calls recorded: **${agg.calls}**`);
161
+ if (agg.earliest_ts && agg.latest_ts) {
162
+ lines.push(`- ts range: \`${agg.earliest_ts}\` → \`${agg.latest_ts}\``);
163
+ }
164
+ lines.push('');
165
+ if (agg.calls === 0) {
166
+ lines.push(ZERO_NOTE);
167
+ lines.push('');
168
+ return `${lines.join('\n')}`;
169
+ }
170
+ lines.push('| tool | calls |');
171
+ lines.push('|---|---:|');
172
+ for (const [tool, n] of _sorted(agg.by_tool)) {
173
+ check_id_redaction('mcp_lite.by_tool.id', tool);
174
+ lines.push(`| ${tool} | ${n} |`);
175
+ }
176
+ lines.push('');
177
+ lines.push('| host | calls |');
178
+ lines.push('|---|---:|');
179
+ for (const [host, n] of _sorted(agg.by_host)) {
180
+ check_id_redaction('mcp_lite.by_host.id', host);
181
+ lines.push(`| ${host} | ${n} |`);
182
+ }
183
+ lines.push('');
184
+ return `${lines.join('\n')}`;
185
+ }
186
+
187
+ /** Plain object for the JSON format. Same validation as the markdown path. */
188
+ export function mcp_lite_json(agg: McpLiteAggregate): Record<string, unknown> {
189
+ const by_tool: Record<string, number> = {};
190
+ for (const [tool, n] of _sorted(agg.by_tool)) {
191
+ check_id_redaction('mcp_lite.by_tool.id', tool);
192
+ by_tool[tool] = n;
193
+ }
194
+ const by_host: Record<string, number> = {};
195
+ for (const [host, n] of _sorted(agg.by_host)) {
196
+ check_id_redaction('mcp_lite.by_host.id', host);
197
+ by_host[host] = n;
198
+ }
199
+ const by_outcome: Record<string, number> = {};
200
+ for (const [outcome, n] of _sorted(agg.by_outcome)) {
201
+ by_outcome[outcome] = n;
202
+ }
203
+ return {
204
+ log_path: agg.log_path,
205
+ calls: agg.calls,
206
+ skipped_lines: agg.skipped_lines,
207
+ by_tool,
208
+ by_host,
209
+ by_outcome,
210
+ earliest_ts: agg.earliest_ts,
211
+ latest_ts: agg.latest_ts,
212
+ };
213
+ }
@@ -12,6 +12,7 @@ import {
12
12
  rank_artefacts,
13
13
  } from './aggregator.js';
14
14
  import { ALLOWED_OUTCOMES, check_id_redaction } from './engagement.js';
15
+ import { mcp_lite_json, render_mcp_lite_markdown, type McpLiteAggregate } from './mcp_lite.js';
15
16
 
16
17
  export const QUARTILE_TOP_RATIO = 0.20;
17
18
  export const QUARTILE_BOTTOM_RATIO = 0.20;
@@ -57,6 +58,17 @@ export function bucketise(stats: ArtefactStat[]): BucketedStat[] {
57
58
  export interface RenderOptions {
58
59
  top?: number | null;
59
60
  since_label?: string | null;
61
+ /**
62
+ * The MCP-lite reading, when the caller resolved one (step 4.2).
63
+ *
64
+ * Optional, and ABSENT is the default: a caller that does not supply it
65
+ * gets exactly the report this renderer produced before, byte for byte.
66
+ * That matters concretely — the Python→TS parity goldens for this script
67
+ * are frozen snapshots of a `.py` that no longer exists and can never be
68
+ * re-captured, so a section that rendered unconditionally would break a
69
+ * pin that cannot be repaired.
70
+ */
71
+ mcp_lite?: McpLiteAggregate | null;
60
72
  }
61
73
 
62
74
  /** Render a markdown report. `top` truncates each bucket; null keeps all. */
@@ -137,6 +149,9 @@ export function render_markdown(aggregate: AggregateResult, opts: RenderOptions
137
149
  }
138
150
  lines.push('');
139
151
  }
152
+ if (opts.mcp_lite) {
153
+ lines.push(render_mcp_lite_markdown(opts.mcp_lite));
154
+ }
140
155
  return `${_pyRStrip(lines.join('\n'))}\n`;
141
156
  }
142
157
 
@@ -181,6 +196,7 @@ export function render_json(aggregate: AggregateResult, opts: RenderOptions = {}
181
196
  },
182
197
  outcomes: outcomes_payload,
183
198
  buckets: grouped,
199
+ ...(opts.mcp_lite ? { mcp_lite: mcp_lite_json(opts.mcp_lite) } : {}),
184
200
  };
185
201
  return `${_py_json_dumps_indent2_sorted(payload)}\n`;
186
202
  }
@@ -19,6 +19,7 @@ import { aggregate } from './telemetry/aggregator.js';
19
19
  import { EngagementSchemaError } from './telemetry/engagement.js';
20
20
  import { render_json, render_markdown } from './telemetry/report_renderer.js';
21
21
  import { read_settings } from './telemetry/settings.js';
22
+ import { MCP_LITE_LOG_REL, aggregate_mcp_lite } from './telemetry/mcp_lite.js';
22
23
 
23
24
  const _DURATION_RE = /^\s*(\d+)\s*([dhm])\s*$/u;
24
25
 
@@ -59,6 +60,7 @@ interface ReportArgs {
59
60
  format: string;
60
61
  log_path: string | null;
61
62
  settings: string;
63
+ mcp_log_path: string | null;
62
64
  }
63
65
 
64
66
  class ArgError extends Error {}
@@ -70,6 +72,7 @@ function _parseArgs(argv: string[]): ReportArgs {
70
72
  format: 'markdown',
71
73
  log_path: null,
72
74
  settings: '.agent-settings.yml',
75
+ mcp_log_path: null,
73
76
  };
74
77
  const formatChoices = ['markdown', 'json'];
75
78
  for (let i = 0; i < argv.length; i += 1) {
@@ -104,6 +107,7 @@ function _parseArgs(argv: string[]): ReportArgs {
104
107
  }
105
108
  a.format = v;
106
109
  } else if (tok === '--log-path') a.log_path = next();
110
+ else if (tok === '--mcp-log-path') a.mcp_log_path = next();
107
111
  else if (tok === '--settings') a.settings = next();
108
112
  else throw new ArgError(`unrecognized arguments: ${argv[i]}`);
109
113
  }
@@ -155,13 +159,29 @@ export function main(argv: string[] = process.argv.slice(2)): number {
155
159
  return 2;
156
160
  }
157
161
 
162
+ // The MCP-lite section (step 4.2) renders in the DEFAULT, settings-driven
163
+ // invocation — which is what `agent-config telemetry:report` is — and when
164
+ // a caller names the sink explicitly. It does NOT render when the caller
165
+ // pointed `--log-path` at one specific engagement log and named no MCP
166
+ // sink: in that mode the report is scoped to the file it was handed and
167
+ // does not go looking for other stores on the machine. That rule also keeps
168
+ // the frozen Python-parity goldens intact, since every pinned invocation
169
+ // passes `--log-path`; the goldens cannot be re-captured, the `.py` having
170
+ // been retired, so an unconditional section would break a pin permanently.
171
+ const want_mcp = args.mcp_log_path !== null || args.log_path === null;
172
+ const mcp_lite = want_mcp
173
+ ? aggregate_mcp_lite(args.mcp_log_path ?? path.join(process.cwd(), MCP_LITE_LOG_REL), {
174
+ since: cutoff,
175
+ })
176
+ : null;
177
+
158
178
  const top = args.top <= 0 ? null : args.top;
159
179
  let rendered: string;
160
180
  try {
161
181
  if (args.format === 'json') {
162
- rendered = render_json(result, { top, since_label });
182
+ rendered = render_json(result, { top, since_label, mcp_lite });
163
183
  } else {
164
- rendered = render_markdown(result, { top, since_label });
184
+ rendered = render_markdown(result, { top, since_label, mcp_lite });
165
185
  }
166
186
  } catch (exc) {
167
187
  if (exc instanceof EngagementSchemaError) {
@@ -154,6 +154,15 @@
154
154
  "evidence": "src/scripts/grade_target_readiness.ts — 'semgrep' and 'bandit' tokens; the python fixture reports 'no SAST config'",
155
155
  "revisit_if": "the SAST probe stops distinguishing presence from absence on the fixtures"
156
156
  },
157
+ "self-security-scan": {
158
+ "axis": "self",
159
+ "owner_surface": "src/scripts/lint_agent_security.ts",
160
+ "projection": null,
161
+ "state": "available",
162
+ "rationale": "This package's own artifact tree is scanned for hidden-instruction payloads by an umbrella that spawns five child linters. Each child now has a closed terminal outcome — completed, failed or skipped(reason) — and a child that did not answer makes the aggregate name it and exit 1, so the umbrella's exit code establishes that all five ran rather than only that none of them spoke. Distinct from the target-axis security-scan entry: that one grades a consumer repository, this one grades this repository's scan of itself. Registered `degraded` on 2026-09-07 and moved here in the same change that repaired the defect; the degraded state is in this file's history rather than erased from it.",
163
+ "evidence": "tests/scripts/lint_agent_security_fail_closed.test.ts — five child-failure classes (non-zero with empty stdout, exit 0 with unparsable stdout, never spawned, a non-array payload, and the negative control of a non-zero exit WITH valid findings). The first four were written green against the pre-repair aggregation and flipped red-then-named by the repair; the negative control did not move in either direction. Sabotage probe on the real production path, 2026-09-07: a module-level throw in lint_confusables.ts made the umbrella print `mixed-script-confusable: did not complete` and exit 1, where the same sabotage previously printed the clean line and exited 0.",
164
+ "revisit_if": "a sixth child linter is added to the LINTERS watch list without a terminal outcome, or the aggregate stops failing on a non-completing child — either of which is caught by the failure corpus, so a revisit means that corpus was weakened first."
165
+ },
157
166
  "runtime-probe": {
158
167
  "axis": "target",
159
168
  "owner_surface": "agents/roadmaps/stubs/road-to-target-project-bootstrap-enforce.md",