@event4u/agent-config 13.0.0 → 14.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +195 -315
- package/README.md +1 -1
- package/dist/agent-src/commands/evals/roadmap.json +8 -0
- package/dist/agent-src/commands/fix/ci.md +39 -1
- package/dist/agent-src/commands/pr/create.md +65 -4
- package/dist/agent-src/commands/roadmap/next.md +28 -5
- package/dist/agent-src/commands/work.md +16 -0
- package/dist/agent-src/contexts/contracts/command-suggestion-flow.md +47 -0
- package/dist/agent-src/contexts/execution/contract-decision-sheet.md +94 -0
- package/dist/agent-src/contexts/execution/plan-confidence-gate.md +30 -0
- package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +21 -8
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +55 -8
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +42 -0
- package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +29 -0
- package/dist/agent-src/rules/command-suggestion-policy.md +33 -0
- package/dist/agent-src/rules/design-review-after-ui-write.md +25 -8
- package/dist/agent-src/rules/ui-audit-gate.md +25 -8
- package/dist/agent-src/scripts/gate_execute.ts +395 -0
- package/dist/agent-src/scripts/resume_probe.ts +422 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +138 -4
- package/dist/agent-src/scripts/update_roadmap_progress.ts +53 -2
- package/dist/agent-src/skills/ai-council/SKILL.md +61 -0
- package/dist/agent-src/skills/experiment-loop/SKILL.md +116 -0
- package/dist/agent-src/skills/experiment-loop/evals/output-schema.yml +27 -0
- package/dist/agent-src/skills/experiment-loop/evals/triggers.json +17 -0
- package/dist/agent-src/skills/experiment-loop/references/pivot-ladder.md +54 -0
- package/dist/agent-src/skills/experiment-loop/references/protocol.md +61 -0
- package/dist/agent-src/skills/experiment-loop/references/register-format.md +66 -0
- package/dist/agent-src/skills/roadmap-management/SKILL.md +2 -2
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
- package/dist/agent-src/skills/subagent-orchestration/schemas/team-review-status.json +8 -3
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +43 -3
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-N7FEST6D.js → chunk-7PZ4FSZB.js} +351 -174
- package/dist/cli-delegate/{chunk-3B72LSZL.js → chunk-F3YUF7SA.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +336 -112
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +40 -18
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +6 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +5 -4
- package/dist/hooks/dispatch.js +2128 -1326
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1764 -899
- package/dist/router.json +1 -1
- package/dist/scripts/condense.js +1 -1
- package/dist/scripts/condense.js.map +1 -1
- package/dist/ui/assets/{index-B0aMJnuz.js → index-C1pBRm1f.js} +2 -2
- package/dist/ui/assets/index-C1pBRm1f.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/gate_execute.ts +395 -0
- package/src/agent-src/scripts/resume_probe.ts +422 -0
- package/src/agent-src/scripts/roadmap_gates.ts +138 -4
- package/src/agent-src/scripts/update_roadmap_progress.ts +53 -2
- package/src/config/ci-local-parity.yml +13 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +111 -5
- package/src/config/gate-violation-baselines.json +5 -5
- package/src/config/rule-activation-census.json +41 -0
- package/src/config/rule-stub-ceilings.json +52 -0
- package/src/scripts/_dispatch.bash +4 -0
- package/src/scripts/_lib/evaluator_contract.ts +159 -0
- package/src/scripts/_lib/gate_ledger.ts +5 -1
- package/src/scripts/_lib/gh_transient.ts +80 -0
- package/src/scripts/_lib/release_material.ts +16 -5
- package/src/scripts/_lib/review_independence.ts +111 -0
- package/src/scripts/_lib/session_register.ts +14 -0
- package/src/scripts/_lib/skill_catalogue.ts +20 -0
- package/src/scripts/_lib/skill_catalogue_series.ts +411 -0
- package/src/scripts/_lib/turn_end_refusals.ts +482 -0
- package/src/scripts/ai_council/_py_json.ts +96 -0
- package/src/scripts/ai_council/cli_call_budget.ts +357 -0
- package/src/scripts/ai_council/clients.ts +80 -143
- package/src/scripts/ai_council/config.ts +25 -11
- package/src/scripts/ai_council/probe_store.ts +196 -0
- package/src/scripts/ai_council/qualification.ts +425 -0
- package/src/scripts/ai_council/qualification_wiring.ts +243 -0
- package/src/scripts/ai_council/quorum_wiring.ts +211 -0
- package/src/scripts/ai_team/team_dispatch.ts +40 -6
- package/src/scripts/build_archive_index.ts +62 -3
- package/src/scripts/capture_skill_catalogue.ts +264 -4
- package/src/scripts/check_evaluator_schema.ts +249 -0
- package/src/scripts/check_gate_coverage.ts +93 -0
- package/src/scripts/check_pr_ci_current.ts +492 -0
- package/src/scripts/check_review_schema.ts +195 -0
- package/src/scripts/check_rule_activation_census.ts +451 -0
- package/src/scripts/check_rule_stub_ceiling.ts +1128 -0
- package/src/scripts/condense.ts +1 -1
- package/src/scripts/council_cli.ts +141 -191
- package/src/scripts/dispatch_r2_reviewer.ts +8 -1
- package/src/scripts/git_authorization_hook.ts +186 -1
- package/src/scripts/hook_manifest.yaml +72 -5
- package/src/scripts/hooks/block_unauthorized_git.ts +62 -6
- package/src/scripts/hooks/concern_registry.ts +4 -0
- package/src/scripts/hooks/dispatch_hook.ts +43 -1
- package/src/scripts/hooks/interruption_ledger_hook.ts +256 -0
- package/src/scripts/hooks/ship_diff_volume_hook.ts +180 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +119 -26
- package/src/scripts/hooks_doctor.ts +112 -0
- package/src/scripts/interruption_report.ts +443 -0
- package/src/scripts/lint_evidence_artifacts.ts +359 -0
- package/src/scripts/lint_roadmap_blockers.ts +65 -0
- package/src/scripts/lint_roadmap_complexity.ts +7 -1
- package/src/scripts/release.ts +38 -53
- package/src/scripts/rule_activation_census.ts +292 -0
- package/src/scripts/schemas/evaluator-output.schema.json +60 -0
- package/src/scripts/schemas/review-findings.schema.json +31 -0
- package/src/scripts/self_review_gate.ts +6 -0
- package/src/scripts/session_register_hook.ts +30 -1
- package/src/scripts/sync_pr_branch.ts +271 -0
- package/src/scripts/test_council_qualification.ts +213 -0
- package/dist/ui/assets/index-B0aMJnuz.js.map +0 -1
|
@@ -34,6 +34,13 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
34
34
|
import { read_dispatch_issues } from "./hooks/dispatch_issues.js";
|
|
35
35
|
import { type JsonObject, type JsonValue, _load_yaml } from "./hooks/dispatch_hook.js";
|
|
36
36
|
import * as hooks_status from "./hooks_status.js";
|
|
37
|
+
import {
|
|
38
|
+
DETECTOR_IDS,
|
|
39
|
+
collectRefusalStats,
|
|
40
|
+
readInstallBoundary,
|
|
41
|
+
type InstallBoundary,
|
|
42
|
+
type RefusalStats,
|
|
43
|
+
} from "./_lib/turn_end_refusals.js";
|
|
37
44
|
|
|
38
45
|
// src/scripts/hooks_doctor.ts → parents[2] (../../..) is the repo root,
|
|
39
46
|
// mirroring the Python `Path(__file__).resolve().parents[2]`.
|
|
@@ -171,6 +178,17 @@ export interface DoctorPayload {
|
|
|
171
178
|
concerns: ConcernRow[];
|
|
172
179
|
trampolines: TrampolineRow[];
|
|
173
180
|
dispatch_issues: JsonObject[];
|
|
181
|
+
/**
|
|
182
|
+
* Turn-end refusals, per detector, from this workspace's own records.
|
|
183
|
+
*
|
|
184
|
+
* `road-to-stop-gate-honesty` § D-2: every advisory concern in this estate
|
|
185
|
+
* carries a registered kill standard and the one BLOCKING concern carries
|
|
186
|
+
* none, because nobody could see how often it fires. The doctor is where a
|
|
187
|
+
* concern's health is already read, so this is where the number belongs.
|
|
188
|
+
*/
|
|
189
|
+
turn_end_refusals: RefusalStats;
|
|
190
|
+
/** The machine's install stamp, for reading the version split below it. */
|
|
191
|
+
install_boundary: InstallBoundary;
|
|
174
192
|
}
|
|
175
193
|
|
|
176
194
|
/** Build the doctor payload — JSON-serialisable. */
|
|
@@ -229,12 +247,31 @@ export function collect(
|
|
|
229
247
|
issues = [];
|
|
230
248
|
}
|
|
231
249
|
|
|
250
|
+
let turn_end_refusals: RefusalStats;
|
|
251
|
+
try {
|
|
252
|
+
turn_end_refusals = collectRefusalStats(project_root);
|
|
253
|
+
} catch {
|
|
254
|
+
turn_end_refusals = {
|
|
255
|
+
sessionsWithRefusals: 0,
|
|
256
|
+
total: 0,
|
|
257
|
+
byDetector: { promissory: 0, language: 0, verification: 0, completion: 0 },
|
|
258
|
+
byPeriod: [],
|
|
259
|
+
legacyRecords: 0,
|
|
260
|
+
unversionedRecords: 0,
|
|
261
|
+
byVersion: [],
|
|
262
|
+
earliest: null,
|
|
263
|
+
latest: null,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
232
267
|
return {
|
|
233
268
|
schema_version: 1,
|
|
234
269
|
platforms: matrix.platforms,
|
|
235
270
|
concerns,
|
|
236
271
|
trampolines,
|
|
237
272
|
dispatch_issues: issues,
|
|
273
|
+
turn_end_refusals,
|
|
274
|
+
install_boundary: readInstallBoundary(),
|
|
238
275
|
};
|
|
239
276
|
}
|
|
240
277
|
|
|
@@ -242,6 +279,79 @@ function _strVal(v: JsonValue | undefined): string {
|
|
|
242
279
|
return v === undefined || v === null ? "undefined" : String(v);
|
|
243
280
|
}
|
|
244
281
|
|
|
282
|
+
/**
|
|
283
|
+
* The turn-end-gate's own number.
|
|
284
|
+
*
|
|
285
|
+
* Reports what the records can support and refuses the number they cannot: the
|
|
286
|
+
* denominator is *sessions that had at least one refusal*, never all sessions,
|
|
287
|
+
* because a session that was never refused writes no record. Labelling a
|
|
288
|
+
* per-refused-session figure as a per-session rate would be the same
|
|
289
|
+
* denominator inflation this estate's measurement discipline exists to stop.
|
|
290
|
+
*/
|
|
291
|
+
export function _render_refusals(payload: DoctorPayload): string[] {
|
|
292
|
+
const s = payload.turn_end_refusals;
|
|
293
|
+
const lines: string[] = [];
|
|
294
|
+
lines.push("Turn-end refusals");
|
|
295
|
+
lines.push("-".repeat(60));
|
|
296
|
+
if (s.sessionsWithRefusals === 0) {
|
|
297
|
+
lines.push("· no refusal records under agents/runtime/state/turn-end-gate/");
|
|
298
|
+
return lines;
|
|
299
|
+
}
|
|
300
|
+
const perSession = (s.total / s.sessionsWithRefusals).toFixed(2);
|
|
301
|
+
lines.push(
|
|
302
|
+
` ${s.total} refusal(s) across ${s.sessionsWithRefusals} session(s) ` +
|
|
303
|
+
`that were refused at least once — ${perSession} per such session.`,
|
|
304
|
+
);
|
|
305
|
+
lines.push(
|
|
306
|
+
" NOT a per-session rate: sessions with no refusal write no record, so " +
|
|
307
|
+
"the wider denominator is unavailable here by construction.",
|
|
308
|
+
);
|
|
309
|
+
if (s.earliest && s.latest) {
|
|
310
|
+
lines.push(` window: ${s.earliest.slice(0, 10)} … ${s.latest.slice(0, 10)}`);
|
|
311
|
+
}
|
|
312
|
+
for (const id of DETECTOR_IDS) {
|
|
313
|
+
const n = s.byDetector[id];
|
|
314
|
+
const share = s.total > 0 ? Math.round((n / s.total) * 100) : 0;
|
|
315
|
+
lines.push(` ${id.padEnd(14)} ${String(n).padStart(4)} ${share}%`);
|
|
316
|
+
}
|
|
317
|
+
if (s.legacyRecords > 0) {
|
|
318
|
+
lines.push(
|
|
319
|
+
` ${s.legacyRecords} record(s) predate per-detector counting and ` +
|
|
320
|
+
"contribute one refusal each — the total is a FLOOR, not an exact count.",
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
const boundary = payload.install_boundary;
|
|
324
|
+
if (boundary.version || boundary.installed_at) {
|
|
325
|
+
lines.push(
|
|
326
|
+
` installed: ${boundary.version ?? "(unknown version)"} at ` +
|
|
327
|
+
`${boundary.installed_at ?? "(unknown date)"}`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
if (s.byVersion.length > 0) {
|
|
331
|
+
lines.push(" by recorded version:");
|
|
332
|
+
for (const v of s.byVersion) {
|
|
333
|
+
lines.push(` ${v.version.padEnd(16)} ${String(v.total).padStart(4)}`);
|
|
334
|
+
}
|
|
335
|
+
if (s.unversionedRecords > 0) {
|
|
336
|
+
lines.push(
|
|
337
|
+
" (unrecorded) rows predate version stamping — they cannot test " +
|
|
338
|
+
"the install-date prediction in either direction.",
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const recent = s.byPeriod.slice(0, 7);
|
|
343
|
+
if (recent.length > 0) {
|
|
344
|
+
lines.push(" most recent days:");
|
|
345
|
+
for (const p of recent) {
|
|
346
|
+
lines.push(
|
|
347
|
+
` ${p.period} ${String(p.total).padStart(3)} refusal(s) / ` +
|
|
348
|
+
`${p.sessions} session(s)`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return lines;
|
|
353
|
+
}
|
|
354
|
+
|
|
245
355
|
export function _render_table(payload: DoctorPayload): string {
|
|
246
356
|
const lines: string[] = [];
|
|
247
357
|
// Phase 1 CTA — surfaces at the TOP when issues exist, so a user
|
|
@@ -286,6 +396,8 @@ export function _render_table(payload: DoctorPayload): string {
|
|
|
286
396
|
const suffix = t.required ? "" : " (not required)";
|
|
287
397
|
lines.push(`${marker}${t.platform.padEnd(9)} ${t.expected}${suffix}`);
|
|
288
398
|
}
|
|
399
|
+
lines.push("");
|
|
400
|
+
lines.push(..._render_refusals(payload));
|
|
289
401
|
// Dispatch-issues detail — last 20 grouped by concern.
|
|
290
402
|
if (payload.dispatch_issues.length > 0) {
|
|
291
403
|
lines.push("");
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* interruption_report — road-to-user-out-of-the-loop Phase 0 Step 2.
|
|
3
|
+
*
|
|
4
|
+
* Reports the two axes the roadmap's Goal names and pre-registers as separate
|
|
5
|
+
* claims, because a run can ask zero questions and still be slow:
|
|
6
|
+
*
|
|
7
|
+
* CONTACT AXIS asks per run, hand-backs per run, halts per run, and the
|
|
8
|
+
* median user wait between an agent turn and the user's reply.
|
|
9
|
+
* WALL-CLOCK AXIS total elapsed per run versus agent working time — elapsed
|
|
10
|
+
* minus the time the run spent waiting on a human.
|
|
11
|
+
*
|
|
12
|
+
* ── WHAT THIS REPORT REFUSES TO DO ────────────────────────────────────────
|
|
13
|
+
*
|
|
14
|
+
* The step specifies a 30-session window "matching the conformance window".
|
|
15
|
+
* Measured on this tree the day it was written, the rolling history held
|
|
16
|
+
* **5 sessions, all from one day** — the file is a rolling buffer, not an
|
|
17
|
+
* archive, so 30 is a request rather than a guarantee.
|
|
18
|
+
*
|
|
19
|
+
* So the window is a REQUEST and `sessions_found` is reported next to it, with
|
|
20
|
+
* an explicit `window_short` flag when fewer sessions exist than were asked
|
|
21
|
+
* for. A baseline computed over 5 sessions and labelled "30" is exactly the
|
|
22
|
+
* measurement-artifact-as-decision-input failure this repository has already
|
|
23
|
+
* recorded once (`skill-usage-report.md` publishing "Active: 0"). A short
|
|
24
|
+
* window is a real finding about retention; hiding it would make the number
|
|
25
|
+
* look authoritative and the retention problem invisible.
|
|
26
|
+
*
|
|
27
|
+
* ── THE JOIN ──────────────────────────────────────────────────────────────
|
|
28
|
+
*
|
|
29
|
+
* Contacts come from `agents/runtime/state/interruptions.jsonl` (the Phase 0
|
|
30
|
+
* Step 1 concern). Timestamps come from `agents/runtime/.agent-chat-history`.
|
|
31
|
+
* They join on the session tag: the ledger writes `run_id` using
|
|
32
|
+
* `derive_session_tag`, the same derivation that file writes as `s`.
|
|
33
|
+
*
|
|
34
|
+
* A run present in one source and not the other is reported, never silently
|
|
35
|
+
* dropped — a ledger entry with no timestamps yields contact counts and a null
|
|
36
|
+
* wall clock, and history with no ledger entry yields timing and a null contact
|
|
37
|
+
* count. Dropping either would bias the medians in a direction nobody could see.
|
|
38
|
+
*
|
|
39
|
+
* ── SYNTHETIC USER TURNS ──────────────────────────────────────────────────
|
|
40
|
+
*
|
|
41
|
+
* The harness writes task notifications and system reminders into the USER
|
|
42
|
+
* role. Counting those as "the user replied" would collapse every measured wait
|
|
43
|
+
* to near zero and make the contact axis read as free. They are excluded by
|
|
44
|
+
* marker, and the count of what was excluded is reported so the exclusion is
|
|
45
|
+
* auditable rather than a silent filter.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import * as fs from 'node:fs';
|
|
49
|
+
import * as path from 'node:path';
|
|
50
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
51
|
+
|
|
52
|
+
export const DEFAULT_WINDOW = 30;
|
|
53
|
+
|
|
54
|
+
export interface LedgerRow {
|
|
55
|
+
run_id: string;
|
|
56
|
+
turn: number;
|
|
57
|
+
kind: string;
|
|
58
|
+
class: string;
|
|
59
|
+
roadmap: string | null;
|
|
60
|
+
at: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface HistoryTurn {
|
|
64
|
+
session: string;
|
|
65
|
+
role: 'user' | 'agent';
|
|
66
|
+
ts: string;
|
|
67
|
+
synthetic: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface RunReport {
|
|
71
|
+
run_id: string;
|
|
72
|
+
roadmap: string | null;
|
|
73
|
+
asks: number;
|
|
74
|
+
handbacks: number;
|
|
75
|
+
contacts: number;
|
|
76
|
+
halts: number;
|
|
77
|
+
elapsed_minutes: number | null;
|
|
78
|
+
waiting_minutes: number | null;
|
|
79
|
+
working_minutes: number | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface Report {
|
|
83
|
+
window_requested: number;
|
|
84
|
+
sessions_found: number;
|
|
85
|
+
window_short: boolean;
|
|
86
|
+
synthetic_user_turns_excluded: number;
|
|
87
|
+
runs: RunReport[];
|
|
88
|
+
median_contacts_per_run: number | null;
|
|
89
|
+
median_user_wait_minutes: number | null;
|
|
90
|
+
median_elapsed_minutes: number | null;
|
|
91
|
+
median_working_minutes: number | null;
|
|
92
|
+
notes: string[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A user-role entry the harness produced, not the human. Matched on the markers
|
|
97
|
+
* the harness actually emits; anything else counts as a real reply.
|
|
98
|
+
*/
|
|
99
|
+
const SYNTHETIC_MARKERS = [
|
|
100
|
+
'<task-notification>',
|
|
101
|
+
'<system-reminder>',
|
|
102
|
+
'<local-command-caveat>',
|
|
103
|
+
'<command-name>',
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
export function isSyntheticUserText(text: string): boolean {
|
|
107
|
+
return SYNTHETIC_MARKERS.some((m) => text.includes(m));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function median(values: readonly number[]): number | null {
|
|
111
|
+
if (values.length === 0) return null;
|
|
112
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
113
|
+
const mid = Math.floor(sorted.length / 2);
|
|
114
|
+
return sorted.length % 2 === 1 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function readLedger(root: string): LedgerRow[] {
|
|
118
|
+
const file = path.join(root, 'agents', 'runtime', 'state', 'interruptions.jsonl');
|
|
119
|
+
let raw: string;
|
|
120
|
+
try {
|
|
121
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
122
|
+
} catch {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
const out: LedgerRow[] = [];
|
|
126
|
+
for (const line of raw.split('\n')) {
|
|
127
|
+
if (!line.trim()) continue;
|
|
128
|
+
try {
|
|
129
|
+
const o: unknown = JSON.parse(line);
|
|
130
|
+
if (o === null || typeof o !== 'object') continue;
|
|
131
|
+
const r = o as Record<string, unknown>;
|
|
132
|
+
if (typeof r['run_id'] !== 'string') continue;
|
|
133
|
+
out.push({
|
|
134
|
+
run_id: r['run_id'],
|
|
135
|
+
turn: typeof r['turn'] === 'number' ? r['turn'] : 0,
|
|
136
|
+
kind: typeof r['kind'] === 'string' ? r['kind'] : 'none',
|
|
137
|
+
class: typeof r['class'] === 'string' ? r['class'] : 'none',
|
|
138
|
+
roadmap: typeof r['roadmap'] === 'string' ? r['roadmap'] : null,
|
|
139
|
+
at: typeof r['at'] === 'string' ? r['at'] : '',
|
|
140
|
+
});
|
|
141
|
+
} catch {
|
|
142
|
+
// A corrupt line is one lost observation, never a failed report.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function readHistory(root: string): { turns: HistoryTurn[]; synthetic: number } {
|
|
149
|
+
const file = process.env['AGENT_CHAT_HISTORY_FILE'] || path.join(root, 'agents', 'runtime', '.agent-chat-history');
|
|
150
|
+
let raw: string;
|
|
151
|
+
try {
|
|
152
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
153
|
+
} catch {
|
|
154
|
+
return { turns: [], synthetic: 0 };
|
|
155
|
+
}
|
|
156
|
+
const turns: HistoryTurn[] = [];
|
|
157
|
+
let synthetic = 0;
|
|
158
|
+
for (const line of raw.split('\n')) {
|
|
159
|
+
if (!line.trim()) continue;
|
|
160
|
+
try {
|
|
161
|
+
const o: unknown = JSON.parse(line);
|
|
162
|
+
if (o === null || typeof o !== 'object') continue;
|
|
163
|
+
const r = o as Record<string, unknown>;
|
|
164
|
+
const t = r['t'];
|
|
165
|
+
if (t !== 'user' && t !== 'agent') continue;
|
|
166
|
+
const session = typeof r['s'] === 'string' ? r['s'] : '';
|
|
167
|
+
const ts = typeof r['ts'] === 'string' ? r['ts'] : '';
|
|
168
|
+
if (!session || !ts) continue;
|
|
169
|
+
const text = typeof r['text'] === 'string' ? r['text'] : '';
|
|
170
|
+
const isSynthetic = t === 'user' && isSyntheticUserText(text);
|
|
171
|
+
if (isSynthetic) synthetic += 1;
|
|
172
|
+
turns.push({ session, role: t, ts, synthetic: isSynthetic });
|
|
173
|
+
} catch {
|
|
174
|
+
// Same reasoning as the ledger: one bad line is not a failed report.
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { turns, synthetic };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const MINUTE_MS = 60_000;
|
|
181
|
+
|
|
182
|
+
function minutesBetween(a: string, b: string): number | null {
|
|
183
|
+
const ta = Date.parse(a);
|
|
184
|
+
const tb = Date.parse(b);
|
|
185
|
+
if (Number.isNaN(ta) || Number.isNaN(tb)) return null;
|
|
186
|
+
return (tb - ta) / MINUTE_MS;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Per-session wait gaps: an agent turn followed by a REAL user turn. Synthetic
|
|
191
|
+
* user entries are skipped rather than treated as replies, so a run the harness
|
|
192
|
+
* woke up four times still shows the one human wait it actually had.
|
|
193
|
+
*/
|
|
194
|
+
export function waitGaps(turns: readonly HistoryTurn[]): number[] {
|
|
195
|
+
const gaps: number[] = [];
|
|
196
|
+
let lastAgentTs: string | null = null;
|
|
197
|
+
for (const turn of turns) {
|
|
198
|
+
if (turn.role === 'agent') {
|
|
199
|
+
lastAgentTs = turn.ts;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (turn.synthetic) continue;
|
|
203
|
+
if (lastAgentTs === null) continue;
|
|
204
|
+
const gap = minutesBetween(lastAgentTs, turn.ts);
|
|
205
|
+
if (gap !== null && gap >= 0) gaps.push(gap);
|
|
206
|
+
lastAgentTs = null;
|
|
207
|
+
}
|
|
208
|
+
return gaps;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function buildReport(root: string, windowRequested: number): Report {
|
|
212
|
+
const ledger = readLedger(root);
|
|
213
|
+
const { turns, synthetic } = readHistory(root);
|
|
214
|
+
|
|
215
|
+
const bySession = new Map<string, HistoryTurn[]>();
|
|
216
|
+
for (const turn of turns) {
|
|
217
|
+
const list = bySession.get(turn.session);
|
|
218
|
+
if (list) list.push(turn);
|
|
219
|
+
else bySession.set(turn.session, [turn]);
|
|
220
|
+
}
|
|
221
|
+
for (const list of bySession.values()) list.sort((a, b) => a.ts.localeCompare(b.ts));
|
|
222
|
+
|
|
223
|
+
// Newest sessions first, capped at the requested window. Ordering is by the
|
|
224
|
+
// session's LAST turn, so a long-running session that started earliest is
|
|
225
|
+
// still current.
|
|
226
|
+
const ordered = [...bySession.entries()].sort((a, b) => {
|
|
227
|
+
const la = a[1][a[1].length - 1]?.ts ?? '';
|
|
228
|
+
const lb = b[1][b[1].length - 1]?.ts ?? '';
|
|
229
|
+
return lb.localeCompare(la);
|
|
230
|
+
});
|
|
231
|
+
const windowed = ordered.slice(0, windowRequested);
|
|
232
|
+
const windowedIds = new Set(windowed.map(([id]) => id));
|
|
233
|
+
|
|
234
|
+
const ledgerRuns = new Set(ledger.map((r) => r.run_id));
|
|
235
|
+
const runIds = new Set<string>([...windowedIds]);
|
|
236
|
+
// A ledger run with no history is still a run; it contributes contacts and a
|
|
237
|
+
// null wall clock rather than disappearing from the denominator.
|
|
238
|
+
for (const id of ledgerRuns) if (!windowedIds.has(id)) runIds.add(id);
|
|
239
|
+
|
|
240
|
+
const allGaps: number[] = [];
|
|
241
|
+
const runs: RunReport[] = [];
|
|
242
|
+
|
|
243
|
+
for (const runId of runIds) {
|
|
244
|
+
const rows = ledger.filter((r) => r.run_id === runId);
|
|
245
|
+
const contacts = rows.filter((r) => r.kind !== 'none');
|
|
246
|
+
const asks = contacts.filter((r) => r.kind === 'ask').length;
|
|
247
|
+
const handbacks = contacts.filter((r) => r.kind === 'handback').length;
|
|
248
|
+
|
|
249
|
+
// A halt is a contact on the run's LAST recorded turn: the run stopped
|
|
250
|
+
// there and did not resume inside this window. Every other contact was
|
|
251
|
+
// answered and the run continued, which is a different, cheaper event.
|
|
252
|
+
const maxTurn = rows.reduce((m, r) => Math.max(m, r.turn), -1);
|
|
253
|
+
const halts = contacts.filter((r) => r.turn === maxTurn).length;
|
|
254
|
+
|
|
255
|
+
const sessionTurns = bySession.get(runId) ?? [];
|
|
256
|
+
const gaps = waitGaps(sessionTurns);
|
|
257
|
+
allGaps.push(...gaps);
|
|
258
|
+
|
|
259
|
+
let elapsed: number | null = null;
|
|
260
|
+
let waiting: number | null = null;
|
|
261
|
+
let working: number | null = null;
|
|
262
|
+
if (sessionTurns.length >= 2) {
|
|
263
|
+
elapsed = minutesBetween(sessionTurns[0]!.ts, sessionTurns[sessionTurns.length - 1]!.ts);
|
|
264
|
+
waiting = gaps.reduce((a, b) => a + b, 0);
|
|
265
|
+
if (elapsed !== null) working = Math.max(0, elapsed - waiting);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
runs.push({
|
|
269
|
+
run_id: runId,
|
|
270
|
+
roadmap: rows.find((r) => r.roadmap !== null)?.roadmap ?? null,
|
|
271
|
+
asks,
|
|
272
|
+
handbacks,
|
|
273
|
+
contacts: contacts.length,
|
|
274
|
+
halts,
|
|
275
|
+
elapsed_minutes: elapsed,
|
|
276
|
+
waiting_minutes: waiting,
|
|
277
|
+
working_minutes: working,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
runs.sort((a, b) => a.run_id.localeCompare(b.run_id));
|
|
282
|
+
|
|
283
|
+
const notes: string[] = [];
|
|
284
|
+
if (ledger.length === 0) {
|
|
285
|
+
notes.push(
|
|
286
|
+
'interruptions.jsonl is empty or absent — the contact axis has no observations yet. ' +
|
|
287
|
+
'The Phase 0 concern records on `stop`; a baseline needs sessions to have run since it landed.',
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
if (bySession.size === 0) {
|
|
291
|
+
notes.push('no chat history with session tags — the wall-clock axis has no observations.');
|
|
292
|
+
}
|
|
293
|
+
const runsWithLedger = runs.filter((r) => ledgerRuns.has(r.run_id)).length;
|
|
294
|
+
if (runsWithLedger < runs.length) {
|
|
295
|
+
notes.push(
|
|
296
|
+
`${runs.length - runsWithLedger} run(s) in the window have timing but no ledger entry ` +
|
|
297
|
+
'— reported with null contact counts rather than as zero contacts.',
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
window_requested: windowRequested,
|
|
303
|
+
sessions_found: bySession.size,
|
|
304
|
+
window_short: bySession.size < windowRequested,
|
|
305
|
+
synthetic_user_turns_excluded: synthetic,
|
|
306
|
+
runs,
|
|
307
|
+
notes,
|
|
308
|
+
median_contacts_per_run: median(
|
|
309
|
+
runs.filter((r) => ledgerRuns.has(r.run_id)).map((r) => r.contacts),
|
|
310
|
+
),
|
|
311
|
+
median_user_wait_minutes: median(allGaps),
|
|
312
|
+
median_elapsed_minutes: median(
|
|
313
|
+
runs.map((r) => r.elapsed_minutes).filter((v): v is number => v !== null),
|
|
314
|
+
),
|
|
315
|
+
median_working_minutes: median(
|
|
316
|
+
runs.map((r) => r.working_minutes).filter((v): v is number => v !== null),
|
|
317
|
+
),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function fmt(v: number | null, unit = ''): string {
|
|
322
|
+
return v === null ? 'n/a' : `${Math.round(v * 10) / 10}${unit}`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function renderText(report: Report): string {
|
|
326
|
+
const lines: string[] = [];
|
|
327
|
+
lines.push('interruption_report — road-to-user-out-of-the-loop Phase 0');
|
|
328
|
+
lines.push('');
|
|
329
|
+
lines.push(
|
|
330
|
+
`window: ${report.sessions_found} session(s) found / ${report.window_requested} requested` +
|
|
331
|
+
(report.window_short ? ' ⚠️ SHORT WINDOW — see notes' : ''),
|
|
332
|
+
);
|
|
333
|
+
lines.push(`synthetic user turns excluded: ${report.synthetic_user_turns_excluded}`);
|
|
334
|
+
lines.push('');
|
|
335
|
+
lines.push('CONTACT AXIS');
|
|
336
|
+
lines.push(` median contacts per run: ${fmt(report.median_contacts_per_run)}`);
|
|
337
|
+
lines.push(` median user wait: ${fmt(report.median_user_wait_minutes, ' min')}`);
|
|
338
|
+
lines.push('');
|
|
339
|
+
lines.push('WALL-CLOCK AXIS');
|
|
340
|
+
lines.push(` median elapsed per run: ${fmt(report.median_elapsed_minutes, ' min')}`);
|
|
341
|
+
lines.push(` median agent working time: ${fmt(report.median_working_minutes, ' min')}`);
|
|
342
|
+
lines.push('');
|
|
343
|
+
lines.push(`runs: ${report.runs.length}`);
|
|
344
|
+
for (const run of report.runs) {
|
|
345
|
+
lines.push(
|
|
346
|
+
` ${run.run_id} asks=${run.asks} handbacks=${run.handbacks} halts=${run.halts}` +
|
|
347
|
+
` elapsed=${fmt(run.elapsed_minutes)} working=${fmt(run.working_minutes)}` +
|
|
348
|
+
` roadmap=${run.roadmap ?? '-'}`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
if (report.window_short) {
|
|
352
|
+
lines.push('');
|
|
353
|
+
lines.push(
|
|
354
|
+
'SHORT WINDOW — the rolling chat history is a buffer, not an archive. The number ' +
|
|
355
|
+
'above is computed over the sessions that exist, and is labelled with that count ' +
|
|
356
|
+
'rather than with the requested window. Do not cite it as an N-session baseline.',
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
for (const note of report.notes) {
|
|
360
|
+
lines.push('');
|
|
361
|
+
lines.push(`note: ${note}`);
|
|
362
|
+
}
|
|
363
|
+
return lines.join('\n');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const USAGE = `usage: interruption_report [--window N] [--json] [--root PATH]
|
|
367
|
+
|
|
368
|
+
Reports the two axes road-to-user-out-of-the-loop pre-registers:
|
|
369
|
+
contact axis asks / hand-backs / halts per run, median user wait
|
|
370
|
+
wall-clock axis elapsed per run versus agent working time
|
|
371
|
+
|
|
372
|
+
--window N sessions to include, newest first (default ${DEFAULT_WINDOW}).
|
|
373
|
+
A REQUEST, not a guarantee: the chat history is a rolling
|
|
374
|
+
buffer, so the report states how many sessions it actually
|
|
375
|
+
found and flags a short window rather than mislabelling it.
|
|
376
|
+
--json machine-readable output.
|
|
377
|
+
--root PATH repository root (default: cwd).
|
|
378
|
+
`;
|
|
379
|
+
|
|
380
|
+
export function main(argv: string[] = process.argv.slice(2)): number {
|
|
381
|
+
if (argv.includes('-h') || argv.includes('--help')) {
|
|
382
|
+
process.stdout.write(USAGE);
|
|
383
|
+
return 0;
|
|
384
|
+
}
|
|
385
|
+
let windowRequested = DEFAULT_WINDOW;
|
|
386
|
+
let root = process.cwd();
|
|
387
|
+
let asJson = false;
|
|
388
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
389
|
+
const arg = argv[i];
|
|
390
|
+
if (arg === '--json') {
|
|
391
|
+
asJson = true;
|
|
392
|
+
} else if (arg === '--window') {
|
|
393
|
+
const raw = argv[i + 1];
|
|
394
|
+
const parsed = Number(raw);
|
|
395
|
+
if (raw === undefined || !Number.isFinite(parsed) || parsed <= 0) {
|
|
396
|
+
process.stderr.write(`interruption_report: --window needs a positive number\n`);
|
|
397
|
+
return 2;
|
|
398
|
+
}
|
|
399
|
+
windowRequested = Math.floor(parsed);
|
|
400
|
+
i += 1;
|
|
401
|
+
} else if (arg === '--root') {
|
|
402
|
+
const raw = argv[i + 1];
|
|
403
|
+
if (raw === undefined) {
|
|
404
|
+
process.stderr.write('interruption_report: --root needs a path\n');
|
|
405
|
+
return 2;
|
|
406
|
+
}
|
|
407
|
+
root = raw;
|
|
408
|
+
i += 1;
|
|
409
|
+
} else {
|
|
410
|
+
process.stderr.write(`interruption_report: unknown argument '${String(arg)}'\n`);
|
|
411
|
+
process.stderr.write(USAGE);
|
|
412
|
+
return 2;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const report = buildReport(root, windowRequested);
|
|
417
|
+
process.stdout.write(asJson ? `${JSON.stringify(report, null, 2)}\n` : `${renderText(report)}\n`);
|
|
418
|
+
return 0;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function _isCliEntry(): boolean {
|
|
422
|
+
if (process.argv[1] === undefined) {
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
426
|
+
if (import.meta.url === argvUrl) {
|
|
427
|
+
return true;
|
|
428
|
+
}
|
|
429
|
+
// Symlinked invocation (an installed projection, or macOS /var → /private/var):
|
|
430
|
+
// import.meta.url is the real path while argv[1] keeps the symlink, so the raw
|
|
431
|
+
// URLs differ and the CLI would silently no-op. Compare realpaths.
|
|
432
|
+
try {
|
|
433
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
434
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
435
|
+
return here === argv;
|
|
436
|
+
} catch {
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (_isCliEntry()) {
|
|
442
|
+
process.exit(main());
|
|
443
|
+
}
|