@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.
Files changed (44) hide show
  1. package/README.md +2 -2
  2. package/capabilities/catalog.json +2 -2
  3. package/dist/compact-before.mjs +84 -84
  4. package/dist/doctor.mjs +89 -89
  5. package/dist/init-project.mjs +87 -87
  6. package/dist/install-runtime.mjs +86 -86
  7. package/dist/lessons-cli.mjs +83 -83
  8. package/dist/obs-cli.mjs +86 -86
  9. package/dist/price-lookup.mjs +1 -1
  10. package/dist/prompt-submit.mjs +84 -84
  11. package/dist/refresh-model-prices.mjs +88 -88
  12. package/dist/response-after.mjs +84 -84
  13. package/dist/run.mjs +84 -84
  14. package/dist/session-end.mjs +89 -89
  15. package/dist/session-start.mjs +91 -91
  16. package/dist/shim.mjs +85 -85
  17. package/dist/ship-gate.mjs +85 -85
  18. package/dist/stop.mjs +92 -92
  19. package/dist/subagent-start.mjs +84 -84
  20. package/dist/subagent-stop.mjs +85 -85
  21. package/dist/support.mjs +87 -87
  22. package/dist/tlc-cli.mjs +104 -104
  23. package/dist/tool-after.mjs +80 -80
  24. package/dist/tool-before.mjs +88 -88
  25. package/dist/tool-failure.mjs +84 -84
  26. package/dist/uninstall-runtime.mjs +4 -4
  27. package/docs/concepts.md +13 -7
  28. package/docs/diagnose.md +2 -2
  29. package/docs/log.md +6 -0
  30. package/package.json +1 -1
  31. package/src/core/core.facade.ts +20 -3
  32. package/src/core/gate/gate.artifact.ts +43 -7
  33. package/src/core/observability/observability.service.ts +1 -1
  34. package/src/core/presence/presence.service.ts +68 -0
  35. package/src/core/rules/rules.trigger.ts +89 -5
  36. package/src/core/turn/turn.activity.ts +14 -4
  37. package/src/entrypoints/run.ts +39 -2
  38. package/src/entrypoints/session-start.ts +1 -0
  39. package/src/entrypoints/stop.ts +14 -2
  40. package/src/entrypoints/support.ts +8 -1
  41. package/src/entrypoints/tool-before.ts +10 -2
  42. package/src/platform/paths.ts +4 -0
  43. package/src/providers/cursor/cursor.inbound.ts +22 -1
  44. package/tools/obs-cli.ts +12 -2
@@ -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(root: string, kind: string, attrs: Record<string, unknown>): void {
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: "unknown",
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);
@@ -44,6 +44,7 @@ export const sessionStartHandler: Handler = async (
44
44
  });
45
45
 
46
46
  coreFacade.handoff.pruneDeadHandoffSessions(root);
47
+ coreFacade.gate.pruneGateSessions(root);
47
48
 
48
49
  await coreFacade.handoff.patchHandoff(root, event.provider, event.sessionKey, {
49
50
  shared: {
@@ -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(coreFacade.gate.readLastGate(args.root), args.gate, inputs);
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 changedFiles = await listChangedRepoFiles(shaRoot, turnBase);
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 changedFiles = await listChangedRepoFiles(gitRoot, turnBase);
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 = { event: event.event, toolName: event.toolName, command: event.command };
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) ? localRepoRemote(shaRoot) : undefined,
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,
@@ -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 = asRecord(raw.tool_input);
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 = readSignalEvents(root, DEFAULT_OBS.signalPath, limitFrom(arg, 40));
80
+ const events = liveEvents(root, limitFrom(arg, 40));
71
81
  if (json) {
72
82
  emitJson(liveJson(events));
73
83
  } else {