@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
@@ -14,9 +14,10 @@ export const subagentStopHandler: Handler = async (
14
14
  // rule asks for. It cannot change the decision below ([/decisions/ad-100.md](/decisions/ad-100.md)).
15
15
  await observeForRules(event, ctx);
16
16
 
17
- const handoff = coreFacade.handoff.readHandoff(event.projectDir, event.provider);
18
- // why: `blockers` is per-project, and every Task subagent shares the parent's session_id
19
- // (anthropics/claude-code#7881), so this cannot tell its own subagent apart from another. A "budget"
17
+ const handoff = coreFacade.handoff.readHandoff(event.projectDir, event.provider, event.sessionKey);
18
+ // why: `blockers` is per-session ([/decisions/ad-122.md](/decisions/ad-122.md)), and every Task subagent
19
+ // shares the parent's session_id (anthropics/claude-code#7881), so this cannot tell its own subagent apart
20
+ // from another. A "budget"
20
21
  // category means the session's turn budget ran out, not that the tree is broken — unlike a gate
21
22
  // failure, it is not valid evidence here ([/decisions/ad-073.md](/decisions/ad-073.md)).
22
23
  const isBudgetBlocker = handoff.last_failure_category === "budget";
@@ -98,10 +98,11 @@ export type TurnScope = {
98
98
  export async function computeTurnScope(
99
99
  root: string,
100
100
  provider: string,
101
+ sessionKey: string,
101
102
  policy: Pick<Policy, "codePaths">,
102
103
  ): Promise<TurnScope> {
103
- const seal = coreFacade.handoff.handoffInjectable(root);
104
- const handoff = seal.ok ? coreFacade.handoff.readHandoff(root, provider) : undefined;
104
+ const seal = coreFacade.handoff.handoffInjectable(root, sessionKey);
105
+ const handoff = seal.ok ? coreFacade.handoff.readHandoff(root, provider, sessionKey) : undefined;
105
106
  const turnBase = handoff?.turn_base_sha ?? "HEAD";
106
107
  const changedFiles = await listChangedRepoFiles(root, turnBase);
107
108
  const codeTargets = filterCodeTargets(changedFiles, policy.codePaths);
@@ -128,12 +129,13 @@ export async function computeTurnScope(
128
129
  export async function pendingCommentViolations(
129
130
  root: string,
130
131
  provider: string,
132
+ sessionKey: string,
131
133
  policy: Pick<Policy, "comments" | "codePaths">,
132
134
  ): Promise<CommentFinding[]> {
133
135
  if (!policy.comments.enabled || policy.comments.onViolation !== "followup") {
134
136
  return [];
135
137
  }
136
- const scope = await computeTurnScope(root, provider, policy);
138
+ const scope = await computeTurnScope(root, provider, sessionKey, policy);
137
139
  if (scope.commentTargets.length === 0) {
138
140
  return [];
139
141
  }
@@ -68,7 +68,7 @@ async function commentEditAdvisory(event: HarnessEvent, ctx: HandlerContext): Pr
68
68
  if (targets.length === 0) {
69
69
  return null;
70
70
  }
71
- const handoff = coreFacade.handoff.readHandoff(event.projectDir, event.provider);
71
+ const handoff = coreFacade.handoff.readHandoff(event.projectDir, event.provider, event.sessionKey);
72
72
  const hits = await coreFacade.commentPolicy.scanAddedComments(
73
73
  event.projectDir,
74
74
  targets,
@@ -133,6 +133,26 @@ export const toolAfterHandler: Handler = async (event: HarnessEvent, ctx: Handle
133
133
  });
134
134
  }
135
135
 
136
+ if (ctx.policy.secrets.redactOutput && event.toolOutput) {
137
+ const output = event.toolOutput;
138
+ const matches = coreFacade.secretScan.scanForSecrets(output);
139
+ if (matches.length > 0) {
140
+ let masked = output;
141
+ // why: reversed so each replacement's [start, end) offsets, computed against the original string, stay
142
+ // valid as the string's length changes underneath earlier ones.
143
+ for (const match of [...matches].reverse()) {
144
+ const placeholder = coreFacade.secretScan.placeholderFor(
145
+ event.projectDir,
146
+ event.sessionKey,
147
+ output.slice(match.start, match.end),
148
+ match.kind,
149
+ );
150
+ masked = `${masked.slice(0, match.start)}${placeholder}${masked.slice(match.end)}`;
151
+ }
152
+ return { kind: "rewriteOutput", output: masked };
153
+ }
154
+ }
155
+
136
156
  const untrustedDecision = coreFacade.untrusted.evaluateUntrustedContent({
137
157
  root: event.projectDir,
138
158
  sessionKey: event.sessionKey,
@@ -26,7 +26,7 @@ async function commentGateBeforeCommit(event: HarnessEvent, ctx: HandlerContext)
26
26
  if (!coreFacade.rules.triggerMatches({ kind: "commit" }, context)) {
27
27
  return { kind: "abstain" };
28
28
  }
29
- const hits = await pendingCommentViolations(event.projectDir, event.provider, ctx.policy);
29
+ const hits = await pendingCommentViolations(event.projectDir, event.provider, event.sessionKey, ctx.policy);
30
30
  if (hits.length === 0) {
31
31
  return { kind: "abstain" };
32
32
  }
@@ -183,6 +183,7 @@ export const toolBeforeHandler: Handler = async (
183
183
  filePath: filePathOf(event),
184
184
  command: event.command,
185
185
  isReadEvent: event.event === "read.before",
186
+ protectedPaths: ctx.protectedPaths,
186
187
  });
187
188
  if (floor.kind !== "allow") {
188
189
  // invariant: one rail owns the record of every shell decision. The floor short-circuits before the shell
@@ -128,6 +128,10 @@ export function bootDir(root: string): string {
128
128
  return join(projectStateDir(root), "boot");
129
129
  }
130
130
 
131
+ export function handoffSessionsDir(root: string): string {
132
+ return join(projectStateDir(root), "handoff-sessions");
133
+ }
134
+
131
135
  // why: inside the state directory on purpose — the baseline that proves the policy was not switched off
132
136
  // inherits the same protection as the policy itself.
133
137
  export function policyBaselineDir(root: string): string {
@@ -1,7 +1,43 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { hostname } from "node:os";
2
3
 
3
4
  const TIMEOUT_EXIT_CODE = 124;
4
5
 
6
+ /** Asks the OS whether a pid exists. Injected so the ESRCH and EPERM branches are testable on every platform. */
7
+ export type ProcessProbe = (pid: number) => void;
8
+
9
+ const defaultProbe: ProcessProbe = (pid) => {
10
+ process.kill(pid, 0);
11
+ };
12
+
13
+ /**
14
+ * Whether the process that recorded `pid`/`host` is still running, right now, on this machine.
15
+ *
16
+ * why: a pid means nothing on another host, so a mismatch answers "alive" rather than guessing.
17
+ * `process.kill(pid, 0)` sends no signal, it only asks whether the process exists; `ESRCH` is the one answer
18
+ * that proves it does not. Extracted from `gate.lock.ts`'s original `isLockOwnerGone`, its only consumer — a
19
+ * pid outlives one long-running gate command's own duration ([/decisions/ad-122.md](/decisions/ad-122.md)).
20
+ */
21
+ export function isProcessAlive(
22
+ pid: number,
23
+ host: string,
24
+ thisHost: string = hostname(),
25
+ probe: ProcessProbe = defaultProbe,
26
+ ): boolean {
27
+ if (host !== thisHost) {
28
+ return true;
29
+ }
30
+ if (!Number.isInteger(pid) || pid <= 0) {
31
+ return true;
32
+ }
33
+ try {
34
+ probe(pid);
35
+ return true;
36
+ } catch (error) {
37
+ return (error as NodeJS.ErrnoException).code !== "ESRCH";
38
+ }
39
+ }
40
+
5
41
  export async function readStdinText(): Promise<string> {
6
42
  const chunks: Buffer[] = [];
7
43
  for await (const chunk of process.stdin) {
@@ -8,7 +8,9 @@ export function claudeCapabilities(): ProviderCapabilities {
8
8
  nativeLoopCounter: false,
9
9
  dedicatedShellEvent: false,
10
10
  toolInputRewrite: true,
11
- toolOutputRewrite: true,
11
+ // why: `updatedToolOutput` is documented on every event this adapter fires PostToolUse/PostToolUseFailure
12
+ // for — both of the after-events a rewrite could ever apply to.
13
+ toolOutputRewriteOn: ["tool.after", "tool.failure"],
12
14
  contextAtToolBefore: true,
13
15
  contextAtToolAfter: true,
14
16
  // why: `Stop` accepts `hookSpecificOutput.additionalContext` for feedback that continues the turn, which is
@@ -2,7 +2,7 @@ import { isEffortLevel } from "../../contracts/effort.ts";
2
2
  import type { EffortLevel, HarnessEvent, HarnessEventKind } from "../../contracts/index.ts";
3
3
  import { sanitizeSegment } from "../../platform/sanitize.ts";
4
4
 
5
- const EVENT_KIND_BY_HOOK: Record<string, HarnessEventKind> = {
5
+ export const EVENT_KIND_BY_HOOK: Record<string, HarnessEventKind> = {
6
6
  SessionStart: "session.start",
7
7
  SessionEnd: "session.end",
8
8
  UserPromptSubmit: "prompt.submit",
@@ -16,6 +16,40 @@ const EVENT_KIND_BY_HOOK: Record<string, HarnessEventKind> = {
16
16
 
17
17
  const MCP_TOOL_NAME = /^mcp__/;
18
18
 
19
+ type ToolNameFanOutRule = { match: RegExp | string; kind: HarnessEventKind };
20
+
21
+ // why: PreToolUse fans out by tool_name — Claude has no dedicated shell/MCP/read event.
22
+ export const PRE_TOOL_USE_FAN_OUT: readonly ToolNameFanOutRule[] = [
23
+ { match: "Bash", kind: "shell.before" },
24
+ { match: MCP_TOOL_NAME, kind: "mcp.before" },
25
+ { match: "Read", kind: "read.before" },
26
+ ];
27
+
28
+ // why: PostToolUse fans out the same way, for the after-half of each of those tool classes.
29
+ export const POST_TOOL_USE_FAN_OUT: readonly ToolNameFanOutRule[] = [
30
+ { match: "Bash", kind: "shell.after" },
31
+ { match: MCP_TOOL_NAME, kind: "mcp.after" },
32
+ { match: "Edit", kind: "edit.after" },
33
+ { match: "Write", kind: "edit.after" },
34
+ ];
35
+
36
+ function matchFanOut(
37
+ table: readonly ToolNameFanOutRule[],
38
+ toolName: string | undefined,
39
+ fallback: HarnessEventKind,
40
+ ): HarnessEventKind {
41
+ if (toolName === undefined) {
42
+ return fallback;
43
+ }
44
+ for (const rule of table) {
45
+ const matched = typeof rule.match === "string" ? rule.match === toolName : rule.match.test(toolName);
46
+ if (matched) {
47
+ return rule.kind;
48
+ }
49
+ }
50
+ return fallback;
51
+ }
52
+
19
53
  function asString(value: unknown): string | undefined {
20
54
  return typeof value === "string" ? value : undefined;
21
55
  }
@@ -57,31 +91,12 @@ function effortFor(raw: Record<string, unknown>): EffortLevel | undefined {
57
91
  return isEffortLevel(level) ? level : undefined;
58
92
  }
59
93
 
60
- // why: PreToolUse fans out by tool_name — Claude has no dedicated shell/MCP/read event.
61
94
  function preToolUseKind(toolName: string | undefined): HarnessEventKind {
62
- if (toolName === "Bash") {
63
- return "shell.before";
64
- }
65
- if (toolName && MCP_TOOL_NAME.test(toolName)) {
66
- return "mcp.before";
67
- }
68
- if (toolName === "Read") {
69
- return "read.before";
70
- }
71
- return "tool.before";
95
+ return matchFanOut(PRE_TOOL_USE_FAN_OUT, toolName, "tool.before");
72
96
  }
73
97
 
74
98
  function postToolUseKind(toolName: string | undefined): HarnessEventKind {
75
- if (toolName === "Bash") {
76
- return "shell.after";
77
- }
78
- if (toolName && MCP_TOOL_NAME.test(toolName)) {
79
- return "mcp.after";
80
- }
81
- if (toolName === "Edit" || toolName === "Write") {
82
- return "edit.after";
83
- }
84
- return "tool.after";
99
+ return matchFanOut(POST_TOOL_USE_FAN_OUT, toolName, "tool.after");
85
100
  }
86
101
 
87
102
  /** Never throws on a malformed payload — returns null instead. */
@@ -57,6 +57,10 @@ export function claudeRender(decision: Decision, event: HarnessEvent): Rendered
57
57
  stdout: JSON.stringify({ hookSpecificOutput: { hookEventName, updatedInput: decision.input } }),
58
58
  exitCode: 0,
59
59
  };
60
+ // why: the documented schema for updatedToolOutput places it at the top of the JSON, unlike every other
61
+ // field this adapter emits — nesting it under hookSpecificOutput would not be read.
62
+ case "rewriteOutput":
63
+ return { stdout: JSON.stringify({ updatedToolOutput: decision.output }), exitCode: 0 };
60
64
  default: {
61
65
  const exhaustive: never = decision;
62
66
  throw new Error(`unreachable decision kind: ${JSON.stringify(exhaustive)}`);
@@ -31,6 +31,10 @@ export function claudeSettingsPath(): string {
31
31
  return join(claudeConfigDir(), "settings.json");
32
32
  }
33
33
 
34
+ export function claudeWiringTargets(): string[] {
35
+ return [claudeSettingsPath()];
36
+ }
37
+
34
38
  // why: exec form only, on every platform — it bypasses shell tokenization, so there is no quoting variant to get wrong.
35
39
  export function claudeWiring(runtime: RuntimePaths): ProviderWiring {
36
40
  const entries: WiringEntry[] = ENTRY_SPECS.map((spec) => ({
@@ -4,7 +4,7 @@ import { detectClaude } from "./claude.detect.ts";
4
4
  import { claudeToEvent } from "./claude.inbound.ts";
5
5
  import { claudeRender } from "./claude.outbound.ts";
6
6
  import { claudePolicyDefaults } from "./claude.policy-defaults.ts";
7
- import { claudeWiring } from "./claude.wiring.ts";
7
+ import { claudeWiring, claudeWiringTargets } from "./claude.wiring.ts";
8
8
 
9
9
  export const claudeProvider: ProviderPort = {
10
10
  name: "claude",
@@ -14,4 +14,5 @@ export const claudeProvider: ProviderPort = {
14
14
  toEvent: claudeToEvent,
15
15
  render: claudeRender,
16
16
  wiring: claudeWiring,
17
+ wiringTargets: claudeWiringTargets,
17
18
  };
@@ -8,7 +8,9 @@ export function cursorCapabilities(): ProviderCapabilities {
8
8
  nativeLoopCounter: true,
9
9
  dedicatedShellEvent: true,
10
10
  toolInputRewrite: true,
11
- toolOutputRewrite: true,
11
+ // why: `updated_mcp_tool_output` is scoped to `afterMCPExecution` only — `afterShellExecution` and
12
+ // `afterFileEdit` are documented as observation-only, with no equivalent field.
13
+ toolOutputRewriteOn: ["mcp.after"],
12
14
  contextAtToolBefore: false,
13
15
  contextAtToolAfter: true,
14
16
  // why: the `stop` output schema carries `followup_message` and nothing else, so a context decision raised there
@@ -1,7 +1,7 @@
1
1
  import type { HarnessEvent, HarnessEventKind } from "../../contracts/index.ts";
2
2
  import { sanitizeSegment } from "../../platform/sanitize.ts";
3
3
 
4
- const EVENT_KIND_BY_HOOK: Record<string, HarnessEventKind> = {
4
+ export const EVENT_KIND_BY_HOOK: Record<string, HarnessEventKind> = {
5
5
  sessionStart: "session.start",
6
6
  sessionEnd: "session.end",
7
7
  beforeSubmitPrompt: "prompt.submit",
@@ -35,6 +35,15 @@ export function cursorRender(decision: Decision, _event: HarnessEvent): Rendered
35
35
  return { stdout: JSON.stringify({ followup_message: decision.text }), exitCode: 0 };
36
36
  case "rewriteInput":
37
37
  return { stdout: JSON.stringify({ updated_input: decision.input }), exitCode: 0 };
38
+ // hazard: Cursor's docs declare `updated_mcp_tool_output` as an object with no documented inner keys —
39
+ // `result_json` mirrors the field this same event reads the original output from (`cursor.inbound.ts`).
40
+ // `provider.degrade.ts` only lets `rewriteOutput` reach here for `mcp.after`; every other event is already
41
+ // `context` by the time render runs.
42
+ case "rewriteOutput":
43
+ return {
44
+ stdout: JSON.stringify({ updated_mcp_tool_output: { result_json: decision.output } }),
45
+ exitCode: 0,
46
+ };
38
47
  default: {
39
48
  const exhaustive: never = decision;
40
49
  throw new Error(`unreachable decision kind: ${JSON.stringify(exhaustive)}`);
@@ -12,6 +12,14 @@ type EntrySpec = {
12
12
  };
13
13
 
14
14
  // why: mirrors bin/write-user-hooks.mjs verbatim — same hook keys, timeouts, failClosed/matcher/loopLimit values, and handler order.
15
+ export function cursorHooksPath(): string {
16
+ return join(cursorConfigDir(), "hooks.json");
17
+ }
18
+
19
+ export function cursorWiringTargets(): string[] {
20
+ return [cursorHooksPath()];
21
+ }
22
+
15
23
  const ENTRY_SPECS: readonly EntrySpec[] = [
16
24
  { hookEvent: "sessionStart", handler: "session-start", timeoutSeconds: 10 },
17
25
  { hookEvent: "sessionEnd", handler: "session-end", timeoutSeconds: 10 },
@@ -57,7 +65,7 @@ export function cursorWiring(runtime: RuntimePaths): ProviderWiring {
57
65
  }));
58
66
 
59
67
  return {
60
- target: join(cursorConfigDir(), "hooks.json"),
68
+ target: cursorHooksPath(),
61
69
  strategy: "replace",
62
70
  entries,
63
71
  };
@@ -4,7 +4,7 @@ import { detectCursor } from "./cursor.detect.ts";
4
4
  import { cursorToEvent } from "./cursor.inbound.ts";
5
5
  import { cursorRender } from "./cursor.outbound.ts";
6
6
  import { cursorPolicyDefaults } from "./cursor.policy-defaults.ts";
7
- import { cursorWiring } from "./cursor.wiring.ts";
7
+ import { cursorWiring, cursorWiringTargets } from "./cursor.wiring.ts";
8
8
 
9
9
  export const cursorProvider: ProviderPort = {
10
10
  name: "cursor",
@@ -14,4 +14,5 @@ export const cursorProvider: ProviderPort = {
14
14
  toEvent: cursorToEvent,
15
15
  render: cursorRender,
16
16
  wiring: cursorWiring,
17
+ wiringTargets: cursorWiringTargets,
17
18
  };
@@ -20,6 +20,10 @@ const NO_HUMAN_PREFIX = "No operator is answering prompts in this permission mod
20
20
  const NO_HUMAN_MODES = new Set(["bypassPermissions", "dontAsk"]);
21
21
  const ADVISORY_PREFIX = "ADVISORY — this provider cannot enforce: ";
22
22
  const TRUNCATION_MARKER = "\n…(truncated — over context budget)";
23
+ // why: the model already saw the masked output verbatim on a host where rewrite is unsupported for this event —
24
+ // a `context` notice cannot erase that, so it names the constraint instead of pretending the value is gone.
25
+ const REWRITE_OUTPUT_UNAVAILABLE_NOTICE =
26
+ "A secret-shaped value was masked in this tool's output and must not be repeated.";
23
27
 
24
28
  function isEnforcing(decision: Decision): boolean {
25
29
  return (
@@ -133,6 +137,21 @@ export function degrade(
133
137
  };
134
138
  }
135
139
 
140
+ if (decision.kind === "rewriteOutput") {
141
+ if (capabilities.toolOutputRewriteOn.includes(event.event)) {
142
+ return decision;
143
+ }
144
+ // why: a degraded rewriteOutput becomes exactly a context decision, and recursing here — instead of
145
+ // duplicating the handling below — is what runs it through the same canCarryContext/budget logic every
146
+ // other context decision already gets, with no special case for this one.
147
+ return degrade(
148
+ { kind: "context", text: REWRITE_OUTPUT_UNAVAILABLE_NOTICE },
149
+ event,
150
+ capabilities,
151
+ options,
152
+ );
153
+ }
154
+
136
155
  if (decision.kind === "context") {
137
156
  // hazard: contextAtToolBefore and contextAtToolAfter were declared by every adapter and read by
138
157
  // nothing. A provider that cannot carry context on the current event would have had the text rendered
@@ -17,4 +17,5 @@ export type ProviderPort = {
17
17
  toEvent(raw: Record<string, unknown>): HarnessEvent | null;
18
18
  render(decision: Decision, event: HarnessEvent): Rendered;
19
19
  wiring(runtime: RuntimePaths): ProviderWiring;
20
+ wiringTargets(): string[];
20
21
  };
@@ -0,0 +1,251 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ // hazard: this file lived at tools/dev/new-provider.ts (two levels below root) until it moved to tools/ —
6
+ // tools/dev/ is excluded from the published package, and bin/tlc-cli.ts's `new-provider` subcommand imports
7
+ // this module at runtime, so a real install had no file to import at all.
8
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
9
+
10
+ // invariant: mirrors provider.contract.test.ts's own safety check ("provider name is safe as a state-file
11
+ // key segment") — no whitespace, no slashes. Reserved names collide with this scaffold's own file layout
12
+ // (`index.ts`) or the generic module concept ("provider").
13
+ const UNSAFE_NAME = /[\s/\\]/;
14
+ const RESERVED_NAMES = new Set(["index", "provider"]);
15
+
16
+ export type NameValidity = { ok: true } | { ok: false; reason: string };
17
+
18
+ export function validateProviderName(name: string): NameValidity {
19
+ if (name.length === 0) {
20
+ return { ok: false, reason: "provider name must not be empty" };
21
+ }
22
+ if (UNSAFE_NAME.test(name)) {
23
+ return { ok: false, reason: "provider name must not contain whitespace or a slash" };
24
+ }
25
+ if (RESERVED_NAMES.has(name.toLowerCase())) {
26
+ return { ok: false, reason: `"${name}" is a reserved name` };
27
+ }
28
+ return { ok: true };
29
+ }
30
+
31
+ function capitalize(name: string): string {
32
+ return name.length === 0 ? name : `${name[0]?.toUpperCase()}${name.slice(1)}`;
33
+ }
34
+
35
+ function detectStub(name: string): string {
36
+ const cap = capitalize(name);
37
+ return `// why: always false until real detection logic replaces it, so the provider stays inert.
38
+ export function detect${cap}(_raw: unknown): boolean {
39
+ return false;
40
+ }
41
+ `;
42
+ }
43
+
44
+ function capabilitiesStub(name: string): string {
45
+ return `import type { HarnessEventKind, ProviderCapabilities } from "../../contracts/index.ts";
46
+
47
+ export function ${name}Capabilities(): ProviderCapabilities {
48
+ return {
49
+ enforcesHooks: true,
50
+ askSupportedOn: [],
51
+ sessionEnv: false,
52
+ nativeLoopCounter: false,
53
+ dedicatedShellEvent: false,
54
+ toolInputRewrite: false,
55
+ // why: a placeholder, deliberately not a real HarnessEventKind — fails assertSatisfiesContract until replaced.
56
+ toolOutputRewriteOn: ["TODO-fill-in-real-capabilities" as unknown as HarnessEventKind],
57
+ contextAtToolBefore: false,
58
+ contextAtToolAfter: false,
59
+ contextAtStop: false,
60
+ sessionStartContextReliable: false,
61
+ toolOutputAtAfter: false,
62
+ usageInPayload: false,
63
+ effortSignal: false,
64
+ thoughtEvent: false,
65
+ };
66
+ }
67
+ `;
68
+ }
69
+
70
+ function policyDefaultsStub(name: string): string {
71
+ return `import type { ProviderPolicyDefaults } from "../../contracts/index.ts";
72
+
73
+ export function ${name}PolicyDefaults(): ProviderPolicyDefaults {
74
+ return {
75
+ blockedPatterns: [],
76
+ minEffort: null,
77
+ untrustedTools: [],
78
+ };
79
+ }
80
+ `;
81
+ }
82
+
83
+ function inboundStub(name: string): string {
84
+ return `import type { HarnessEvent, HarnessEventKind } from "../../contracts/index.ts";
85
+
86
+ // why: empty until this host's own hook names are mapped, so every payload falls through to null.
87
+ export const EVENT_KIND_BY_HOOK: Record<string, HarnessEventKind> = {};
88
+
89
+ /** Never throws on a malformed payload — returns null instead. */
90
+ export function ${name}ToEvent(_raw: Record<string, unknown>): HarnessEvent | null {
91
+ return null;
92
+ }
93
+ `;
94
+ }
95
+
96
+ function wiringStub(name: string): string {
97
+ return `import type { ProviderWiring, RuntimePaths } from "../../contracts/index.ts";
98
+
99
+ // why: a placeholder path, until the real file this provider's host reads hooks from is named.
100
+ const WIRING_TARGET = "TODO: replace with the real wiring target path";
101
+
102
+ export function ${name}WiringTargets(): string[] {
103
+ return [WIRING_TARGET];
104
+ }
105
+
106
+ // why: an empty entries list, until this adapter's real hook entries replace it (see claude.wiring.ts's ENTRY_SPECS shape).
107
+ export function ${name}Wiring(_runtime: RuntimePaths): ProviderWiring {
108
+ return { target: WIRING_TARGET, strategy: "replace", entries: [] };
109
+ }
110
+ `;
111
+ }
112
+
113
+ function outboundStub(name: string): string {
114
+ return `import type { Decision, HarnessEvent, Rendered } from "../../contracts/index.ts";
115
+
116
+ // why: an unconditional abstain, until this host's real hook response schema replaces it.
117
+ export function ${name}Render(_decision: Decision, _event: HarnessEvent): Rendered {
118
+ return { stdout: null, exitCode: 0 };
119
+ }
120
+ `;
121
+ }
122
+
123
+ function indexStub(name: string): string {
124
+ const cap = capitalize(name);
125
+ return `import type { ProviderPort } from "../provider.port.ts";
126
+ import { ${name}Capabilities } from "./${name}.capabilities.ts";
127
+ import { detect${cap} } from "./${name}.detect.ts";
128
+ import { ${name}ToEvent } from "./${name}.inbound.ts";
129
+ import { ${name}Render } from "./${name}.outbound.ts";
130
+ import { ${name}PolicyDefaults } from "./${name}.policy-defaults.ts";
131
+ import { ${name}Wiring, ${name}WiringTargets } from "./${name}.wiring.ts";
132
+
133
+ // why: assembled here so \`tlc harness new-provider\` has one ProviderPort to import into provider.registry.ts.
134
+ export const ${name}Provider: ProviderPort = {
135
+ name: "${name}",
136
+ detect: detect${cap},
137
+ capabilities: ${name}Capabilities,
138
+ policyDefaults: ${name}PolicyDefaults,
139
+ toEvent: ${name}ToEvent,
140
+ render: ${name}Render,
141
+ wiring: ${name}Wiring,
142
+ wiringTargets: ${name}WiringTargets,
143
+ };
144
+ `;
145
+ }
146
+
147
+ function docSkeleton(name: string): string {
148
+ const cap = capitalize(name);
149
+ const timestamp = new Date().toISOString().slice(0, 10);
150
+ return `---
151
+ type: Provider
152
+ title: "${cap} provider"
153
+ description: "The ${cap} adapter — capability descriptor, event mapping, and wiring target."
154
+ tags: [provider, ${name}]
155
+ timestamp: "${timestamp}"
156
+ ---
157
+
158
+ # ${cap} provider
159
+
160
+ Source: \`src/providers/${name}/\`.
161
+
162
+ ## Detection
163
+
164
+ TODO: describe \`${name}.detect.ts\`'s real detection rule.
165
+
166
+ ## Capability descriptor
167
+
168
+ \`${name}.capabilities.ts\`:
169
+
170
+ <!-- generated:capabilities -->
171
+
172
+ <!-- /generated -->
173
+
174
+ ## Policy defaults
175
+
176
+ TODO: describe \`${name}.policy-defaults.ts\`'s defaults.
177
+
178
+ ## Event mapping
179
+
180
+ \`${name}.inbound.ts\` maps ${cap}'s own hook names to \`HarnessEventKind\`:
181
+
182
+ <!-- generated:event-mapping -->
183
+
184
+ <!-- /generated -->
185
+
186
+ ## Wiring target
187
+
188
+ TODO: describe \`${name}.wiring.ts\`'s wiring target and strategy.
189
+
190
+ ## See also
191
+
192
+ - [/providers/index.md](/providers/index.md)
193
+ `;
194
+ }
195
+
196
+ export type ScaffoldFile = { path: string; content: string };
197
+
198
+ export function scaffoldFiles(name: string): ScaffoldFile[] {
199
+ const dir = join("src", "providers", name);
200
+ return [
201
+ { path: join(dir, `${name}.detect.ts`), content: detectStub(name) },
202
+ { path: join(dir, `${name}.capabilities.ts`), content: capabilitiesStub(name) },
203
+ { path: join(dir, `${name}.policy-defaults.ts`), content: policyDefaultsStub(name) },
204
+ { path: join(dir, `${name}.inbound.ts`), content: inboundStub(name) },
205
+ { path: join(dir, `${name}.wiring.ts`), content: wiringStub(name) },
206
+ // why: SPEC_DEVIATION — emits 8 files, not the 6 named in design.md/tasks.md, adding `<name>.outbound.ts` and
207
+ // `index.ts`. Reason: ProviderPort requires a `render` field and an assembled object, the same two-extra-file
208
+ // shape claude/index.ts and cursor/index.ts already use — without them there is no ProviderPort value for T6
209
+ // to register or T7 to run through assertSatisfiesContract (see commit 20da58e).
210
+ { path: join(dir, `${name}.outbound.ts`), content: outboundStub(name) },
211
+ { path: join(dir, "index.ts"), content: indexStub(name) },
212
+ { path: join("docs", "providers", `${name}.md`), content: docSkeleton(name) },
213
+ ];
214
+ }
215
+
216
+ export function scaffold(name: string, root = repoRoot): NameValidity {
217
+ const validity = validateProviderName(name);
218
+ if (!validity.ok) {
219
+ return validity;
220
+ }
221
+ const providerDir = join(root, "src", "providers", name);
222
+ if (existsSync(providerDir)) {
223
+ return { ok: false, reason: `${join("src", "providers", name)} already exists` };
224
+ }
225
+ for (const file of scaffoldFiles(name)) {
226
+ const fullPath = join(root, file.path);
227
+ mkdirSync(dirname(fullPath), { recursive: true });
228
+ writeFileSync(fullPath, file.content, "utf8");
229
+ }
230
+ return { ok: true };
231
+ }
232
+
233
+ export function main(argv: string[], root = repoRoot): void {
234
+ const name = argv[0];
235
+ if (!name) {
236
+ console.error("usage: node tools/dev/new-provider.ts <name>");
237
+ process.exitCode = 1;
238
+ return;
239
+ }
240
+ const result = scaffold(name, root);
241
+ if (!result.ok) {
242
+ console.error(`new-provider: refusing — ${result.reason}`);
243
+ process.exitCode = 1;
244
+ return;
245
+ }
246
+ console.log(`new-provider: scaffolded src/providers/${name}/ and docs/providers/${name}.md`);
247
+ }
248
+
249
+ if (import.meta.main) {
250
+ main(process.argv.slice(2));
251
+ }