@tech-leads-club/harness-toolkit 0.10.5 → 0.11.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.
Files changed (81) hide show
  1. package/README.md +6 -4
  2. package/bin/tlc-cli.ts +73 -12
  3. package/capabilities/catalog.json +18 -1
  4. package/dist/compact-before.mjs +80 -79
  5. package/dist/doctor.mjs +83 -82
  6. package/dist/init-project.mjs +86 -85
  7. package/dist/install-runtime.mjs +80 -79
  8. package/dist/lessons-cli.mjs +82 -81
  9. package/dist/new-provider.mjs +126 -0
  10. package/dist/obs-cli.mjs +86 -85
  11. package/dist/price-lookup.mjs +1 -1
  12. package/dist/prompt-submit.mjs +80 -79
  13. package/dist/refresh-model-prices.mjs +80 -79
  14. package/dist/response-after.mjs +80 -79
  15. package/dist/run.mjs +80 -79
  16. package/dist/session-end.mjs +85 -84
  17. package/dist/session-start.mjs +87 -86
  18. package/dist/shim.mjs +81 -80
  19. package/dist/ship-gate.mjs +80 -79
  20. package/dist/stop.mjs +86 -85
  21. package/dist/subagent-start.mjs +80 -79
  22. package/dist/subagent-stop.mjs +81 -80
  23. package/dist/support.mjs +84 -83
  24. package/dist/tlc-cli.mjs +224 -96
  25. package/dist/tool-after.mjs +80 -79
  26. package/dist/tool-before.mjs +81 -80
  27. package/dist/tool-failure.mjs +80 -79
  28. package/dist/uninstall-runtime.mjs +4 -4
  29. package/docs/architecture.md +16 -3
  30. package/docs/concepts.md +11 -0
  31. package/docs/log.md +8 -0
  32. package/docs/providers/claude-code.md +42 -21
  33. package/docs/providers/cursor.md +23 -7
  34. package/docs/troubleshooting.md +1 -0
  35. package/package.json +1 -1
  36. package/schema.json +12 -0
  37. package/skills/harness-init/references/capabilities.md +1 -0
  38. package/src/contracts/capabilities.ts +8 -1
  39. package/src/contracts/decision.ts +2 -1
  40. package/src/core/core.facade.ts +14 -0
  41. package/src/core/floor/floor.catalog.ts +5 -0
  42. package/src/core/floor/floor.paths.ts +17 -1
  43. package/src/core/floor/floor.policy-surface.ts +26 -9
  44. package/src/core/floor/floor.service.ts +37 -5
  45. package/src/core/gate/gate.lock.ts +8 -32
  46. package/src/core/handoff/handoff.service.ts +110 -27
  47. package/src/core/handoff/handoff.session-store.ts +200 -0
  48. package/src/core/handoff/handoff.store.ts +5 -20
  49. package/src/core/handoff/handoff.types.ts +42 -9
  50. package/src/core/policy/policy.defaults.ts +3 -0
  51. package/src/core/policy/policy.loader.ts +1 -0
  52. package/src/core/policy/policy.types.ts +4 -0
  53. package/src/core/presence/presence.service.ts +59 -13
  54. package/src/core/secret-scan/secret-scan.service.ts +96 -0
  55. package/src/core/secret-scan/secret-scan.store.ts +56 -0
  56. package/src/entrypoints/prompt-submit.ts +1 -1
  57. package/src/entrypoints/response-after.ts +4 -4
  58. package/src/entrypoints/run.ts +3 -1
  59. package/src/entrypoints/session-end.ts +1 -1
  60. package/src/entrypoints/session-start.ts +5 -4
  61. package/src/entrypoints/ship-gate.ts +2 -2
  62. package/src/entrypoints/stop.ts +18 -18
  63. package/src/entrypoints/subagent-stop.ts +4 -3
  64. package/src/entrypoints/support.ts +5 -3
  65. package/src/entrypoints/tool-after.ts +21 -1
  66. package/src/entrypoints/tool-before.ts +2 -1
  67. package/src/platform/paths.ts +4 -0
  68. package/src/platform/process.ts +36 -0
  69. package/src/providers/claude/claude.capabilities.ts +3 -1
  70. package/src/providers/claude/claude.inbound.ts +37 -22
  71. package/src/providers/claude/claude.outbound.ts +4 -0
  72. package/src/providers/claude/claude.wiring.ts +4 -0
  73. package/src/providers/claude/index.ts +2 -1
  74. package/src/providers/cursor/cursor.capabilities.ts +3 -1
  75. package/src/providers/cursor/cursor.inbound.ts +1 -1
  76. package/src/providers/cursor/cursor.outbound.ts +9 -0
  77. package/src/providers/cursor/cursor.wiring.ts +9 -1
  78. package/src/providers/cursor/index.ts +2 -1
  79. package/src/providers/provider.degrade.ts +19 -0
  80. package/src/providers/provider.port.ts +1 -0
  81. package/tools/new-provider.ts +251 -0
