@event4u/agent-config 9.27.0 → 9.28.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/.claude-plugin/marketplace.json +1 -1
- package/AGENTS.md +12 -11
- package/CHANGELOG.md +99 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/conformance.md +24 -0
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +10 -3
- package/dist/agent-src/rules/artifact-engagement-recording.md +4 -1
- package/dist/agent-src/rules/autonomous-execution.md +1 -1
- package/dist/agent-src/rules/brand-source-of-truth.md +33 -6
- package/dist/agent-src/rules/context-hygiene.md +3 -1
- package/dist/agent-src/rules/council-availability.md +93 -0
- package/dist/agent-src/rules/cross-source-consistency.md +3 -0
- package/dist/agent-src/rules/delegation-policy.md +29 -5
- package/dist/agent-src/rules/design-fidelity.md +3 -0
- package/dist/agent-src/rules/evaluator-independence.md +2 -1
- package/dist/agent-src/rules/external-code-graph-interop.md +2 -1
- package/dist/agent-src/rules/finance-safety-floor.md +1 -1
- package/dist/agent-src/rules/git-history-discipline.md +1 -1
- package/dist/agent-src/rules/history-discipline.md +3 -1
- package/dist/agent-src/rules/legal-safety-floor.md +1 -1
- package/dist/agent-src/rules/no-pr-progress-comments.md +3 -1
- package/dist/agent-src/rules/onboarding-gate.md +3 -1
- package/dist/agent-src/rules/roadmap-ci-steps-policy.md +4 -1
- package/dist/agent-src/rules/role-mode-adherence.md +1 -1
- package/dist/agent-src/rules/scale-discipline.md +3 -1
- package/dist/agent-src/rules/self-repair-loop.md +103 -0
- package/dist/agent-src/rules/session-canary.md +2 -1
- package/dist/agent-src/rules/strategy-safety-floor.md +1 -1
- package/dist/agent-src/rules/token-budget-discipline.md +3 -1
- package/dist/agent-src/rules/tool-safety.md +5 -1
- package/dist/agent-src/skills/adversarial-review/SKILL.md +1 -1
- package/dist/agent-src/skills/analysis-autonomous-mode/SKILL.md +1 -1
- package/dist/agent-src/skills/performance-analysis/SKILL.md +1 -1
- package/dist/agent-src/skills/persona-improvement/SKILL.md +1 -1
- package/dist/agent-src/skills/project-analyzer/SKILL.md +1 -1
- package/dist/agent-src/skills/security-audit/SKILL.md +1 -1
- package/dist/agent-src/skills/sequential-thinking/SKILL.md +1 -1
- package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +1 -1
- package/dist/agent-src/skills/universal-project-analysis/SKILL.md +1 -1
- package/dist/agent-src/templates/AGENTS.md +6 -10
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +7 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-22GCIJ2O.js +270 -0
- package/dist/cli-delegate/{chunk-BCPIOAGV.js → chunk-2BE5I2MN.js} +8 -8
- package/dist/cli-delegate/{chunk-AIIUU73N.js → chunk-7OMQCH4V.js} +1 -1
- package/dist/cli-delegate/{chunk-HJ6DAJHR.js → chunk-GB2FSUEE.js} +57 -7
- package/dist/cli-delegate/{chunk-WR6GTRHJ.js → chunk-JXARCGVN.js} +3 -3
- package/dist/cli-delegate/{chunk-3KJIMFTB.js → chunk-PQSAQKHW.js} +466 -304
- package/dist/cli-delegate/chunk-REKCMYOU.js +2991 -0
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +134 -0
- package/dist/cli-delegate/cmd_conformance.js +7 -7
- package/dist/cli-delegate/cmd_converge.js +3 -3
- package/dist/cli-delegate/cmd_doctor.js +4 -4
- package/dist/cli-delegate/cmd_explain.js +80 -330
- package/dist/cli-delegate/cmd_export.js +5 -5
- package/dist/cli-delegate/cmd_fleet.js +3 -3
- package/dist/cli-delegate/cmd_handoff.js +2 -2
- package/dist/cli-delegate/cmd_mcp_available.js +433 -0
- package/dist/cli-delegate/cmd_migrate.js +3 -3
- package/dist/cli-delegate/cmd_packs_active.js +153 -0
- package/dist/cli-delegate/cmd_preflight.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +5 -5
- package/dist/cli-delegate/cmd_route_audit.js +8 -8
- package/dist/cli-delegate/cmd_route_explain.js +4 -4
- package/dist/cli-delegate/cmd_settings_get.js +272 -0
- package/dist/cli-delegate/cmd_settings_migrate.js +5 -5
- package/dist/cli-delegate/cmd_settings_set.js +7 -2967
- package/dist/cli-delegate/cmd_sync.js +5 -5
- package/dist/cli-delegate/cmd_uninstall.js +5 -5
- package/dist/cli-delegate/cmd_update.js +4 -4
- package/dist/cli-delegate/cmd_upgrade.js +4 -4
- package/dist/cli-delegate/cmd_validate.js +5 -5
- package/dist/cli-delegate/cmd_versions.js +4 -4
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +100 -40
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +7 -5
- package/dist/discovery/trust-report.md +11 -11
- package/dist/discovery/workspaces.json +6 -4
- package/dist/hooks/dispatch.js +9004 -6132
- package/dist/install/install.mjs +697 -507
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +127 -20
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/agent_settings.js +80 -15
- package/dist/scripts/_lib/agent_settings.js.map +1 -1
- package/docs/contracts/settings-classes.md +29 -8
- package/docs/guidelines/agent-infra/memory-access.md +14 -3
- package/package.json +1 -1
- package/src/agent-src/templates/AGENTS.md +6 -10
- package/src/config/budgets.yml +29 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +26 -0
- package/src/scripts/_cli/cmd_brand_status.ts +174 -0
- package/src/scripts/_cli/cmd_mcp_available.ts +266 -0
- package/src/scripts/_cli/cmd_packs_active.ts +225 -0
- package/src/scripts/_cli/cmd_settings_get.ts +324 -0
- package/src/scripts/_dispatch.bash +57 -0
- package/src/scripts/_lib/agent_settings.ts +82 -16
- package/src/scripts/_lib/carrier_divergence.ts +135 -0
- package/src/scripts/_lib/host_capability.ts +84 -1
- package/src/scripts/_lib/prompt_shape.ts +88 -0
- package/src/scripts/_lib/review_skipped_record.ts +102 -0
- package/src/scripts/_lib/rule_layer_overlap.ts +254 -0
- package/src/scripts/_lib/self_repair.ts +342 -0
- package/src/scripts/_lib/self_repair_store.ts +100 -0
- package/src/scripts/_lib/transcript_entry.ts +80 -0
- package/src/scripts/check_branch_freshness.ts +255 -0
- package/src/scripts/check_council_config_location.ts +125 -1
- package/src/scripts/check_standing_rule_delivery.ts +314 -0
- package/src/scripts/config/profiles.ts +13 -3
- package/src/scripts/conformance_scan.ts +272 -24
- package/src/scripts/council_availability_hook.ts +188 -0
- package/src/scripts/council_cli.ts +126 -7
- package/src/scripts/hook_manifest.yaml +107 -26
- package/src/scripts/hooks/block_no_verify.ts +39 -2
- package/src/scripts/hooks/block_unauthorized_git.ts +156 -7
- package/src/scripts/hooks/concern_registry.ts +10 -0
- package/src/scripts/hooks/delegation_nudge_hook.ts +450 -0
- package/src/scripts/hooks/end_review_nudge_hook.ts +726 -0
- package/src/scripts/hooks/orchestration_record_hook.ts +342 -0
- package/src/scripts/install.ts +151 -0
- package/src/scripts/language_mirror_hook.ts +30 -37
- package/src/scripts/measure_scope_dedup.ts +19 -13
- package/src/scripts/onboarding_gate_hook.ts +40 -1
- package/src/scripts/orchestration_record.ts +4 -1
- package/src/scripts/preamble_byte_census.ts +9 -20
- package/src/scripts/report_carrier_divergence.ts +364 -0
- package/src/scripts/report_skill_activation.ts +257 -0
- package/src/scripts/report_skill_obligation_violations.ts +579 -0
- package/src/scripts/routing_doctor.ts +77 -6
- package/src/scripts/self_repair_cli.ts +240 -0
- package/src/scripts/self_repair_hook.ts +241 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-repair core — the pure half of the "the package fixes its own defects"
|
|
3
|
+
* loop (road-to-self-repair Phase 1).
|
|
4
|
+
*
|
|
5
|
+
* Two intake paths produce the same record shape:
|
|
6
|
+
*
|
|
7
|
+
* - USER-REPORTED — the user says the agent worked wrongly
|
|
8
|
+
* (`user_prompt_submit`).
|
|
9
|
+
* - SELF-DETECTED — a deterministic detector fires over the finished turn
|
|
10
|
+
* (`stop`).
|
|
11
|
+
*
|
|
12
|
+
* A record is a *defect against agent-config*, not against the consumer's
|
|
13
|
+
* project. It carries the class, a short redacted evidence span, and an
|
|
14
|
+
* occurrence count; the agent authors the actual fix, and the one outward
|
|
15
|
+
* step (push + PR, or an issue when a PR is impossible) stays behind the
|
|
16
|
+
* `non-destructive-by-default` Hard Floor.
|
|
17
|
+
*
|
|
18
|
+
* DELIBERATELY NOT HERE: an `attempt → critic → re-attempt` loop. That exact
|
|
19
|
+
* mechanism was built, benchmarked and falsified under ADR-106 (capability
|
|
20
|
+
* Δ = 0, McNemar p = 1.0; council verdict TERMINAL), and the same verdict
|
|
21
|
+
* names the replacement lever — "refining the rules on the failure tail" —
|
|
22
|
+
* which is what a defect record feeds. Detection and reporting are in scope;
|
|
23
|
+
* silently re-running the turn to paper over the miss is not.
|
|
24
|
+
*
|
|
25
|
+
* Everything in this module is pure and clock-injected so the detectors can be
|
|
26
|
+
* unit-tested without a host, a transcript, or a network.
|
|
27
|
+
*/
|
|
28
|
+
import { createHash } from 'node:crypto';
|
|
29
|
+
|
|
30
|
+
import { redact_low_impact_entry } from '../ai_council/redact_low_impact_entry.js';
|
|
31
|
+
|
|
32
|
+
/** Where a record came from. */
|
|
33
|
+
export type DefectSource = 'user-reported' | 'self-detected';
|
|
34
|
+
|
|
35
|
+
/** The defect classes a record can carry. One class = one detector. */
|
|
36
|
+
export type DefectClass =
|
|
37
|
+
| 'user-reported'
|
|
38
|
+
| 'council-availability-claim'
|
|
39
|
+
| 'language-mirror';
|
|
40
|
+
|
|
41
|
+
/** How far a record may travel outward. */
|
|
42
|
+
export type EgressRoute = 'pull-request' | 'issue' | 'local-only';
|
|
43
|
+
|
|
44
|
+
export interface DefectFinding {
|
|
45
|
+
defect_class: DefectClass;
|
|
46
|
+
source: DefectSource;
|
|
47
|
+
/** Short span quoted from the offending text — sanitized at capture. */
|
|
48
|
+
evidence: string;
|
|
49
|
+
/** One line naming what the fix should change. */
|
|
50
|
+
suggested_surface: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface DefectRecord extends DefectFinding {
|
|
54
|
+
fingerprint: string;
|
|
55
|
+
first_seen: string;
|
|
56
|
+
last_seen: string;
|
|
57
|
+
occurrences: number;
|
|
58
|
+
status: 'open' | 'released';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One finished turn, as much of it as a hook can observe. */
|
|
62
|
+
export interface TurnSnapshot {
|
|
63
|
+
/** The user's prompt for this turn. */
|
|
64
|
+
prompt: string;
|
|
65
|
+
/** The assistant's final reply text. */
|
|
66
|
+
reply: string;
|
|
67
|
+
/** Shell commands / tool names invoked during the turn. */
|
|
68
|
+
toolCommands: readonly string[];
|
|
69
|
+
/** The language the turn was pinned to, when the host pinned one. */
|
|
70
|
+
pinnedLanguage?: 'de' | 'en' | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── evidence hygiene ───────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
const MAX_EVIDENCE = 160;
|
|
76
|
+
const HOME_PATH_RE = /(\/Users\/|\/home\/|[A-Za-z]:\\Users\\)[^\s/\\]+/g;
|
|
77
|
+
const EMAIL_RE = /[^\s@]+@[^\s@]+\.[^\s@]+/g;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Capture-time hygiene: drop the two identifier classes that a quoted span
|
|
81
|
+
* realistically carries, collapse whitespace, and cap the length. This is a
|
|
82
|
+
* narrowing pass, never a clearance — `egressBlockedReason` is the gate.
|
|
83
|
+
*/
|
|
84
|
+
export function sanitizeEvidence(raw: string): string {
|
|
85
|
+
const flat = raw
|
|
86
|
+
.replace(HOME_PATH_RE, '<home>')
|
|
87
|
+
.replace(EMAIL_RE, '<email>')
|
|
88
|
+
.replace(/\s+/g, ' ')
|
|
89
|
+
.trim();
|
|
90
|
+
return flat.length <= MAX_EVIDENCE ? flat : `${flat.slice(0, MAX_EVIDENCE - 1)}…`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Publication gate. Runs the record's own text through the audited privacy
|
|
95
|
+
* floor (`redact_low_impact_entry`) and returns a refusal reason, or null when
|
|
96
|
+
* the record may leave the machine.
|
|
97
|
+
*
|
|
98
|
+
* The redactor refuses rather than rewrites — a rewritten secret is still a
|
|
99
|
+
* leaked secret — so a violation downgrades the record to `local-only` instead
|
|
100
|
+
* of being scrubbed into publishable shape.
|
|
101
|
+
*/
|
|
102
|
+
export function egressBlockedReason(record: DefectRecord, repoRoot?: string | null): string | null {
|
|
103
|
+
const result = redact_low_impact_entry(`${record.suggested_surface}\n${record.evidence}`, {
|
|
104
|
+
repoRoot: repoRoot ?? null,
|
|
105
|
+
});
|
|
106
|
+
if (result.ok) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const kinds = result.violations.map((v) => v.category).join(', ');
|
|
110
|
+
return `privacy floor refused the evidence (${kinds}) — record stays local`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── detectors ──────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
// An agent-directed complaint, not a complaint about the code. Deliberately
|
|
116
|
+
// narrow: a spurious record becomes a spurious PR, so precision beats recall.
|
|
117
|
+
const COMPLAINT_PATTERNS: readonly RegExp[] = [
|
|
118
|
+
/\bdu hast\b[^.!?]{0,60}\b(nicht|falsch|vergessen|ignoriert|übersehen)\b/i,
|
|
119
|
+
/\bdu (ignorierst|übersiehst)\b/i,
|
|
120
|
+
/\b(das|es) (war|ist) (aber )?falsch\b/i,
|
|
121
|
+
/\bhat nicht (richtig|korrekt) (gearbeitet|funktioniert)\b/i,
|
|
122
|
+
/\bnicht richtig gearbeitet\b/i,
|
|
123
|
+
/\byou (didn'?t|did not|failed to|ignored|forgot)\b/i,
|
|
124
|
+
/\bthat'?s (wrong|not right|incorrect)\b/i,
|
|
125
|
+
/\byou (worked|did (it|that)) (wrong|incorrectly)\b/i,
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
/** Intake path 1 — the user says the agent worked wrongly. */
|
|
129
|
+
export function detectUserReport(prompt: string): DefectFinding | null {
|
|
130
|
+
for (const re of COMPLAINT_PATTERNS) {
|
|
131
|
+
const m = re.exec(prompt);
|
|
132
|
+
if (m !== null) {
|
|
133
|
+
return {
|
|
134
|
+
defect_class: 'user-reported',
|
|
135
|
+
source: 'user-reported',
|
|
136
|
+
evidence: sanitizeEvidence(m[0]),
|
|
137
|
+
suggested_surface:
|
|
138
|
+
'Analyse the turn the user is objecting to and name the rule, skill, or ' +
|
|
139
|
+
'gate whose absence allowed it.',
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The #1218 defect, now with a runtime detector: the claim is only a defect
|
|
147
|
+
// when the resolver was never consulted — running the probe and reporting its
|
|
148
|
+
// answer is correct behaviour.
|
|
149
|
+
const COUNCIL_CLAIM_RE =
|
|
150
|
+
/\b(kein|keine|no)\b[^.!?\n]{0,40}\bcouncil\b[^.!?\n]{0,60}\b(konfiguriert|verfügbar|configured|available|infra)\b/i;
|
|
151
|
+
const COUNCIL_PROBE_RE =
|
|
152
|
+
/council[:_](status|estimate|run|cli)|council_cli|AI_COUNCIL_CONFIG/i;
|
|
153
|
+
|
|
154
|
+
/** Detector 1 — "council not configured" asserted without asking the resolver. */
|
|
155
|
+
export function detectCouncilClaim(turn: TurnSnapshot): DefectFinding | null {
|
|
156
|
+
const m = COUNCIL_CLAIM_RE.exec(turn.reply);
|
|
157
|
+
if (m === null) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
if (turn.toolCommands.some((c) => COUNCIL_PROBE_RE.test(c))) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
defect_class: 'council-availability-claim',
|
|
165
|
+
source: 'self-detected',
|
|
166
|
+
evidence: sanitizeEvidence(m[0]),
|
|
167
|
+
suggested_surface:
|
|
168
|
+
'Council availability was asserted without running the resolver — see the ' +
|
|
169
|
+
'council-availability rule; report why its Iron Law did not reach the decision.',
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Conservative language check: the Iron Law is about the FIRST token, so only
|
|
174
|
+
// the opening is inspected, and only a one-sided margin fires. A heuristic that
|
|
175
|
+
// fires on a mixed-language paragraph would manufacture defects.
|
|
176
|
+
const DE_MARKERS =
|
|
177
|
+
/\b(der|die|das|und|ich|nicht|ist|ein|eine|für|mit|auf|dass|wird|sind|hat|kann|noch|schon|aber|oder|wenn|habe|beim|vom)\b/gi;
|
|
178
|
+
const EN_MARKERS =
|
|
179
|
+
/\b(the|and|is|are|you|not|for|with|that|this|have|has|can|will|but|or|if|from|there|were)\b/gi;
|
|
180
|
+
const OPENING_CHARS = 240;
|
|
181
|
+
|
|
182
|
+
function countMatches(text: string, re: RegExp): number {
|
|
183
|
+
return (text.match(re) ?? []).length;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Strip the spans whose language says nothing about the reply's own prose. */
|
|
187
|
+
function proseOpening(reply: string): string {
|
|
188
|
+
return reply
|
|
189
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
190
|
+
.replace(/`[^`\n]*`/g, ' ')
|
|
191
|
+
.replace(/https?:\/\/\S+/g, ' ')
|
|
192
|
+
.trim()
|
|
193
|
+
.slice(0, OPENING_CHARS);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Detector 2 — the reply opens in the language the turn was NOT pinned to. */
|
|
197
|
+
export function detectLanguageMirror(turn: TurnSnapshot): DefectFinding | null {
|
|
198
|
+
const pinned = turn.pinnedLanguage ?? null;
|
|
199
|
+
if (pinned === null) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
const opening = proseOpening(turn.reply);
|
|
203
|
+
if (opening.length < 40) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
const de = countMatches(opening, DE_MARKERS) + (/[äöüßÄÖÜ]/.test(opening) ? 2 : 0);
|
|
207
|
+
const en = countMatches(opening, EN_MARKERS);
|
|
208
|
+
const wrong = pinned === 'de' ? en : de;
|
|
209
|
+
const right = pinned === 'de' ? de : en;
|
|
210
|
+
// One-sided margin: the wrong language is clearly present and the pinned
|
|
211
|
+
// one is entirely absent from the opening.
|
|
212
|
+
if (wrong < 3 || right > 0) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
defect_class: 'language-mirror',
|
|
217
|
+
source: 'self-detected',
|
|
218
|
+
evidence: sanitizeEvidence(opening.slice(0, 120)),
|
|
219
|
+
suggested_surface:
|
|
220
|
+
`Reply opened in the language the turn was not pinned to (pinned: ${pinned}) — ` +
|
|
221
|
+
'report which surface carried the pin and why it did not bind.',
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Run every self-detected detector over a finished turn. */
|
|
226
|
+
export function runDetectors(turn: TurnSnapshot): DefectFinding[] {
|
|
227
|
+
const out: DefectFinding[] = [];
|
|
228
|
+
for (const d of [detectCouncilClaim, detectLanguageMirror]) {
|
|
229
|
+
const f = d(turn);
|
|
230
|
+
if (f !== null) {
|
|
231
|
+
out.push(f);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── record identity ────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Fingerprint = class + shape of the evidence, never its incidentals. Digits,
|
|
241
|
+
* quoted spans and punctuation are normalized away so the same defect seen
|
|
242
|
+
* with a different name or count folds into one record instead of flooding
|
|
243
|
+
* the queue with near-duplicates.
|
|
244
|
+
*/
|
|
245
|
+
export function fingerprint(defect_class: DefectClass, evidence: string): string {
|
|
246
|
+
const shape = evidence
|
|
247
|
+
.toLowerCase()
|
|
248
|
+
.replace(/\d+/g, '#')
|
|
249
|
+
.replace(/[`"'‚'„"»«]/g, '')
|
|
250
|
+
.replace(/[^a-zà-ÿ#\s]/g, ' ')
|
|
251
|
+
.replace(/\s+/g, ' ')
|
|
252
|
+
.trim();
|
|
253
|
+
return createHash('sha256').update(`${defect_class}|${shape}`).digest('hex').slice(0, 16);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Fold a finding into an existing record, or open a new one. */
|
|
257
|
+
export function mergeRecord(
|
|
258
|
+
existing: DefectRecord | null,
|
|
259
|
+
finding: DefectFinding,
|
|
260
|
+
now: string,
|
|
261
|
+
): DefectRecord {
|
|
262
|
+
const fp = fingerprint(finding.defect_class, finding.evidence);
|
|
263
|
+
if (existing !== null && existing.fingerprint === fp) {
|
|
264
|
+
return {
|
|
265
|
+
...existing,
|
|
266
|
+
last_seen: now,
|
|
267
|
+
occurrences: existing.occurrences + 1,
|
|
268
|
+
// A record the user re-reports after a release is open again.
|
|
269
|
+
status: 'open',
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
return {
|
|
273
|
+
...finding,
|
|
274
|
+
fingerprint: fp,
|
|
275
|
+
first_seen: now,
|
|
276
|
+
last_seen: now,
|
|
277
|
+
occurrences: 1,
|
|
278
|
+
status: 'open',
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── egress ─────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
export interface EgressCapability {
|
|
285
|
+
/** An agent-config source checkout the fix can be authored in. */
|
|
286
|
+
hasAgentConfigCheckout: boolean;
|
|
287
|
+
/** `gh` is installed AND authenticated. */
|
|
288
|
+
ghAuthenticated: boolean;
|
|
289
|
+
/** The checkout can push a branch to the remote. */
|
|
290
|
+
canPush: boolean;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Route selection, exactly as specified: a pull request when the fix can be
|
|
295
|
+
* authored and pushed, an issue when it cannot, and a local record when
|
|
296
|
+
* nothing can leave the machine.
|
|
297
|
+
*/
|
|
298
|
+
export function chooseEgressRoute(cap: EgressCapability): EgressRoute {
|
|
299
|
+
if (cap.hasAgentConfigCheckout && cap.ghAuthenticated && cap.canPush) {
|
|
300
|
+
return 'pull-request';
|
|
301
|
+
}
|
|
302
|
+
if (cap.ghAuthenticated) {
|
|
303
|
+
return 'issue';
|
|
304
|
+
}
|
|
305
|
+
return 'local-only';
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The PR / issue body. Deterministic — same record, same bytes. */
|
|
309
|
+
export function renderReport(record: DefectRecord, route: EgressRoute): string {
|
|
310
|
+
const seen =
|
|
311
|
+
record.occurrences === 1
|
|
312
|
+
? `once (${record.first_seen})`
|
|
313
|
+
: `${record.occurrences}× (${record.first_seen} → ${record.last_seen})`;
|
|
314
|
+
const intake =
|
|
315
|
+
record.source === 'user-reported'
|
|
316
|
+
? 'A user stated the agent worked wrongly.'
|
|
317
|
+
: 'A deterministic detector fired at turn end; no user complaint was needed.';
|
|
318
|
+
return [
|
|
319
|
+
`## Self-repair report — \`${record.defect_class}\``,
|
|
320
|
+
'',
|
|
321
|
+
intake,
|
|
322
|
+
'',
|
|
323
|
+
`- **Fingerprint:** \`${record.fingerprint}\``,
|
|
324
|
+
`- **Seen:** ${seen}`,
|
|
325
|
+
`- **Route:** ${route}`,
|
|
326
|
+
'',
|
|
327
|
+
'### Evidence',
|
|
328
|
+
'',
|
|
329
|
+
'> ' + record.evidence,
|
|
330
|
+
'',
|
|
331
|
+
'### What the fix should address',
|
|
332
|
+
'',
|
|
333
|
+
record.suggested_surface,
|
|
334
|
+
'',
|
|
335
|
+
'---',
|
|
336
|
+
'',
|
|
337
|
+
'Filed by the agent-config self-repair loop. The evidence span is capture-time',
|
|
338
|
+
'sanitized and was cleared by the privacy floor before this report left the',
|
|
339
|
+
'machine. No project paths, prompts, or file contents are included by',
|
|
340
|
+
'construction — the record type has no field that can carry them.',
|
|
341
|
+
].join('\n');
|
|
342
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-repair record store — the I/O half kept out of `self_repair.ts` so the
|
|
3
|
+
* detectors stay pure and testable.
|
|
4
|
+
*
|
|
5
|
+
* Records live under `agents/runtime/self-repair/`, which the repo `.gitignore`
|
|
6
|
+
* already covers via the `/agents/runtime/` catch-all: a defect record is
|
|
7
|
+
* machine-generated local state, never a tracked artefact. One file per
|
|
8
|
+
* fingerprint, so a recurring defect increments a counter instead of adding a
|
|
9
|
+
* queue entry.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { type DefectFinding, type DefectRecord, fingerprint, mergeRecord } from './self_repair.js';
|
|
15
|
+
|
|
16
|
+
export const STORE_REL = path.join('agents', 'runtime', 'self-repair');
|
|
17
|
+
|
|
18
|
+
export function storeDir(root: string): string {
|
|
19
|
+
return path.join(root, STORE_REL);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function recordPath(root: string, fp: string): string {
|
|
23
|
+
return path.join(storeDir(root), `${fp}.json`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isRecord(v: unknown): v is DefectRecord {
|
|
27
|
+
if (typeof v !== 'object' || v === null) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
const r = v as Partial<DefectRecord>;
|
|
31
|
+
return typeof r.fingerprint === 'string' && typeof r.defect_class === 'string';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Read one record, or null when absent / unreadable / malformed. */
|
|
35
|
+
export function readRecord(root: string, fp: string): DefectRecord | null {
|
|
36
|
+
try {
|
|
37
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(recordPath(root, fp), 'utf-8'));
|
|
38
|
+
return isRecord(parsed) ? parsed : null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function writeRecord(root: string, record: DefectRecord): void {
|
|
45
|
+
const dir = storeDir(root);
|
|
46
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
47
|
+
fs.writeFileSync(
|
|
48
|
+
recordPath(root, record.fingerprint),
|
|
49
|
+
`${JSON.stringify(record, null, 2)}\n`,
|
|
50
|
+
'utf-8',
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Every record in the store, newest activity first. */
|
|
55
|
+
export function listRecords(root: string): DefectRecord[] {
|
|
56
|
+
let names: string[];
|
|
57
|
+
try {
|
|
58
|
+
names = fs.readdirSync(storeDir(root));
|
|
59
|
+
} catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
const out: DefectRecord[] = [];
|
|
63
|
+
for (const n of names.sort()) {
|
|
64
|
+
if (!n.endsWith('.json')) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const rec = readRecord(root, n.slice(0, -'.json'.length));
|
|
68
|
+
if (rec !== null) {
|
|
69
|
+
out.push(rec);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
out.sort((a, b) => (a.last_seen < b.last_seen ? 1 : a.last_seen > b.last_seen ? -1 : 0));
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function openRecords(root: string): DefectRecord[] {
|
|
77
|
+
return listRecords(root).filter((r) => r.status === 'open');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Fold a finding into the store: increments the existing record when the
|
|
82
|
+
* fingerprint matches, otherwise opens a new one. Returns the stored record.
|
|
83
|
+
*/
|
|
84
|
+
export function upsertFinding(root: string, finding: DefectFinding, now: string): DefectRecord {
|
|
85
|
+
const fp = fingerprint(finding.defect_class, finding.evidence);
|
|
86
|
+
const merged = mergeRecord(readRecord(root, fp), finding, now);
|
|
87
|
+
writeRecord(root, merged);
|
|
88
|
+
return merged;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Mark a record released (a PR or issue now carries it). */
|
|
92
|
+
export function markReleased(root: string, fp: string, now: string): DefectRecord | null {
|
|
93
|
+
const rec = readRecord(root, fp);
|
|
94
|
+
if (rec === null) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const next: DefectRecord = { ...rec, status: 'released', last_seen: now };
|
|
98
|
+
writeRecord(root, next);
|
|
99
|
+
return next;
|
|
100
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One reader for a transcript entry's text — the shape half, shared.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS, measured rather than anticipated. A Claude Code transcript
|
|
5
|
+
* carries a user entry's `message.content` in TWO shapes: a bare string, and an
|
|
6
|
+
* array of content blocks. `conformance_scan` handled both; a second reader
|
|
7
|
+
* written against the same field handled only the string, and the store says why
|
|
8
|
+
* that is fatal rather than cosmetic:
|
|
9
|
+
*
|
|
10
|
+
* user entries in one 30-session store: 1 440 string · 23 907 array
|
|
11
|
+
* injected skill bodies among them: 0 via string · 41 via array
|
|
12
|
+
*
|
|
13
|
+
* So the narrower reader saw **none** of them, and the failure was invisible:
|
|
14
|
+
* a detector whose loaded-set is empty returns no findings, which is
|
|
15
|
+
* indistinguishable from compliance. `grep` over the raw JSONL finds the marker
|
|
16
|
+
* either way — reading raw text is not evidence about the parse path, and that
|
|
17
|
+
* is the mistake this module removes the opportunity to repeat.
|
|
18
|
+
*
|
|
19
|
+
* Scope is deliberately just the shape, plus the sidechain exclusion. Semantic
|
|
20
|
+
* filters (a `<system-reminder>` is not a chat message, an injected body is not a
|
|
21
|
+
* user turn) stay with the caller that needs them: they differ per question, and
|
|
22
|
+
* folding them in here would make one module answer two.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** A parsed transcript line. */
|
|
26
|
+
export type TranscriptEntry = Record<string, unknown>;
|
|
27
|
+
|
|
28
|
+
function _isObject(v: unknown): v is Record<string, unknown> {
|
|
29
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A subagent's own turns. Excluded by every reader in this tree: a sidechain turn
|
|
34
|
+
* did not happen in the main thread, so counting it lets a skill loaded in one
|
|
35
|
+
* context be paired with an act taken in another.
|
|
36
|
+
*/
|
|
37
|
+
export function isSidechain(entry: TranscriptEntry): boolean {
|
|
38
|
+
return entry['isSidechain'] === true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Flatten `message.content` to text, whichever shape it arrived in.
|
|
43
|
+
*
|
|
44
|
+
* Returns `''` when there is no text (a tool-result-only entry, an unparseable
|
|
45
|
+
* shape) — never `null`, so a caller cannot forget the second branch.
|
|
46
|
+
*/
|
|
47
|
+
export function entryText(entry: TranscriptEntry): string {
|
|
48
|
+
const msg = entry['message'];
|
|
49
|
+
const content = _isObject(msg) ? msg['content'] : undefined;
|
|
50
|
+
if (typeof content === 'string') {
|
|
51
|
+
return content;
|
|
52
|
+
}
|
|
53
|
+
if (!Array.isArray(content)) {
|
|
54
|
+
return '';
|
|
55
|
+
}
|
|
56
|
+
return content
|
|
57
|
+
.filter((b) => _isObject(b) && b['type'] === 'text')
|
|
58
|
+
.map((b) => String((b as Record<string, unknown>)['text'] ?? ''))
|
|
59
|
+
.join('\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Tool calls in an entry, flattened to name + input. Both shapes are arrays. */
|
|
63
|
+
export function toolUses(entry: TranscriptEntry): Array<{ name: string; input: Record<string, unknown> }> {
|
|
64
|
+
const msg = entry['message'];
|
|
65
|
+
const content = _isObject(msg) ? msg['content'] : undefined;
|
|
66
|
+
if (!Array.isArray(content)) {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
const out: Array<{ name: string; input: Record<string, unknown> }> = [];
|
|
70
|
+
for (const part of content) {
|
|
71
|
+
if (!_isObject(part) || part['type'] !== 'tool_use') {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
out.push({
|
|
75
|
+
name: typeof part['name'] === 'string' ? part['name'] : '',
|
|
76
|
+
input: _isObject(part['input']) ? part['input'] : {},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|