@tech-leads-club/harness-toolkit 0.2.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.
- package/CHANGELOG.md +95 -0
- package/LICENSE +84 -0
- package/NOTICE +9 -0
- package/README.md +480 -0
- package/bin/tlc +10 -0
- package/bin/tlc-build +80 -0
- package/bin/tlc-cli.ts +1509 -0
- package/bin/tlc-exec +10 -0
- package/bin/tlc-exec.cmd +4 -0
- package/bin/tlc-exec.d.mts +60 -0
- package/bin/tlc-exec.mjs +248 -0
- package/bin/tlc.cmd +4 -0
- package/bin/tlc.mjs +7 -0
- package/bin/write-user-hooks.d.mts +40 -0
- package/bin/write-user-hooks.mjs +123 -0
- package/capabilities/catalog.json +418 -0
- package/config.example.json +51 -0
- package/dist/compact-before.mjs +7839 -0
- package/dist/doctor.mjs +8263 -0
- package/dist/help-topic.mjs +82 -0
- package/dist/init-project.mjs +993 -0
- package/dist/install-runtime.mjs +969 -0
- package/dist/lessons-cli.mjs +7258 -0
- package/dist/obs-cli.mjs +7025 -0
- package/dist/price-lookup.mjs +221 -0
- package/dist/prompt-submit.mjs +7860 -0
- package/dist/refresh-model-prices.mjs +158 -0
- package/dist/response-after.mjs +7879 -0
- package/dist/run.mjs +7830 -0
- package/dist/session-end.mjs +7937 -0
- package/dist/session-start.mjs +8056 -0
- package/dist/shim.mjs +52 -0
- package/dist/stop.mjs +8494 -0
- package/dist/subagent-start.mjs +7861 -0
- package/dist/subagent-stop.mjs +7851 -0
- package/dist/support.mjs +7045 -0
- package/dist/tlc-cli.mjs +8063 -0
- package/dist/tool-after.mjs +8113 -0
- package/dist/tool-before.mjs +7985 -0
- package/dist/tool-failure.mjs +7850 -0
- package/dist/uninstall-runtime.mjs +1307 -0
- package/docs/architecture.md +270 -0
- package/docs/concepts.md +562 -0
- package/docs/coverage.md +55 -0
- package/docs/decisions/ad-001.md +32 -0
- package/docs/decisions/ad-002.md +51 -0
- package/docs/decisions/ad-003.md +30 -0
- package/docs/decisions/ad-004.md +37 -0
- package/docs/decisions/ad-005.md +36 -0
- package/docs/decisions/ad-006.md +49 -0
- package/docs/decisions/ad-007.md +36 -0
- package/docs/decisions/ad-008.md +54 -0
- package/docs/decisions/ad-009.md +61 -0
- package/docs/decisions/ad-010.md +45 -0
- package/docs/decisions/ad-011.md +59 -0
- package/docs/decisions/ad-012.md +71 -0
- package/docs/decisions/ad-013.md +87 -0
- package/docs/decisions/ad-014.md +56 -0
- package/docs/decisions/ad-015.md +33 -0
- package/docs/decisions/ad-016.md +98 -0
- package/docs/decisions/ad-017.md +65 -0
- package/docs/decisions/ad-018.md +77 -0
- package/docs/decisions/ad-019.md +75 -0
- package/docs/decisions/ad-020.md +88 -0
- package/docs/decisions/ad-021.md +57 -0
- package/docs/decisions/ad-022.md +120 -0
- package/docs/decisions/ad-023.md +87 -0
- package/docs/decisions/ad-024.md +85 -0
- package/docs/decisions/ad-025.md +92 -0
- package/docs/decisions/ad-026.md +131 -0
- package/docs/decisions/ad-027.md +90 -0
- package/docs/decisions/ad-028.md +90 -0
- package/docs/decisions/ad-029.md +73 -0
- package/docs/decisions/ad-030.md +97 -0
- package/docs/decisions/ad-031.md +94 -0
- package/docs/decisions/ad-032.md +83 -0
- package/docs/decisions/ad-033.md +89 -0
- package/docs/decisions/ad-034.md +86 -0
- package/docs/decisions/ad-035.md +86 -0
- package/docs/decisions/ad-036.md +68 -0
- package/docs/decisions/ad-037.md +47 -0
- package/docs/decisions/ad-038.md +52 -0
- package/docs/decisions/ad-039.md +69 -0
- package/docs/decisions/ad-040.md +89 -0
- package/docs/decisions/ad-041.md +98 -0
- package/docs/decisions/ad-042.md +82 -0
- package/docs/decisions/ad-043.md +79 -0
- package/docs/decisions/ad-044.md +61 -0
- package/docs/decisions/ad-045.md +94 -0
- package/docs/decisions/ad-046.md +111 -0
- package/docs/decisions/ad-047.md +96 -0
- package/docs/decisions/ad-048.md +85 -0
- package/docs/decisions/ad-049.md +66 -0
- package/docs/decisions/ad-050.md +94 -0
- package/docs/decisions/ad-051.md +69 -0
- package/docs/decisions/ad-052.md +69 -0
- package/docs/decisions/ad-053.md +78 -0
- package/docs/decisions/ad-054.md +98 -0
- package/docs/decisions/ad-055.md +74 -0
- package/docs/decisions/ad-056.md +85 -0
- package/docs/decisions/ad-057.md +68 -0
- package/docs/decisions/ad-058.md +97 -0
- package/docs/decisions/ad-059.md +82 -0
- package/docs/decisions/ad-060.md +75 -0
- package/docs/decisions/ad-061.md +68 -0
- package/docs/decisions/ad-062.md +72 -0
- package/docs/decisions/ad-063.md +84 -0
- package/docs/decisions/ad-064.md +79 -0
- package/docs/decisions/ad-065.md +81 -0
- package/docs/decisions/ad-066.md +111 -0
- package/docs/decisions/ad-067.md +64 -0
- package/docs/decisions/ad-068.md +79 -0
- package/docs/decisions/ad-069.md +74 -0
- package/docs/decisions/ad-070.md +86 -0
- package/docs/decisions/ad-071.md +93 -0
- package/docs/decisions/ad-072.md +82 -0
- package/docs/decisions/ad-073.md +102 -0
- package/docs/decisions/ad-074.md +91 -0
- package/docs/decisions/ad-075.md +79 -0
- package/docs/decisions/ad-076.md +102 -0
- package/docs/decisions/ad-077.md +94 -0
- package/docs/decisions/ad-078.md +84 -0
- package/docs/decisions/ad-079.md +73 -0
- package/docs/decisions/ad-080.md +86 -0
- package/docs/decisions/ad-081.md +70 -0
- package/docs/decisions/ad-082.md +79 -0
- package/docs/decisions/ad-083.md +88 -0
- package/docs/decisions/index.md +111 -0
- package/docs/diagnose.md +248 -0
- package/docs/index.md +52 -0
- package/docs/init.md +101 -0
- package/docs/lessons.md +294 -0
- package/docs/log.md +139 -0
- package/docs/measure.md +178 -0
- package/docs/providers/claude-code.md +118 -0
- package/docs/providers/cursor.md +110 -0
- package/docs/providers/index.md +71 -0
- package/docs/troubleshooting.md +138 -0
- package/model-aliases.json +12 -0
- package/model-prices.cursor.json +410 -0
- package/model-prices.json +1 -0
- package/package.json +57 -0
- package/skills/harness-init/SKILL.md +241 -0
- package/skills/harness-init/evals/trigger_evals.json +86 -0
- package/skills/harness-init/references/capabilities.md +113 -0
- package/src/contracts/capabilities.ts +30 -0
- package/src/contracts/decision.ts +21 -0
- package/src/contracts/effort.ts +15 -0
- package/src/contracts/harness-event.ts +87 -0
- package/src/contracts/index.ts +6 -0
- package/src/contracts/policy-defaults.ts +14 -0
- package/src/contracts/wiring.ts +20 -0
- package/src/core/attest/attest.service.ts +0 -0
- package/src/core/capability/capability.service.ts +62 -0
- package/src/core/capability/capability.store.ts +51 -0
- package/src/core/capability/capability.types.ts +56 -0
- package/src/core/comment-policy/comment-policy.service.ts +307 -0
- package/src/core/comment-policy/comment-policy.types.ts +6 -0
- package/src/core/comment-policy/comment-resolvability.ts +134 -0
- package/src/core/comment-policy/comment-syntax.catalog.ts +310 -0
- package/src/core/comment-policy/comment-syntax.store.ts +65 -0
- package/src/core/comment-policy/comment-syntax.types.ts +24 -0
- package/src/core/core.facade.ts +461 -0
- package/src/core/duplication/duplication.service.ts +219 -0
- package/src/core/floor/floor.catalog.ts +54 -0
- package/src/core/floor/floor.paths.ts +90 -0
- package/src/core/floor/floor.policy-surface.ts +311 -0
- package/src/core/floor/floor.service.ts +306 -0
- package/src/core/floor/floor.tokenize.ts +179 -0
- package/src/core/floor/floor.verb.ts +28 -0
- package/src/core/floor/index.ts +1 -0
- package/src/core/gate/gate.artifact.ts +157 -0
- package/src/core/gate/gate.command.ts +130 -0
- package/src/core/gate/gate.findings.ts +201 -0
- package/src/core/gate/gate.inputs.ts +0 -0
- package/src/core/gate/gate.lock.ts +283 -0
- package/src/core/gate/gate.service.ts +28 -0
- package/src/core/gate/gate.types.ts +67 -0
- package/src/core/handoff/handoff.service.ts +43 -0
- package/src/core/handoff/handoff.store.ts +59 -0
- package/src/core/handoff/handoff.types.ts +89 -0
- package/src/core/index.ts +52 -0
- package/src/core/integrity/state-seal.ts +81 -0
- package/src/core/lesson/lesson.authored.ts +96 -0
- package/src/core/lesson/lesson.credit.ts +51 -0
- package/src/core/lesson/lesson.garden.ts +283 -0
- package/src/core/lesson/lesson.link.ts +93 -0
- package/src/core/lesson/lesson.score.ts +61 -0
- package/src/core/lesson/lesson.select.ts +209 -0
- package/src/core/lesson/lesson.service.ts +110 -0
- package/src/core/lesson/lesson.store.ts +317 -0
- package/src/core/lesson/lesson.sync.ts +70 -0
- package/src/core/lesson/lesson.types.ts +92 -0
- package/src/core/lesson/lesson.validity.ts +44 -0
- package/src/core/observability/observability.report.ts +326 -0
- package/src/core/observability/observability.service.ts +328 -0
- package/src/core/observability/observability.store.ts +269 -0
- package/src/core/observability/observability.types.ts +193 -0
- package/src/core/observability/observability.why.ts +206 -0
- package/src/core/observe/observe.service.ts +76 -0
- package/src/core/plan/plan.detect.ts +47 -0
- package/src/core/plan/plan.service.ts +81 -0
- package/src/core/plan/plan.types.ts +25 -0
- package/src/core/policy/policy.defaults.ts +103 -0
- package/src/core/policy/policy.guard.ts +34 -0
- package/src/core/policy/policy.integrity.ts +238 -0
- package/src/core/policy/policy.loader.ts +118 -0
- package/src/core/policy/policy.operator.ts +82 -0
- package/src/core/policy/policy.posture.ts +79 -0
- package/src/core/policy/policy.rails.ts +45 -0
- package/src/core/policy/policy.types.ts +157 -0
- package/src/core/presence/presence.service.ts +111 -0
- package/src/core/presence/presence.store.ts +59 -0
- package/src/core/presence/presence.types.ts +9 -0
- package/src/core/release/release.decisions.ts +147 -0
- package/src/core/release/release.seen.ts +38 -0
- package/src/core/shell-policy/shell-policy.service.ts +215 -0
- package/src/core/shell-policy/shell-policy.stall.ts +57 -0
- package/src/core/shell-policy/shell-policy.types.ts +26 -0
- package/src/core/ship/ship.ledger.ts +76 -0
- package/src/core/ship/ship.service.ts +137 -0
- package/src/core/ship/ship.types.ts +19 -0
- package/src/core/stagnation/stagnation.resolution.ts +87 -0
- package/src/core/stagnation/stagnation.service.ts +20 -0
- package/src/core/stagnation/stagnation.store.ts +49 -0
- package/src/core/stagnation/stagnation.types.ts +12 -0
- package/src/core/subagent-policy/subagent-policy.parent-model.ts +202 -0
- package/src/core/subagent-policy/subagent-policy.service.ts +139 -0
- package/src/core/subagent-policy/subagent-policy.types.ts +22 -0
- package/src/core/supply-chain/supply-chain.catalog.ts +51 -0
- package/src/core/supply-chain/supply-chain.service.ts +217 -0
- package/src/core/turn/turn.activity.ts +94 -0
- package/src/core/turn/turn.autopilot.ts +131 -0
- package/src/core/turn/turn.failure-signals.ts +193 -0
- package/src/core/turn/turn.loop-counter.ts +71 -0
- package/src/core/turn/turn.types.ts +14 -0
- package/src/core/untrusted/untrusted.detect.ts +53 -0
- package/src/core/untrusted/untrusted.recall.ts +87 -0
- package/src/core/untrusted/untrusted.service.ts +129 -0
- package/src/core/untrusted/untrusted.store.ts +75 -0
- package/src/core/untrusted/untrusted.types.ts +42 -0
- package/src/entrypoints/compact-before.ts +14 -0
- package/src/entrypoints/prompt-submit.ts +25 -0
- package/src/entrypoints/response-after.ts +60 -0
- package/src/entrypoints/run.ts +179 -0
- package/src/entrypoints/session-end.ts +58 -0
- package/src/entrypoints/session-start.ts +172 -0
- package/src/entrypoints/shim.ts +46 -0
- package/src/entrypoints/stop.ts +876 -0
- package/src/entrypoints/subagent-start.ts +16 -0
- package/src/entrypoints/subagent-stop.ts +32 -0
- package/src/entrypoints/support.ts +182 -0
- package/src/entrypoints/tool-after.ts +111 -0
- package/src/entrypoints/tool-before.ts +186 -0
- package/src/entrypoints/tool-failure.ts +26 -0
- package/src/platform/backoff.ts +49 -0
- package/src/platform/cli-output.ts +30 -0
- package/src/platform/env-scope.ts +22 -0
- package/src/platform/fs-atomic.ts +142 -0
- package/src/platform/fs-jsonl.ts +24 -0
- package/src/platform/git.ts +187 -0
- package/src/platform/paths.ts +80 -0
- package/src/platform/pricing.ts +229 -0
- package/src/platform/process.ts +64 -0
- package/src/platform/sanitize.ts +23 -0
- package/src/platform/screen.ts +46 -0
- package/src/platform/style.ts +103 -0
- package/src/providers/claude/claude.capabilities.ts +25 -0
- package/src/providers/claude/claude.detect.ts +14 -0
- package/src/providers/claude/claude.inbound.ts +252 -0
- package/src/providers/claude/claude.lessons-view.ts +33 -0
- package/src/providers/claude/claude.outbound.ts +65 -0
- package/src/providers/claude/claude.policy-defaults.ts +9 -0
- package/src/providers/claude/claude.transcript.ts +62 -0
- package/src/providers/claude/claude.wiring.ts +245 -0
- package/src/providers/claude/index.ts +17 -0
- package/src/providers/cursor/cursor.capabilities.ts +28 -0
- package/src/providers/cursor/cursor.detect.ts +14 -0
- package/src/providers/cursor/cursor.inbound.ts +203 -0
- package/src/providers/cursor/cursor.lessons-view.ts +30 -0
- package/src/providers/cursor/cursor.outbound.ts +43 -0
- package/src/providers/cursor/cursor.policy-defaults.ts +9 -0
- package/src/providers/cursor/cursor.wiring.ts +214 -0
- package/src/providers/cursor/index.ts +17 -0
- package/src/providers/index.ts +9 -0
- package/src/providers/provider.degrade.ts +152 -0
- package/src/providers/provider.port.ts +20 -0
- package/src/providers/provider.registry.ts +28 -0
- package/tools/doctor.ts +631 -0
- package/tools/help-topic.ts +74 -0
- package/tools/init-project.ts +277 -0
- package/tools/install-runtime.ts +164 -0
- package/tools/lessons-cli.ts +474 -0
- package/tools/obs-cli.ts +175 -0
- package/tools/price-lookup.ts +57 -0
- package/tools/refresh-model-prices.ts +210 -0
- package/tools/test-env.mjs +28 -0
- package/tools/test-env.names.d.mts +1 -0
- package/tools/test-env.names.mjs +14 -0
- package/tools/uninstall-runtime.ts +463 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readTail } from "../../platform/fs-jsonl.ts";
|
|
2
|
+
|
|
3
|
+
export type TranscriptUsage = {
|
|
4
|
+
inputTokens: number;
|
|
5
|
+
outputTokens: number;
|
|
6
|
+
cacheReadTokens: number;
|
|
7
|
+
cacheWriteTokens: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const DEFAULT_TAIL_LINES = 200;
|
|
11
|
+
|
|
12
|
+
function asNumber(value: unknown): number {
|
|
13
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
17
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
18
|
+
? (value as Record<string, unknown>)
|
|
19
|
+
: undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function usageOf(record: unknown): Record<string, unknown> | undefined {
|
|
23
|
+
const message = asRecord(asRecord(record)?.message);
|
|
24
|
+
return asRecord(message?.usage);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Best-effort — the transcript is written asynchronously and may lag, so any read failure degrades to null. */
|
|
28
|
+
export function readClaudeUsage(
|
|
29
|
+
transcriptPath: string | undefined,
|
|
30
|
+
tailLines: number = DEFAULT_TAIL_LINES,
|
|
31
|
+
): TranscriptUsage | null {
|
|
32
|
+
if (!transcriptPath) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let records: unknown[];
|
|
37
|
+
try {
|
|
38
|
+
records = readTail<unknown>(transcriptPath, tailLines);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let inputTokens = 0;
|
|
44
|
+
let outputTokens = 0;
|
|
45
|
+
let cacheReadTokens = 0;
|
|
46
|
+
let cacheWriteTokens = 0;
|
|
47
|
+
let found = false;
|
|
48
|
+
|
|
49
|
+
for (const record of records) {
|
|
50
|
+
const usage = usageOf(record);
|
|
51
|
+
if (!usage) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
found = true;
|
|
55
|
+
inputTokens += asNumber(usage.input_tokens);
|
|
56
|
+
outputTokens += asNumber(usage.output_tokens);
|
|
57
|
+
cacheReadTokens += asNumber(usage.cache_read_input_tokens);
|
|
58
|
+
cacheWriteTokens += asNumber(usage.cache_creation_input_tokens);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return found ? { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens } : null;
|
|
62
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import type { ProviderWiring, RuntimePaths, WiringEntry } from "../../contracts/index.ts";
|
|
4
|
+
import { claudeConfigDir } from "../../platform/paths.ts";
|
|
5
|
+
|
|
6
|
+
type EntrySpec = {
|
|
7
|
+
hookEvent: string;
|
|
8
|
+
handler: string;
|
|
9
|
+
timeoutSeconds: number;
|
|
10
|
+
failClosed?: boolean;
|
|
11
|
+
loopLimit?: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// why: PreToolUse and PostToolUse are each a single dispatcher entry — the fan-out lives in claude.inbound.ts,
|
|
15
|
+
// so Claude never registers a hook per tool the way Cursor registers one per dedicated event.
|
|
16
|
+
const ENTRY_SPECS: readonly EntrySpec[] = [
|
|
17
|
+
{ hookEvent: "SessionStart", handler: "session-start", timeoutSeconds: 10 },
|
|
18
|
+
{ hookEvent: "SessionEnd", handler: "session-end", timeoutSeconds: 10 },
|
|
19
|
+
{ hookEvent: "UserPromptSubmit", handler: "prompt-submit", timeoutSeconds: 5 },
|
|
20
|
+
{ hookEvent: "PreToolUse", handler: "tool-before", timeoutSeconds: 10, failClosed: true },
|
|
21
|
+
{ hookEvent: "PostToolUse", handler: "tool-after", timeoutSeconds: 10 },
|
|
22
|
+
{ hookEvent: "PostToolUseFailure", handler: "tool-failure", timeoutSeconds: 5 },
|
|
23
|
+
{ hookEvent: "SubagentStart", handler: "subagent-start", timeoutSeconds: 5, failClosed: true },
|
|
24
|
+
{ hookEvent: "SubagentStop", handler: "subagent-stop", timeoutSeconds: 5 },
|
|
25
|
+
{ hookEvent: "Stop", handler: "stop", timeoutSeconds: 120, loopLimit: 5 },
|
|
26
|
+
{ hookEvent: "PreCompact", handler: "compact-before", timeoutSeconds: 5 },
|
|
27
|
+
{ hookEvent: "MessageDisplay", handler: "response-after", timeoutSeconds: 5 },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export function claudeSettingsPath(): string {
|
|
31
|
+
return join(claudeConfigDir(), "settings.json");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// why: exec form only, on every platform — it bypasses shell tokenization, so there is no quoting variant to get wrong.
|
|
35
|
+
export function claudeWiring(runtime: RuntimePaths): ProviderWiring {
|
|
36
|
+
const entries: WiringEntry[] = ENTRY_SPECS.map((spec) => ({
|
|
37
|
+
hookEvent: spec.hookEvent,
|
|
38
|
+
handler: spec.handler,
|
|
39
|
+
command: "node",
|
|
40
|
+
args: [runtime.launcherPath, spec.handler],
|
|
41
|
+
timeoutSeconds: spec.timeoutSeconds,
|
|
42
|
+
...(spec.failClosed !== undefined ? { failClosed: spec.failClosed } : {}),
|
|
43
|
+
...(spec.loopLimit !== undefined ? { loopLimit: spec.loopLimit } : {}),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
target: claudeSettingsPath(),
|
|
48
|
+
strategy: "merge",
|
|
49
|
+
entries,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type ClaudeSettingsHookEntry = { type: "command"; command: string; args: string[] };
|
|
54
|
+
export type ClaudeSettingsHookGroup = { hooks: ClaudeSettingsHookEntry[] };
|
|
55
|
+
export type ClaudeSettingsHooks = Record<string, ClaudeSettingsHookGroup[]>;
|
|
56
|
+
|
|
57
|
+
export type MergeSuccess = { ok: true; settingsText: string; changed: boolean };
|
|
58
|
+
export type MergeFailure = { ok: false; error: string; block: string };
|
|
59
|
+
export type MergeResult = MergeSuccess | MergeFailure;
|
|
60
|
+
|
|
61
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
62
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isHooksRecord(value: unknown): value is ClaudeSettingsHooks {
|
|
66
|
+
return isPlainRecord(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
70
|
+
if (a === b) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
74
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return a.every((item, index) => deepEqual(item, b[index]));
|
|
78
|
+
}
|
|
79
|
+
if (isPlainRecord(a) && isPlainRecord(b)) {
|
|
80
|
+
const aKeys = Object.keys(a);
|
|
81
|
+
const bKeys = Object.keys(b);
|
|
82
|
+
if (aKeys.length !== bKeys.length) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
return aKeys.every((key) => bKeys.includes(key) && deepEqual(a[key], b[key]));
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function desiredHooksFor(entries: readonly WiringEntry[]): ClaudeSettingsHooks {
|
|
91
|
+
const hooks: ClaudeSettingsHooks = {};
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
const group: ClaudeSettingsHookGroup = {
|
|
94
|
+
hooks: [{ type: "command", command: entry.command, args: entry.args }],
|
|
95
|
+
};
|
|
96
|
+
hooks[entry.hookEvent] = [...(hooks[entry.hookEvent] ?? []), group];
|
|
97
|
+
}
|
|
98
|
+
return hooks;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const LAUNCHER_MARKER = "tlc-exec.mjs";
|
|
102
|
+
|
|
103
|
+
export function isHarnessGroup(group: unknown): boolean {
|
|
104
|
+
return JSON.stringify(group ?? null).includes(LAUNCHER_MARKER);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function canonicalLauncherPath(path: string, resolve: (p: string) => string = realpathSync): string {
|
|
108
|
+
try {
|
|
109
|
+
return resolve(path);
|
|
110
|
+
} catch {
|
|
111
|
+
return path;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// hazard: an installation reached through a symlink writes one path into settings.json and resolves another
|
|
116
|
+
// at runtime, so a structural comparison reports the wiring as broken every time. Comparing the launcher by
|
|
117
|
+
// the file it actually points at is what makes "wired" mean wired, and stops every update from rewriting
|
|
118
|
+
// a settings.json that was already correct.
|
|
119
|
+
export function canonicalizeGroups(groups: unknown, resolve?: (p: string) => string): unknown {
|
|
120
|
+
return JSON.parse(
|
|
121
|
+
JSON.stringify(groups ?? null, (_key, value) =>
|
|
122
|
+
typeof value === "string" && value.includes(LAUNCHER_MARKER)
|
|
123
|
+
? canonicalLauncherPath(value, resolve)
|
|
124
|
+
: value,
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function mergeClaudeSettings(
|
|
130
|
+
existingText: string | null,
|
|
131
|
+
entries: readonly WiringEntry[],
|
|
132
|
+
): MergeResult {
|
|
133
|
+
const desired = desiredHooksFor(entries);
|
|
134
|
+
|
|
135
|
+
let settings: Record<string, unknown> = {};
|
|
136
|
+
if (existingText !== null && existingText.trim() !== "") {
|
|
137
|
+
let parsed: unknown;
|
|
138
|
+
try {
|
|
139
|
+
parsed = JSON.parse(existingText);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
142
|
+
return { ok: false, error: message, block: JSON.stringify({ hooks: desired }, null, 2) };
|
|
143
|
+
}
|
|
144
|
+
if (!isPlainRecord(parsed)) {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
error: "settings.json root is not a JSON object",
|
|
148
|
+
block: JSON.stringify({ hooks: desired }, null, 2),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
settings = parsed;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const currentHooks = isHooksRecord(settings.hooks) ? settings.hooks : {};
|
|
155
|
+
const mergedHooks: ClaudeSettingsHooks = { ...currentHooks };
|
|
156
|
+
let changed = false;
|
|
157
|
+
|
|
158
|
+
for (const [hookEvent, groups] of Object.entries(desired)) {
|
|
159
|
+
const existingGroups = mergedHooks[hookEvent] ?? [];
|
|
160
|
+
// hazard: appending only what is missing leaves a stale copy behind when the launcher path
|
|
161
|
+
// changes, and every hook then fires twice. Ours are replaced wholesale; foreign ones stay.
|
|
162
|
+
const foreign = existingGroups.filter((group) => !isHarnessGroup(group));
|
|
163
|
+
const nextGroups = [...foreign, ...groups];
|
|
164
|
+
if (!deepEqual(canonicalizeGroups(existingGroups), canonicalizeGroups(nextGroups))) {
|
|
165
|
+
changed = true;
|
|
166
|
+
}
|
|
167
|
+
mergedHooks[hookEvent] = nextGroups;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const mergedSettings = { ...settings, hooks: mergedHooks };
|
|
171
|
+
return { ok: true, settingsText: JSON.stringify(mergedSettings, null, 2), changed };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The exact inverse of `mergeClaudeSettings`: every group `isHarnessGroup` matches leaves, everything else stays
|
|
176
|
+
* where it was.
|
|
177
|
+
*
|
|
178
|
+
* hazard: this file is the operator's, not ours. It carries `permissions`, `sandbox`, `env` and `statusLine`
|
|
179
|
+
* alongside our eleven hook groups, so deleting it — or rewriting it from a template — destroys work the
|
|
180
|
+
* installer never wrote. Only groups are dropped, and only ours ([/decisions/ad-066.md](/decisions/ad-066.md)).
|
|
181
|
+
*/
|
|
182
|
+
export function unmergeClaudeSettings(existingText: string | null): MergeResult {
|
|
183
|
+
if (existingText === null || existingText.trim() === "") {
|
|
184
|
+
return { ok: true, settingsText: "", changed: false };
|
|
185
|
+
}
|
|
186
|
+
let parsed: unknown;
|
|
187
|
+
try {
|
|
188
|
+
parsed = JSON.parse(existingText);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
191
|
+
return { ok: false, error: message, block: "" };
|
|
192
|
+
}
|
|
193
|
+
if (!isPlainRecord(parsed)) {
|
|
194
|
+
return { ok: false, error: "settings.json root is not a JSON object", block: "" };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const currentHooks = isHooksRecord(parsed.hooks) ? parsed.hooks : {};
|
|
198
|
+
const remainingHooks: ClaudeSettingsHooks = {};
|
|
199
|
+
let changed = false;
|
|
200
|
+
|
|
201
|
+
for (const [hookEvent, groups] of Object.entries(currentHooks)) {
|
|
202
|
+
const foreign = groups.filter((group) => !isHarnessGroup(group));
|
|
203
|
+
if (foreign.length !== groups.length) {
|
|
204
|
+
changed = true;
|
|
205
|
+
}
|
|
206
|
+
// invariant: an event whose only groups were ours loses its key. Leaving `"Stop": []` behind is residue that
|
|
207
|
+
// reads as configuration, and it is exactly what makes a second run report work still to do.
|
|
208
|
+
if (foreign.length > 0) {
|
|
209
|
+
remainingHooks[hookEvent] = foreign;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// why: rebuilt key by key rather than spread-and-override, so `hooks` keeps its position. An operator diffing
|
|
214
|
+
// their own settings.json after this should see the hook groups leave and nothing else move.
|
|
215
|
+
const next: Record<string, unknown> = {};
|
|
216
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
217
|
+
if (key !== "hooks") {
|
|
218
|
+
next[key] = value;
|
|
219
|
+
} else if (Object.keys(remainingHooks).length > 0) {
|
|
220
|
+
next.hooks = remainingHooks;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return { ok: true, settingsText: JSON.stringify(next, null, 2), changed };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function removeClaudeWiring(settingsPath: string): MergeResult {
|
|
227
|
+
if (!existsSync(settingsPath)) {
|
|
228
|
+
return { ok: true, settingsText: "", changed: false };
|
|
229
|
+
}
|
|
230
|
+
const result = unmergeClaudeSettings(readFileSync(settingsPath, "utf8"));
|
|
231
|
+
if (result.ok && result.changed) {
|
|
232
|
+
writeFileSync(settingsPath, result.settingsText, "utf8");
|
|
233
|
+
}
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function applyClaudeWiring(settingsPath: string, entries: readonly WiringEntry[]): MergeResult {
|
|
238
|
+
const existingText = existsSync(settingsPath) ? readFileSync(settingsPath, "utf8") : null;
|
|
239
|
+
const result = mergeClaudeSettings(existingText, entries);
|
|
240
|
+
if (result.ok && result.changed) {
|
|
241
|
+
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
242
|
+
writeFileSync(settingsPath, result.settingsText, "utf8");
|
|
243
|
+
}
|
|
244
|
+
return result;
|
|
245
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ProviderPort } from "../provider.port.ts";
|
|
2
|
+
import { claudeCapabilities } from "./claude.capabilities.ts";
|
|
3
|
+
import { detectClaude } from "./claude.detect.ts";
|
|
4
|
+
import { claudeToEvent } from "./claude.inbound.ts";
|
|
5
|
+
import { claudeRender } from "./claude.outbound.ts";
|
|
6
|
+
import { claudePolicyDefaults } from "./claude.policy-defaults.ts";
|
|
7
|
+
import { claudeWiring } from "./claude.wiring.ts";
|
|
8
|
+
|
|
9
|
+
export const claudeProvider: ProviderPort = {
|
|
10
|
+
name: "claude",
|
|
11
|
+
detect: detectClaude,
|
|
12
|
+
capabilities: claudeCapabilities,
|
|
13
|
+
policyDefaults: claudePolicyDefaults,
|
|
14
|
+
toEvent: claudeToEvent,
|
|
15
|
+
render: claudeRender,
|
|
16
|
+
wiring: claudeWiring,
|
|
17
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ProviderCapabilities } from "../../contracts/index.ts";
|
|
2
|
+
|
|
3
|
+
export function cursorCapabilities(): ProviderCapabilities {
|
|
4
|
+
return {
|
|
5
|
+
enforcesHooks: true,
|
|
6
|
+
askSupportedOn: ["shell.before", "mcp.before"],
|
|
7
|
+
sessionEnv: true,
|
|
8
|
+
nativeLoopCounter: true,
|
|
9
|
+
dedicatedShellEvent: true,
|
|
10
|
+
toolInputRewrite: true,
|
|
11
|
+
toolOutputRewrite: true,
|
|
12
|
+
contextAtToolBefore: false,
|
|
13
|
+
contextAtToolAfter: true,
|
|
14
|
+
// why: the `stop` output schema carries `followup_message` and nothing else, so a context decision raised there
|
|
15
|
+
// has no field to travel in. `followup_message` is not the fallback — it auto-submits.
|
|
16
|
+
contextAtStop: false,
|
|
17
|
+
// hazard: the field exists and is documented, and the hook log says "Merged 1 valid response(s)". It is still
|
|
18
|
+
// dropped: Cursor staff called it "a bug on our side… a timing issue between when the hook runs and when the
|
|
19
|
+
// composer handle is created" (forum thread 158452, 2026-04-20), and it was reported again against 3.14.7 on
|
|
20
|
+
// 2026-08-02 with no changelog entry fixing it. `env` on the same payload arrives, because that is a different
|
|
21
|
+
// code path ([/decisions/ad-050.md](/decisions/ad-050.md)).
|
|
22
|
+
sessionStartContextReliable: false,
|
|
23
|
+
toolOutputAtAfter: true,
|
|
24
|
+
usageInPayload: true,
|
|
25
|
+
effortSignal: false,
|
|
26
|
+
thoughtEvent: true,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// why: Cursor payloads use camelCase hook_event_name + workspace_roots; Claude payloads don't.
|
|
2
|
+
const CAMEL_CASE_EVENT_NAME = /^[a-z][a-zA-Z0-9]*$/;
|
|
3
|
+
|
|
4
|
+
export function detectCursor(raw: unknown): boolean {
|
|
5
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
const value = raw as Record<string, unknown>;
|
|
9
|
+
const eventName = value.hook_event_name;
|
|
10
|
+
if (typeof eventName !== "string" || !CAMEL_CASE_EVENT_NAME.test(eventName)) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
return Array.isArray(value.workspace_roots);
|
|
14
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { HarnessEvent, HarnessEventKind } from "../../contracts/index.ts";
|
|
2
|
+
import { sanitizeSegment } from "../../platform/sanitize.ts";
|
|
3
|
+
|
|
4
|
+
const EVENT_KIND_BY_HOOK: Record<string, HarnessEventKind> = {
|
|
5
|
+
sessionStart: "session.start",
|
|
6
|
+
sessionEnd: "session.end",
|
|
7
|
+
beforeSubmitPrompt: "prompt.submit",
|
|
8
|
+
preToolUse: "tool.before",
|
|
9
|
+
postToolUse: "tool.after",
|
|
10
|
+
postToolUseFailure: "tool.failure",
|
|
11
|
+
beforeShellExecution: "shell.before",
|
|
12
|
+
afterShellExecution: "shell.after",
|
|
13
|
+
beforeMCPExecution: "mcp.before",
|
|
14
|
+
afterMCPExecution: "mcp.after",
|
|
15
|
+
beforeReadFile: "read.before",
|
|
16
|
+
afterFileEdit: "edit.after",
|
|
17
|
+
subagentStart: "subagent.start",
|
|
18
|
+
subagentStop: "subagent.stop",
|
|
19
|
+
stop: "stop",
|
|
20
|
+
preCompact: "compact.before",
|
|
21
|
+
afterAgentResponse: "response.after",
|
|
22
|
+
afterAgentThought: "thought.after",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function asString(value: unknown): string | undefined {
|
|
26
|
+
return typeof value === "string" ? value : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function asNumber(value: unknown): number | undefined {
|
|
30
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
34
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
35
|
+
? (value as Record<string, unknown>)
|
|
36
|
+
: undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function asStatus(value: unknown): "completed" | "aborted" | "error" | undefined {
|
|
40
|
+
return value === "completed" || value === "aborted" || value === "error" ? value : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sessionKeyFor(raw: Record<string, unknown>): string {
|
|
44
|
+
const seed = asString(raw.conversation_id) || asString(raw.session_id) || "default";
|
|
45
|
+
return `cursor-${sanitizeSegment(seed)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function projectDirFor(raw: Record<string, unknown>): string {
|
|
49
|
+
const envDir = process.env.CURSOR_PROJECT_DIR;
|
|
50
|
+
if (envDir) {
|
|
51
|
+
return envDir;
|
|
52
|
+
}
|
|
53
|
+
const roots = raw.workspace_roots;
|
|
54
|
+
if (Array.isArray(roots)) {
|
|
55
|
+
const first = asString(roots[0]);
|
|
56
|
+
if (first) {
|
|
57
|
+
return first;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return process.cwd();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Never throws on a malformed payload — returns null instead. */
|
|
64
|
+
export function cursorToEvent(raw: Record<string, unknown>): HarnessEvent | null {
|
|
65
|
+
const hookEventName = asString(raw.hook_event_name);
|
|
66
|
+
const eventKind = hookEventName ? EVENT_KIND_BY_HOOK[hookEventName] : undefined;
|
|
67
|
+
if (!eventKind) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const event: HarnessEvent = {
|
|
72
|
+
provider: "cursor",
|
|
73
|
+
event: eventKind,
|
|
74
|
+
sessionKey: sessionKeyFor(raw),
|
|
75
|
+
projectDir: projectDirFor(raw),
|
|
76
|
+
raw,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const model = asString(raw.model);
|
|
80
|
+
if (model) {
|
|
81
|
+
event.model = model;
|
|
82
|
+
}
|
|
83
|
+
const contextUsagePercent = asNumber(raw.context_usage_percent);
|
|
84
|
+
if (contextUsagePercent !== undefined) {
|
|
85
|
+
event.contextUsagePercent = contextUsagePercent;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
switch (eventKind) {
|
|
89
|
+
case "prompt.submit": {
|
|
90
|
+
const text = asString(raw.prompt);
|
|
91
|
+
if (text !== undefined) {
|
|
92
|
+
event.text = text;
|
|
93
|
+
}
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
case "response.after": {
|
|
97
|
+
const text = asString(raw.text);
|
|
98
|
+
if (text !== undefined) {
|
|
99
|
+
event.text = text;
|
|
100
|
+
}
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
case "thought.after": {
|
|
104
|
+
const text = asString(raw.thought) ?? asString(raw.text);
|
|
105
|
+
if (text !== undefined) {
|
|
106
|
+
event.text = text;
|
|
107
|
+
}
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
case "tool.before":
|
|
111
|
+
case "tool.after":
|
|
112
|
+
case "tool.failure": {
|
|
113
|
+
const toolName = asString(raw.tool_name);
|
|
114
|
+
if (toolName) {
|
|
115
|
+
event.toolName = toolName;
|
|
116
|
+
}
|
|
117
|
+
const toolInput = asRecord(raw.tool_input);
|
|
118
|
+
if (toolInput) {
|
|
119
|
+
event.toolInput = toolInput;
|
|
120
|
+
}
|
|
121
|
+
const subagentType = asString(raw.subagent_type);
|
|
122
|
+
if (subagentType) {
|
|
123
|
+
event.subagentType = subagentType;
|
|
124
|
+
}
|
|
125
|
+
const toolSpawnModel = toolInput ? asString(toolInput.model) : undefined;
|
|
126
|
+
if (toolSpawnModel) {
|
|
127
|
+
event.spawnModel = toolSpawnModel;
|
|
128
|
+
}
|
|
129
|
+
const toolSpawnType = toolInput ? asString(toolInput.subagent_type) : undefined;
|
|
130
|
+
if (toolSpawnType) {
|
|
131
|
+
event.spawnSubagentType = toolSpawnType;
|
|
132
|
+
}
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
case "shell.before":
|
|
136
|
+
case "shell.after": {
|
|
137
|
+
const command = asString(raw.command);
|
|
138
|
+
if (command !== undefined) {
|
|
139
|
+
event.command = command;
|
|
140
|
+
}
|
|
141
|
+
const output = asString(raw.output);
|
|
142
|
+
if (output !== undefined) {
|
|
143
|
+
event.toolOutput = output;
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case "mcp.before":
|
|
148
|
+
case "mcp.after": {
|
|
149
|
+
const resultJson = asString(raw.result_json);
|
|
150
|
+
if (resultJson !== undefined) {
|
|
151
|
+
event.toolOutput = resultJson;
|
|
152
|
+
}
|
|
153
|
+
const toolName = asString(raw.tool_name);
|
|
154
|
+
if (toolName) {
|
|
155
|
+
event.toolName = toolName;
|
|
156
|
+
}
|
|
157
|
+
const toolInput = asRecord(raw.tool_input);
|
|
158
|
+
if (toolInput) {
|
|
159
|
+
event.toolInput = toolInput;
|
|
160
|
+
}
|
|
161
|
+
const command = asString(raw.command);
|
|
162
|
+
if (command !== undefined) {
|
|
163
|
+
event.command = command;
|
|
164
|
+
}
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case "read.before":
|
|
168
|
+
case "edit.after": {
|
|
169
|
+
const filePath = asString(raw.file_path);
|
|
170
|
+
if (filePath !== undefined) {
|
|
171
|
+
event.filePath = filePath;
|
|
172
|
+
}
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
case "subagent.start":
|
|
176
|
+
case "subagent.stop": {
|
|
177
|
+
const spawnSubagentType = asString(raw.subagent_type);
|
|
178
|
+
if (spawnSubagentType) {
|
|
179
|
+
event.spawnSubagentType = spawnSubagentType;
|
|
180
|
+
}
|
|
181
|
+
const spawnModel = asString(raw.subagent_model);
|
|
182
|
+
if (spawnModel) {
|
|
183
|
+
event.spawnModel = spawnModel;
|
|
184
|
+
}
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case "stop": {
|
|
188
|
+
const status = asStatus(raw.status);
|
|
189
|
+
if (status) {
|
|
190
|
+
event.status = status;
|
|
191
|
+
}
|
|
192
|
+
const loopCount = asNumber(raw.loop_count);
|
|
193
|
+
if (loopCount !== undefined) {
|
|
194
|
+
event.loopCount = loopCount;
|
|
195
|
+
}
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
default:
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return event;
|
|
203
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { projectConfigPath } from "../../platform/paths.ts";
|
|
4
|
+
|
|
5
|
+
// why: mirrors core/lesson/lesson.garden.ts's lessonsMarkdownPath — providers cannot import core, so the join is duplicated.
|
|
6
|
+
export function cursorLessonsSourcePath(root: string): string {
|
|
7
|
+
return join(dirname(projectConfigPath(root)), "lessons.md");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function cursorLessonsViewPath(root: string): string {
|
|
11
|
+
return join(root, ".cursor", "rules", "harness-lessons.mdc");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function renderCursorLessonsView(root: string): string | null {
|
|
15
|
+
const sourcePath = cursorLessonsSourcePath(root);
|
|
16
|
+
if (!existsSync(sourcePath)) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const body = readFileSync(sourcePath, "utf8");
|
|
20
|
+
const path = cursorLessonsViewPath(root);
|
|
21
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
22
|
+
const content = `---
|
|
23
|
+
description: Harness-learned lessons (auto-synced from gate failures)
|
|
24
|
+
alwaysApply: true
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
${body}`;
|
|
28
|
+
writeFileSync(path, content, "utf8");
|
|
29
|
+
return path;
|
|
30
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Decision, HarnessEvent, Rendered } from "../../contracts/index.ts";
|
|
2
|
+
|
|
3
|
+
// why: key order (permission, user_message, agent_message) matches the captured golden byte-for-byte.
|
|
4
|
+
function renderDenyOrAsk(
|
|
5
|
+
permission: "deny" | "ask",
|
|
6
|
+
decision: { reason: string; userNote?: string },
|
|
7
|
+
): string {
|
|
8
|
+
const body: Record<string, unknown> = { permission };
|
|
9
|
+
if (decision.userNote !== undefined) {
|
|
10
|
+
body.user_message = decision.userNote;
|
|
11
|
+
}
|
|
12
|
+
body.agent_message = decision.reason;
|
|
13
|
+
return JSON.stringify(body);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function cursorRender(decision: Decision, _event: HarnessEvent): Rendered {
|
|
17
|
+
switch (decision.kind) {
|
|
18
|
+
case "abstain":
|
|
19
|
+
return { stdout: "{}", exitCode: 0 };
|
|
20
|
+
case "allow":
|
|
21
|
+
return { stdout: JSON.stringify({ permission: "allow" }), exitCode: 0 };
|
|
22
|
+
case "deny":
|
|
23
|
+
return { stdout: renderDenyOrAsk("deny", decision), exitCode: 0 };
|
|
24
|
+
case "ask":
|
|
25
|
+
return { stdout: renderDenyOrAsk("ask", decision), exitCode: 0 };
|
|
26
|
+
case "context": {
|
|
27
|
+
const body: Record<string, unknown> = {};
|
|
28
|
+
if (decision.env !== undefined) {
|
|
29
|
+
body.env = decision.env;
|
|
30
|
+
}
|
|
31
|
+
body.additional_context = decision.text;
|
|
32
|
+
return { stdout: JSON.stringify(body), exitCode: 0 };
|
|
33
|
+
}
|
|
34
|
+
case "continue":
|
|
35
|
+
return { stdout: JSON.stringify({ followup_message: decision.text }), exitCode: 0 };
|
|
36
|
+
case "rewriteInput":
|
|
37
|
+
return { stdout: JSON.stringify({ updated_input: decision.input }), exitCode: 0 };
|
|
38
|
+
default: {
|
|
39
|
+
const exhaustive: never = decision;
|
|
40
|
+
throw new Error(`unreachable decision kind: ${JSON.stringify(exhaustive)}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ProviderPolicyDefaults } from "../../contracts/index.ts";
|
|
2
|
+
|
|
3
|
+
export function cursorPolicyDefaults(): ProviderPolicyDefaults {
|
|
4
|
+
return {
|
|
5
|
+
blockedPatterns: ["-fast(?:$|[^a-z0-9])", "/fast(?:$|[^a-z0-9])", "composer-2\\.5-fast"],
|
|
6
|
+
minEffort: null,
|
|
7
|
+
untrustedTools: ["Fetch", "WebSearch"],
|
|
8
|
+
};
|
|
9
|
+
}
|