@tech-leads-club/harness-toolkit 0.3.4 → 0.3.6

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
@@ -16,6 +16,7 @@ newest first. For what landed in which npm release, see `CHANGELOG.md` at the re
16
16
  ## 2026-08-20
17
17
 
18
18
  - **AD-098** — Splitting is reverted: a shared chunk ran the CLI's main ([/decisions/ad-098.md](/decisions/ad-098.md))
19
+ - **AD-099** — Reading a file claimed it, so a review agent locked the operator out of writing ([/decisions/ad-099.md](/decisions/ad-099.md))
19
20
 
20
21
  ## 2026-08-19
21
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tech-leads-club/harness-toolkit",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "type": "module",
5
5
  "description": "Multi-provider agent steering: gates, follow-up, handoff, policy",
6
6
  "keywords": [
@@ -3,4 +3,5 @@ export * from "./decision.ts";
3
3
  export * from "./effort.ts";
4
4
  export * from "./harness-event.ts";
5
5
  export * from "./policy-defaults.ts";
6
+ export * from "./tool-names.ts";
6
7
  export * from "./wiring.ts";
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The host tool names that write to a file.
3
+ *
4
+ * why here: two rails need the same answer — the policy-surface guard, which refuses an agent write to policy, and
5
+ * the presence claim, which decides whether another session may lose work. A second copy of this list is a second
6
+ * thing to forget when a host adds a tool ([/decisions/ad-010.md](/decisions/ad-010.md)).
7
+ *
8
+ * hazard: the presence claim did not consult any list. It recorded whatever file the event carried, and
9
+ * `read.before` carries one — so reading a file claimed it for ten minutes and blocked every other session from
10
+ * writing it, under a rule called `edit-collision` and a message that said the file had been edited. Measured on a
11
+ * real machine: a review agent that only read poisoned two files, and the operator's own `git status` showed one
12
+ * modification, theirs ([/decisions/ad-099.md](/decisions/ad-099.md)).
13
+ */
14
+ export const WRITE_TOOLS: ReadonlySet<string> = new Set([
15
+ "Edit",
16
+ "Write",
17
+ "Delete",
18
+ "MultiEdit",
19
+ "NotebookEdit",
20
+ ]);
21
+
22
+ /** invariant: an absent tool name is not a write. A read carries a path too, and only writes may claim one. */
23
+ export function isWriteTool(toolName: string | undefined): boolean {
24
+ return toolName !== undefined && WRITE_TOOLS.has(toolName);
25
+ }
@@ -1,16 +1,15 @@
1
1
  import type { Decision } from "../../contracts/decision.ts";
2
+ import { isWriteTool } from "../../contracts/tool-names.ts";
2
3
  import { isPolicySurface } from "../floor/floor.paths.ts";
3
4
 
4
5
  export { isPolicySurface };
5
6
 
6
- const WRITE_TOOLS = new Set(["Edit", "Write", "Delete", "MultiEdit", "NotebookEdit"]);
7
-
8
7
  export function guardPolicySurface(args: {
9
8
  projectDir: string;
10
9
  toolName: string | undefined;
11
10
  filePath: string | undefined;
12
11
  }): Decision {
13
- if (!args.toolName || !WRITE_TOOLS.has(args.toolName) || !args.filePath) {
12
+ if (!isWriteTool(args.toolName) || !args.filePath) {
14
13
  return { kind: "allow" };
15
14
  }
16
15
  if (!isPolicySurface(args.projectDir, args.filePath)) {
@@ -81,10 +81,18 @@ export function checkCollision(
81
81
  continue;
82
82
  }
83
83
  const elapsed = elapsedLabel(record, nowMs);
84
+ /**
85
+ * hazard: this said "touched" and "edited this file" for a claim that a *read* could create, so the message
86
+ * asserted an edit that had not happened — and an operator checking `git status` found one modification, their
87
+ * own. Only a write claims now, and the words say so ([/decisions/ad-099.md](/decisions/ad-099.md)).
88
+ *
89
+ * invariant: the way out is named. `ask` becomes a refusal wherever no operator can answer the prompt, and a
90
+ * refusal with no exit is a lock-out.
91
+ */
84
92
  return {
85
93
  kind: "ask",
86
- reason: `${record.provider} session ${record.session} touched ${file} ${elapsed}.`,
87
- userNote: `Another agent (${record.provider}, session ${record.session}) edited this file ${elapsed}. Coordinate before proceeding.`,
94
+ reason: `${record.provider} session ${record.session} wrote ${file} ${elapsed}.`,
95
+ userNote: `Another agent (${record.provider}, session ${record.session}) wrote this file ${elapsed}. Coordinate before proceeding: end that session, or wait for its claim to go stale (10 minutes without a heartbeat). \`tlc harness status\` lists the live sessions.`,
88
96
  // why: this asks unconditionally, like the floor does, and carried no rule — so an operator reading a rate of
89
97
  // interruptions could see the count and not the cause.
90
98
  rule: "edit-collision",
@@ -1,5 +1,6 @@
1
1
  import { join } from "node:path";
2
2
  import type { Decision, HarnessEvent, ProviderCapabilities, Rendered } from "../contracts/index.ts";
3
+ import { isWriteTool } from "../contracts/tool-names.ts";
3
4
  import { coreFacade, type Policy } from "../core/index.ts";
4
5
  import { appendRecord } from "../platform/fs-jsonl.ts";
5
6
  import { projectStateDir } from "../platform/paths.ts";
@@ -36,6 +37,25 @@ export type RunOutcome = {
36
37
  // (lessons.maxCharsSession) — reusing that here truncated the operator posture and handoff.
37
38
  export const CONTEXT_BUDGET_CHARS = 6000;
38
39
 
40
+ /**
41
+ * Whether this event may claim its file against other sessions.
42
+ *
43
+ * invariant: a claim exists so that two writers do not lose each other's work. A reader loses nothing, so a read
44
+ * carries no claim however many files it opens ([/decisions/ad-099.md](/decisions/ad-099.md)).
45
+ *
46
+ * why `edit.after` with no tool name still claims: the write already happened, and the host does not always name
47
+ * the tool on that event. An event that reports a completed edit is a writer by definition.
48
+ */
49
+ export function claimsFile(event: HarnessEvent): boolean {
50
+ if (event.filePath === undefined) {
51
+ return false;
52
+ }
53
+ if (event.event === "edit.after") {
54
+ return true;
55
+ }
56
+ return isWriteTool(event.toolName);
57
+ }
58
+
39
59
  function errorMessage(error: unknown): string {
40
60
  return error instanceof Error ? error.message : String(error);
41
61
  }
@@ -144,10 +164,20 @@ export async function runHandler(handler: Handler, io: RunIo = {}): Promise<RunO
144
164
  effectiveBlockedPatterns(policy.subagents.blockedPatterns, provider),
145
165
  );
146
166
  }
167
+ /**
168
+ * hazard: this passed `event.filePath` for every event, and `read.before` carries one. So reading a file
169
+ * claimed it for ten minutes, and the next session to write it was refused — under a rule called
170
+ * `edit-collision`, with a message saying the file had been edited. Measured on a real machine: a review agent
171
+ * that only read blocked the operator's own writes to two files, while their `git status` showed a single
172
+ * modification, theirs ([/decisions/ad-099.md](/decisions/ad-099.md)).
173
+ *
174
+ * invariant: the heartbeat is unconditional — a reading session is still a live session, and staleness is what
175
+ * expires a claim. Only the *claim* is write-only, because only a writer can lose somebody's work.
176
+ */
147
177
  coreFacade.presence.heartbeat(event.projectDir, {
148
178
  provider: event.provider,
149
179
  session: sessionIdFromKey(event),
150
- file: event.filePath,
180
+ ...(claimsFile(event) ? { file: event.filePath } : {}),
151
181
  now,
152
182
  });
153
183
  const context: HandlerContext = { policy, capabilities, provider, now };