@tech-leads-club/harness-toolkit 0.14.2 → 0.15.1
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 +2 -2
- package/capabilities/catalog.json +2 -2
- package/dist/compact-before.mjs +84 -84
- package/dist/doctor.mjs +89 -89
- package/dist/init-project.mjs +87 -87
- package/dist/install-runtime.mjs +86 -86
- package/dist/lessons-cli.mjs +83 -83
- package/dist/obs-cli.mjs +86 -86
- package/dist/price-lookup.mjs +1 -1
- package/dist/prompt-submit.mjs +84 -84
- package/dist/refresh-model-prices.mjs +88 -88
- package/dist/response-after.mjs +84 -84
- package/dist/run.mjs +84 -84
- package/dist/session-end.mjs +89 -89
- package/dist/session-start.mjs +91 -91
- package/dist/shim.mjs +85 -85
- package/dist/ship-gate.mjs +85 -85
- package/dist/stop.mjs +92 -92
- package/dist/subagent-start.mjs +84 -84
- package/dist/subagent-stop.mjs +85 -85
- package/dist/support.mjs +87 -87
- package/dist/tlc-cli.mjs +104 -104
- package/dist/tool-after.mjs +80 -80
- package/dist/tool-before.mjs +88 -88
- package/dist/tool-failure.mjs +84 -84
- package/dist/uninstall-runtime.mjs +4 -4
- package/docs/concepts.md +13 -7
- package/docs/diagnose.md +2 -2
- package/docs/log.md +6 -0
- package/package.json +1 -1
- package/src/core/core.facade.ts +20 -3
- package/src/core/gate/gate.artifact.ts +43 -7
- package/src/core/observability/observability.service.ts +1 -1
- package/src/core/presence/presence.service.ts +68 -0
- package/src/core/rules/rules.trigger.ts +89 -5
- package/src/core/turn/turn.activity.ts +14 -4
- package/src/entrypoints/run.ts +39 -2
- package/src/entrypoints/session-start.ts +1 -0
- package/src/entrypoints/stop.ts +14 -2
- package/src/entrypoints/support.ts +8 -1
- package/src/entrypoints/tool-before.ts +10 -2
- package/src/platform/paths.ts +4 -0
- package/src/providers/cursor/cursor.inbound.ts +22 -1
- package/tools/obs-cli.ts +12 -2
package/src/entrypoints/run.ts
CHANGED
|
@@ -62,19 +62,55 @@ function errorMessage(error: unknown): string {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
// why: ObsKind is a closed union with no adapter-boundary member — these fire before a provider/session is known, so they bypass core's typed observability rather than widening that union from outside core.
|
|
65
|
-
function recordAdapterEvent(
|
|
65
|
+
function recordAdapterEvent(
|
|
66
|
+
root: string,
|
|
67
|
+
kind: string,
|
|
68
|
+
attrs: Record<string, unknown>,
|
|
69
|
+
provider = "unknown",
|
|
70
|
+
top: Record<string, unknown> = {},
|
|
71
|
+
): void {
|
|
66
72
|
try {
|
|
67
73
|
appendRecord(join(projectStateDir(root), "obs.jsonl"), {
|
|
68
74
|
schema: "harness.observability.v1",
|
|
69
|
-
provider
|
|
75
|
+
provider,
|
|
70
76
|
kind,
|
|
71
77
|
level: "signal",
|
|
72
78
|
ts: new Date().toISOString(),
|
|
79
|
+
...top,
|
|
73
80
|
attrs,
|
|
74
81
|
});
|
|
75
82
|
} catch {}
|
|
76
83
|
}
|
|
77
84
|
|
|
85
|
+
// hazard: unscoped, one record per hook invocation of any kind flooded the signal plane and pushed
|
|
86
|
+
// `prompt.submit`/`policy.deny` out of two readers' fixed-count tails ([/decisions/ad-136.md](/decisions/ad-136.md)).
|
|
87
|
+
function isGateRelevantHookEvent(event: HarnessEvent): boolean {
|
|
88
|
+
return event.event === "shell.before" || event.event === "mcp.before";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* AD-136 — a hook invocation that never reaches the point where any existing record gets written (the handler
|
|
93
|
+
* killed mid-flight, a host-side timeout) previously left nothing in `obs.jsonl` at all — indistinguishable
|
|
94
|
+
* from a hook that never started. This is the earliest point after an event is known: before `loadPolicy`,
|
|
95
|
+
* before `handler` runs, before anything that could throw or take time. A future incident reads as "entered,
|
|
96
|
+
* no paired completion" instead of being reconstructed from raw provider traces after the fact.
|
|
97
|
+
*/
|
|
98
|
+
function recordHookEnter(event: HarnessEvent): void {
|
|
99
|
+
if (!isGateRelevantHookEvent(event)) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
recordAdapterEvent(
|
|
103
|
+
event.projectDir,
|
|
104
|
+
"hook.enter",
|
|
105
|
+
{ event: event.event, toolName: event.toolName ?? "none", sessionKey: event.sessionKey },
|
|
106
|
+
event.provider,
|
|
107
|
+
{
|
|
108
|
+
trace_id: coreFacade.observability.deriveTraceId(event.sessionKey),
|
|
109
|
+
session_id: event.sessionKey,
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
78
114
|
/**
|
|
79
115
|
* hazard: `policy.deny` fed `rollup.denials` and the report's "Policy denials" line, and had no producer — so a
|
|
80
116
|
* harness whose whole purpose is refusing things reported zero refusals
|
|
@@ -155,6 +191,7 @@ export async function runHandler(handler: Handler, io: RunIo = {}): Promise<RunO
|
|
|
155
191
|
}
|
|
156
192
|
|
|
157
193
|
const capabilities = provider.capabilities();
|
|
194
|
+
recordHookEnter(event);
|
|
158
195
|
|
|
159
196
|
try {
|
|
160
197
|
const policy = coreFacade.policy.loadPolicy(event.projectDir);
|
package/src/entrypoints/stop.ts
CHANGED
|
@@ -205,7 +205,11 @@ export async function runLockedGate(args: {
|
|
|
205
205
|
}): Promise<GateRun> {
|
|
206
206
|
const command = [...args.command, ...args.argvFiles];
|
|
207
207
|
const inputs = coreFacade.gate.computeInputsHash(args.root, args.recordFiles, command);
|
|
208
|
-
const cached = coreFacade.gate.cachedVerdict(
|
|
208
|
+
const cached = coreFacade.gate.cachedVerdict(
|
|
209
|
+
coreFacade.gate.readLastGate(args.root, args.sessionKey),
|
|
210
|
+
args.gate,
|
|
211
|
+
inputs,
|
|
212
|
+
);
|
|
209
213
|
|
|
210
214
|
let artifact: LastGateArtifact;
|
|
211
215
|
if (cached !== null) {
|
|
@@ -220,6 +224,7 @@ export async function runLockedGate(args: {
|
|
|
220
224
|
const result = await runCommand(args.root, args.command, args.argvFiles);
|
|
221
225
|
return coreFacade.gate.writeLastGate({
|
|
222
226
|
root: args.root,
|
|
227
|
+
sessionKey: args.sessionKey,
|
|
223
228
|
gate: args.gate,
|
|
224
229
|
exitCode: result.exitCode,
|
|
225
230
|
command,
|
|
@@ -491,7 +496,14 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
|
|
|
491
496
|
* says so once at the end instead of three times ([/decisions/ad-073.md](/decisions/ad-073.md)).
|
|
492
497
|
*/
|
|
493
498
|
const deferred: string[] = [];
|
|
494
|
-
const
|
|
499
|
+
const rawChangedFiles = await listChangedRepoFiles(shaRoot, turnBase);
|
|
500
|
+
const otherSessionFiles = await coreFacade.presence.filesClaimedByOtherLiveSessions(
|
|
501
|
+
root,
|
|
502
|
+
shaRoot,
|
|
503
|
+
provider,
|
|
504
|
+
sessionKey,
|
|
505
|
+
);
|
|
506
|
+
const changedFiles = rawChangedFiles.filter((file) => !otherSessionFiles.has(file));
|
|
495
507
|
const codeTargets = filterCodeTargets(changedFiles, policy.codePaths);
|
|
496
508
|
const testTargets = filterTestTargets(changedFiles);
|
|
497
509
|
// why: the comment rail scopes by the syntax catalog (40+ languages), not `codeTargets`'s nine-extension
|
|
@@ -108,7 +108,14 @@ export async function computeTurnScope(
|
|
|
108
108
|
const seal = coreFacade.handoff.handoffInjectable(root, sessionKey);
|
|
109
109
|
const handoff = seal.ok ? coreFacade.handoff.readHandoff(root, provider, sessionKey) : undefined;
|
|
110
110
|
const turnBase = handoff?.turn_base_sha ?? "HEAD";
|
|
111
|
-
const
|
|
111
|
+
const rawChangedFiles = await listChangedRepoFiles(gitRoot, turnBase);
|
|
112
|
+
const otherSessionFiles = await coreFacade.presence.filesClaimedByOtherLiveSessions(
|
|
113
|
+
root,
|
|
114
|
+
gitRoot,
|
|
115
|
+
provider,
|
|
116
|
+
sessionKey,
|
|
117
|
+
);
|
|
118
|
+
const changedFiles = rawChangedFiles.filter((file) => !otherSessionFiles.has(file));
|
|
112
119
|
const codeTargets = filterCodeTargets(changedFiles, policy.codePaths);
|
|
113
120
|
const testTargets = filterTestTargets(changedFiles);
|
|
114
121
|
const commentScope = changedFiles.filter((file) =>
|
|
@@ -94,7 +94,12 @@ function recordShellDecisionIfShell(event: HarnessEvent, ctx: HandlerContext, de
|
|
|
94
94
|
*/
|
|
95
95
|
async function rulesDecision(event: HarnessEvent, ctx: HandlerContext): Promise<Decision> {
|
|
96
96
|
const config = ctx.policy.rules;
|
|
97
|
-
const trigger = {
|
|
97
|
+
const trigger = {
|
|
98
|
+
event: event.event,
|
|
99
|
+
toolName: event.toolName,
|
|
100
|
+
command: event.command,
|
|
101
|
+
toolInput: event.toolInput,
|
|
102
|
+
};
|
|
98
103
|
const shaRoot = shaScopeRoot(event);
|
|
99
104
|
const dryRun = coreFacade.rules.decideAction(event.projectDir, config, trigger, {
|
|
100
105
|
sha: null,
|
|
@@ -110,7 +115,10 @@ async function rulesDecision(event: HarnessEvent, ctx: HandlerContext): Promise<
|
|
|
110
115
|
// ([/decisions/ad-130.md](/decisions/ad-130.md)) — a CLI shape or an unrelated trigger never reads it.
|
|
111
116
|
const [sha, repoRemote] = await Promise.all([
|
|
112
117
|
currentGitSha(shaRoot),
|
|
113
|
-
event.command && coreFacade.rules.mentionsGhApi(event.command)
|
|
118
|
+
(event.command && coreFacade.rules.mentionsGhApi(event.command)) ||
|
|
119
|
+
coreFacade.rules.mentionsMcpAct(event.toolName)
|
|
120
|
+
? localRepoRemote(shaRoot)
|
|
121
|
+
: undefined,
|
|
114
122
|
]);
|
|
115
123
|
const verdict = coreFacade.rules.decideAction(
|
|
116
124
|
event.projectDir,
|
package/src/platform/paths.ts
CHANGED
|
@@ -132,6 +132,10 @@ export function handoffSessionsDir(root: string): string {
|
|
|
132
132
|
return join(projectStateDir(root), "handoff-sessions");
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
export function gateSessionsDir(root: string): string {
|
|
136
|
+
return join(projectStateDir(root), "gate-sessions");
|
|
137
|
+
}
|
|
138
|
+
|
|
135
139
|
// why: inside the state directory on purpose — the baseline that proves the policy was not switched off
|
|
136
140
|
// inherits the same protection as the policy itself.
|
|
137
141
|
export function policyBaselineDir(root: string): string {
|
|
@@ -36,6 +36,27 @@ function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
|
36
36
|
: undefined;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* hazard: `beforeMCPExecution`'s own `tool_input` is documented as a JSON *string* ("JSON params string that
|
|
41
|
+
* will be passed to the tool"), not an object like `preToolUse`'s — `asRecord` alone left it `undefined` for
|
|
42
|
+
* every MCP call on this host, silently skipping any rule that reads `toolInput` fields
|
|
43
|
+
* ([/decisions/ad-135.md](/decisions/ad-135.md)).
|
|
44
|
+
*/
|
|
45
|
+
function mcpToolInput(value: unknown): Record<string, unknown> | undefined {
|
|
46
|
+
const direct = asRecord(value);
|
|
47
|
+
if (direct) {
|
|
48
|
+
return direct;
|
|
49
|
+
}
|
|
50
|
+
if (typeof value !== "string") {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return asRecord(JSON.parse(value));
|
|
55
|
+
} catch {
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
39
60
|
function asStatus(value: unknown): "completed" | "aborted" | "error" | undefined {
|
|
40
61
|
return value === "completed" || value === "aborted" || value === "error" ? value : undefined;
|
|
41
62
|
}
|
|
@@ -164,7 +185,7 @@ export function cursorToEvent(raw: Record<string, unknown>): HarnessEvent | null
|
|
|
164
185
|
if (toolName) {
|
|
165
186
|
event.toolName = toolName;
|
|
166
187
|
}
|
|
167
|
-
const toolInput =
|
|
188
|
+
const toolInput = mcpToolInput(raw.tool_input);
|
|
168
189
|
if (toolInput) {
|
|
169
190
|
event.toolInput = toolInput;
|
|
170
191
|
}
|
package/tools/obs-cli.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "nod
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { coreFacade } from "../src/core/index.ts";
|
|
4
4
|
import { readSignalEvents } from "../src/core/observability/observability.store.ts";
|
|
5
|
-
import { DEFAULT_OBS, type ObsEvent } from "../src/core/observability/observability.types.ts";
|
|
5
|
+
import { DEFAULT_OBS, LIVE_ALLOWLIST, type ObsEvent } from "../src/core/observability/observability.types.ts";
|
|
6
6
|
import { loadPolicy } from "../src/core/policy/policy.loader.ts";
|
|
7
7
|
import { emitJson, takeJsonFlag } from "../src/platform/cli-output.ts";
|
|
8
8
|
import { projectStateDir } from "../src/platform/paths.ts";
|
|
@@ -31,6 +31,16 @@ export function limitFrom(raw: string | undefined, fallback: number): number {
|
|
|
31
31
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// hazard: filtering a tail already truncated to `limit` leaves a noise kind (`hook.enter`, one record per
|
|
35
|
+
// gate-relevant hook call) able to push every allowlisted event out of the window the operator actually reads
|
|
36
|
+
// — the window has to be read wide, filtered, then cut to `limit`, never the other order
|
|
37
|
+
// ([/decisions/ad-136.md](/decisions/ad-136.md)).
|
|
38
|
+
export function liveEvents(root: string, limit: number, readWide = 20): readonly ObsEvent[] {
|
|
39
|
+
const wide = readSignalEvents(root, DEFAULT_OBS.signalPath, Math.max(limit * readWide, limit));
|
|
40
|
+
const allowed = wide.filter((event) => LIVE_ALLOWLIST.has(event.kind));
|
|
41
|
+
return allowed.slice(-limit);
|
|
42
|
+
}
|
|
43
|
+
|
|
34
44
|
/**
|
|
35
45
|
* hazard: this sorted by filename. A session id is a UUID, so alphabetical order has no relation to time — on
|
|
36
46
|
* this machine it picked a session from thirteen days earlier whose every counter was zero, which reads exactly
|
|
@@ -67,7 +77,7 @@ function main(argv: string[]): void {
|
|
|
67
77
|
const arg = rest[1];
|
|
68
78
|
|
|
69
79
|
if (cmd === "live") {
|
|
70
|
-
const events =
|
|
80
|
+
const events = liveEvents(root, limitFrom(arg, 40));
|
|
71
81
|
if (json) {
|
|
72
82
|
emitJson(liveJson(events));
|
|
73
83
|
} else {
|