@groundtrack/hooks 0.1.22 → 0.1.23
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/README.md +29 -0
- package/package.json +2 -1
- package/skills/groundtrack-impact/SKILL.md +53 -0
- package/skills/groundtrack-impact/agents/openai.yaml +2 -0
- package/skills/groundtrack-impact/scripts/context.mjs +93 -0
- package/skills/groundtrack-impact/scripts/evidence.mjs +420 -0
- package/skills/groundtrack-impact/scripts/scan.mjs +309 -0
package/README.md
CHANGED
|
@@ -103,6 +103,35 @@ The lower-level `runDoctor` API remains for existing consumers. New setup uses
|
|
|
103
103
|
client-specific plans for MCP, hooks/plugins, and instructions, so it can preview
|
|
104
104
|
and repair several selected agents safely.
|
|
105
105
|
|
|
106
|
+
## Local impact skill (opt-in)
|
|
107
|
+
|
|
108
|
+
The package includes a read-only `groundtrack-impact` skill for Claude Code and
|
|
109
|
+
Codex. It audits local sessions to produce an evidence-backed report on how
|
|
110
|
+
Groundtrack changed your work. **Setup does not install or invoke this skill**;
|
|
111
|
+
install it explicitly only when you want to give an agent access to local
|
|
112
|
+
transcripts. From this repository checkout, choose either or both agents:
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
mkdir -p "${CODEX_HOME:-$HOME/.codex}/skills"
|
|
116
|
+
cp -R packages/hooks/skills/groundtrack-impact "${CODEX_HOME:-$HOME/.codex}/skills/"
|
|
117
|
+
mkdir -p "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills"
|
|
118
|
+
cp -R packages/hooks/skills/groundtrack-impact "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
If `@groundtrack/hooks` is installed in another project, use
|
|
122
|
+
`node_modules/@groundtrack/hooks/skills/groundtrack-impact` as the copy source
|
|
123
|
+
instead. For Claude Code, set this copied skill to **user-only** in `/skills`
|
|
124
|
+
(or set `"groundtrack-impact": "user-invocable-only"` under `skillOverrides` in
|
|
125
|
+
Claude settings) before using it; that prevents Claude from loading this
|
|
126
|
+
transcript-reading workflow on its own. Codex's packaged `agents/openai.yaml` already
|
|
127
|
+
disables implicit invocation. Restart your agent if needed, then explicitly invoke
|
|
128
|
+
`$groundtrack-impact` in Codex or `/groundtrack-impact` in Claude Code and
|
|
129
|
+
authorize the session scope. The audit reads Claude
|
|
130
|
+
sessions from `CLAUDE_CONFIG_DIR` (default `~/.claude`) and Codex sessions from
|
|
131
|
+
`CODEX_HOME` (default `~/.codex`); it never uploads transcripts or runs the
|
|
132
|
+
session-import command. The agent's model provider may receive selected local
|
|
133
|
+
context during analysis.
|
|
134
|
+
|
|
106
135
|
## Import coding-agent sessions
|
|
107
136
|
|
|
108
137
|
Import completed Codex, Claude Code, Cursor, or OpenCode sessions with an API key created in Groundtrack
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@groundtrack/hooks",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
4
4
|
"description": "Lifecycle hook utilities for Groundtrack MCP clients.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"dist/",
|
|
21
21
|
"templates/",
|
|
22
|
+
"skills/",
|
|
22
23
|
"src/agents.ts",
|
|
23
24
|
"README.md",
|
|
24
25
|
"LICENSE"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: groundtrack-impact
|
|
3
|
+
description: Audit local Claude Code and Codex histories for evidence-backed knowledge-use stories and initiative-driven environment changes. Use only when the user explicitly invokes this skill and authorizes local session access.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Groundtrack impact
|
|
7
|
+
|
|
8
|
+
Answer “How has Groundtrack concretely helped me?” with an at-a-glance value summary followed by specific stories. This is a user-invoked, read-only local audit, not the server-side Impact Analyst or a causal ROI study. Make the strongest supportable positive case, including all defensible instances rather than stopping after finding a few examples.
|
|
9
|
+
|
|
10
|
+
## Scope and privacy
|
|
11
|
+
|
|
12
|
+
- Confirm explicit authorization to read local coding sessions. Use requested agents, repositories, and dates; otherwise cover **all locally available** Claude Code and Codex sessions on this machine, including archived sessions and subagents. State the actual scope and unavailable history. For a large corpus, spend the time to audit it; do not silently substitute a recent sample. If authorization is absent, ask before opening transcripts.
|
|
13
|
+
- Read session files in place. Do not import or upload transcripts, use write-capable Groundtrack tools (including feedback or outcome recording), mutate repositories, or save transcript copies. Never run Groundtrack's session-import command. A hosted agent may send selected content to its model provider during analysis; do not claim device-only processing.
|
|
14
|
+
- Keep private prompts, credentials, raw local paths, and unrelated transcript content out of the customer-facing report. Paraphrase sensitive details. Supporting record/PR links are useful where available, but exact citations are a recommendation, not a gate.
|
|
15
|
+
|
|
16
|
+
## Census and audit
|
|
17
|
+
|
|
18
|
+
1. Freeze an ISO UTC cutoff at the start of the audit and pass the same `--until TIMESTAMP` to all scanner/evidence runs; exclude the audit's own later activity from the report. Run `node scripts/scan.mjs --until TIMESTAMP` from this skill directory (or pass `--since YYYY-MM-DD` to both scripts for an explicitly requested UTC event-time window; `--codex-home` and `--claude-home` can point at alternate local homes). The scanner only reads local JSONL and emits an inventory of structured Groundtrack-call candidates, file locations, line numbers, and coverage. It does not identify impact. Record found, readable, unparseable, and missing histories, plus candidate and noncandidate sessions by agent. Its wrapper-call matches are **leads**: inspect the actual call and result to reject quoted code, unsuccessful calls, duplicates, or setup-only activity. If the scanner cannot run, perform an equivalent full inventory using the supported session locations and disclose what was not covered.
|
|
19
|
+
2. Run `node scripts/evidence.mjs --until TIMESTAMP` for a compact per-session index of feedback and outcomes; `--reports` lists feedback leads, `--session ID` shows a session's evidence rows, and `--chains` joins the latest successful helpful verdict per retrieval to successful outcomes by retrieval ID and to asserted PR/commit links by raw-event ID. The index exposes both raw `helpedReports` and `currentHelpfulRetrievals` after corrections; use the latter for a current positive-feedback session count. The parser never executes transcript code and never determines impact. **Do not report its raw counts as ROI.** Check the actual call result, the retrieval it refers to, and neighboring work before accepting a feedback claim. Verify PR merge/deployment status, whether a link was accepted or pending, and later reversals with read-only checks. A PR mentioned nearby in a long chat is not a join. Distinguish historically merged work from still-surviving work, especially after a feature was reverted or removed.
|
|
20
|
+
3. Triage **every Groundtrack-active session in scope**, not merely the most recent or most promising: review its actual structured calls/results, feedback verdicts, outcomes, and Review leads, including setup-only sessions and later corrections. Then inspect neighboring transcript context around **every plausible distinct positive work-item lead**, from initial retrieval or finding through action and outcome; do not treat index rows alone as impact. `node scripts/context.mjs FILE LINE [RADIUS]` displays a bounded excerpt at an evidence-row location; it may contain sensitive text and is for local analysis only. Include retrievals, feedback, outcomes, initiatives, and Review activity even if the latter has no `search_experiences` call. Flag impact-audit/evaluation sessions already in the historical corpus: include them in coverage but exclude their self-referential feedback and outcomes from the customer-value numerator. Join repeated calls on the same issue; a single long session or repeated feedback must not inflate a count. Build a private evidence ledger with a stable issue/work-item key, source session(s), actual Groundtrack evidence, the earlier plan or code, subsequent action, validation, outcome, and claim level. Do not put raw transcript text in this ledger or the report. Report both `sessions triaged / Groundtrack-active sessions` and `positive work items deeply checked`; if plausible leads remain unchecked, label the benefit count a verified lower bound, not an exhaustive total.
|
|
21
|
+
4. Consult available **read-only** Groundtrack evidence for candidate retrievals, experiences and raw events, feedback, outcomes, initiatives, and Review findings. Check corrections, contradictions, supersessions, and whether a warning originated earlier in the same session. Local session context is primary evidence of changed behavior; backend feedback alone is not. Groundtrack's backend index may be incomplete, so never use it as the denominator for all coding sessions. If a detail source is unavailable, count only what local evidence supports and note the gap briefly.
|
|
22
|
+
5. Classify each distinct issue/work item once at its highest supported level:
|
|
23
|
+
- **Verified defect detected or fixed**: Groundtrack's specific retrieval, prior experience, or Review finding directed an investigation that revealed a real defect, or identified it directly. The session shows the subsequent fix and independent confirmation (test, merged fix, deployment check, or actual failure matching the prediction). Distinguish pre-merge catches from production incidents diagnosed after deployment; never imply that an incident was prevented if it happened.
|
|
24
|
+
- **Mistake caught before completion**: a concrete risky plan or implementation is visible _before_ Groundtrack surfaced the relevant warning/finding; the subsequent session shows a targeted correction; and code, tests, review, or another independent artifact corroborates the change. Count observed interceptions, not hypothetical production incidents or failures that “would have shipped.” A generic warning or a preexisting fix does not qualify. A verified defect that was caught here belongs to the previous bucket, not both.
|
|
25
|
+
- **Other observed course correction**: a specific Groundtrack retrieval or Review finding preceded and informed a visible decision or change, but the earlier approach was not demonstrably a mistake or independent verification is missing.
|
|
26
|
+
- **Agent-reported help only**: explicit helpful-use feedback without a traceable changed action. Report separately, never as a prevented mistake.
|
|
27
|
+
- **Retrieved / reviewed only**: no demonstrated help; keep in the denominator, not a positive numerator. Do not turn “work completed after retrieval” into causal credit.
|
|
28
|
+
One issue belongs to only one bucket. Separate **verified completion** (merged fix, deployed work, passing validation, or an outcome tied to the issue) from the help bucket; completion is a parallel attribute, not another additive impact count. Count Review-only work with the same rules. Check that superficially similar cases are distinct before aggregating. An `avoided_failure` feedback effect is a lead, never sufficient evidence of prevention: check whether the agent had already found or fixed the same issue before retrieval.
|
|
29
|
+
6. For before/after, look at comparable local pre-Groundtrack tasks if the history actually contains them. Report the comparable population and a descriptive contrast; changes in task mix, agent, or observability invalidate a causal efficiency claim. Time, money, tokens, and avoided incidents require separately sourced measurements or explicit estimates. Never invent a subscription ROI multiple.
|
|
30
|
+
|
|
31
|
+
## Initiative impact (separate, optional)
|
|
32
|
+
|
|
33
|
+
After the knowledge-loop audit, search read-only Groundtrack initiatives using the recurring friction and system changes found in the local sessions. Inspect current initiative detail (`search_initiatives` then `get_initiative` when available), including supporting and opposing events, relationships, dates, and linked outcomes; follow the local coding sessions and actual PR/deployment evidence. Verify that the initiative preceded or shaped the claimed intervention; a record created after a completed fix is retrospective context, not proof Groundtrack drove it. Search results, initiative state, an agent's attribution, or a `resolved` label alone do not establish implementation or impact. Check whether an apparent fix is a partial slice, was later replaced, or had a subsequent recurrence. The backend index may omit detail; use it as a lead, not a complete initiative census.
|
|
34
|
+
|
|
35
|
+
Classify only credible, distinct initiative stories:
|
|
36
|
+
|
|
37
|
+
- **Realized environment improvement:** recurring friction is documented; Groundtrack surfaced or organized a specific initiative; the intended system change landed; and comparable, time-ordered evidence after deployment shows that the same failure mode declined or stopped over a stated observation window. Describe the exposure and observation limits. Silence, a green test, or an absence of later search hits is not a measured decline, and the initiative must not receive sole causal credit when other interventions contributed.
|
|
38
|
+
- **Implemented but unverified:** a clearly linked intervention landed and its immediate behavior may be validated, but later recurrence has not been measured sufficiently. State the implemented scope and what would verify durable improvement. A proposed initiative can fall here when independent code/PR evidence proves implementation; its state alone cannot.
|
|
39
|
+
- **Identified opportunity:** a draft or proposal identifies a recurring, supported problem and a prospective intervention, but that intervention has not landed. Partial progress that leaves the stated failure mode open must be described as partial, not realized. Never count a draft or proposal as an achieved improvement.
|
|
40
|
+
|
|
41
|
+
Keep initiative trajectory separate from retrieval/Review work-item attribution. If the same PR, defect, or change appears in both tracks, tell one story with an initiative label or cross-reference it; count the work item once in the headline, and never add initiative totals to defect or safeguard totals. Include a compact initiative section only when at least one story survives this audit; omit it entirely when none does, rather than padding the report with initiative counts. An identified opportunity is a next step, not realized value.
|
|
42
|
+
|
|
43
|
+
## Report
|
|
44
|
+
|
|
45
|
+
Start with a short, positive **at-a-glance** line or compact scorecard containing the strongest supportable numbers. When available, put broad **reach** (historically merged/deployed changes with a traceable Groundtrack chain, sessions with current positive agent feedback) beside **demonstrated changed work** (distinct completed work items, verified fixes split pre-merge/post-deploy, concrete mistakes caught before completion, and other course corrections). The largest defensible reach number can lead, but label it as reach in the same glance that identifies the smaller, stronger changed-work count; never imply the counts are additive or equally causal. Do not open with file/session inventory, triage methodology, or a wall of caveats. Use real numbers, including zero when fully audited; use “at least” and a partial denominator if coverage is incomplete.
|
|
46
|
+
|
|
47
|
+
For reach, count only historically merged/deployed changes with an explicit retrieval → latest successful helpful feedback verdict → outcome → raw-event → PR/commit chain, after checking tool-call success and the claimed PR/revision. This **usage footprint** is not a count of prevented failures, current functionality, or ROI. Keep agent-reported-only help distinct from demonstrated changes, exclude self-audits from value numerators, deduplicate issues across sessions, and disclose historical work later removed.
|
|
48
|
+
|
|
49
|
+
Then tell a few memorable, strongest stories up front and group other checked examples by recurring failure mode, with enough compact detail to substantiate the headline. Each should explain the task, the Groundtrack evidence, the agent's visible change, and what followed; identify whether the issue was caught before merge, repaired after deployment, or was a design/release safeguard. Favor cross-session reuse and non-Groundtrack work among equally strong cases. Offer useful evidence links when available. Put the nuanced distinction (reported / observed / verified) in compact labels and make the value clear without repetitive hedging. If a claim does not survive audit, omit it from positive stories rather than padding the report with weak anecdotes. If an initiative story qualifies, label its category and put it after or alongside the relevant work-item story without increasing the headline work-item count; state the implementation and recurrence evidence separately.
|
|
50
|
+
|
|
51
|
+
When the same failure mode recurs in **distinct work items**, add a concise pattern line such as “Cross-user access mistakes caught in 3 separate changes,” with the three issue keys and corroborating records available on request. A repeated warning or feedback in one work item counts once, and a taxonomy match alone is not proof that each intervention changed the work.
|
|
52
|
+
|
|
53
|
+
End with a brief scope note: `N/M Groundtrack-active sessions triaged; K distinct work items deeply checked; A/B total local sessions screened` (by agent if useful), local histories missing or unaudited, unavailable backend detail, and any comparable baseline. Name the most actionable instrumentation gap. If no positive case survives, do not fabricate one: report the completed census, what evidence is missing, and a concrete path to a stronger future measurement.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Display a bounded local JSONL excerpt for human audit; never writes or uploads.
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
function summarize(record, limit) {
|
|
9
|
+
if (record?.type === "response_item") {
|
|
10
|
+
const p = record.payload;
|
|
11
|
+
if (p?.type === "message" && ["user", "assistant"].includes(p.role)) {
|
|
12
|
+
return {
|
|
13
|
+
kind: p.role,
|
|
14
|
+
text: p.content
|
|
15
|
+
?.filter((x) => x.type === "input_text" || x.type === "output_text")
|
|
16
|
+
.map((x) => x.text)
|
|
17
|
+
.join("\n")
|
|
18
|
+
.slice(0, limit),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
if (["custom_tool_call", "function_call"].includes(p?.type))
|
|
22
|
+
return {
|
|
23
|
+
kind: "tool_call",
|
|
24
|
+
name: p.name,
|
|
25
|
+
text: String(p.input ?? p.arguments ?? "").slice(0, limit),
|
|
26
|
+
};
|
|
27
|
+
if (["custom_tool_call_output", "function_call_output"].includes(p?.type))
|
|
28
|
+
return {
|
|
29
|
+
kind: "tool_result",
|
|
30
|
+
text: (typeof p.output === "string" ? p.output : JSON.stringify(p.output ?? "")).slice(
|
|
31
|
+
0,
|
|
32
|
+
limit,
|
|
33
|
+
),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (["user", "assistant"].includes(record?.type)) {
|
|
37
|
+
const blocks = Array.isArray(record.message?.content) ? record.message.content : [];
|
|
38
|
+
const content = blocks
|
|
39
|
+
.filter((x) => ["text", "tool_use", "tool_result"].includes(x?.type))
|
|
40
|
+
.map((x) =>
|
|
41
|
+
x.type === "text"
|
|
42
|
+
? x.text
|
|
43
|
+
: x.type === "tool_use"
|
|
44
|
+
? `${x.name} ${JSON.stringify(x.input)}`
|
|
45
|
+
: `result ${JSON.stringify(x.content)}`,
|
|
46
|
+
);
|
|
47
|
+
return { kind: record.type, text: content.join("\n").slice(0, limit) };
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function context(path, center, radius = 15, limit = 650) {
|
|
53
|
+
const rows = [];
|
|
54
|
+
const input = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
|
|
55
|
+
let line = 0;
|
|
56
|
+
for await (const text of input) {
|
|
57
|
+
line++;
|
|
58
|
+
if (line < center - radius || line > center + radius) continue;
|
|
59
|
+
let record;
|
|
60
|
+
try {
|
|
61
|
+
record = JSON.parse(text);
|
|
62
|
+
} catch {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const detail = summarize(record, limit);
|
|
66
|
+
if (detail) rows.push({ line, ...detail });
|
|
67
|
+
}
|
|
68
|
+
return rows;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
72
|
+
const [path, rawLine, rawRadius] = process.argv.slice(2);
|
|
73
|
+
const line = Number(rawLine);
|
|
74
|
+
const radius = rawRadius === undefined ? 15 : Number(rawRadius);
|
|
75
|
+
if (
|
|
76
|
+
!path ||
|
|
77
|
+
!Number.isSafeInteger(line) ||
|
|
78
|
+
line < 1 ||
|
|
79
|
+
!Number.isSafeInteger(radius) ||
|
|
80
|
+
radius < 0 ||
|
|
81
|
+
radius > 100
|
|
82
|
+
) {
|
|
83
|
+
process.stderr.write("Usage: node scripts/context.mjs FILE LINE [RADIUS<=100]\n");
|
|
84
|
+
process.exitCode = 1;
|
|
85
|
+
} else {
|
|
86
|
+
try {
|
|
87
|
+
process.stdout.write(`${JSON.stringify(await context(path, line, radius), null, 2)}\n`);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
process.stderr.write(`${error.message}\n`);
|
|
90
|
+
process.exitCode = 1;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Read-only, local-only triage. Outputs selected evidence, not whole transcripts.
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { createInterface } from "node:readline";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
import { scan } from "./scan.mjs";
|
|
10
|
+
|
|
11
|
+
function property(source, key) {
|
|
12
|
+
if (!source) return null;
|
|
13
|
+
const pattern = new RegExp(`(?:["']?${key}["']?)\\s*:\\s*(["'\x60])`, "g");
|
|
14
|
+
const match = pattern.exec(source);
|
|
15
|
+
if (!match) return null;
|
|
16
|
+
const quote = match[1];
|
|
17
|
+
let value = "";
|
|
18
|
+
for (let i = pattern.lastIndex; i < source.length; i++) {
|
|
19
|
+
const char = source[i];
|
|
20
|
+
if (char === "\\" && i + 1 < source.length) {
|
|
21
|
+
value += source[++i];
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (char === quote) return value;
|
|
25
|
+
value += char;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function invocationArguments(source, start) {
|
|
31
|
+
const brackets = [];
|
|
32
|
+
let quote = null;
|
|
33
|
+
let escaped = false;
|
|
34
|
+
for (let i = start; i < source.length; i++) {
|
|
35
|
+
const char = source[i];
|
|
36
|
+
if (quote) {
|
|
37
|
+
if (escaped) escaped = false;
|
|
38
|
+
else if (char === "\\") escaped = true;
|
|
39
|
+
else if (char === quote) quote = null;
|
|
40
|
+
} else if ("\"'`".includes(char)) quote = char;
|
|
41
|
+
else if ("({[".includes(char)) brackets.push(char);
|
|
42
|
+
else if (")}]".includes(char)) {
|
|
43
|
+
const expected = { ")": "(", "}": "{", "]": "[" }[char];
|
|
44
|
+
if (brackets.pop() !== expected) return "";
|
|
45
|
+
if (!brackets.length) return source.slice(start + 1, i);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function invocation(tool, input) {
|
|
52
|
+
const direct = /^mcp__groundtrack__(\w+)$/.exec(tool || "");
|
|
53
|
+
if (direct) return [{ tool: direct[1], args: input }];
|
|
54
|
+
if (!["functions.exec", "exec"].includes(tool) || typeof input !== "string") return [];
|
|
55
|
+
return [...input.matchAll(/\btools\.mcp__groundtrack__(\w+)\s*\(/g)].map((match) => ({
|
|
56
|
+
tool: match[1],
|
|
57
|
+
args: invocationArguments(input, match.index + match[0].length - 1),
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function field(args, key) {
|
|
62
|
+
return typeof args === "string" ? property(args, key) : (args?.[key] ?? null);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function listField(args, key) {
|
|
66
|
+
if (typeof args !== "string") return args?.[key] ?? null;
|
|
67
|
+
const match = new RegExp(`(?:["']?${key}["']?)\\s*:\\s*\\[([^\\]]*)\\]`).exec(args);
|
|
68
|
+
return match ? [...match[1].matchAll(/["']([^"']+)["']/g)].map((item) => item[1]) : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function numberField(args, key) {
|
|
72
|
+
if (typeof args !== "string") return args?.[key] ?? null;
|
|
73
|
+
const match = new RegExp(`(?:["']?${key}["']?)\\s*:\\s*(\\d+)`).exec(args);
|
|
74
|
+
return match ? Number(match[1]) : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function snippets(record, agent, line) {
|
|
78
|
+
if (agent === "codex") {
|
|
79
|
+
if (record?.type !== "response_item") return [];
|
|
80
|
+
const payload = record.payload;
|
|
81
|
+
if (["custom_tool_call", "function_call"].includes(payload?.type)) {
|
|
82
|
+
return invocation(payload.name, payload.input ?? payload.arguments).map(({ tool, args }) => ({
|
|
83
|
+
tool,
|
|
84
|
+
args,
|
|
85
|
+
line,
|
|
86
|
+
callId: payload.call_id || null,
|
|
87
|
+
at: record.timestamp || null,
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
if (record?.type !== "assistant" || !Array.isArray(record.message?.content)) return [];
|
|
93
|
+
return record.message.content.flatMap((block) =>
|
|
94
|
+
block?.type === "tool_use"
|
|
95
|
+
? invocation(block.name, block.input).map(({ tool, args }) => ({
|
|
96
|
+
tool,
|
|
97
|
+
args,
|
|
98
|
+
line,
|
|
99
|
+
callId: block.id || null,
|
|
100
|
+
at: record.timestamp || null,
|
|
101
|
+
}))
|
|
102
|
+
: [],
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function acceptedLinkEventIds(body) {
|
|
107
|
+
const ids = new Set();
|
|
108
|
+
function inspect(value, depth = 0) {
|
|
109
|
+
if (depth > 8 || value == null) return;
|
|
110
|
+
if (typeof value === "string") {
|
|
111
|
+
try {
|
|
112
|
+
inspect(JSON.parse(value), depth + 1);
|
|
113
|
+
} catch {
|
|
114
|
+
// Wrapper output can include status text; only structured responses prove acceptance.
|
|
115
|
+
}
|
|
116
|
+
} else if (Array.isArray(value)) {
|
|
117
|
+
for (const item of value) inspect(item, depth + 1);
|
|
118
|
+
} else if (typeof value === "object" && value.isError !== true) {
|
|
119
|
+
if (Array.isArray(value.linked)) {
|
|
120
|
+
for (const item of value.linked) {
|
|
121
|
+
if (typeof item?.rawEventId === "string") ids.add(item.rawEventId);
|
|
122
|
+
}
|
|
123
|
+
} else inspect(value.linked, depth + 1);
|
|
124
|
+
inspect(value.link, depth + 1);
|
|
125
|
+
inspect(value.result, depth + 1);
|
|
126
|
+
inspect(value.structuredContent, depth + 1);
|
|
127
|
+
inspect(value.content, depth + 1);
|
|
128
|
+
if (typeof value.text === "string") inspect(value.text, depth + 1);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
inspect(body);
|
|
132
|
+
return [...ids];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function readEvidence(path, agent, sinceMs, untilMs) {
|
|
136
|
+
const rows = [];
|
|
137
|
+
const results = new Map();
|
|
138
|
+
let line = 0;
|
|
139
|
+
const input = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
|
|
140
|
+
for await (const text of input) {
|
|
141
|
+
line++;
|
|
142
|
+
let record;
|
|
143
|
+
try {
|
|
144
|
+
record = JSON.parse(text);
|
|
145
|
+
} catch {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const at = Date.parse(record?.timestamp || record?.payload?.timestamp || "");
|
|
149
|
+
if (untilMs !== null && Number.isFinite(at) && at > untilMs) continue;
|
|
150
|
+
if (sinceMs !== null && (!Number.isFinite(at) || at < sinceMs)) continue;
|
|
151
|
+
for (const entry of snippets(record, agent, line)) rows.push({ ...entry, path });
|
|
152
|
+
if (
|
|
153
|
+
agent === "codex" &&
|
|
154
|
+
record?.type === "response_item" &&
|
|
155
|
+
["custom_tool_call_output", "function_call_output"].includes(record.payload?.type) &&
|
|
156
|
+
record.payload.call_id
|
|
157
|
+
) {
|
|
158
|
+
results.set(record.payload.call_id, {
|
|
159
|
+
body:
|
|
160
|
+
typeof record.payload.output === "string"
|
|
161
|
+
? record.payload.output
|
|
162
|
+
: JSON.stringify(record.payload.output),
|
|
163
|
+
error: false,
|
|
164
|
+
});
|
|
165
|
+
} else if (
|
|
166
|
+
agent === "claude" &&
|
|
167
|
+
record?.type === "user" &&
|
|
168
|
+
Array.isArray(record.message?.content)
|
|
169
|
+
) {
|
|
170
|
+
for (const block of record.message.content.filter(
|
|
171
|
+
(part) => part?.type === "tool_result" && part.tool_use_id,
|
|
172
|
+
)) {
|
|
173
|
+
results.set(block.tool_use_id, {
|
|
174
|
+
body: typeof block.content === "string" ? block.content : JSON.stringify(block.content),
|
|
175
|
+
error: block.is_error === true,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return rows.map((row) => {
|
|
181
|
+
const result = results.get(row.callId);
|
|
182
|
+
const requestedEventIds = listField(row.args, "rawEventIds") || [];
|
|
183
|
+
const acceptedRawEventIds =
|
|
184
|
+
row.tool === "link_outcomes" && result && !result.error
|
|
185
|
+
? acceptedLinkEventIds(result.body).filter((id) => requestedEventIds.includes(id))
|
|
186
|
+
: [];
|
|
187
|
+
return {
|
|
188
|
+
...row,
|
|
189
|
+
resultSeen: Boolean(result),
|
|
190
|
+
resultError: result?.error || false,
|
|
191
|
+
...(row.tool === "link_outcomes" ? { acceptedRawEventIds } : {}),
|
|
192
|
+
successMarker:
|
|
193
|
+
row.tool === "link_outcomes"
|
|
194
|
+
? acceptedRawEventIds.length > 0
|
|
195
|
+
: (result &&
|
|
196
|
+
!result.error &&
|
|
197
|
+
result.body?.includes(
|
|
198
|
+
row.tool === "report_experience_use"
|
|
199
|
+
? "reportId"
|
|
200
|
+
: row.tool === "record_outcome"
|
|
201
|
+
? "completedAt"
|
|
202
|
+
: "retrievalId",
|
|
203
|
+
)) ||
|
|
204
|
+
false,
|
|
205
|
+
};
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function present(row) {
|
|
210
|
+
const { args, ...location } = row;
|
|
211
|
+
if (row.tool === "report_experience_use") {
|
|
212
|
+
return {
|
|
213
|
+
...location,
|
|
214
|
+
verdict: field(args, "verdict"),
|
|
215
|
+
explanation: field(args, "explanation"),
|
|
216
|
+
retrievalId: field(args, "retrievalId"),
|
|
217
|
+
effects:
|
|
218
|
+
typeof args === "string"
|
|
219
|
+
? [
|
|
220
|
+
...args.matchAll(
|
|
221
|
+
/(?:["']?effect["']?)\s*:\s*["'](applied|avoided_failure|relevant|irrelevant|rejected)["']/g,
|
|
222
|
+
),
|
|
223
|
+
].map((match) => match[1])
|
|
224
|
+
: args?.effects?.map((effect) => effect.effect) || [],
|
|
225
|
+
experienceIds:
|
|
226
|
+
typeof args === "string"
|
|
227
|
+
? [...args.matchAll(/(?:["']?experienceId["']?)\s*:\s*["']([^"']+)["']/g)].map(
|
|
228
|
+
(match) => match[1],
|
|
229
|
+
)
|
|
230
|
+
: args?.effects?.map((effect) => effect.experienceId) || [],
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
if (row.tool === "record_outcome")
|
|
234
|
+
return {
|
|
235
|
+
...location,
|
|
236
|
+
title: field(args, "title"),
|
|
237
|
+
summary: field(args, "summary"),
|
|
238
|
+
retrievalIds: listField(args, "retrievalIds"),
|
|
239
|
+
rawEventIds: listField(args, "rawEventIds"),
|
|
240
|
+
};
|
|
241
|
+
if (row.tool === "search_experiences") return { ...location, query: field(args, "query") };
|
|
242
|
+
if (row.tool === "link_outcomes")
|
|
243
|
+
return {
|
|
244
|
+
...location,
|
|
245
|
+
prNumber: numberField(args, "prNumber"),
|
|
246
|
+
prUrl: field(args, "prUrl"),
|
|
247
|
+
repoKey: field(args, "repoKey"),
|
|
248
|
+
commitSha: field(args, "commitSha"),
|
|
249
|
+
rawEventIds: listField(args, "rawEventIds"),
|
|
250
|
+
};
|
|
251
|
+
return location;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function evidence(options, sessionId = null) {
|
|
255
|
+
const census = await scan(options);
|
|
256
|
+
const sessions = [];
|
|
257
|
+
for (const candidate of census.candidates.filter(
|
|
258
|
+
(row) => !sessionId || row.sessionId === sessionId,
|
|
259
|
+
)) {
|
|
260
|
+
const rows = [];
|
|
261
|
+
const seen = new Set();
|
|
262
|
+
for (const path of candidate.files) {
|
|
263
|
+
for (const row of await readEvidence(
|
|
264
|
+
path,
|
|
265
|
+
candidate.agent,
|
|
266
|
+
options.since ? Date.parse(options.since) : null,
|
|
267
|
+
options.until ? Date.parse(options.until) : null,
|
|
268
|
+
)) {
|
|
269
|
+
const key = row.callId
|
|
270
|
+
? `${row.callId}:${row.tool}:${row.args && JSON.stringify(row.args)}`
|
|
271
|
+
: `${row.path}:${row.line}:${row.tool}`;
|
|
272
|
+
if (seen.has(key)) continue;
|
|
273
|
+
seen.add(key);
|
|
274
|
+
rows.push(present(row));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
sessions.push({
|
|
278
|
+
agent: candidate.agent,
|
|
279
|
+
sessionId: candidate.sessionId,
|
|
280
|
+
repository: candidate.repository,
|
|
281
|
+
startedAt: candidate.startedAt,
|
|
282
|
+
files: candidate.files,
|
|
283
|
+
rows,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return { counts: census.counts, sessions };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function latestSuccessfulReports(rows) {
|
|
290
|
+
const latest = new Map();
|
|
291
|
+
for (const row of rows
|
|
292
|
+
.filter(
|
|
293
|
+
(item) => item.tool === "report_experience_use" && item.retrievalId && item.successMarker,
|
|
294
|
+
)
|
|
295
|
+
.sort((a, b) => (Date.parse(a.at || "") || 0) - (Date.parse(b.at || "") || 0))) {
|
|
296
|
+
latest.set(row.retrievalId, row);
|
|
297
|
+
}
|
|
298
|
+
return [...latest.values()];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function chains(report) {
|
|
302
|
+
const found = new Map();
|
|
303
|
+
for (const session of report.sessions) {
|
|
304
|
+
// A later successful correction supersedes an earlier helpful verdict.
|
|
305
|
+
const reports = latestSuccessfulReports(session.rows).filter((row) => row.verdict === "helped");
|
|
306
|
+
const links = session.rows.filter(
|
|
307
|
+
(row) =>
|
|
308
|
+
row.tool === "link_outcomes" &&
|
|
309
|
+
row.successMarker &&
|
|
310
|
+
row.acceptedRawEventIds?.length &&
|
|
311
|
+
(row.prNumber || row.commitSha),
|
|
312
|
+
);
|
|
313
|
+
for (const outcome of session.rows.filter(
|
|
314
|
+
(row) =>
|
|
315
|
+
row.tool === "record_outcome" &&
|
|
316
|
+
row.title &&
|
|
317
|
+
row.retrievalIds?.length &&
|
|
318
|
+
row.rawEventIds?.length &&
|
|
319
|
+
row.successMarker,
|
|
320
|
+
)) {
|
|
321
|
+
const helpful = reports.filter((row) => outcome.retrievalIds.includes(row.retrievalId));
|
|
322
|
+
if (!helpful.length) continue;
|
|
323
|
+
for (const link of links.filter((row) =>
|
|
324
|
+
row.acceptedRawEventIds.some((id) => outcome.rawEventIds.includes(id)),
|
|
325
|
+
)) {
|
|
326
|
+
const key = `${link.repoKey || session.repository}:${link.prNumber || link.commitSha}:${outcome.title}`;
|
|
327
|
+
found.set(key, {
|
|
328
|
+
agent: session.agent,
|
|
329
|
+
sessionId: session.sessionId,
|
|
330
|
+
repository: link.repoKey || session.repository,
|
|
331
|
+
prNumber: link.prNumber,
|
|
332
|
+
prUrl: link.prUrl,
|
|
333
|
+
commitSha: link.commitSha,
|
|
334
|
+
title: outcome.title,
|
|
335
|
+
retrievalIds: [...new Set(helpful.map((row) => row.retrievalId))],
|
|
336
|
+
reportedHelp: [...new Set(helpful.map((row) => row.explanation).filter(Boolean))],
|
|
337
|
+
linkResultSeen: true,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return [...found.values()];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function cli(args) {
|
|
346
|
+
const options = {
|
|
347
|
+
codexHome: process.env.CODEX_HOME || join(homedir(), ".codex"),
|
|
348
|
+
claudeHome: process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"),
|
|
349
|
+
since: null,
|
|
350
|
+
until: null,
|
|
351
|
+
};
|
|
352
|
+
let session = null;
|
|
353
|
+
let reports = false;
|
|
354
|
+
let showChains = false;
|
|
355
|
+
for (let i = 0; i < args.length; i++) {
|
|
356
|
+
const key = args[i];
|
|
357
|
+
if (key === "--reports") reports = true;
|
|
358
|
+
else if (key === "--chains") showChains = true;
|
|
359
|
+
else if (
|
|
360
|
+
["--session", "--since", "--until", "--codex-home", "--claude-home"].includes(key) &&
|
|
361
|
+
args[i + 1]
|
|
362
|
+
) {
|
|
363
|
+
const value = args[++i];
|
|
364
|
+
if (key === "--session") session = value;
|
|
365
|
+
else if (key === "--since") options.since = value;
|
|
366
|
+
else if (key === "--until") options.until = value;
|
|
367
|
+
else options[key === "--codex-home" ? "codexHome" : "claudeHome"] = resolve(value);
|
|
368
|
+
} else throw new Error(`Unknown or incomplete option: ${key}`);
|
|
369
|
+
}
|
|
370
|
+
return { options, session, reports, showChains };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
374
|
+
try {
|
|
375
|
+
const { options, session, reports, showChains } = cli(process.argv.slice(2));
|
|
376
|
+
const report = await evidence(options, session);
|
|
377
|
+
const selected = session
|
|
378
|
+
? report.sessions.filter((row) => row.sessionId === session)
|
|
379
|
+
: report.sessions;
|
|
380
|
+
if (session && !selected.length) throw new Error(`Session not found: ${session}`);
|
|
381
|
+
const result = showChains
|
|
382
|
+
? chains(report)
|
|
383
|
+
: session
|
|
384
|
+
? selected
|
|
385
|
+
: reports
|
|
386
|
+
? selected.flatMap((row) =>
|
|
387
|
+
row.rows
|
|
388
|
+
.filter((item) => item.tool === "report_experience_use")
|
|
389
|
+
.map((item) => ({
|
|
390
|
+
agent: row.agent,
|
|
391
|
+
sessionId: row.sessionId,
|
|
392
|
+
repository: row.repository,
|
|
393
|
+
startedAt: row.startedAt,
|
|
394
|
+
...item,
|
|
395
|
+
})),
|
|
396
|
+
)
|
|
397
|
+
: {
|
|
398
|
+
counts: report.counts,
|
|
399
|
+
sessions: selected.map((row) => ({
|
|
400
|
+
agent: row.agent,
|
|
401
|
+
sessionId: row.sessionId,
|
|
402
|
+
repository: row.repository,
|
|
403
|
+
startedAt: row.startedAt,
|
|
404
|
+
calls: row.rows.length,
|
|
405
|
+
helpedReports: row.rows.filter((item) => item.verdict === "helped").length,
|
|
406
|
+
currentHelpfulRetrievals: latestSuccessfulReports(row.rows).filter(
|
|
407
|
+
(item) => item.verdict === "helped",
|
|
408
|
+
).length,
|
|
409
|
+
otherReports: row.rows.filter(
|
|
410
|
+
(item) => item.tool === "report_experience_use" && item.verdict !== "helped",
|
|
411
|
+
).length,
|
|
412
|
+
outcomes: row.rows.filter((item) => item.tool === "record_outcome").length,
|
|
413
|
+
})),
|
|
414
|
+
};
|
|
415
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
416
|
+
} catch (error) {
|
|
417
|
+
process.stderr.write(`${error.message}\n`);
|
|
418
|
+
process.exitCode = 1;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Read-only candidate index. Transcript contents never appear in output.
|
|
3
|
+
import { createReadStream, existsSync, readdirSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { createInterface } from "node:readline";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
function repositoryLabel(value) {
|
|
10
|
+
if (typeof value !== "string") return null;
|
|
11
|
+
const match = value.replace(/\.git\/?$/, "").match(/(?:[:/])([^/:]+\/[^/:]+)$/);
|
|
12
|
+
return match ? match[1] : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function optionsFrom(args) {
|
|
16
|
+
const options = {
|
|
17
|
+
codexHome: process.env.CODEX_HOME || join(homedir(), ".codex"),
|
|
18
|
+
claudeHome: process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"),
|
|
19
|
+
since: null,
|
|
20
|
+
until: null,
|
|
21
|
+
};
|
|
22
|
+
for (let i = 0; i < args.length; i++) {
|
|
23
|
+
const key = args[i];
|
|
24
|
+
if (!["--codex-home", "--claude-home", "--since", "--until"].includes(key) || !args[i + 1]) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`Expected --codex-home PATH, --claude-home PATH, --since YYYY-MM-DD, or --until ISO timestamp; got ${key}`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
const value = args[++i];
|
|
30
|
+
if (key === "--since") {
|
|
31
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(Date.parse(value))) {
|
|
32
|
+
throw new Error(`Invalid --since date: ${value}`);
|
|
33
|
+
}
|
|
34
|
+
options.since = value;
|
|
35
|
+
} else if (key === "--until") {
|
|
36
|
+
if (
|
|
37
|
+
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(value) ||
|
|
38
|
+
Number.isNaN(Date.parse(value))
|
|
39
|
+
) {
|
|
40
|
+
throw new Error(`Invalid --until timestamp: ${value}`);
|
|
41
|
+
}
|
|
42
|
+
options.until = value;
|
|
43
|
+
} else options[key === "--codex-home" ? "codexHome" : "claudeHome"] = resolve(value);
|
|
44
|
+
}
|
|
45
|
+
return options;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function walk(root, files, unreadable) {
|
|
49
|
+
if (!existsSync(root)) return false;
|
|
50
|
+
let entries;
|
|
51
|
+
try {
|
|
52
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
53
|
+
} catch (error) {
|
|
54
|
+
unreadable.push({ path: root, error: error.code || error.message });
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
const path = join(root, entry.name);
|
|
59
|
+
if (entry.isDirectory()) walk(path, files, unreadable);
|
|
60
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(path);
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function groundtrackCalls(name, input) {
|
|
66
|
+
const direct = /^mcp__groundtrack__(\w+)$/.exec(name || "");
|
|
67
|
+
if (direct) return [{ tool: direct[1], kind: "direct" }];
|
|
68
|
+
if (!["functions.exec", "exec"].includes(name) || typeof input !== "string") return [];
|
|
69
|
+
// Proxy calls are leads only: a quoted invocation in JS may match too.
|
|
70
|
+
return [...input.matchAll(/\btools\.mcp__groundtrack__(\w+)\s*\(/g)].map((match) => ({
|
|
71
|
+
tool: match[1],
|
|
72
|
+
kind: "wrapper-lead",
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function callsIn(record, agent) {
|
|
77
|
+
if (agent === "codex") {
|
|
78
|
+
if (record?.type !== "response_item") return [];
|
|
79
|
+
const payload = record.payload;
|
|
80
|
+
if (!["custom_tool_call", "function_call"].includes(payload?.type)) return [];
|
|
81
|
+
return groundtrackCalls(payload.name, payload.input ?? payload.arguments).map((call) => ({
|
|
82
|
+
...call,
|
|
83
|
+
callId: payload.call_id || null,
|
|
84
|
+
timestamp: record.timestamp || null,
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
if (record?.type !== "assistant" || !Array.isArray(record.message?.content)) return [];
|
|
88
|
+
return record.message.content.flatMap((block) => {
|
|
89
|
+
if (block?.type !== "tool_use") return [];
|
|
90
|
+
return groundtrackCalls(block.name, block.input).map((call) => ({
|
|
91
|
+
...call,
|
|
92
|
+
callId: block.id || null,
|
|
93
|
+
timestamp: record.timestamp || null,
|
|
94
|
+
}));
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function reviewLeadIn(record, agent) {
|
|
99
|
+
let output;
|
|
100
|
+
let callId;
|
|
101
|
+
if (
|
|
102
|
+
agent === "codex" &&
|
|
103
|
+
record?.type === "response_item" &&
|
|
104
|
+
["custom_tool_call_output", "function_call_output"].includes(record.payload?.type)
|
|
105
|
+
) {
|
|
106
|
+
output = record.payload.output;
|
|
107
|
+
callId = record.payload.call_id;
|
|
108
|
+
} else if (
|
|
109
|
+
agent === "claude" &&
|
|
110
|
+
record?.type === "user" &&
|
|
111
|
+
Array.isArray(record.message?.content)
|
|
112
|
+
) {
|
|
113
|
+
const blocks = record.message.content.filter((part) => part?.type === "tool_result");
|
|
114
|
+
output = blocks.map((part) => part.content);
|
|
115
|
+
callId = blocks.map((part) => part.tool_use_id).join(",");
|
|
116
|
+
}
|
|
117
|
+
if (output == null) return null;
|
|
118
|
+
// Only tool results are searched; this is a lead, not proof of a Review finding.
|
|
119
|
+
const text = typeof output === "string" ? output : JSON.stringify(output);
|
|
120
|
+
return /\bgroundtrack[ -]review\b/i.test(text)
|
|
121
|
+
? { callId: callId || null, timestamp: record.timestamp || null }
|
|
122
|
+
: null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function scanFile(path, agent, sinceMs, untilMs) {
|
|
126
|
+
const found = {
|
|
127
|
+
path,
|
|
128
|
+
agent,
|
|
129
|
+
sessionId: null,
|
|
130
|
+
startedAt: null,
|
|
131
|
+
repository: null,
|
|
132
|
+
calls: [],
|
|
133
|
+
reviewLeads: [],
|
|
134
|
+
malformedLines: 0,
|
|
135
|
+
readable: true,
|
|
136
|
+
eligibleRecords: 0,
|
|
137
|
+
};
|
|
138
|
+
let line = 0;
|
|
139
|
+
try {
|
|
140
|
+
const input = createInterface({ input: createReadStream(path), crlfDelay: Infinity });
|
|
141
|
+
for await (const text of input) {
|
|
142
|
+
line++;
|
|
143
|
+
if (!text.trim()) continue;
|
|
144
|
+
let record;
|
|
145
|
+
try {
|
|
146
|
+
record = JSON.parse(text);
|
|
147
|
+
} catch {
|
|
148
|
+
found.malformedLines++;
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const at = Date.parse(record?.timestamp || record?.payload?.timestamp || "");
|
|
152
|
+
if (untilMs !== null && Number.isFinite(at) && at > untilMs) continue;
|
|
153
|
+
if (agent === "codex" && record?.type === "session_meta" && !found.sessionId) {
|
|
154
|
+
found.sessionId = record.payload?.session_id || record.payload?.id || null;
|
|
155
|
+
found.startedAt = record.payload?.timestamp || record.timestamp || null;
|
|
156
|
+
found.repository = repositoryLabel(record.payload?.git?.repository_url);
|
|
157
|
+
} else if (agent === "claude" && !found.sessionId) {
|
|
158
|
+
found.sessionId = record?.sessionId || record?.session_id || null;
|
|
159
|
+
found.startedAt = record?.timestamp || null;
|
|
160
|
+
}
|
|
161
|
+
if (sinceMs !== null && (!Number.isFinite(at) || at < sinceMs)) continue;
|
|
162
|
+
found.eligibleRecords++;
|
|
163
|
+
for (const call of callsIn(record, agent)) found.calls.push({ ...call, path, line });
|
|
164
|
+
const lead = reviewLeadIn(record, agent);
|
|
165
|
+
if (lead) found.reviewLeads.push({ ...lead, path, line });
|
|
166
|
+
}
|
|
167
|
+
} catch (error) {
|
|
168
|
+
found.readable = false;
|
|
169
|
+
found.error = error.code || error.message;
|
|
170
|
+
}
|
|
171
|
+
return found;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function scan(options) {
|
|
175
|
+
if (
|
|
176
|
+
options.since &&
|
|
177
|
+
(!/^\d{4}-\d{2}-\d{2}$/.test(options.since) || Number.isNaN(Date.parse(options.since)))
|
|
178
|
+
)
|
|
179
|
+
throw new Error(`Invalid --since date: ${options.since}`);
|
|
180
|
+
if (
|
|
181
|
+
options.until &&
|
|
182
|
+
(!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(options.until) ||
|
|
183
|
+
Number.isNaN(Date.parse(options.until)))
|
|
184
|
+
)
|
|
185
|
+
throw new Error(`Invalid --until timestamp: ${options.until}`);
|
|
186
|
+
const roots = [
|
|
187
|
+
{ agent: "codex", path: join(options.codexHome, "sessions") },
|
|
188
|
+
{ agent: "codex", path: join(options.codexHome, "archived_sessions") },
|
|
189
|
+
{ agent: "claude", path: join(options.claudeHome, "projects") },
|
|
190
|
+
];
|
|
191
|
+
const missingRoots = [];
|
|
192
|
+
const unreadable = [];
|
|
193
|
+
const files = [];
|
|
194
|
+
for (const root of roots) {
|
|
195
|
+
const paths = [];
|
|
196
|
+
if (!walk(root.path, paths, unreadable)) missingRoots.push(root);
|
|
197
|
+
for (const path of paths) files.push({ path, agent: root.agent });
|
|
198
|
+
}
|
|
199
|
+
const counts = Object.fromEntries(
|
|
200
|
+
["codex", "claude"].map((agent) => [
|
|
201
|
+
agent,
|
|
202
|
+
{
|
|
203
|
+
filesFound: 0,
|
|
204
|
+
filesEligible: 0,
|
|
205
|
+
filesReadable: 0,
|
|
206
|
+
filesUnreadable: 0,
|
|
207
|
+
filesAfterUntil: 0,
|
|
208
|
+
filesOutsideWindow: 0,
|
|
209
|
+
malformedLines: 0,
|
|
210
|
+
sessions: 0,
|
|
211
|
+
candidateSessions: 0,
|
|
212
|
+
noncandidateSessions: 0,
|
|
213
|
+
sessionsWithoutMetadata: 0,
|
|
214
|
+
groundtrackCallSessions: 0,
|
|
215
|
+
reviewLeadSessions: 0,
|
|
216
|
+
},
|
|
217
|
+
]),
|
|
218
|
+
);
|
|
219
|
+
const sessions = new Map();
|
|
220
|
+
for (const file of files) {
|
|
221
|
+
const count = counts[file.agent];
|
|
222
|
+
count.filesFound++;
|
|
223
|
+
count.filesEligible++;
|
|
224
|
+
const result = await scanFile(
|
|
225
|
+
file.path,
|
|
226
|
+
file.agent,
|
|
227
|
+
options.since ? Date.parse(options.since) : null,
|
|
228
|
+
options.until ? Date.parse(options.until) : null,
|
|
229
|
+
);
|
|
230
|
+
count.malformedLines += result.malformedLines;
|
|
231
|
+
if (!result.readable) {
|
|
232
|
+
count.filesUnreadable++;
|
|
233
|
+
unreadable.push({ path: file.path, error: result.error });
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (!result.eligibleRecords) {
|
|
237
|
+
count.filesEligible--;
|
|
238
|
+
if (options.since) count.filesOutsideWindow++;
|
|
239
|
+
else count.filesAfterUntil++;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
count.filesReadable++;
|
|
243
|
+
if (!result.sessionId) count.sessionsWithoutMetadata++;
|
|
244
|
+
const key = `${file.agent}:${result.sessionId || file.path}`;
|
|
245
|
+
const session = sessions.get(key) || {
|
|
246
|
+
agent: file.agent,
|
|
247
|
+
sessionId: result.sessionId,
|
|
248
|
+
startedAt: result.startedAt,
|
|
249
|
+
repository: result.repository,
|
|
250
|
+
files: [],
|
|
251
|
+
calls: [],
|
|
252
|
+
reviewLeads: [],
|
|
253
|
+
_callKeys: new Set(),
|
|
254
|
+
};
|
|
255
|
+
session.files.push(file.path);
|
|
256
|
+
session.startedAt ||= result.startedAt;
|
|
257
|
+
session.repository ||= result.repository;
|
|
258
|
+
for (const call of result.calls) {
|
|
259
|
+
const callKey = call.callId
|
|
260
|
+
? `${call.callId}:${call.tool}`
|
|
261
|
+
: `${call.path}:${call.line}:${call.tool}`;
|
|
262
|
+
if (!session._callKeys.has(callKey)) {
|
|
263
|
+
session.calls.push(call);
|
|
264
|
+
session._callKeys.add(callKey);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
for (const lead of result.reviewLeads) {
|
|
268
|
+
const leadKey = `review:${lead.callId || `${lead.path}:${lead.line}`}`;
|
|
269
|
+
if (!session._callKeys.has(leadKey)) {
|
|
270
|
+
session.reviewLeads.push(lead);
|
|
271
|
+
session._callKeys.add(leadKey);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
sessions.set(key, session);
|
|
275
|
+
}
|
|
276
|
+
const candidates = [];
|
|
277
|
+
for (const session of sessions.values()) {
|
|
278
|
+
const count = counts[session.agent];
|
|
279
|
+
count.sessions++;
|
|
280
|
+
if (session.calls.length) count.groundtrackCallSessions++;
|
|
281
|
+
if (session.reviewLeads.length) count.reviewLeadSessions++;
|
|
282
|
+
if (session.calls.length || session.reviewLeads.length) {
|
|
283
|
+
count.candidateSessions++;
|
|
284
|
+
delete session._callKeys;
|
|
285
|
+
candidates.push(session);
|
|
286
|
+
} else count.noncandidateSessions++;
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
scope: {
|
|
290
|
+
sinceRecordTimestamp: options.since,
|
|
291
|
+
untilRecordTimestamp: options.until || null,
|
|
292
|
+
roots,
|
|
293
|
+
missingRoots,
|
|
294
|
+
},
|
|
295
|
+
counts,
|
|
296
|
+
unreadable,
|
|
297
|
+
candidates,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
302
|
+
try {
|
|
303
|
+
const report = await scan(optionsFrom(process.argv.slice(2)));
|
|
304
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
process.stderr.write(`${error.message}\n`);
|
|
307
|
+
process.exitCode = 1;
|
|
308
|
+
}
|
|
309
|
+
}
|