@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,16 @@
|
|
|
1
|
+
import type { Decision, HarnessEvent } from "../contracts/index.ts";
|
|
2
|
+
import { coreFacade } from "../core/index.ts";
|
|
3
|
+
import type { Handler, HandlerContext } from "./run.ts";
|
|
4
|
+
import { main } from "./run.ts";
|
|
5
|
+
import { subagentSpawnInput } from "./support.ts";
|
|
6
|
+
|
|
7
|
+
export const subagentStartHandler: Handler = (event: HarnessEvent, ctx: HandlerContext): Decision => {
|
|
8
|
+
const { policy, provider } = ctx;
|
|
9
|
+
return coreFacade.subagentPolicy.evaluateSubagentSpawn(
|
|
10
|
+
subagentSpawnInput(event, policy, provider, event.spawnModel ?? ""),
|
|
11
|
+
);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
if (import.meta.main) {
|
|
15
|
+
await main(subagentStartHandler);
|
|
16
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Decision, HarnessEvent } from "../contracts/index.ts";
|
|
2
|
+
import { coreFacade } from "../core/index.ts";
|
|
3
|
+
import type { Handler, HandlerContext } from "./run.ts";
|
|
4
|
+
import { main } from "./run.ts";
|
|
5
|
+
|
|
6
|
+
// why: no legacy predecessor covers subagent.stop verification — this reuses the same unfinished-work
|
|
7
|
+
// signal (blockers/pending/in_progress/previous_gaps) already carried on the handoff slice.
|
|
8
|
+
export const subagentStopHandler: Handler = (event: HarnessEvent, _ctx: HandlerContext): Decision => {
|
|
9
|
+
const handoff = coreFacade.handoff.readHandoff(event.projectDir, event.provider);
|
|
10
|
+
const unfinishedWork =
|
|
11
|
+
Boolean(handoff.blockers) ||
|
|
12
|
+
Boolean(handoff.previous_gaps?.length) ||
|
|
13
|
+
Boolean(handoff.pending?.length) ||
|
|
14
|
+
Boolean(handoff.in_progress?.length);
|
|
15
|
+
|
|
16
|
+
if (!unfinishedWork) {
|
|
17
|
+
return { kind: "abstain" };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
kind: "continue",
|
|
22
|
+
text: [
|
|
23
|
+
"BLOCKED: unfinished work remains before this subagent stops.",
|
|
24
|
+
`TRIED: subagent (${event.spawnSubagentType ?? "unknown"}) reported stop.`,
|
|
25
|
+
`NEED: ${handoff.next_action ?? "resolve the open blockers before ending this subagent turn."}`,
|
|
26
|
+
].join("\n"),
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
if (import.meta.main) {
|
|
31
|
+
await main(subagentStopHandler);
|
|
32
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { EffortLevel, HarnessEvent } from "../contracts/index.ts";
|
|
4
|
+
import { coreFacade, type HarnessLesson, type ObservabilityConfig, type Policy } from "../core/index.ts";
|
|
5
|
+
import { runProcess } from "../platform/process.ts";
|
|
6
|
+
import { type ProviderPort, renderClaudeLessonsView, renderCursorLessonsView } from "../providers/index.ts";
|
|
7
|
+
|
|
8
|
+
// invariant: one definition, taken from core rather than restated.
|
|
9
|
+
export const OBS_CONFIG = coreFacade.observability.DEFAULT_OBS;
|
|
10
|
+
|
|
11
|
+
// why: tool.end, shell.end, mcp.end and file.edit are debug-level kinds, so the passive audit trail only
|
|
12
|
+
// persists when debug writing is on. The difference from OBS_CONFIG is stated here once instead of being
|
|
13
|
+
// re-declared per entrypoint.
|
|
14
|
+
export const OBS_CONFIG_AUDIT = { ...OBS_CONFIG, debugEnabled: true };
|
|
15
|
+
|
|
16
|
+
// why: the base configs are module constants, so the one operator-controlled field has to be layered on
|
|
17
|
+
// per call rather than baked in at import time.
|
|
18
|
+
export function obsConfigFor(
|
|
19
|
+
policy: { obs: Policy["obs"] },
|
|
20
|
+
base: ObservabilityConfig = OBS_CONFIG,
|
|
21
|
+
): ObservabilityConfig {
|
|
22
|
+
return {
|
|
23
|
+
...base,
|
|
24
|
+
globalSpool: policy.obs.globalSpool,
|
|
25
|
+
// why: debugEnabled is deliberately absent from Policy.obs. The only events that resolve to debug level
|
|
26
|
+
// are emitted with OBS_CONFIG_AUDIT, which forces it on for the audit trail (AD-016 item 7), so there is
|
|
27
|
+
// nothing a project could switch. Exposing it would repeat the dead-section mistake this replaces.
|
|
28
|
+
includePayloads: policy.obs.includePayloads,
|
|
29
|
+
maxAttrChars: policy.obs.maxAttrChars,
|
|
30
|
+
sessionCostAlertUsd: policy.obs.sessionCostAlertUsd,
|
|
31
|
+
retentionDays: policy.obs.retentionDays,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Characters on disk, or zero when the file went away between the write and the read. */
|
|
36
|
+
export function sizeOf(path: string): number {
|
|
37
|
+
try {
|
|
38
|
+
return statSync(path).size;
|
|
39
|
+
} catch {
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function sessionIdFromKey(event: HarnessEvent): string {
|
|
45
|
+
const prefix = `${event.provider}-`;
|
|
46
|
+
return event.sessionKey.startsWith(prefix) ? event.sessionKey.slice(prefix.length) : event.sessionKey;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function currentGitBranch(root: string): Promise<string | null> {
|
|
50
|
+
if (!existsSync(join(root, ".git"))) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
const result = await runProcess({ command: ["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd: root });
|
|
54
|
+
if (result.exitCode !== 0) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const branch = result.stdout.trim();
|
|
58
|
+
return branch.length > 0 ? branch : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function currentGitSha(root: string): Promise<string | null> {
|
|
62
|
+
if (!existsSync(join(root, ".git"))) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const result = await runProcess({ command: ["git", "rev-parse", "--short", "HEAD"], cwd: root });
|
|
66
|
+
if (result.exitCode !== 0) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const sha = result.stdout.trim();
|
|
70
|
+
return sha.length > 0 ? sha : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* hazard: this fell back to a shipped list whenever the project's was empty, so a spawn could be refused by an
|
|
75
|
+
* allowlist that exists nowhere in the project — and the refusal named no source, so an operator reading `[]` in
|
|
76
|
+
* their own config could only conclude that empty meant none. There is no shipped list now: the effective one is
|
|
77
|
+
* exactly what the project configured ([/decisions/ad-053.md](/decisions/ad-053.md)).
|
|
78
|
+
*/
|
|
79
|
+
export function effectiveAllowedModels(
|
|
80
|
+
configured: string[] | Record<string, string[]> | undefined,
|
|
81
|
+
provider: ProviderPort,
|
|
82
|
+
): string[] {
|
|
83
|
+
return coreFacade.policy.forProvider(configured, provider.name) ?? [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function effectiveBlockedPatterns(
|
|
87
|
+
configured: string[] | Record<string, string[]> | undefined,
|
|
88
|
+
provider: ProviderPort,
|
|
89
|
+
): string[] {
|
|
90
|
+
const fromConfig = coreFacade.policy.forProvider(configured, provider.name) ?? [];
|
|
91
|
+
return [...fromConfig, ...provider.policyDefaults().blockedPatterns];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function effectiveMinEffort(
|
|
95
|
+
configured: EffortLevel | null,
|
|
96
|
+
provider: ProviderPort,
|
|
97
|
+
): EffortLevel | null {
|
|
98
|
+
return configured ?? provider.policyDefaults().minEffort;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Everything `evaluateSubagentSpawn` needs about a spawn, assembled once.
|
|
103
|
+
*
|
|
104
|
+
* hazard: `subagent-start` and `tool-before` each built this object, seven identical lines apart from the
|
|
105
|
+
* indentation, so a new field in `policy.subagents` had to be remembered in two places — the shape where a
|
|
106
|
+
* consumer stops growing with its producer ([/decisions/ad-065.md](/decisions/ad-065.md)). The duplication rail
|
|
107
|
+
* found it on its first honest run ([/decisions/ad-071.md](/decisions/ad-071.md)).
|
|
108
|
+
*/
|
|
109
|
+
export function subagentSpawnInput(
|
|
110
|
+
event: HarnessEvent,
|
|
111
|
+
policy: Policy,
|
|
112
|
+
provider: ProviderPort,
|
|
113
|
+
model: string,
|
|
114
|
+
): Parameters<typeof coreFacade.subagentPolicy.evaluateSubagentSpawn>[0] {
|
|
115
|
+
return {
|
|
116
|
+
provider: provider.name,
|
|
117
|
+
sessionKey: event.sessionKey,
|
|
118
|
+
projectDir: event.projectDir,
|
|
119
|
+
model,
|
|
120
|
+
effort: event.effort,
|
|
121
|
+
allowedModels: effectiveAllowedModels(policy.subagents.allowedModels, provider),
|
|
122
|
+
blockedPatterns: effectiveBlockedPatterns(policy.subagents.blockedPatterns, provider),
|
|
123
|
+
minEffort: effectiveMinEffort(policy.subagents.minEffort, provider),
|
|
124
|
+
requireModel: policy.subagents.requireModel,
|
|
125
|
+
enforceAllowlist: policy.subagents.enforceAllowlist,
|
|
126
|
+
blockParentFast: policy.subagents.blockParentFast,
|
|
127
|
+
blockMode: policy.subagents.blockMode,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function readModelFromToolInput(toolInput: Record<string, unknown> | undefined): string {
|
|
132
|
+
if (!toolInput) {
|
|
133
|
+
return "";
|
|
134
|
+
}
|
|
135
|
+
const model = toolInput.model ?? toolInput.Model;
|
|
136
|
+
return typeof model === "string" ? model : "";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* hazard: this used to be a copy of the core renderer, on the reasoning that presentation is not core's business.
|
|
141
|
+
* The copy is what the model actually receives, so the tier added to the core block rendered in `lessons list` and
|
|
142
|
+
* in nothing an agent ever saw. Two renderers for one string is the same defect as a consumer without a producer,
|
|
143
|
+
* pointed sideways ([/decisions/ad-040.md](/decisions/ad-040.md)).
|
|
144
|
+
*/
|
|
145
|
+
export function renderLessonLine(lesson: HarnessLesson): string {
|
|
146
|
+
return coreFacade.lesson.renderLessonBlock(lesson);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* invariant: one dispatcher, imported by both session entrypoints. The durable view is written at session start and
|
|
151
|
+
* again at session end, and a copy of this switch in each would be the AD-042 defect a second time.
|
|
152
|
+
*/
|
|
153
|
+
export function renderProviderLessonsView(providerName: string, root: string): string | null {
|
|
154
|
+
if (providerName === "cursor") {
|
|
155
|
+
return renderCursorLessonsView(root);
|
|
156
|
+
}
|
|
157
|
+
if (providerName === "claude") {
|
|
158
|
+
return renderClaudeLessonsView(root);
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* why: `omitted` is rendered because the char budget silently cuts below `maxInjectSession` — the count promises
|
|
165
|
+
* five and a 900-char budget fits about two. A reader who cannot tell that eligible lessons were dropped has no
|
|
166
|
+
* way to know the budget is the binding constraint ([/decisions/ad-043.md](/decisions/ad-043.md)).
|
|
167
|
+
*
|
|
168
|
+
* invariant: silent when nothing was dropped. A note on every healthy turn is one more line to skim past.
|
|
169
|
+
*/
|
|
170
|
+
export function formatLessonsBlock(lessons: HarnessLesson[], title: string, omitted = 0): string {
|
|
171
|
+
if (lessons.length === 0) {
|
|
172
|
+
return "";
|
|
173
|
+
}
|
|
174
|
+
const lines = [title, ...lessons.map(renderLessonLine)];
|
|
175
|
+
if (omitted > 0) {
|
|
176
|
+
const noun = omitted === 1 ? "lesson" : "lessons";
|
|
177
|
+
lines.push(
|
|
178
|
+
` (${omitted} more eligible ${noun} omitted under the char budget — raise maxCharsSession to see them)`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
return lines.join("\n");
|
|
182
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { HarnessEvent, HarnessEventKind } from "../contracts/index.ts";
|
|
2
|
+
import { coreFacade, type ObsKind } from "../core/index.ts";
|
|
3
|
+
import { estimateCostUsd, mapPoolToNeutral } from "../platform/pricing.ts";
|
|
4
|
+
import { readClaudeUsage } from "../providers/index.ts";
|
|
5
|
+
import type { Handler, HandlerContext } from "./run.ts";
|
|
6
|
+
import { main } from "./run.ts";
|
|
7
|
+
import { OBS_CONFIG_AUDIT, obsConfigFor } from "./support.ts";
|
|
8
|
+
|
|
9
|
+
const OBS_KIND_BY_EVENT: Partial<Record<HarnessEventKind, ObsKind>> = {
|
|
10
|
+
"tool.after": "tool.end",
|
|
11
|
+
"shell.after": "shell.end",
|
|
12
|
+
"mcp.after": "mcp.end",
|
|
13
|
+
"edit.after": "file.edit",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function rawString(raw: Record<string, unknown>, key: string): string | undefined {
|
|
17
|
+
const value = raw[key];
|
|
18
|
+
return typeof value === "string" ? value : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function rawBoolean(raw: Record<string, unknown>, key: string): boolean | undefined {
|
|
22
|
+
const value = raw[key];
|
|
23
|
+
return typeof value === "boolean" ? value : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function usageGenAi(event: HarnessEvent, ctx: HandlerContext): Record<string, unknown> | undefined {
|
|
27
|
+
if (ctx.capabilities.usageInPayload || !event.transcriptPath) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const usage = readClaudeUsage(event.transcriptPath);
|
|
31
|
+
if (!usage) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
const cost = estimateCostUsd(event.provider, event.model, {
|
|
35
|
+
inputTokens: usage.inputTokens,
|
|
36
|
+
outputTokens: usage.outputTokens,
|
|
37
|
+
cacheReadTokens: usage.cacheReadTokens,
|
|
38
|
+
cacheWriteTokens: usage.cacheWriteTokens,
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
input_tokens: usage.inputTokens,
|
|
42
|
+
output_tokens: usage.outputTokens,
|
|
43
|
+
cache_read_tokens: usage.cacheReadTokens,
|
|
44
|
+
cache_write_tokens: usage.cacheWriteTokens,
|
|
45
|
+
cost_usd: cost.costUsd,
|
|
46
|
+
cost_source: cost.source,
|
|
47
|
+
cost_pool: mapPoolToNeutral(cost.pool),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const toolAfterHandler: Handler = (event: HarnessEvent, ctx: HandlerContext) => {
|
|
52
|
+
coreFacade.observability.recordAudit(event.projectDir, event.event, event.raw, ctx.policy.obs.globalSpool);
|
|
53
|
+
|
|
54
|
+
const kind = OBS_KIND_BY_EVENT[event.event];
|
|
55
|
+
if (kind) {
|
|
56
|
+
const attrs: Record<string, unknown> = {
|
|
57
|
+
tool_name: event.toolName,
|
|
58
|
+
command: event.command,
|
|
59
|
+
file_path: event.filePath,
|
|
60
|
+
};
|
|
61
|
+
if (event.event === "shell.after") {
|
|
62
|
+
attrs.cwd = rawString(event.raw, "cwd");
|
|
63
|
+
attrs.sandbox = rawBoolean(event.raw, "sandbox");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
coreFacade.observability.recordObs(event.projectDir, obsConfigFor(ctx.policy, OBS_CONFIG_AUDIT), {
|
|
67
|
+
provider: event.provider,
|
|
68
|
+
kind,
|
|
69
|
+
sessionKey: event.sessionKey,
|
|
70
|
+
model: event.model,
|
|
71
|
+
attrs,
|
|
72
|
+
gen_ai: usageGenAi(event, ctx),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// hazard: no degrade path consults contextAtToolAfter — the capability is declared but unread, so a
|
|
77
|
+
// provider that cannot carry context on this event would swallow the framing and leave the rail reporting
|
|
78
|
+
// a protection it never delivered. Abstaining keeps the marker unset, so a later event can still speak.
|
|
79
|
+
if (!ctx.capabilities.contextAtToolAfter) {
|
|
80
|
+
return { kind: "abstain" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// why: recorded before the framing is decided, because the framing fires once per turn and the content of every
|
|
84
|
+
// untrusted read still has to be remembered ([/decisions/ad-077.md](/decisions/ad-077.md)).
|
|
85
|
+
if (ctx.provider.capabilities().toolOutputAtAfter) {
|
|
86
|
+
coreFacade.untrusted.rememberUntrustedOutput({
|
|
87
|
+
root: event.projectDir,
|
|
88
|
+
sessionKey: event.sessionKey,
|
|
89
|
+
event: event.event,
|
|
90
|
+
toolName: event.toolName,
|
|
91
|
+
command: event.command,
|
|
92
|
+
toolOutput: event.toolOutput,
|
|
93
|
+
config: ctx.policy.untrustedContent,
|
|
94
|
+
providerTools: ctx.provider.policyDefaults().untrustedTools,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return coreFacade.untrusted.evaluateUntrustedContent({
|
|
99
|
+
root: event.projectDir,
|
|
100
|
+
sessionKey: event.sessionKey,
|
|
101
|
+
event: event.event,
|
|
102
|
+
toolName: event.toolName,
|
|
103
|
+
command: event.command,
|
|
104
|
+
config: ctx.policy.untrustedContent,
|
|
105
|
+
providerTools: ctx.provider.policyDefaults().untrustedTools,
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
if (import.meta.main) {
|
|
110
|
+
await main(toolAfterHandler);
|
|
111
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { Decision, HarnessEvent } from "../contracts/index.ts";
|
|
2
|
+
import { coreFacade } from "../core/index.ts";
|
|
3
|
+
import type { Handler, HandlerContext } from "./run.ts";
|
|
4
|
+
import { main } from "./run.ts";
|
|
5
|
+
import { obsConfigFor, readModelFromToolInput, subagentSpawnInput } from "./support.ts";
|
|
6
|
+
|
|
7
|
+
const READONLY_BLOCKED_TOOLS = new Set(["Write", "Delete", "Shell"]);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* hazard: `attrs.permission` was read in two places and written in none. `observability.service.ts` increments
|
|
11
|
+
* `shell.ask`/`shell.deny` from it, `observability.types.ts` grades an event `signal` when it is not `allow`, and
|
|
12
|
+
* the session report prints `Shell allow/ask/deny` — so both counters were structurally zero and the report
|
|
13
|
+
* printed a truthful-looking `0` for every ask that ever happened. Obs was emitted only on `*.after` events,
|
|
14
|
+
* which means the moment a decision is made was the one moment never recorded.
|
|
15
|
+
*
|
|
16
|
+
* why: the base config leaves `debugEnabled` false, and an `allow` grades as debug. So an allow is computed and
|
|
17
|
+
* dropped — costing nothing in the common path — while asks and denials reach disk. `shell.allow` keeps coming
|
|
18
|
+
* from `shell.end`, so nothing is double-counted.
|
|
19
|
+
*
|
|
20
|
+
* invariant: recorded after the decision and never able to change it. A rail that measures interruptions must not
|
|
21
|
+
* become one.
|
|
22
|
+
*/
|
|
23
|
+
function recordShellDecision(event: HarnessEvent, ctx: HandlerContext, decision: Decision): void {
|
|
24
|
+
coreFacade.observability.recordObs(event.projectDir, obsConfigFor(ctx.policy), {
|
|
25
|
+
provider: event.provider,
|
|
26
|
+
kind: "shell.start",
|
|
27
|
+
sessionKey: event.sessionKey,
|
|
28
|
+
model: event.model,
|
|
29
|
+
attrs: {
|
|
30
|
+
command: event.command,
|
|
31
|
+
permission: decision.kind,
|
|
32
|
+
posture: ctx.policy.mode,
|
|
33
|
+
// why: unattributed rather than guessed. A rate an operator cannot trace to a switch is a number, not a
|
|
34
|
+
// signal.
|
|
35
|
+
rule: "rule" in decision && decision.rule ? decision.rule : "none",
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function recordShellDecisionIfShell(event: HarnessEvent, ctx: HandlerContext, decision: Decision): void {
|
|
41
|
+
if (event.event === "shell.before") {
|
|
42
|
+
recordShellDecision(event, ctx, decision);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function handleShellBefore(event: HarnessEvent, ctx: HandlerContext): Decision {
|
|
47
|
+
const { policy } = ctx;
|
|
48
|
+
const decision = coreFacade.shellPolicy.evaluateShellCommand({
|
|
49
|
+
command: event.command ?? "",
|
|
50
|
+
sessionKey: event.sessionKey,
|
|
51
|
+
projectDir: event.projectDir,
|
|
52
|
+
mode: policy.mode,
|
|
53
|
+
catastrophicAsk: policy.shell.catastrophicAsk,
|
|
54
|
+
stallDetection: policy.shell.stallDetection,
|
|
55
|
+
stallRepeatThreshold: policy.shell.stallRepeatThreshold,
|
|
56
|
+
});
|
|
57
|
+
recordShellDecision(event, ctx, decision);
|
|
58
|
+
return decision;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// why: a read cannot mutate the policy surface, so it is the one class of event that stays available while a
|
|
62
|
+
// divergence is unresolved. Without it the agent cannot even read the file that explains the block.
|
|
63
|
+
function isReadOnlyEvent(event: HarnessEvent): boolean {
|
|
64
|
+
return event.event === "read.before" || event.event === "mcp.before";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function filePathOf(event: HarnessEvent): string | undefined {
|
|
68
|
+
if (event.filePath) {
|
|
69
|
+
return event.filePath;
|
|
70
|
+
}
|
|
71
|
+
const fromInput = event.toolInput?.file_path;
|
|
72
|
+
return typeof fromInput === "string" ? fromInput : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function handleToolBefore(event: HarnessEvent, ctx: HandlerContext): Promise<Decision> {
|
|
76
|
+
const { policy, provider } = ctx;
|
|
77
|
+
|
|
78
|
+
const isReadOnlySubagent =
|
|
79
|
+
event.subagentType !== undefined && policy.subagents.readOnlyTypes.includes(event.subagentType);
|
|
80
|
+
if (isReadOnlySubagent && event.toolName !== undefined && READONLY_BLOCKED_TOOLS.has(event.toolName)) {
|
|
81
|
+
return {
|
|
82
|
+
kind: "deny",
|
|
83
|
+
reason: `Explore/read-only subagents cannot use ${event.toolName}. Return findings to the parent agent.`,
|
|
84
|
+
rule: "subagent-read-only",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (event.toolName === "Task") {
|
|
89
|
+
const model = event.spawnModel ?? readModelFromToolInput(event.toolInput);
|
|
90
|
+
const spawnDecision = coreFacade.subagentPolicy.evaluateSubagentSpawn(
|
|
91
|
+
subagentSpawnInput(event, policy, provider, model),
|
|
92
|
+
);
|
|
93
|
+
if (spawnDecision.kind !== "allow") {
|
|
94
|
+
return spawnDecision;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (event.toolName === "Edit" || event.toolName === "Write") {
|
|
99
|
+
const filePath = filePathOf(event);
|
|
100
|
+
if (filePath) {
|
|
101
|
+
const collision = coreFacade.presence.checkCollision(event.projectDir, filePath, event.sessionKey);
|
|
102
|
+
if (collision.kind !== "allow") {
|
|
103
|
+
return collision;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { kind: "allow" };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const toolBeforeHandler: Handler = (
|
|
112
|
+
event: HarnessEvent,
|
|
113
|
+
ctx: HandlerContext,
|
|
114
|
+
): Decision | Promise<Decision> => {
|
|
115
|
+
// invariant: the floor runs first and reads no policy, so no config value and no agent edit can
|
|
116
|
+
// reach a decision before it.
|
|
117
|
+
const floor = coreFacade.floor.evaluateFloor({
|
|
118
|
+
projectDir: event.projectDir,
|
|
119
|
+
toolName: event.toolName,
|
|
120
|
+
filePath: filePathOf(event),
|
|
121
|
+
command: event.command,
|
|
122
|
+
isReadEvent: event.event === "read.before",
|
|
123
|
+
});
|
|
124
|
+
if (floor.kind !== "allow") {
|
|
125
|
+
// invariant: one rail owns the record of every shell decision. The floor short-circuits before the shell
|
|
126
|
+
// rail runs, so without this a floor denial of a shell command was recorded by nothing at all — and the
|
|
127
|
+
// shared refusal path deliberately skips `shell.before` to avoid double-counting what this rail owns.
|
|
128
|
+
recordShellDecisionIfShell(event, ctx, floor);
|
|
129
|
+
return floor;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* why: after the floor, because the floor is unconditional and this is a rail. A command the floor already
|
|
134
|
+
* refuses never needs an operator's opinion ([/decisions/ad-077.md](/decisions/ad-077.md)).
|
|
135
|
+
*/
|
|
136
|
+
const untrustedAsk = coreFacade.untrusted.askIfFromUntrusted({
|
|
137
|
+
root: event.projectDir,
|
|
138
|
+
sessionKey: event.sessionKey,
|
|
139
|
+
command: event.command,
|
|
140
|
+
config: ctx.policy.untrustedContent,
|
|
141
|
+
});
|
|
142
|
+
if (untrustedAsk.kind !== "abstain") {
|
|
143
|
+
return untrustedAsk;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// invariant: unconditional, for the same reason the floor is. This detects a policy that changed without
|
|
147
|
+
// a harness command, so reading a policy field to decide whether to look would let the mutation switch
|
|
148
|
+
// off its own detector.
|
|
149
|
+
//
|
|
150
|
+
// hazard: it used to deny every event, reads included, which left the agent unable to look at anything —
|
|
151
|
+
// it could not diagnose the divergence or explain it, only go mute. Measured: it locked its own author out
|
|
152
|
+
// of the file holding the fix. A read cannot change a policy, so reads pass and the agent can investigate
|
|
153
|
+
// and report; everything that acts is still refused until the operator clears it.
|
|
154
|
+
if (!isReadOnlyEvent(event)) {
|
|
155
|
+
const integrity = coreFacade.policy.checkPolicyBaseline(event.projectDir, event.sessionKey);
|
|
156
|
+
if (integrity.kind !== "allow") {
|
|
157
|
+
recordShellDecisionIfShell(event, ctx, integrity);
|
|
158
|
+
return integrity;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
switch (event.event) {
|
|
163
|
+
case "shell.before":
|
|
164
|
+
return handleShellBefore(event, ctx);
|
|
165
|
+
case "mcp.before":
|
|
166
|
+
case "read.before":
|
|
167
|
+
return { kind: "allow" };
|
|
168
|
+
case "tool.before": {
|
|
169
|
+
const guard = coreFacade.policy.guardPolicySurface({
|
|
170
|
+
projectDir: event.projectDir,
|
|
171
|
+
toolName: event.toolName,
|
|
172
|
+
filePath: filePathOf(event),
|
|
173
|
+
});
|
|
174
|
+
if (guard.kind !== "allow") {
|
|
175
|
+
return guard;
|
|
176
|
+
}
|
|
177
|
+
return handleToolBefore(event, ctx);
|
|
178
|
+
}
|
|
179
|
+
default:
|
|
180
|
+
return { kind: "allow" };
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
if (import.meta.main) {
|
|
185
|
+
await main(toolBeforeHandler);
|
|
186
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { HarnessEvent } from "../contracts/index.ts";
|
|
2
|
+
import { coreFacade } from "../core/index.ts";
|
|
3
|
+
import type { Handler, HandlerContext } from "./run.ts";
|
|
4
|
+
import { main } from "./run.ts";
|
|
5
|
+
import { OBS_CONFIG_AUDIT, obsConfigFor } from "./support.ts";
|
|
6
|
+
|
|
7
|
+
export const toolFailureHandler: Handler = (event: HarnessEvent, ctx: HandlerContext) => {
|
|
8
|
+
coreFacade.observability.recordAudit(event.projectDir, event.event, event.raw, ctx.policy.obs.globalSpool);
|
|
9
|
+
|
|
10
|
+
coreFacade.observability.recordObs(event.projectDir, obsConfigFor(ctx.policy, OBS_CONFIG_AUDIT), {
|
|
11
|
+
provider: event.provider,
|
|
12
|
+
kind: "tool.fail",
|
|
13
|
+
sessionKey: event.sessionKey,
|
|
14
|
+
model: event.model,
|
|
15
|
+
attrs: {
|
|
16
|
+
tool_name: event.toolName,
|
|
17
|
+
file_path: event.filePath,
|
|
18
|
+
status: event.status,
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
return { kind: "abstain" };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
if (import.meta.main) {
|
|
25
|
+
await main(toolFailureHandler);
|
|
26
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export type NextDelayOptions = {
|
|
2
|
+
attempt: number;
|
|
3
|
+
baseMs: number;
|
|
4
|
+
capMs: number;
|
|
5
|
+
random?: () => number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function nextDelay(options: NextDelayOptions): number {
|
|
9
|
+
const { attempt, baseMs, capMs, random = Math.random } = options;
|
|
10
|
+
const uncapped = baseMs * 2 ** attempt;
|
|
11
|
+
const ceiling = Math.min(capMs, uncapped);
|
|
12
|
+
return random() * ceiling;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type RetryOptions = {
|
|
16
|
+
attempts: number;
|
|
17
|
+
shouldRetry?: (error: unknown) => boolean;
|
|
18
|
+
sleep?: (ms: number) => Promise<void>;
|
|
19
|
+
random?: () => number;
|
|
20
|
+
baseMs?: number;
|
|
21
|
+
capMs?: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function defaultSleep(ms: number): Promise<void> {
|
|
25
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function retry<T>(fn: () => Promise<T> | T, options: RetryOptions): Promise<T> {
|
|
29
|
+
const {
|
|
30
|
+
attempts,
|
|
31
|
+
shouldRetry = () => true,
|
|
32
|
+
sleep = defaultSleep,
|
|
33
|
+
random = Math.random,
|
|
34
|
+
baseMs = 50,
|
|
35
|
+
capMs = 2000,
|
|
36
|
+
} = options;
|
|
37
|
+
|
|
38
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
39
|
+
try {
|
|
40
|
+
return await fn();
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (attempt === attempts - 1 || !shouldRetry(error)) {
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
await sleep(nextDelay({ attempt, baseMs, capMs, random }));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
throw new Error("retry: unreachable");
|
|
49
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type JsonFlagResult = { json: boolean; rest: string[] };
|
|
2
|
+
|
|
3
|
+
export const JSON_FLAG = "--json";
|
|
4
|
+
|
|
5
|
+
export function takeJsonFlag(args: readonly string[]): JsonFlagResult {
|
|
6
|
+
const rest: string[] = [];
|
|
7
|
+
let json = false;
|
|
8
|
+
for (const arg of args) {
|
|
9
|
+
if (arg === JSON_FLAG) {
|
|
10
|
+
json = true;
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
rest.push(arg);
|
|
14
|
+
}
|
|
15
|
+
return { json, rest };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// why: one line per invocation keeps the contract stream-parseable — a caller can read a single line and
|
|
19
|
+
// hand it to a JSON parser without buffering to EOF or stripping a trailing newline.
|
|
20
|
+
export function emitJson(value: unknown, write: (text: string) => void = writeStdout): void {
|
|
21
|
+
write(`${JSON.stringify(value)}\n`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function writeStdout(text: string): void {
|
|
25
|
+
process.stdout.write(text);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function unknownFlags(args: readonly string[]): string[] {
|
|
29
|
+
return args.filter((arg) => arg.startsWith("--"));
|
|
30
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* why: variables that name WHICH project. A gate that runs with one of these set is reading a project root the
|
|
3
|
+
* hook supplied rather than the one the command assumed, and a suite that builds fixtures in temp directories
|
|
4
|
+
* reads the real repository under them. That is documented in `tools/test-env.mjs` and it cost four stop loops
|
|
5
|
+
* of editing code that was not broken ([/decisions/ad-060.md](/decisions/ad-060.md)).
|
|
6
|
+
*
|
|
7
|
+
* invariant: this list lives in `platform/` because `core/` may not spell a vendor identifier
|
|
8
|
+
* (`tools/dev/check-boundaries.ts`). Core asks which ones are set and never names one.
|
|
9
|
+
*
|
|
10
|
+
* invariant: `TLC_HOME` is deliberately absent. It names which runtime, not which project, and CI sets it on
|
|
11
|
+
* purpose.
|
|
12
|
+
*/
|
|
13
|
+
export const PROJECT_SCOPED_ENV_NAMES = [
|
|
14
|
+
"CLAUDE_PROJECT_DIR",
|
|
15
|
+
"CURSOR_PROJECT_DIR",
|
|
16
|
+
"TLC_PROJECT_DIR",
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
/** The names that are set and non-empty, in declaration order, so the reading is stable across runs. */
|
|
20
|
+
export function setProjectScopedEnv(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
21
|
+
return PROJECT_SCOPED_ENV_NAMES.filter((name) => (env[name] ?? "").trim() !== "");
|
|
22
|
+
}
|