harnessgap 0.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/LICENSE +21 -0
- package/README.md +160 -0
- package/dist/adapter/correction.d.ts +14 -0
- package/dist/adapter/correction.js +68 -0
- package/dist/adapter/correction.js.map +1 -0
- package/dist/adapter/parse.d.ts +21 -0
- package/dist/adapter/parse.js +261 -0
- package/dist/adapter/parse.js.map +1 -0
- package/dist/adapter/scrub.d.ts +15 -0
- package/dist/adapter/scrub.js +97 -0
- package/dist/adapter/scrub.js.map +1 -0
- package/dist/adapter/stream.d.ts +14 -0
- package/dist/adapter/stream.js +207 -0
- package/dist/adapter/stream.js.map +1 -0
- package/dist/adapter/taxonomy.d.ts +7 -0
- package/dist/adapter/taxonomy.js +21 -0
- package/dist/adapter/taxonomy.js.map +1 -0
- package/dist/aggregate/leaderboard.d.ts +26 -0
- package/dist/aggregate/leaderboard.js +168 -0
- package/dist/aggregate/leaderboard.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +67 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +25 -0
- package/dist/config.js +191 -0
- package/dist/config.js.map +1 -0
- package/dist/detector/areas.d.ts +13 -0
- package/dist/detector/areas.js +95 -0
- package/dist/detector/areas.js.map +1 -0
- package/dist/detector/index.d.ts +14 -0
- package/dist/detector/index.js +31 -0
- package/dist/detector/index.js.map +1 -0
- package/dist/detector/record.d.ts +24 -0
- package/dist/detector/record.js +29 -0
- package/dist/detector/record.js.map +1 -0
- package/dist/detector/scoring.d.ts +21 -0
- package/dist/detector/scoring.js +140 -0
- package/dist/detector/scoring.js.map +1 -0
- package/dist/detector/signals.d.ts +6 -0
- package/dist/detector/signals.js +221 -0
- package/dist/detector/signals.js.map +1 -0
- package/dist/egress.d.ts +8 -0
- package/dist/egress.js +37 -0
- package/dist/egress.js.map +1 -0
- package/dist/git.d.ts +16 -0
- package/dist/git.js +59 -0
- package/dist/git.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/output/calibrate.d.ts +35 -0
- package/dist/output/calibrate.js +149 -0
- package/dist/output/calibrate.js.map +1 -0
- package/dist/output/human.d.ts +24 -0
- package/dist/output/human.js +83 -0
- package/dist/output/human.js.map +1 -0
- package/dist/output/json.d.ts +17 -0
- package/dist/output/json.js +22 -0
- package/dist/output/json.js.map +1 -0
- package/dist/pipeline.d.ts +24 -0
- package/dist/pipeline.js +164 -0
- package/dist/pipeline.js.map +1 -0
- package/dist/types.d.ts +122 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/walk.d.ts +12 -0
- package/dist/walk.js +92 -0
- package/dist/walk.js.map +1 -0
- package/package.json +49 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Pure human-readable leaderboard formatter (the §8 table). Turns area rows +
|
|
2
|
+
// summary + warnings into a column-aligned string. No I/O, no raw prose — only
|
|
3
|
+
// area keys, counts, scores, and signal display strings appear in the output.
|
|
4
|
+
// Fixed column widths for the leaderboard table.
|
|
5
|
+
const AREA_W = 32;
|
|
6
|
+
const FLAGGED_W = 8;
|
|
7
|
+
const MEAN_W = 11;
|
|
8
|
+
// Fixed canonical order + human labels for the warnings line. Zero-count
|
|
9
|
+
// categories are omitted from the output.
|
|
10
|
+
const WARNING_PARTS = [
|
|
11
|
+
{ key: 'malformed_lines', label: 'malformed lines' },
|
|
12
|
+
{ key: 'oversized_lines', label: 'oversized lines' },
|
|
13
|
+
{ key: 'skipped_sessions', label: 'skipped sessions' },
|
|
14
|
+
{ key: 'truncated_sessions', label: 'truncated sessions' },
|
|
15
|
+
{ key: 'symlinks_rejected', label: 'symlinks rejected' },
|
|
16
|
+
{ key: 'unresolvable_cwd', label: 'unresolvable cwd' },
|
|
17
|
+
];
|
|
18
|
+
/**
|
|
19
|
+
* Format the scan leaderboard as a human-readable string. Pure.
|
|
20
|
+
*
|
|
21
|
+
* Layout: header line, column-aligned table (or a "no flagged areas" line when
|
|
22
|
+
* empty), summary line, and a warnings line (only non-zero categories). The
|
|
23
|
+
* bootstrap count in the summary line is `sessionCount` when mode is bootstrap,
|
|
24
|
+
* else 0.
|
|
25
|
+
*/
|
|
26
|
+
export function formatHuman(input) {
|
|
27
|
+
const { repo, mode, sessionCount, areas, summary, warnings } = input;
|
|
28
|
+
const lines = [];
|
|
29
|
+
lines.push(`harnessgap scan — repo: ${repo} · ${sessionCount} sessions · mode: ${mode}`);
|
|
30
|
+
if (areas.length === 0) {
|
|
31
|
+
lines.push('No flagged areas.');
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
lines.push(tableHeader());
|
|
35
|
+
for (const area of areas) {
|
|
36
|
+
lines.push(tableRow(area));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const bootCount = mode === 'bootstrap' ? sessionCount : 0;
|
|
40
|
+
lines.push(`${summary.flagged} areas flagged · ${summary.unflagged} unflagged · ${summary.unlocalized} unlocalized · bootstrap: ${bootCount} sessions`);
|
|
41
|
+
const wLine = warningsLine(warnings);
|
|
42
|
+
if (wLine !== null)
|
|
43
|
+
lines.push(wLine);
|
|
44
|
+
return lines.join('\n');
|
|
45
|
+
}
|
|
46
|
+
/** The column-header row, aligned to match the data rows. */
|
|
47
|
+
function tableHeader() {
|
|
48
|
+
return `${'AREA'.padEnd(AREA_W)} | ${'FLAGGED'.padStart(FLAGGED_W)} | ${'MEAN SCORE'.padStart(MEAN_W)} | TOP SIGNALS`;
|
|
49
|
+
}
|
|
50
|
+
/** One area row, column-aligned. */
|
|
51
|
+
function tableRow(area) {
|
|
52
|
+
const areaCol = fit(area.key, AREA_W);
|
|
53
|
+
const flaggedCol = String(area.sessions_flagged).padStart(FLAGGED_W);
|
|
54
|
+
const meanCol = area.mean_score.toFixed(1).padStart(MEAN_W);
|
|
55
|
+
const signalsCol = area.top_signals.map((t) => t.display).join(', ');
|
|
56
|
+
return `${areaCol} | ${flaggedCol} | ${meanCol} | ${signalsCol}`;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Fit a string to `width`: pad with spaces if shorter, truncate with "..." if
|
|
60
|
+
* longer. Keeps the column alignment intact.
|
|
61
|
+
*/
|
|
62
|
+
function fit(s, width) {
|
|
63
|
+
if (s.length <= width)
|
|
64
|
+
return s.padEnd(width);
|
|
65
|
+
return s.slice(0, width - 3) + '...';
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Build the warnings line as `warnings: <n> <label>, <n> <label>`, omitting any
|
|
69
|
+
* category whose count is 0. Returns null when every category is 0 (line
|
|
70
|
+
* omitted entirely).
|
|
71
|
+
*/
|
|
72
|
+
function warningsLine(warnings) {
|
|
73
|
+
const parts = [];
|
|
74
|
+
for (const { key, label } of WARNING_PARTS) {
|
|
75
|
+
const count = warnings[key];
|
|
76
|
+
if (count > 0)
|
|
77
|
+
parts.push(`${count} ${label}`);
|
|
78
|
+
}
|
|
79
|
+
if (parts.length === 0)
|
|
80
|
+
return null;
|
|
81
|
+
return `warnings: ${parts.join(', ')}`;
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=human.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"human.js","sourceRoot":"","sources":["../../src/output/human.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AreaRow, JsonOutput, ScoringMode, StruggleRecord, Warnings } from '../types.js';
|
|
2
|
+
/** Inputs to `buildJsonEnvelope`; all fields projected verbatim onto JsonOutput. */
|
|
3
|
+
interface JsonEnvelopeInput {
|
|
4
|
+
repo: string;
|
|
5
|
+
mode: ScoringMode;
|
|
6
|
+
session_count: number;
|
|
7
|
+
warnings: Warnings;
|
|
8
|
+
sessions: StruggleRecord[];
|
|
9
|
+
areas: AreaRow[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Assemble the `JsonOutput` envelope. Pure: returns a new object whose fields
|
|
13
|
+
* are the inputs plus `schema_version: 1`. Does not clone `sessions`/`areas`
|
|
14
|
+
* (callers may share references; the envelope is read-only by contract).
|
|
15
|
+
*/
|
|
16
|
+
export declare function buildJsonEnvelope(input: JsonEnvelopeInput): JsonOutput;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Pure JSON envelope assembler. Turns the detector + aggregator outputs into
|
|
2
|
+
// the `JsonOutput` shape consumed by the `--json` CLI flag. No I/O, no
|
|
3
|
+
// transformation beyond direct field projection: sessions and areas are passed
|
|
4
|
+
// through as-is (already scrubbed of prose upstream), warnings are integer
|
|
5
|
+
// counts. schema_version is pinned to 1.
|
|
6
|
+
/**
|
|
7
|
+
* Assemble the `JsonOutput` envelope. Pure: returns a new object whose fields
|
|
8
|
+
* are the inputs plus `schema_version: 1`. Does not clone `sessions`/`areas`
|
|
9
|
+
* (callers may share references; the envelope is read-only by contract).
|
|
10
|
+
*/
|
|
11
|
+
export function buildJsonEnvelope(input) {
|
|
12
|
+
return {
|
|
13
|
+
schema_version: 1,
|
|
14
|
+
repo: input.repo,
|
|
15
|
+
mode: input.mode,
|
|
16
|
+
session_count: input.session_count,
|
|
17
|
+
warnings: input.warnings,
|
|
18
|
+
sessions: input.sessions,
|
|
19
|
+
areas: input.areas,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
//# sourceMappingURL=json.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"json.js","sourceRoot":"","sources":["../../src/output/json.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,uEAAuE;AACvE,+EAA+E;AAC/E,2EAA2E;AAC3E,yCAAyC;AAoBzC;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAAwB;IACxD,OAAO;QACL,cAAc,EAAE,CAAC;QACjB,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,aAAa,EAAE,KAAK,CAAC,aAAa;QAClC,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,QAAQ,EAAE,KAAK,CAAC,QAAQ;QACxB,KAAK,EAAE,KAAK,CAAC,KAAK;KACnB,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ScoringMode, Warnings } from './types.js';
|
|
2
|
+
export interface ScanOptions {
|
|
3
|
+
repo?: string;
|
|
4
|
+
since?: string;
|
|
5
|
+
limit?: number;
|
|
6
|
+
json?: boolean;
|
|
7
|
+
calibrate?: boolean;
|
|
8
|
+
bootstrap?: boolean;
|
|
9
|
+
configPath?: string;
|
|
10
|
+
claudeDir?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ScanResult {
|
|
13
|
+
output: string;
|
|
14
|
+
mode: ScoringMode;
|
|
15
|
+
sessionCount: number;
|
|
16
|
+
warnings: Warnings;
|
|
17
|
+
exitCode: 0 | 1;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Orchestrate the full harnessgap scan. Async (awaits streamSession +
|
|
21
|
+
* resolveToplevel). Returns the output string, mode, session count, aggregated
|
|
22
|
+
* warnings, and exitCode (always 0 — misconfig throws propagate to the CLI).
|
|
23
|
+
*/
|
|
24
|
+
export declare function runScan(opts: ScanOptions): Promise<ScanResult>;
|
package/dist/pipeline.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Stateless scan pipeline — the thin I/O shell that orchestrates walk → stream
|
|
2
|
+
// → git-resolve → detect → aggregate → output. All pure logic lives in the
|
|
3
|
+
// modules it composes; this file threads them together, applies filters, and
|
|
4
|
+
// branches the output form. No disk writes, no network. Async because
|
|
5
|
+
// streamSession and resolveToplevel are async.
|
|
6
|
+
//
|
|
7
|
+
// Filter / mode / output-branching choices (documented here, pinned to the
|
|
8
|
+
// task-16 brief + resolution notes):
|
|
9
|
+
//
|
|
10
|
+
// - repo filter when opts.repo is unset: resolve `resolveToplevel(process.cwd())`.
|
|
11
|
+
// If it resolves, filter envelopes to that toplevel; if it does NOT resolve
|
|
12
|
+
// (process.cwd() is not a repo), keep ALL envelopes with a non-empty repo.
|
|
13
|
+
// - mode when no records: "bootstrap" (consistent with scoreSessions: 0 sessions
|
|
14
|
+
// < bootstrap_session_floor → bootstrap).
|
|
15
|
+
// - repo for output: the filtered repo (opts.repo, or the resolved process.cwd()
|
|
16
|
+
// toplevel, or "" if neither resolves).
|
|
17
|
+
// - --since: parseDuration(opts.since) → ms (Infinity → no filter). Filter:
|
|
18
|
+
// Date.parse(started_at) >= now − sinceMs. Empty/unparseable started_at →
|
|
19
|
+
// excluded (can't date it).
|
|
20
|
+
// - --limit: applied AFTER all filtering. Negative limits are ignored.
|
|
21
|
+
// - empty cwd (streamSession returned cwd=''): treated as unresolvable
|
|
22
|
+
// (unresolvable_cwd++, skipped) — resolveToplevel('') would wrongly resolve
|
|
23
|
+
// process.cwd()'s repo.
|
|
24
|
+
// - ConfigError from loadConfig / parseDuration is NOT caught here — it
|
|
25
|
+
// propagates to the CLI for non-zero exit. runScan's exitCode is always 0.
|
|
26
|
+
import { loadConfig, parseDuration } from './config.js';
|
|
27
|
+
import { discoverTranscripts, defaultClaudeDir } from './walk.js';
|
|
28
|
+
import { streamSession } from './adapter/stream.js';
|
|
29
|
+
import { resolveToplevel } from './git.js';
|
|
30
|
+
import { runDetector } from './detector/index.js';
|
|
31
|
+
import { aggregateAreas } from './aggregate/leaderboard.js';
|
|
32
|
+
import { buildJsonEnvelope } from './output/json.js';
|
|
33
|
+
import { formatHuman } from './output/human.js';
|
|
34
|
+
import { buildCalibrateObject, formatCalibrateTable } from './output/calibrate.js';
|
|
35
|
+
/**
|
|
36
|
+
* Orchestrate the full harnessgap scan. Async (awaits streamSession +
|
|
37
|
+
* resolveToplevel). Returns the output string, mode, session count, aggregated
|
|
38
|
+
* warnings, and exitCode (always 0 — misconfig throws propagate to the CLI).
|
|
39
|
+
*/
|
|
40
|
+
export async function runScan(opts) {
|
|
41
|
+
// 1. Load config (ConfigError propagates — not caught here).
|
|
42
|
+
const cfg = loadConfig(opts.configPath);
|
|
43
|
+
// 2. Discover transcripts.
|
|
44
|
+
const claudeDir = opts.claudeDir ?? defaultClaudeDir();
|
|
45
|
+
const { files, symlinks_rejected } = discoverTranscripts(claudeDir);
|
|
46
|
+
const warnings = {
|
|
47
|
+
malformed_lines: 0,
|
|
48
|
+
oversized_lines: 0,
|
|
49
|
+
skipped_sessions: 0,
|
|
50
|
+
truncated_sessions: 0,
|
|
51
|
+
symlinks_rejected,
|
|
52
|
+
unresolvable_cwd: 0,
|
|
53
|
+
};
|
|
54
|
+
// 3. Stream each file, resolve repo, accumulate warnings. Thread a single
|
|
55
|
+
// git-cache across all sessions (cwd repeats are common across transcripts).
|
|
56
|
+
const cache = new Map();
|
|
57
|
+
const envelopes = [];
|
|
58
|
+
for (const file of files) {
|
|
59
|
+
const { envelope, cwd, warnings: streamWarnings } = await streamSession(file);
|
|
60
|
+
warnings.malformed_lines += streamWarnings.malformed_lines;
|
|
61
|
+
warnings.oversized_lines += streamWarnings.oversized_lines;
|
|
62
|
+
warnings.truncated_sessions += streamWarnings.truncated_sessions;
|
|
63
|
+
// Empty cwd → can't resolve a repo (resolveToplevel('') would resolve
|
|
64
|
+
// process.cwd()'s repo, which is wrong). Skip as unresolvable.
|
|
65
|
+
if (cwd === '') {
|
|
66
|
+
warnings.unresolvable_cwd += 1;
|
|
67
|
+
warnings.skipped_sessions += 1;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const toplevel = await resolveToplevel(cwd, cache);
|
|
71
|
+
if (!toplevel) {
|
|
72
|
+
warnings.unresolvable_cwd += 1;
|
|
73
|
+
warnings.skipped_sessions += 1;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
envelope.repo = toplevel;
|
|
77
|
+
envelopes.push(envelope);
|
|
78
|
+
}
|
|
79
|
+
// 4. Filter by repo.
|
|
80
|
+
let filterRepo;
|
|
81
|
+
if (opts.repo !== undefined) {
|
|
82
|
+
filterRepo = opts.repo;
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const cwdToplevel = await resolveToplevel(process.cwd(), cache);
|
|
86
|
+
filterRepo = cwdToplevel ?? '';
|
|
87
|
+
}
|
|
88
|
+
let filtered;
|
|
89
|
+
if (filterRepo !== '') {
|
|
90
|
+
filtered = envelopes.filter((e) => e.repo === filterRepo);
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
// process.cwd() didn't resolve and no opts.repo — keep all envelopes with
|
|
94
|
+
// a non-empty repo.
|
|
95
|
+
filtered = envelopes.filter((e) => e.repo !== '');
|
|
96
|
+
}
|
|
97
|
+
// 5. Apply --since (filter by started_at ≥ now − duration).
|
|
98
|
+
if (opts.since !== undefined) {
|
|
99
|
+
const sinceMs = parseDuration(opts.since);
|
|
100
|
+
if (sinceMs !== Infinity) {
|
|
101
|
+
const cutoff = Date.now() - sinceMs;
|
|
102
|
+
filtered = filtered.filter((e) => {
|
|
103
|
+
if (e.started_at === '')
|
|
104
|
+
return false;
|
|
105
|
+
const t = Date.parse(e.started_at);
|
|
106
|
+
return !Number.isNaN(t) && t >= cutoff;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Apply --limit (cap count, AFTER all filtering).
|
|
111
|
+
if (opts.limit !== undefined && opts.limit >= 0) {
|
|
112
|
+
filtered = filtered.slice(0, opts.limit);
|
|
113
|
+
}
|
|
114
|
+
// 6. Run detector.
|
|
115
|
+
const forceBootstrap = opts.bootstrap ?? false;
|
|
116
|
+
const records = runDetector(filtered, cfg, forceBootstrap);
|
|
117
|
+
// 7. Aggregate areas.
|
|
118
|
+
const { rows, summary } = aggregateAreas(records, cfg);
|
|
119
|
+
// 8. Determine mode (consistent across all records; default bootstrap when
|
|
120
|
+
// no records — matches scoreSessions' selectMode for n=0).
|
|
121
|
+
const mode = records.length > 0 ? records[0].mode : 'bootstrap';
|
|
122
|
+
// repo for output: the repo envelopes were filtered to (or "" if none).
|
|
123
|
+
const outputRepo = filterRepo;
|
|
124
|
+
// 9. Build output — branch by --calibrate / --json / human.
|
|
125
|
+
let output;
|
|
126
|
+
if (opts.calibrate) {
|
|
127
|
+
const calObj = buildCalibrateObject({
|
|
128
|
+
mode,
|
|
129
|
+
session_count: records.length,
|
|
130
|
+
flag_pct: cfg.detector.flag_pct,
|
|
131
|
+
signals: records.map((r) => r.signals),
|
|
132
|
+
bootstrap_thresholds: cfg.detector.bootstrap_thresholds,
|
|
133
|
+
});
|
|
134
|
+
output = opts.json ? JSON.stringify(calObj) : formatCalibrateTable(calObj);
|
|
135
|
+
}
|
|
136
|
+
else if (opts.json) {
|
|
137
|
+
output = JSON.stringify(buildJsonEnvelope({
|
|
138
|
+
repo: outputRepo,
|
|
139
|
+
mode,
|
|
140
|
+
session_count: records.length,
|
|
141
|
+
warnings,
|
|
142
|
+
sessions: records,
|
|
143
|
+
areas: rows,
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
output = formatHuman({
|
|
148
|
+
repo: outputRepo,
|
|
149
|
+
mode,
|
|
150
|
+
sessionCount: records.length,
|
|
151
|
+
areas: rows,
|
|
152
|
+
summary,
|
|
153
|
+
warnings,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
output,
|
|
158
|
+
mode,
|
|
159
|
+
sessionCount: records.length,
|
|
160
|
+
warnings,
|
|
161
|
+
exitCode: 0,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
//# sourceMappingURL=pipeline.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pipeline.js","sourceRoot":"","sources":["../src/pipeline.ts"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
export type ToolKind = 'read' | 'search' | 'list' | 'edit' | 'exec' | 'other';
|
|
2
|
+
export type EventKind = 'user_msg' | 'assistant_msg' | 'tool_call';
|
|
3
|
+
export type SignalName = 'explore_ratio' | 'reread' | 'failure_streak' | 'corrections' | 'abandonment' | 'oscillation' | 'wall_clock_per_line';
|
|
4
|
+
export type ScoringMode = 'percentile' | 'bootstrap';
|
|
5
|
+
export interface InputDigest {
|
|
6
|
+
files: string[];
|
|
7
|
+
cmd: string | null;
|
|
8
|
+
query: string | null;
|
|
9
|
+
lines_changed: number | null;
|
|
10
|
+
}
|
|
11
|
+
export interface Correction {
|
|
12
|
+
matched: boolean;
|
|
13
|
+
shape: string | null;
|
|
14
|
+
}
|
|
15
|
+
export interface NormalizedEvent {
|
|
16
|
+
t: string;
|
|
17
|
+
kind: EventKind;
|
|
18
|
+
tool: ToolKind | null;
|
|
19
|
+
input_digest: InputDigest;
|
|
20
|
+
ok: boolean;
|
|
21
|
+
interrupted: boolean;
|
|
22
|
+
duration_ms: number;
|
|
23
|
+
correction: Correction | null;
|
|
24
|
+
}
|
|
25
|
+
export interface NormalizedEnvelope {
|
|
26
|
+
schema_version: 1;
|
|
27
|
+
session_id: string;
|
|
28
|
+
agent: 'claude-code';
|
|
29
|
+
repo: string;
|
|
30
|
+
started_at: string;
|
|
31
|
+
duration_ms: number;
|
|
32
|
+
events: NormalizedEvent[];
|
|
33
|
+
truncated: boolean;
|
|
34
|
+
event_count: number;
|
|
35
|
+
}
|
|
36
|
+
export interface SignalValues {
|
|
37
|
+
explore_ratio: number | null;
|
|
38
|
+
reread: number;
|
|
39
|
+
failure_streak: number;
|
|
40
|
+
corrections: number;
|
|
41
|
+
abandonment: boolean;
|
|
42
|
+
oscillation: number;
|
|
43
|
+
wall_clock_per_line_ms: number | null;
|
|
44
|
+
}
|
|
45
|
+
export interface StruggleRecord {
|
|
46
|
+
session_id: string;
|
|
47
|
+
repo: string;
|
|
48
|
+
started_at: string;
|
|
49
|
+
duration_ms: number;
|
|
50
|
+
score_pct: number;
|
|
51
|
+
mode: ScoringMode;
|
|
52
|
+
flagged: boolean;
|
|
53
|
+
truncated: boolean;
|
|
54
|
+
event_count: number;
|
|
55
|
+
areas: {
|
|
56
|
+
key: string;
|
|
57
|
+
weight: number;
|
|
58
|
+
}[];
|
|
59
|
+
signals: SignalValues;
|
|
60
|
+
}
|
|
61
|
+
export interface Warnings {
|
|
62
|
+
malformed_lines: number;
|
|
63
|
+
oversized_lines: number;
|
|
64
|
+
skipped_sessions: number;
|
|
65
|
+
truncated_sessions: number;
|
|
66
|
+
symlinks_rejected: number;
|
|
67
|
+
unresolvable_cwd: number;
|
|
68
|
+
}
|
|
69
|
+
export interface AreaRow {
|
|
70
|
+
key: string;
|
|
71
|
+
sessions_total: number;
|
|
72
|
+
sessions_flagged: number;
|
|
73
|
+
mean_score: number;
|
|
74
|
+
top_signals: {
|
|
75
|
+
name: SignalName;
|
|
76
|
+
value: number | boolean;
|
|
77
|
+
display: string;
|
|
78
|
+
}[];
|
|
79
|
+
}
|
|
80
|
+
export interface JsonOutput {
|
|
81
|
+
schema_version: 1;
|
|
82
|
+
repo: string;
|
|
83
|
+
mode: ScoringMode;
|
|
84
|
+
session_count: number;
|
|
85
|
+
warnings: Warnings;
|
|
86
|
+
sessions: StruggleRecord[];
|
|
87
|
+
areas: AreaRow[];
|
|
88
|
+
}
|
|
89
|
+
export interface Config {
|
|
90
|
+
detector: {
|
|
91
|
+
thresholds_as: 'percentile' | 'absolute';
|
|
92
|
+
flag_pct: number;
|
|
93
|
+
bootstrap_session_floor: number;
|
|
94
|
+
bootstrap_flag_pct: number;
|
|
95
|
+
reread_threshold: number;
|
|
96
|
+
correction_window_ms: number;
|
|
97
|
+
signal_weights: Record<SignalName, number>;
|
|
98
|
+
bootstrap_thresholds: {
|
|
99
|
+
explore_ratio: number;
|
|
100
|
+
reread: number;
|
|
101
|
+
failure_streak: number;
|
|
102
|
+
corrections: number;
|
|
103
|
+
abandonment: boolean;
|
|
104
|
+
oscillation: number;
|
|
105
|
+
wall_clock_per_line_ms: number;
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
areas: {
|
|
109
|
+
ignore: string[];
|
|
110
|
+
min_weight: number;
|
|
111
|
+
min_depth: number;
|
|
112
|
+
touch_weights: {
|
|
113
|
+
edit: number;
|
|
114
|
+
read: number;
|
|
115
|
+
exec: number;
|
|
116
|
+
};
|
|
117
|
+
tail_fraction: number;
|
|
118
|
+
explore_ratio_min: number;
|
|
119
|
+
suppress_abandonment_when_no_exec: boolean;
|
|
120
|
+
test_cmd_patterns: string[];
|
|
121
|
+
};
|
|
122
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,0EAA0E;AAC1E,kEAAkE"}
|
package/dist/walk.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Default Claude config directory: `~/.claude`. */
|
|
2
|
+
export declare function defaultClaudeDir(): string;
|
|
3
|
+
/**
|
|
4
|
+
* Discover all .jsonl transcripts under <claudeDir>/projects/<slug>/<file>.jsonl
|
|
5
|
+
* (exactly one session-dir level under projects/). Rejects symlinks (lstat,
|
|
6
|
+
* never followed) and confines results to the projects/ prefix. Fail-open on
|
|
7
|
+
* missing projects/ dir.
|
|
8
|
+
*/
|
|
9
|
+
export declare function discoverTranscripts(claudeDir: string): {
|
|
10
|
+
files: string[];
|
|
11
|
+
symlinks_rejected: number;
|
|
12
|
+
};
|
package/dist/walk.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Transcript discovery — finds every *.jsonl under
|
|
2
|
+
// <claudeDir>/projects/*/*.jsonl (exactly one session-dir level under
|
|
3
|
+
// projects/). Security-critical: NEVER follows symlinks.
|
|
4
|
+
//
|
|
5
|
+
// - Directory traversal uses readdir(withFileTypes): Dirent.isDirectory() is
|
|
6
|
+
// false for symlinks (even symlinks-to-dirs), so symlinked session-dirs are
|
|
7
|
+
// skipped at the directory level — never entered, never realpath'd.
|
|
8
|
+
// - Each .jsonl candidate is lstat'd (not stat'd): a symlink .jsonl is rejected
|
|
9
|
+
// and counted in symlinks_rejected regardless of where it points.
|
|
10
|
+
// - A defensive prefix-confinement check rejects any resolved path that escapes
|
|
11
|
+
// <claudeDir>/projects/ (guards against `..`-style traversal in dir names,
|
|
12
|
+
// which readdir itself never produces but we verify anyway).
|
|
13
|
+
//
|
|
14
|
+
// Does NOT read file contents (streamSession in adapter/stream.ts does that).
|
|
15
|
+
// Fail-open: a missing or unreadable projects/ dir yields an empty result,
|
|
16
|
+
// never a throw.
|
|
17
|
+
//
|
|
18
|
+
// Sync I/O is chosen deliberately: the pipeline walks a bounded number of
|
|
19
|
+
// files and sync code avoids an await at every call site. Output is sorted
|
|
20
|
+
// lexicographically so the pipeline sees a stable input order.
|
|
21
|
+
import * as fs from 'node:fs';
|
|
22
|
+
import * as path from 'node:path';
|
|
23
|
+
import * as os from 'node:os';
|
|
24
|
+
/** Default Claude config directory: `~/.claude`. */
|
|
25
|
+
export function defaultClaudeDir() {
|
|
26
|
+
return path.join(os.homedir(), '.claude');
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Discover all .jsonl transcripts under <claudeDir>/projects/<slug>/<file>.jsonl
|
|
30
|
+
* (exactly one session-dir level under projects/). Rejects symlinks (lstat,
|
|
31
|
+
* never followed) and confines results to the projects/ prefix. Fail-open on
|
|
32
|
+
* missing projects/ dir.
|
|
33
|
+
*/
|
|
34
|
+
export function discoverTranscripts(claudeDir) {
|
|
35
|
+
const files = [];
|
|
36
|
+
let symlinks_rejected = 0;
|
|
37
|
+
const projectsDir = path.resolve(claudeDir, 'projects');
|
|
38
|
+
const prefix = projectsDir + path.sep;
|
|
39
|
+
let topEntries;
|
|
40
|
+
try {
|
|
41
|
+
topEntries = fs.readdirSync(projectsDir, { withFileTypes: true });
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Missing or unreadable projects/ dir — fail open.
|
|
45
|
+
return { files, symlinks_rejected };
|
|
46
|
+
}
|
|
47
|
+
for (const dirent of topEntries) {
|
|
48
|
+
// Exactly one session-dir level under projects/. isDirectory() is false
|
|
49
|
+
// for symlinks, so symlinked session-dirs are skipped here without being
|
|
50
|
+
// traversed or realpath'd.
|
|
51
|
+
if (!dirent.isDirectory())
|
|
52
|
+
continue;
|
|
53
|
+
const sessionDir = path.join(projectsDir, dirent.name);
|
|
54
|
+
let sessionEntries;
|
|
55
|
+
try {
|
|
56
|
+
sessionEntries = fs.readdirSync(sessionDir, { withFileTypes: true });
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
continue; // unreadable session-dir — skip
|
|
60
|
+
}
|
|
61
|
+
for (const fe of sessionEntries) {
|
|
62
|
+
if (!fe.name.endsWith('.jsonl'))
|
|
63
|
+
continue;
|
|
64
|
+
const candidate = path.join(sessionDir, fe.name);
|
|
65
|
+
let st;
|
|
66
|
+
try {
|
|
67
|
+
st = fs.lstatSync(candidate);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
continue; // unreadable entry — skip
|
|
71
|
+
}
|
|
72
|
+
// lstat (not stat): never follows symlinks. A symlink .jsonl is rejected
|
|
73
|
+
// and counted, regardless of its target.
|
|
74
|
+
if (st.isSymbolicLink()) {
|
|
75
|
+
symlinks_rejected++;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (!st.isFile())
|
|
79
|
+
continue; // dirs / sockets / fifos named *.jsonl
|
|
80
|
+
// Defensive confinement: the resolved absolute path must live under
|
|
81
|
+
// projects/. Belt-and-suspenders against path-traversal via `..` in dir
|
|
82
|
+
// names (readdir never emits such names, but we verify anyway).
|
|
83
|
+
const resolved = path.resolve(candidate);
|
|
84
|
+
if (!resolved.startsWith(prefix))
|
|
85
|
+
continue;
|
|
86
|
+
files.push(resolved);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
files.sort();
|
|
90
|
+
return { files, symlinks_rejected };
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=walk.js.map
|
package/dist/walk.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"walk.js","sourceRoot":"","sources":["../src/walk.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "harnessgap",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Stateless detection-only CLI that finds harness gaps in Claude Code transcripts",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Dmitry Teryaev",
|
|
7
|
+
"homepage": "https://github.com/HumanBean17/harnessgap#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/HumanBean17/harnessgap.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/HumanBean17/harnessgap/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"claude-code",
|
|
17
|
+
"cli",
|
|
18
|
+
"transcripts",
|
|
19
|
+
"developer-tools",
|
|
20
|
+
"observability",
|
|
21
|
+
"agent-harness"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=22.12"
|
|
26
|
+
},
|
|
27
|
+
"bin": {
|
|
28
|
+
"harnessgap": "dist/cli.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc",
|
|
35
|
+
"dev": "tsx src/cli.ts",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"typecheck": "tsc --noEmit"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"commander": "^15.0.0",
|
|
41
|
+
"yaml": "^2.9.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^26.1.1",
|
|
45
|
+
"tsx": "^4.23.0",
|
|
46
|
+
"typescript": "^7.0.2",
|
|
47
|
+
"vitest": "^4.1.10"
|
|
48
|
+
}
|
|
49
|
+
}
|