@@ -15,7 +15,6 @@ export type HandoffShared = {
15
15
 
16
16
  export type HandoffProviderSlice = {
17
17
  updated_at: string;
18
- session_key?: string;
19
18
  session_narrative?: string;
20
19
  completed?: string[];
21
20
  last_ship_claim_at?: string;
@@ -46,12 +45,13 @@ export type HandoffProviderSlice = {
46
45
  machine_state?: Record<string, unknown>;
47
46
  };
48
47
 
49
- export const HANDOFF_SCHEMA = "harness.handoff.v2" as const;
48
+ export const HANDOFF_SCHEMA = "harness.handoff.v3" as const;
50
49
 
50
+ /** The project-wide facts every session shares — no turn-scoped continuity here (moved to per-session files,
51
+ * [/decisions/ad-122.md](/decisions/ad-122.md)). */
51
52
  export type HandoffFile = {
52
53
  schema: typeof HANDOFF_SCHEMA;
53
54
  shared: HandoffShared;
54
- by_provider: Record<string, HandoffProviderSlice>;
55
55
  };
56
56
 
57
57
  export type ForeignSlice = {
@@ -68,7 +68,6 @@ export function defaultHandoffFile(mode: OperatorMode = "solo"): HandoffFile {
68
68
  return {
69
69
  schema: HANDOFF_SCHEMA,
70
70
  shared: { mode, updated_at: new Date().toISOString() },
71
- by_provider: {},
72
71
  };
73
72
  }
74
73
 
@@ -78,10 +77,44 @@ export function isHandoffFile(value: unknown): value is HandoffFile {
78
77
  }
79
78
  const candidate = value as Partial<HandoffFile>;
80
79
  return (
81
- candidate.schema === HANDOFF_SCHEMA &&
82
- typeof candidate.shared === "object" &&
83
- candidate.shared !== null &&
84
- typeof candidate.by_provider === "object" &&
85
- candidate.by_provider !== null
80
+ candidate.schema === HANDOFF_SCHEMA && typeof candidate.shared === "object" && candidate.shared !== null
81
+ );
82
+ }
83
+
84
+ export const HANDOFF_SESSION_SCHEMA = "harness.handoff-session.v1" as const;
85
+
86
+ /** which session last wrote this continuity file, and when — liveness itself is decided elsewhere, against
87
+ * presence's own heartbeat, not against anything stored here ([/decisions/ad-122.md](/decisions/ad-122.md)). */
88
+ type HandoffSessionOwner = {
89
+ session_key: string;
90
+ provider: string;
91
+ updated_at: string;
92
+ };
93
+
94
+ export type HandoffSessionFile = {
95
+ schema: typeof HANDOFF_SESSION_SCHEMA;
96
+ owner: HandoffSessionOwner;
97
+ slice: HandoffProviderSlice;
98
+ };
99
+
100
+ export function isHandoffSessionFile(value: unknown): value is HandoffSessionFile {
101
+ if (typeof value !== "object" || value === null) {
102
+ return false;
103
+ }
104
+ const candidate = value as Partial<HandoffSessionFile>;
105
+ if (candidate.schema !== HANDOFF_SESSION_SCHEMA) {
106
+ return false;
107
+ }
108
+ const owner = candidate.owner;
109
+ if (typeof owner !== "object" || owner === null) {
110
+ return false;
111
+ }
112
+ const o = owner as Partial<HandoffSessionOwner>;
113
+ return (
114
+ typeof o.session_key === "string" &&
115
+ typeof o.provider === "string" &&
116
+ typeof o.updated_at === "string" &&
117
+ typeof candidate.slice === "object" &&
118
+ candidate.slice !== null
86
119
  );
87
120
  }
@@ -90,6 +90,9 @@ export const DEFAULTS: Policy = {
90
90
  stallDetection: false,
91
91
  stallRepeatThreshold: 3,
92
92
  },
93
+ secrets: {
94
+ redactOutput: true,
95
+ },
93
96
  intelligence: {
94
97
  gapFeedback: true,
95
98
  failureClassification: true,
@@ -37,6 +37,7 @@ function deepMerge(base: Policy, patch: PartialPolicy): Policy {
37
37
  untrustedContent: { ...base.untrustedContent, ...patch.untrustedContent },
38
38
  planGate: { ...base.planGate, ...patch.planGate },
39
39
  shell: { ...base.shell, ...patch.shell },
40
+ secrets: { ...base.secrets, ...patch.secrets },
40
41
  intelligence: {
41
42
  ...base.intelligence,
42
43
  ...patch.intelligence,
@@ -122,6 +122,9 @@ export type Policy = {
122
122
  stallDetection: boolean;
123
123
  stallRepeatThreshold: number;
124
124
  };
125
+ secrets: {
126
+ redactOutput: boolean;
127
+ };
125
128
  intelligence: {
126
129
  gapFeedback: boolean;
127
130
  failureClassification: boolean;
@@ -148,6 +151,7 @@ export type PartialPolicy = Partial<Policy> & {
148
151
  planGate?: Partial<Policy["planGate"]>;
149
152
  rules?: Partial<Policy["rules"]>;
150
153
  shell?: Partial<Policy["shell"]>;
154
+ secrets?: Partial<Policy["secrets"]>;
151
155
  intelligence?: Partial<Policy["intelligence"]> & {
152
156
  lessons?: Partial<LessonsPolicyConfig>;
153
157
  };
@@ -8,7 +8,17 @@ import {
8
8
  } from "./presence.store.ts";
9
9
  import type { PresenceRecord } from "./presence.types.ts";
10
10
 
11
- const STALE_MS = 10 * 60 * 1000;
11
+ /** how long a file-edit claim (`checkCollision`'s `recent_files`) survives without a fresh touch — unrelated
12
+ * to whether the session's own conversation is still open, see `CONVERSATION_STALE_MS`. */
13
+ const CLAIM_STALE_MS = 10 * 60 * 1000;
14
+
15
+ /** why longer than the claim window: a conversation can sit quiet — reading long output, thinking — for well
16
+ * past ten minutes without being over, and both `isSessionLive` and `sweepStale` must agree on that same
17
+ * question so a record is never physically deleted while a reader would still call it live
18
+ * ([/decisions/ad-122.md](/decisions/ad-122.md)), matching this codebase's own precedent for the same class of
19
+ * stale-but-alive tolerance (`GATE_LOCK_STALE_MS`, `src/core/gate/gate.lock.ts`). */
20
+ const CONVERSATION_STALE_MS = 30 * 60 * 1000;
21
+
12
22
  const RECENT_FILES_MAX = 20;
13
23
 
14
24
  export function register(
@@ -29,20 +39,29 @@ export function register(
29
39
  return record;
30
40
  }
31
41
 
42
+ /**
43
+ * why self-healing: `sweepStale` deletes a record once it looks quiet, but quiet is not the same fact as over —
44
+ * a session whose next event arrives after that deletion is not a new session, and treating its own heartbeat
45
+ * as a no-op would leave it misdiagnosed as a dead predecessor by every other reader, permanently, since nothing
46
+ * else ever re-creates the file. This event is itself proof the conversation is still going, so it is always
47
+ * enough to (re)establish presence, register() or not ([/decisions/ad-122.md](/decisions/ad-122.md)).
48
+ */
32
49
  export function heartbeat(
33
50
  root: string,
34
51
  args: { provider: string; session: string; file?: string; now?: Date },
35
- ): PresenceRecord | null {
52
+ ): PresenceRecord {
36
53
  const existing = readPresenceRecord(root, args.provider, args.session);
37
- if (!existing) {
38
- return null;
39
- }
54
+ const now = (args.now ?? new Date()).toISOString();
40
55
  const recent_files = args.file
41
- ? [...existing.recent_files.filter((f) => f !== args.file), args.file].slice(-RECENT_FILES_MAX)
42
- : existing.recent_files;
56
+ ? [...(existing?.recent_files.filter((f) => f !== args.file) ?? []), args.file].slice(-RECENT_FILES_MAX)
57
+ : (existing?.recent_files ?? []);
43
58
  const next: PresenceRecord = {
44
- ...existing,
45
- heartbeat_at: (args.now ?? new Date()).toISOString(),
59
+ provider: args.provider,
60
+ session: args.session,
61
+ pid: process.pid,
62
+ branch: existing?.branch ?? "unknown",
63
+ started_at: existing?.started_at ?? now,
64
+ heartbeat_at: now,
46
65
  recent_files,
47
66
  };
48
67
  writePresenceRecord(root, next);
@@ -54,8 +73,8 @@ function heartbeatAgeMs(record: PresenceRecord, now: number): number {
54
73
  return Number.isNaN(at) ? Number.POSITIVE_INFINITY : now - at;
55
74
  }
56
75
 
57
- function isStale(record: PresenceRecord, now: number): boolean {
58
- return heartbeatAgeMs(record, now) >= STALE_MS;
76
+ function isStale(record: PresenceRecord, now: number, staleMs: number): boolean {
77
+ return heartbeatAgeMs(record, now) >= staleMs;
59
78
  }
60
79
 
61
80
  function elapsedLabel(record: PresenceRecord, now: number): string {
@@ -74,7 +93,7 @@ export function checkCollision(
74
93
  if (presenceSessionKey(record.provider, record.session) === ownSessionKey) {
75
94
  continue;
76
95
  }
77
- if (isStale(record, nowMs)) {
96
+ if (isStale(record, nowMs, CLAIM_STALE_MS)) {
78
97
  continue;
79
98
  }
80
99
  if (!record.recent_files.includes(file)) {
@@ -104,7 +123,7 @@ export function checkCollision(
104
123
  export function sweepStale(root: string, now: Date = new Date()): number {
105
124
  let swept = 0;
106
125
  for (const record of listPresenceRecords(root)) {
107
- if (isStale(record, now.getTime())) {
126
+ if (isStale(record, now.getTime(), CONVERSATION_STALE_MS)) {
108
127
  deletePresenceRecord(root, record.provider, record.session);
109
128
  swept += 1;
110
129
  }
@@ -116,4 +135,31 @@ export function release(root: string, provider: string, session: string): void {
116
135
  deletePresenceRecord(root, provider, session);
117
136
  }
118
137
 
138
+ /**
139
+ * why the session key, not the raw session id: every caller outside this module already carries the prefixed
140
+ * form (`${provider}-${id}`) — every inbound mapper produces it that way — and asking each one to also derive
141
+ * the bare id is the duplication a shared helper exists to remove.
142
+ */
143
+ function sessionIdFromSessionKey(provider: string, sessionKey: string): string {
144
+ const prefix = `${provider}-`;
145
+ return sessionKey.startsWith(prefix) ? sessionKey.slice(prefix.length) : sessionKey;
146
+ }
147
+
148
+ /**
149
+ * why heartbeat and not a process check: this harness's hook processes are one-shot — every entrypoint calls
150
+ * `process.exit` at the end of the single event it handled ([/decisions/ad-122.md](/decisions/ad-122.md)) — so
151
+ * no pid outlives the event that recorded it, and a pid-liveness check answers "dead" for every session,
152
+ * including one still very much in conversation. A heartbeat refreshed on every recognized event, regardless of
153
+ * which process handled it, is the one signal that survives the process boundary.
154
+ */
155
+ export function isSessionLive(
156
+ root: string,
157
+ provider: string,
158
+ sessionKey: string,
159
+ now: Date = new Date(),
160
+ ): boolean {
161
+ const record = readPresenceRecord(root, provider, sessionIdFromSessionKey(provider, sessionKey));
162
+ return record !== null && !isStale(record, now.getTime(), CONVERSATION_STALE_MS);
163
+ }
164
+
119
165
  export { listPresenceRecords, presenceSessionKey, readPresenceRecord };
@@ -0,0 +1,96 @@
1
+ export type SecretMatch = {
2
+ start: number;
3
+ end: number;
4
+ kind: string;
5
+ };
6
+
7
+ // invariant: shaped like the real thing, never the real thing — a signature fixture that could pass for a working
8
+ // credential in its own test file would be the exact leak this module exists to catch.
9
+ export const SIGNATURES: Record<string, RegExp> = {
10
+ "aws-access-key": /\bAKIA[0-9A-Z]{16}\b/g,
11
+ "github-token": /\bgh[pousr]_[A-Za-z0-9]{36}\b/g,
12
+ "slack-token": /\bxox[baprs]-[0-9A-Za-z-]{10,72}\b/g,
13
+ "stripe-key": /\bsk_(?:live|test)_[A-Za-z0-9]{24,}\b/g,
14
+ "pem-private-key": /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
15
+ jwt: /\beyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\b/g,
16
+ };
17
+
18
+ // why: a run of the base64/hex/token alphabet, long enough to be worth an entropy check at all. Excluding `-` keeps
19
+ // a dash-separated UUID out of this pattern entirely — each of its five segments is shorter than the minimum length
20
+ // below, so it never reaches the entropy test in the first place.
21
+ const ENTROPY_TOKEN_PATTERN = /[A-Za-z0-9+/_=]{20,}/g;
22
+
23
+ /**
24
+ * why these numbers: measured against this module's own fixtures (see `__test__`) — a 40-character git commit SHA
25
+ * scores ~3.8 bits/char (hex alphabet), a hand-picked high-diversity secret-shaped string scores 5.0. 4.5 sits
26
+ * between them, so a hex-alphabet identifier stays out of the false-positive path while a genuinely high-diversity
27
+ * token still crosses it. The 20-character floor keeps a short common token from crossing it by chance alone.
28
+ */
29
+ export const ENTROPY_THRESHOLD = 4.5;
30
+ export const ENTROPY_MIN_LENGTH = 20;
31
+
32
+ export function shannonEntropy(text: string): number {
33
+ if (text.length === 0) {
34
+ return 0;
35
+ }
36
+ const counts = new Map<string, number>();
37
+ for (const ch of text) {
38
+ counts.set(ch, (counts.get(ch) ?? 0) + 1);
39
+ }
40
+ let entropy = 0;
41
+ for (const count of counts.values()) {
42
+ const p = count / text.length;
43
+ entropy -= p * Math.log2(p);
44
+ }
45
+ return entropy;
46
+ }
47
+
48
+ function signatureMatches(text: string): SecretMatch[] {
49
+ const matches: SecretMatch[] = [];
50
+ for (const [kind, pattern] of Object.entries(SIGNATURES)) {
51
+ const regex = new RegExp(pattern.source, pattern.flags);
52
+ for (const match of text.matchAll(regex)) {
53
+ if (match.index === undefined) {
54
+ continue;
55
+ }
56
+ matches.push({ start: match.index, end: match.index + match[0].length, kind });
57
+ }
58
+ }
59
+ return matches;
60
+ }
61
+
62
+ function overlaps(a: { start: number; end: number }, b: { start: number; end: number }): boolean {
63
+ return a.start < b.end && b.start < a.end;
64
+ }
65
+
66
+ // why: a signature match (an AWS key, a JWT segment) is already high-entropy by construction, so scanning it
67
+ // again here would report the same secret as two separate spans — checked against `existing` before this runs.
68
+ function entropyMatches(text: string, existing: readonly SecretMatch[]): SecretMatch[] {
69
+ const matches: SecretMatch[] = [];
70
+ for (const match of text.matchAll(ENTROPY_TOKEN_PATTERN)) {
71
+ if (match.index === undefined) {
72
+ continue;
73
+ }
74
+ const span = { start: match.index, end: match.index + match[0].length };
75
+ if (existing.some((existingMatch) => overlaps(span, existingMatch))) {
76
+ continue;
77
+ }
78
+ if (span.end - span.start < ENTROPY_MIN_LENGTH) {
79
+ continue;
80
+ }
81
+ if (shannonEntropy(match[0]) > ENTROPY_THRESHOLD) {
82
+ matches.push({ ...span, kind: "entropy" });
83
+ }
84
+ }
85
+ return matches;
86
+ }
87
+
88
+ // invariant: pure — no I/O, no network call, no subprocess. Every input is a string already in memory.
89
+ export function scanForSecrets(text: string | undefined): SecretMatch[] {
90
+ if (!text) {
91
+ return [];
92
+ }
93
+ const signature = signatureMatches(text);
94
+ const entropy = entropyMatches(text, signature);
95
+ return [...signature, ...entropy].sort((a, b) => a.start - b.start);
96
+ }
@@ -0,0 +1,56 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { projectStateDir } from "../../platform/paths.ts";
5
+
6
+ // invariant: keyed by a hash of the matched text, never the raw value — this file is the only durable
7
+ // record of a redaction, so it must not become a second copy of the secret it exists to hide.
8
+ type SecretRedactionStore = {
9
+ [sessionKey: string]: {
10
+ [textHash: string]: string;
11
+ };
12
+ };
13
+
14
+ function storePath(root: string): string {
15
+ return join(projectStateDir(root), "secret-redaction.json");
16
+ }
17
+
18
+ function readStore(root: string): SecretRedactionStore {
19
+ const path = storePath(root);
20
+ if (!existsSync(path)) {
21
+ return {};
22
+ }
23
+ try {
24
+ return JSON.parse(readFileSync(path, "utf8")) as SecretRedactionStore;
25
+ } catch {
26
+ return {};
27
+ }
28
+ }
29
+
30
+ function writeStore(root: string, store: SecretRedactionStore): void {
31
+ try {
32
+ mkdirSync(projectStateDir(root), { recursive: true });
33
+ writeFileSync(storePath(root), `${JSON.stringify(store, null, 2)}\n`, "utf8");
34
+ } catch {}
35
+ }
36
+
37
+ function hashOf(matchedText: string): string {
38
+ return createHash("sha256").update(matchedText).digest("hex").slice(0, 8);
39
+ }
40
+
41
+ // invariant: the same matchedText within the same session always resolves to the same placeholder, keyed by a
42
+ // hash rather than the raw value.
43
+ export function placeholderFor(root: string, sessionKey: string, matchedText: string, kind: string): string {
44
+ const store = readStore(root);
45
+ const session = store[sessionKey] ?? {};
46
+ const hash = hashOf(matchedText);
47
+ const existing = session[hash];
48
+ if (existing !== undefined) {
49
+ return existing;
50
+ }
51
+ const placeholder = `[REDACTED:${kind}:${hash}]`;
52
+ session[hash] = placeholder;
53
+ store[sessionKey] = session;
54
+ writeStore(root, store);
55
+ return placeholder;
56
+ }
@@ -16,7 +16,7 @@ export const promptSubmitHandler: Handler = async (event: HarnessEvent, ctx: Han
16
16
  // whole untouched file read as "added this turn" ([/decisions/ad-117.md](/decisions/ad-117.md)).
17
17
  const sha = await currentGitSha(shaScopeRoot(event));
18
18
  if (sha) {
19
- await coreFacade.handoff.patchHandoff(event.projectDir, event.provider, {
19
+ await coreFacade.handoff.patchHandoff(event.projectDir, event.provider, event.sessionKey, {
20
20
  slice: { turn_base_sha: sha },
21
21
  });
22
22
  }
@@ -13,7 +13,7 @@ export const responseAfterHandler: Handler = async (
13
13
  const plan = coreFacade.plan.detectPlan(text);
14
14
  const deviations = coreFacade.plan.detectDeviations(text);
15
15
  if (plan) {
16
- await coreFacade.handoff.patchHandoff(event.projectDir, event.provider, {
16
+ await coreFacade.handoff.patchHandoff(event.projectDir, event.provider, event.sessionKey, {
17
17
  slice: {
18
18
  plan_paths: plan.paths,
19
19
  plan_at: ctx.now.toISOString(),
@@ -25,11 +25,11 @@ export const responseAfterHandler: Handler = async (
25
25
  if (deviations.length > 0) {
26
26
  // why: a deviation can be justified in a later message than the one that declared the plan, so they
27
27
  // accumulate for the plan's window instead of replacing what was already accepted.
28
- const handoff = coreFacade.handoff.readHandoff(event.projectDir, event.provider);
28
+ const handoff = coreFacade.handoff.readHandoff(event.projectDir, event.provider, event.sessionKey);
29
29
  const known = handoff.plan_deviations ?? [];
30
30
  const fresh = deviations.filter((deviation) => !known.some((seen) => seen.path === deviation.path));
31
31
  if (fresh.length > 0) {
32
- await coreFacade.handoff.patchHandoff(event.projectDir, event.provider, {
32
+ await coreFacade.handoff.patchHandoff(event.projectDir, event.provider, event.sessionKey, {
33
33
  slice: { plan_deviations: [...known, ...fresh] },
34
34
  });
35
35
  }
@@ -38,7 +38,7 @@ export const responseAfterHandler: Handler = async (
38
38
 
39
39
  const claim = coreFacade.ship.detectShipClaim(text);
40
40
  if (claim) {
41
- await coreFacade.handoff.patchHandoff(event.projectDir, event.provider, {
41
+ await coreFacade.handoff.patchHandoff(event.projectDir, event.provider, event.sessionKey, {
42
42
  slice: {
43
43
  last_ship_claim_at: ctx.now.toISOString(),
44
44
  last_ship_claim_snippet: claim.snippet,
@@ -18,6 +18,7 @@ export type HandlerContext = {
18
18
  capabilities: ProviderCapabilities;
19
19
  provider: ProviderPort;
20
20
  now: Date;
21
+ protectedPaths: string[];
21
22
  };
22
23
 
23
24
  export type Handler = (event: HarnessEvent, ctx: HandlerContext) => Decision | Promise<Decision>;
@@ -180,7 +181,8 @@ export async function runHandler(handler: Handler, io: RunIo = {}): Promise<RunO
180
181
  ...(claimsFile(event) ? { file: event.filePath } : {}),
181
182
  now,
182
183
  });
183
- const context: HandlerContext = { policy, capabilities, provider, now };
184
+ const protectedPaths = providerRegistry.flatMap((p) => p.wiringTargets());
185
+ const context: HandlerContext = { policy, capabilities, provider, now, protectedPaths };
184
186
  const decision = await handler(event, context);
185
187
  const degraded = degrade(decision, event, capabilities, {
186
188
  contextBudgetChars: CONTEXT_BUDGET_CHARS,
@@ -12,7 +12,7 @@ export const sessionEndHandler: Handler = async (
12
12
  const root = event.projectDir;
13
13
  const session = sessionIdFromKey(event);
14
14
 
15
- await coreFacade.handoff.patchHandoff(root, event.provider, {
15
+ await coreFacade.handoff.patchHandoff(root, event.provider, event.sessionKey, {
16
16
  slice: {
17
17
  next_action: "Session ended. Run `tlc harness handoff` before resuming.",
18
18
  },
@@ -43,7 +43,9 @@ export const sessionStartHandler: Handler = async (
43
43
  branch: branch ?? "unknown",
44
44
  });
45
45
 
46
- await coreFacade.handoff.patchHandoff(root, event.provider, {
46
+ coreFacade.handoff.pruneDeadHandoffSessions(root);
47
+
48
+ await coreFacade.handoff.patchHandoff(root, event.provider, event.sessionKey, {
47
49
  shared: {
48
50
  mode: policy.mode,
49
51
  project_name: policy.projectName,
@@ -51,7 +53,6 @@ export const sessionStartHandler: Handler = async (
51
53
  git_sha: sha ?? undefined,
52
54
  },
53
55
  slice: {
54
- session_key: event.sessionKey,
55
56
  // why: names the command, not the file. The path is on the policy surface, so an instruction pointing at it
56
57
  // asked for something the floor then refused ([/decisions/ad-047.md](/decisions/ad-047.md)).
57
58
  next_action: "Run `tlc harness handoff` if resuming; otherwise start from the user request.",
@@ -63,10 +64,10 @@ export const sessionStartHandler: Handler = async (
63
64
  * did not make is text placed in front of every later turn. Withholding is the answer, not refusing the turn
64
65
  * ([/decisions/ad-078.md](/decisions/ad-078.md)).
65
66
  */
66
- const handoffSeal = coreFacade.handoff.handoffInjectable(root);
67
+ const handoffSeal = coreFacade.handoff.handoffInjectable(root, event.sessionKey);
67
68
  const lessonSeal = coreFacade.lesson.projectLessonsInjectable(root);
68
69
  const handoff = handoffSeal.ok
69
- ? coreFacade.handoff.readHandoff(root, event.provider)
70
+ ? coreFacade.handoff.readHandoff(root, event.provider, event.sessionKey)
70
71
  : ({} as ReturnType<typeof coreFacade.handoff.readHandoff>);
71
72
  const foreign = handoffSeal.ok ? coreFacade.handoff.readForeignSlices(root, event.provider) : [];
72
73
 
@@ -49,10 +49,10 @@ export async function shipGateVerdict(event: HarnessEvent, ctx: HandlerContext):
49
49
  const provider = event.provider;
50
50
  const sessionKey = event.sessionKey;
51
51
  const session = sessionIdFromKey(event);
52
- const scope = await computeTurnScope(root, provider, policy);
52
+ const scope = await computeTurnScope(root, provider, sessionKey, policy);
53
53
  const shaRoot = shaScopeRoot(event);
54
54
 
55
- const commentHits = await pendingCommentViolations(root, provider, policy);
55
+ const commentHits = await pendingCommentViolations(root, provider, sessionKey, policy);
56
56
  if (commentHits.length > 0) {
57
57
  return {
58
58
  kind: "deny",
@@ -179,7 +179,7 @@ async function creditPendingLessons(args: {
179
179
  return;
180
180
  }
181
181
  await coreFacade.lesson.creditLessons(args.root, pending.ids, args.passed ? "helped" : "neutral");
182
- await coreFacade.handoff.patchHandoff(args.root, args.provider, {
182
+ await coreFacade.handoff.patchHandoff(args.root, args.provider, args.sessionKey, {
183
183
  slice: { pending_lesson_credit: undefined },
184
184
  });
185
185
  }
@@ -282,7 +282,7 @@ async function failGate(args: {
282
282
  ? "config"
283
283
  : coreFacade.turn.classifyGateFailure(args.gate);
284
284
  const freshGaps = coreFacade.gate.gapsFromArtifact({ artifact: args.artifact, category });
285
- const handoff = coreFacade.handoff.readHandoff(args.root, args.provider);
285
+ const handoff = coreFacade.handoff.readHandoff(args.root, args.provider, args.sessionKey);
286
286
  const gaps = intel.progressiveContext
287
287
  ? coreFacade.turn.mergeGaps(handoff.previous_gaps, freshGaps)
288
288
  : freshGaps;
@@ -300,7 +300,7 @@ async function failGate(args: {
300
300
  })
301
301
  : null;
302
302
 
303
- await coreFacade.handoff.patchHandoff(args.root, args.provider, {
303
+ await coreFacade.handoff.patchHandoff(args.root, args.provider, args.sessionKey, {
304
304
  slice: {
305
305
  last_gate_result: "fail",
306
306
  last_fingerprint: fingerprint,
@@ -348,7 +348,7 @@ async function failGate(args: {
348
348
  // the thing that grades them ([/decisions/ad-039.md](/decisions/ad-039.md)).
349
349
  if (selected.usedIds.length > 0) {
350
350
  await coreFacade.lesson.markGradeable(args.root, selected.usedIds);
351
- await coreFacade.handoff.patchHandoff(args.root, args.provider, {
351
+ await coreFacade.handoff.patchHandoff(args.root, args.provider, args.sessionKey, {
352
352
  slice: {
353
353
  pending_lesson_credit: {
354
354
  gate: args.gate,
@@ -475,9 +475,9 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
475
475
  * risk — a decision taken from planted text. Withholding here means deciding from the file's absence, which is
476
476
  * the same answer a first turn gets ([/decisions/ad-080.md](/decisions/ad-080.md)).
477
477
  */
478
- const stopSeal = coreFacade.handoff.handoffInjectable(root);
478
+ const stopSeal = coreFacade.handoff.handoffInjectable(root, sessionKey);
479
479
  const handoff = stopSeal.ok
480
- ? coreFacade.handoff.readHandoff(root, provider)
480
+ ? coreFacade.handoff.readHandoff(root, provider, sessionKey)
481
481
  : ({} as ReturnType<typeof coreFacade.handoff.readHandoff>);
482
482
  // hazard: read before the file list, because the list is diffed against it. A turn that commits moves `HEAD`
483
483
  // past its own changes, and every gate below then saw an empty diff and skipped — the comment gate in a repo
@@ -506,7 +506,7 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
506
506
  // stop is still visible when the gate it belongs to runs below.
507
507
  const pendingCredit = handoff.pending_lesson_credit;
508
508
 
509
- await coreFacade.handoff.patchHandoff(root, provider, {
509
+ await coreFacade.handoff.patchHandoff(root, provider, sessionKey, {
510
510
  slice: { last_stop_status: status, last_changed_files: changedFiles, last_gate_result: "skipped" },
511
511
  });
512
512
 
@@ -515,7 +515,7 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
515
515
 
516
516
  if (skipVerify || status !== "completed" || cap.capReached) {
517
517
  if (cap.capReached) {
518
- await coreFacade.handoff.patchHandoff(root, provider, {
518
+ await coreFacade.handoff.patchHandoff(root, provider, sessionKey, {
519
519
  slice: {
520
520
  blockers: `Grind cap hit (${maxLoops} stop loops). Fix manually or pause gates.`,
521
521
  next_action: "Inspect failures, fix root cause, then continue.",
@@ -543,7 +543,7 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
543
543
  //
544
544
  // invariant: this rail records what it saw and never writes a field it reads. `next_action` is not one of
545
545
  // them, and the follow-up text carries the instruction anyway.
546
- await coreFacade.handoff.patchHandoff(root, provider, {
546
+ await coreFacade.handoff.patchHandoff(root, provider, sessionKey, {
547
547
  slice: {
548
548
  last_failure_category: "agent-quality",
549
549
  next_action: "Attempt the work, or proceed under a stated assumption.",
@@ -557,7 +557,7 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
557
557
  (typeof event.contextUsagePercent === "number" && event.contextUsagePercent >= 85);
558
558
 
559
559
  if (intel.budgetContinue && unfinishedWork && budgetPressure) {
560
- await coreFacade.handoff.patchHandoff(root, provider, {
560
+ await coreFacade.handoff.patchHandoff(root, provider, sessionKey, {
561
561
  slice: {
562
562
  last_failure_category: "budget",
563
563
  next_action: coreFacade.turn.suggestionFor("budget", "budget"),
@@ -686,7 +686,7 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
686
686
  turnBase,
687
687
  );
688
688
  if (hits.length > 0) {
689
- await coreFacade.handoff.patchHandoff(root, provider, {
689
+ await coreFacade.handoff.patchHandoff(root, provider, sessionKey, {
690
690
  slice: {
691
691
  last_gate_result: "fail",
692
692
  blockers: `This turn added ${hits.length} undeclared comment line(s).`,
@@ -721,7 +721,7 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
721
721
  },
722
722
  });
723
723
  if (outcome.findings.length > 0) {
724
- await coreFacade.handoff.patchHandoff(root, provider, {
724
+ await coreFacade.handoff.patchHandoff(root, provider, sessionKey, {
725
725
  slice: {
726
726
  last_gate_result: "fail",
727
727
  blockers: `This turn changed the dependency graph in ${outcome.findings.length} way(s) that outlive it.`,
@@ -757,7 +757,7 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
757
757
  );
758
758
  const hits = coreFacade.duplication.findDuplications(added, scan.index, policy.duplication.minRun);
759
759
  if (hits.length > 0) {
760
- await coreFacade.handoff.patchHandoff(root, provider, {
760
+ await coreFacade.handoff.patchHandoff(root, provider, sessionKey, {
761
761
  slice: {
762
762
  last_gate_result: "fail",
763
763
  blockers: `This turn added ${hits.length} run(s) the project already has.`,
@@ -824,7 +824,7 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
824
824
  changedFiles,
825
825
  });
826
826
  if (planDecision.kind !== "abstain") {
827
- await coreFacade.handoff.patchHandoff(root, provider, {
827
+ await coreFacade.handoff.patchHandoff(root, provider, sessionKey, {
828
828
  slice: {
829
829
  last_gate_result: "fail",
830
830
  last_failure_category: "policy",
@@ -845,7 +845,7 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
845
845
  changedFilesCount: changedFiles.length,
846
846
  });
847
847
  if (emptyDiffDecision.kind !== "abstain") {
848
- await coreFacade.handoff.patchHandoff(root, provider, {
848
+ await coreFacade.handoff.patchHandoff(root, provider, sessionKey, {
849
849
  slice: {
850
850
  last_gate_result: "fail",
851
851
  blockers: "Structured ship claim with empty diff.",
@@ -875,7 +875,7 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
875
875
  evidenceNotBeforeMs: coreFacade.ship.newestChangeMs(root, changedFiles),
876
876
  });
877
877
  if (shipEvidenceDecision.kind !== "abstain") {
878
- await coreFacade.handoff.patchHandoff(root, provider, {
878
+ await coreFacade.handoff.patchHandoff(root, provider, sessionKey, {
879
879
  slice: {
880
880
  last_gate_result: "fail",
881
881
  blockers: "HARNESS_SHIP_CLAIM without recent production evidence on runtime changes.",
@@ -922,7 +922,7 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
922
922
  if (stopRules.decision.kind !== "abstain") {
923
923
  if (stopRules.decision.kind !== "context") {
924
924
  const worst = coreFacade.rules.strictest(stopRules.outcomes);
925
- await coreFacade.handoff.patchHandoff(root, provider, {
925
+ await coreFacade.handoff.patchHandoff(root, provider, sessionKey, {
926
926
  slice: {
927
927
  last_gate_result: "fail",
928
928
  last_failure_category: "policy",
@@ -960,7 +960,7 @@ export const stopHandler: Handler = async (event: HarnessEvent, ctx: HandlerCont
960
960
  * a `why` kind, so `tlc harness why` answers it after the fact.
961
961
  */
962
962
  const holders = [...new Set(deferred)];
963
- await coreFacade.handoff.patchHandoff(root, provider, {
963
+ await coreFacade.handoff.patchHandoff(root, provider, sessionKey, {
964
964
  slice: {
965
965
  last_gate_result: holders.length > 0 ? "skipped" : "pass",
966
966
  blockers: undefined,