@tech-leads-club/harness-toolkit 0.8.0 → 0.9.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/docs/log.md CHANGED
@@ -13,6 +13,10 @@ Generated from `docs/decisions/` — do not edit by hand. Run `node tools/render
13
13
  A reserved file of the [OKF v0.1](/decisions/ad-013.md) bundle: entries grouped under ISO 8601 headings,
14
14
  newest first. For what landed in which npm release, see `CHANGELOG.md` at the repository root.
15
15
 
16
+ ## 2026-08-29
17
+
18
+ - **AD-114** — Rule proof `since HEAD` resolves sha from the event's own working directory, not the project root ([/decisions/ad-114.md](/decisions/ad-114.md))
19
+
16
20
  ## 2026-08-26
17
21
 
18
22
  - **AD-106** — A build step's own exit code cannot be the publish guarantee when it is also a recovery path ([/decisions/ad-106.md](/decisions/ad-106.md))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tech-leads-club/harness-toolkit",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Multi-provider agent steering: gates, follow-up, handoff, policy",
6
6
  "keywords": [
@@ -52,6 +52,13 @@ export type HarnessEvent = {
52
52
  event: HarnessEventKind;
53
53
  sessionKey: string;
54
54
  projectDir: string;
55
+ /**
56
+ * The host's own report of the actual working directory for this event — the worktree root after
57
+ * the agent enters one, or the directory after a `cd` ([/decisions/ad-114.md](/decisions/ad-114.md)).
58
+ * Absent when the host does not report a per-event working directory. Distinct from `projectDir`,
59
+ * which anchors state/config and deliberately does not move into a worktree.
60
+ */
61
+ cwd?: string;
55
62
  // hazard: the `spawn*` pair describes the child of a spawn; the unprefixed fields describe the
56
63
  // running agent. Conflating them clobbers sticky parent state.
57
64
  model?: string;
@@ -13,7 +13,13 @@ import {
13
13
  import { flagsDir } from "../platform/paths.ts";
14
14
  import type { Handler, HandlerContext } from "./run.ts";
15
15
  import { main } from "./run.ts";
16
- import { currentGitSha, formatLessonsBlock, obsConfigFor, sessionIdFromKey } from "./support.ts";
16
+ import {
17
+ currentGitSha,
18
+ formatLessonsBlock,
19
+ obsConfigFor,
20
+ sessionIdFromKey,
21
+ shaScopeRoot,
22
+ } from "./support.ts";
17
23
 
18
24
  const STAGNATION_FOLLOWUP = [
19
25
  "BLOCKED: identical validation fingerprint repeated — no progress between attempts.",
@@ -31,6 +37,7 @@ const STAGNATION_FOLLOWUP = [
31
37
  */
32
38
  async function observeGateForRules(args: {
33
39
  root: string;
40
+ shaRoot: string;
34
41
  sessionKey: string;
35
42
  policy: Policy;
36
43
  gate: string;
@@ -40,7 +47,7 @@ async function observeGateForRules(args: {
40
47
  return;
41
48
  }
42
49
  coreFacade.rules.observeGate(args.root, args.policy.rules, args.gate, {
43
- sha: await currentGitSha(args.root),
50
+ sha: await currentGitSha(args.shaRoot),
44
51
  sessionKey: args.sessionKey,
45
52
  at: new Date().toISOString(),
46
53
  });
@@ -179,6 +186,7 @@ async function creditPendingLessons(args: {
179
186
 
180
187
  async function runLockedGate(args: {
181
188
  root: string;
189
+ shaRoot: string;
182
190
  provider: string;
183
191
  session: string;
184
192
  gate: "lint" | "test" | "docs";
@@ -429,6 +437,7 @@ async function failGate(args: {
429
437
  */
430
438
  async function decideStopRules(
431
439
  root: string,
440
+ shaRoot: string,
432
441
  policy: Policy,
433
442
  sessionKey: string,
434
443
  ): Promise<ReturnType<typeof coreFacade.rules.decideStop>> {
@@ -439,13 +448,14 @@ async function decideStopRules(
439
448
  }
440
449
  return coreFacade.rules.decideStop(root, policy.rules, {
441
450
  ...context,
442
- sha: await currentGitSha(root),
451
+ sha: await currentGitSha(shaRoot),
443
452
  });
444
453
  }
445
454
 
446
455
  export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerContext): Promise<Decision> => {
447
456
  const { policy, capabilities } = ctx;
448
457
  const root = event.projectDir;
458
+ const shaRoot = shaScopeRoot(event);
449
459
  const provider = event.provider;
450
460
  const sessionKey = event.sessionKey;
451
461
  const session = sessionIdFromKey(event);
@@ -562,6 +572,7 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
562
572
  if (policy.grind.enabled && policy.grind.lintCommand && codeTargets.length > 0) {
563
573
  const run = await runLockedGate({
564
574
  root,
575
+ shaRoot,
565
576
  provider,
566
577
  session,
567
578
  pendingCredit,
@@ -599,6 +610,7 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
599
610
  const recordFiles = testTargets.length > 0 ? testTargets : codeTargets;
600
611
  const run = await runLockedGate({
601
612
  root,
613
+ shaRoot,
602
614
  provider,
603
615
  session,
604
616
  pendingCredit,
@@ -757,6 +769,7 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
757
769
  if (policy.docs.command && policy.docs.command.length > 0) {
758
770
  const run = await runLockedGate({
759
771
  root,
772
+ shaRoot,
760
773
  provider,
761
774
  session,
762
775
  pendingCredit,
@@ -900,7 +913,7 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
900
913
  * invariant: `warn` returns `context`, which does not block. Everything else refuses the stop, so a rule the
901
914
  * operator wrote cannot be ended past.
902
915
  */
903
- const stopRules = await decideStopRules(root, policy, sessionKey);
916
+ const stopRules = await decideStopRules(root, shaRoot, policy, sessionKey);
904
917
  if (stopRules.decision.kind !== "abstain") {
905
918
  if (stopRules.decision.kind !== "context") {
906
919
  const worst = coreFacade.rules.strictest(stopRules.outcomes);
@@ -58,6 +58,18 @@ export async function currentGitBranch(root: string): Promise<string | null> {
58
58
  return branch.length > 0 ? branch : null;
59
59
  }
60
60
 
61
+ /**
62
+ * why: `event.projectDir` prefers `CLAUDE_PROJECT_DIR`, which the host deliberately keeps pointed at the
63
+ * session's original root — including inside a git worktree, where it would otherwise return the wrong
64
+ * HEAD for a `since HEAD` rule proof. `event.cwd` is the field the host actually moves when the agent is
65
+ * working in a worktree or after a `cd`. Only the rules engine's proof sha needs this; every other use of
66
+ * `event.projectDir` (state dir, policy config, presence) is deliberately left alone
67
+ * ([/decisions/ad-114.md](/decisions/ad-114.md)).
68
+ */
69
+ export function shaScopeRoot(event: HarnessEvent): string {
70
+ return event.cwd ?? event.projectDir;
71
+ }
72
+
61
73
  export async function currentGitSha(root: string): Promise<string | null> {
62
74
  if (!existsSync(join(root, ".git"))) {
63
75
  return null;
@@ -205,7 +217,7 @@ export async function observeForRules(
205
217
  if (!coreFacade.rules.wants(event.projectDir, config, event)) {
206
218
  return;
207
219
  }
208
- const sha = await currentGitSha(event.projectDir);
220
+ const sha = await currentGitSha(shaScopeRoot(event));
209
221
  coreFacade.rules.observe(event.projectDir, config, event, {
210
222
  sha,
211
223
  sessionKey: event.sessionKey,
@@ -2,7 +2,13 @@ import type { Decision, HarnessEvent } from "../contracts/index.ts";
2
2
  import { coreFacade } from "../core/index.ts";
3
3
  import type { Handler, HandlerContext } from "./run.ts";
4
4
  import { main } from "./run.ts";
5
- import { currentGitSha, obsConfigFor, readModelFromToolInput, subagentSpawnInput } from "./support.ts";
5
+ import {
6
+ currentGitSha,
7
+ obsConfigFor,
8
+ readModelFromToolInput,
9
+ shaScopeRoot,
10
+ subagentSpawnInput,
11
+ } from "./support.ts";
6
12
 
7
13
  const READONLY_BLOCKED_TOOLS = new Set(["Write", "Delete", "Shell"]);
8
14
 
@@ -61,7 +67,7 @@ async function rulesDecision(event: HarnessEvent, ctx: HandlerContext): Promise<
61
67
  }
62
68
  // why twice: the first pass answers whether any rule fired at all, which costs no git. Only then is the sha
63
69
  // worth a process, and the second pass is the one whose verdict counts.
64
- const sha = await currentGitSha(event.projectDir);
70
+ const sha = await currentGitSha(shaScopeRoot(event));
65
71
  const verdict = coreFacade.rules.decideAction(event.projectDir, config, trigger, {
66
72
  sha,
67
73
  sessionKey: event.sessionKey,
@@ -119,6 +119,14 @@ export function claudeToEvent(raw: Record<string, unknown>): HarnessEvent | null
119
119
  event.permissionMode = permissionMode;
120
120
  }
121
121
 
122
+ // why: `cwd` is a common field on every Claude hook payload, and the host docs confirm it tracks the
123
+ // agent into a worktree or after a `cd` — unlike `projectDir`, which stays at the session's original
124
+ // root ([/decisions/ad-114.md](/decisions/ad-114.md)).
125
+ const cwd = asString(raw.cwd);
126
+ if (cwd) {
127
+ event.cwd = cwd;
128
+ }
129
+
122
130
  const isSpawnEvent = eventKind === "subagent.start" || eventKind === "subagent.stop";
123
131
  const model = isSpawnEvent ? undefined : asString(raw.model);
124
132
  if (model) {
@@ -142,6 +142,16 @@ export function cursorToEvent(raw: Record<string, unknown>): HarnessEvent | null
142
142
  if (output !== undefined) {
143
143
  event.toolOutput = output;
144
144
  }
145
+ // why: confirmed against cursor.com/docs/hooks — `cwd` exists only on `beforeShellExecution`
146
+ // (this event's `shell.before` half), not on `afterShellExecution` or any other event this
147
+ // adapter maps. Never guessed onto an event kind the host does not report it for
148
+ // ([/decisions/ad-114.md](/decisions/ad-114.md)).
149
+ if (eventKind === "shell.before") {
150
+ const cwd = asString(raw.cwd);
151
+ if (cwd !== undefined) {
152
+ event.cwd = cwd;
153
+ }
154
+ }
145
155
  break;
146
156
  }
147
157
  case "mcp.before":