@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.
- package/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +108 -156
- package/dist/agent-src/commands/agent-handoff.md +42 -41
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +7 -1
- package/dist/agent-src/rules/untrusted-input-defense.md +0 -2
- package/dist/agent-src/skills/evaluate-llm-feature/SKILL.md +1 -1
- package/dist/agent-src/skills/markitdown/SKILL.md +1 -1
- package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +28 -6
- package/dist/agent-src/skills/test-driven-development/SKILL.md +1 -3
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +14 -1
- package/dist/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
- package/dist/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
- package/dist/agent-src/templates/scripts/telemetry_report.ts +22 -2
- package/dist/cli/mcp/dispatch.js +10 -0
- package/dist/cli/mcp/stdio.js +33 -2
- package/dist/cli/mcp/telemetry.js +227 -0
- package/dist/cli-delegate/{chunk-GZTMRYCN.js → chunk-C6IPZRDA.js} +79 -12
- package/dist/cli-delegate/{chunk-CYH5SP6M.js → chunk-I6RHFQEG.js} +151 -16
- package/dist/cli-delegate/{chunk-IDHUQIXP.js → chunk-NUMQHLS5.js} +21 -4
- package/dist/cli-delegate/{chunk-PMEKGDED.js → chunk-PI2GU3DP.js} +163 -1
- package/dist/cli-delegate/{chunk-7PRE6ZV2.js → chunk-YS6XTLEK.js} +199 -4
- package/dist/cli-delegate/chunk-ZVBOHSVF.js +1109 -0
- package/dist/cli-delegate/cmd_conformance.js +73 -109
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +41 -11
- package/dist/cli-delegate/cmd_memory_get.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_route_audit.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +59 -902
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +9 -9
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +7257 -6497
- package/dist/install/install.mjs +393 -168
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +497 -118
- package/dist/scripts/mcp_server/telemetry.js +148 -0
- package/docs/guidelines/agent-infra/naming.md +1 -1
- package/docs/guidelines/agent-infra/security-lint-containment.md +46 -6
- package/docs/guidelines/agent-infra/self-improvement-pipeline.md +4 -4
- package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +1 -1
- package/docs/guidelines/redundancy-taxonomy.md +28 -0
- package/package.json +1 -1
- package/src/agent-src/templates/scripts/telemetry/mcp_lite.ts +213 -0
- package/src/agent-src/templates/scripts/telemetry/report_renderer.ts +16 -0
- package/src/agent-src/templates/scripts/telemetry_report.ts +22 -2
- package/src/config/assurance-capability-registry.json +9 -0
- package/src/config/gate-coverage.yml +55 -0
- package/src/config/gate-violation-baselines.json +15 -3
- package/src/scripts/_cli/cmd_session_recycle.ts +51 -3
- package/src/scripts/_lib/audit_field_provenance.ts +44 -3
- package/src/scripts/_lib/host_capability.ts +38 -0
- package/src/scripts/_lib/install_reach_checks.ts +33 -1
- package/src/scripts/_lib/mcp_bridge.ts +222 -10
- package/src/scripts/_lib/mcp_consent_residual.ts +173 -0
- package/src/scripts/_lib/orchestration_record.ts +23 -1
- package/src/scripts/_lib/recycle_envelope_paths.ts +190 -0
- package/src/scripts/_lib/release_position.ts +96 -0
- package/src/scripts/_lib/review_skipped_record.ts +17 -1
- package/src/scripts/_lib/security_lint.ts +179 -7
- package/src/scripts/_lib/self_repair.ts +141 -6
- package/src/scripts/_lib/self_repair_store.ts +273 -7
- package/src/scripts/_lib/subagent_capsule.ts +155 -3
- package/src/scripts/build_rule_trigger_matrix.ts +5 -0
- package/src/scripts/check_mcp_doc_drift.ts +217 -0
- package/src/scripts/check_pack_size.ts +321 -3
- package/src/scripts/handoff_context_hook.ts +54 -7
- package/src/scripts/hooks/session_eol_hook.ts +18 -7
- package/src/scripts/install.ts +4 -4
- package/src/scripts/lint_agent_security.ts +316 -25
- package/src/scripts/lint_confusables.ts +42 -6
- package/src/scripts/lint_handoffs.ts +0 -132
- package/src/scripts/lint_hidden_unicode.ts +49 -7
- package/src/scripts/lint_instruction_smuggling.ts +42 -6
- package/src/scripts/lint_mcp_config_security.ts +42 -6
- package/src/scripts/lint_roadmap_later_disposition.ts +217 -27
- package/src/scripts/lint_skill_frontmatter_safety.ts +43 -7
- package/src/scripts/mcp_server/resources.ts +41 -0
- package/src/scripts/mcp_server/telemetry.ts +19 -4
- package/src/scripts/orchestration_record.ts +12 -1
- package/src/scripts/release.ts +24 -24
- package/src/scripts/release_publication.ts +6 -2
- package/src/scripts/self_repair_cli.ts +31 -1
- package/src/scripts/skill_scout.ts +69 -0
- package/dist/cli-delegate/chunk-2GNWGMAL.js +0 -172
- 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 | `
|
|
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.
|
|
48
|
+
### 3. Bound pragma
|
|
49
49
|
|
|
50
|
-
A single check can be suppressed for one file with an
|
|
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`,
|
|
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
|
|
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
|
-
|
|
9
|
-
`skill-improvement-pipeline`, and
|
|
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.
|
|
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",
|