@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.
- package/README.md +6 -4
- package/bin/tlc-cli.ts +73 -12
- package/capabilities/catalog.json +18 -1
- package/dist/compact-before.mjs +80 -79
- package/dist/doctor.mjs +83 -82
- package/dist/init-project.mjs +86 -85
- package/dist/install-runtime.mjs +80 -79
- package/dist/lessons-cli.mjs +82 -81
- package/dist/new-provider.mjs +126 -0
- package/dist/obs-cli.mjs +86 -85
- package/dist/price-lookup.mjs +1 -1
- package/dist/prompt-submit.mjs +80 -79
- package/dist/refresh-model-prices.mjs +80 -79
- package/dist/response-after.mjs +80 -79
- package/dist/run.mjs +80 -79
- package/dist/session-end.mjs +85 -84
- package/dist/session-start.mjs +87 -86
- package/dist/shim.mjs +81 -80
- package/dist/ship-gate.mjs +80 -79
- package/dist/stop.mjs +86 -85
- package/dist/subagent-start.mjs +80 -79
- package/dist/subagent-stop.mjs +81 -80
- package/dist/support.mjs +84 -83
- package/dist/tlc-cli.mjs +224 -96
- package/dist/tool-after.mjs +80 -79
- package/dist/tool-before.mjs +81 -80
- package/dist/tool-failure.mjs +80 -79
- package/dist/uninstall-runtime.mjs +4 -4
- package/docs/architecture.md +16 -3
- package/docs/concepts.md +11 -0
- package/docs/log.md +8 -0
- package/docs/providers/claude-code.md +42 -21
- package/docs/providers/cursor.md +23 -7
- package/docs/troubleshooting.md +1 -0
- package/package.json +1 -1
- package/schema.json +12 -0
- package/skills/harness-init/references/capabilities.md +1 -0
- package/src/contracts/capabilities.ts +8 -1
- package/src/contracts/decision.ts +2 -1
- package/src/core/core.facade.ts +14 -0
- package/src/core/floor/floor.catalog.ts +5 -0
- package/src/core/floor/floor.paths.ts +17 -1
- package/src/core/floor/floor.policy-surface.ts +26 -9
- package/src/core/floor/floor.service.ts +37 -5
- package/src/core/gate/gate.lock.ts +8 -32
- package/src/core/handoff/handoff.service.ts +110 -27
- package/src/core/handoff/handoff.session-store.ts +200 -0
- package/src/core/handoff/handoff.store.ts +5 -20
- package/src/core/handoff/handoff.types.ts +42 -9
- package/src/core/policy/policy.defaults.ts +3 -0
- package/src/core/policy/policy.loader.ts +1 -0
- package/src/core/policy/policy.types.ts +4 -0
- package/src/core/presence/presence.service.ts +59 -13
- package/src/core/secret-scan/secret-scan.service.ts +96 -0
- package/src/core/secret-scan/secret-scan.store.ts +56 -0
- package/src/entrypoints/prompt-submit.ts +1 -1
- package/src/entrypoints/response-after.ts +4 -4
- package/src/entrypoints/run.ts +3 -1
- package/src/entrypoints/session-end.ts +1 -1
- package/src/entrypoints/session-start.ts +5 -4
- package/src/entrypoints/ship-gate.ts +2 -2
- package/src/entrypoints/stop.ts +18 -18
- package/src/entrypoints/subagent-stop.ts +4 -3
- package/src/entrypoints/support.ts +5 -3
- package/src/entrypoints/tool-after.ts +21 -1
- package/src/entrypoints/tool-before.ts +2 -1
- package/src/platform/paths.ts +4 -0
- package/src/platform/process.ts +36 -0
- package/src/providers/claude/claude.capabilities.ts +3 -1
- package/src/providers/claude/claude.inbound.ts +37 -22
- package/src/providers/claude/claude.outbound.ts +4 -0
- package/src/providers/claude/claude.wiring.ts +4 -0
- package/src/providers/claude/index.ts +2 -1
- package/src/providers/cursor/cursor.capabilities.ts +3 -1
- package/src/providers/cursor/cursor.inbound.ts +1 -1
- package/src/providers/cursor/cursor.outbound.ts +9 -0
- package/src/providers/cursor/cursor.wiring.ts +9 -1
- package/src/providers/cursor/index.ts +2 -1
- package/src/providers/provider.degrade.ts +19 -0
- package/src/providers/provider.port.ts +1 -0
- package/tools/new-provider.ts +251 -0
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
<p align="center">
|
|
40
40
|
Hooks fire on the editor's own events. The harness answers each one with a decision — <b>allow</b>, <b>ask</b>,
|
|
41
41
|
<b>deny</b>, or text injected into the turn — and writes a record of what it decided and why.
|
|
42
|
-
<b>
|
|
42
|
+
<b>8 floor rules</b> no configuration can reach, <b>3 always-on checks</b>, and <b>24 rails</b> you choose.
|
|
43
43
|
</p>
|
|
44
44
|
|
|
45
45
|
- **[Everything it validates](#everything-it-validates)** — the whole list, one row per check
|
|
@@ -90,11 +90,11 @@ Three tiers, and which tier a check is in decides whether you can turn it off.
|
|
|
90
90
|
|
|
91
91
|
| Tier | Count | Configurable | Runs |
|
|
92
92
|
|------|-------|--------------|------|
|
|
93
|
-
| [Floor](#tier-1--the-floor-no-configuration-reaches-it) |
|
|
93
|
+
| [Floor](#tier-1--the-floor-no-configuration-reaches-it) | 8 rules | Never | Before any policy is loaded, on every tool call, shell command and read |
|
|
94
94
|
| [Always on](#tier-2--always-on-no-switch) | 3 checks | Never | After the floor, on every acting event |
|
|
95
|
-
| [Rails](#tier-3--the-rails-you-choose) |
|
|
95
|
+
| [Rails](#tier-3--the-rails-you-choose) | 25 capabilities | Each one, individually | Where the table says |
|
|
96
96
|
|
|
97
|
-
Nothing else runs. If a message on your screen is not from one of the thirty-
|
|
97
|
+
Nothing else runs. If a message on your screen is not from one of the thirty-six rows below, it is not the
|
|
98
98
|
harness.
|
|
99
99
|
|
|
100
100
|
### Tier 1 — the floor, no configuration reaches it
|
|
@@ -113,6 +113,7 @@ prints `rule=<name>`, and the name is the first column here.
|
|
|
113
113
|
| `machine-control` | `shutdown`, `reboot`, `halt`, `poweroff` | — |
|
|
114
114
|
| `unprovable-execution` | a program fetched over the network and handed to a shell — piped, process-substituted, or inside a shell's `-c`/`eval` substitution. The gate cannot read what would run | a fetch with no shell downstream, and a shell fed a local file the gate can read |
|
|
115
115
|
| `policy-surface-write` | every route an agent has to harness policy and state — a shell redirect, an interpreter, a heredoc program, or a write tool — in the project and under the runtime home, plus the mutating `tlc harness` subcommands from inside a session | reading them with a proven reader (`cat`, `head`, `grep`, `jq`, `ls`, `stat`, `test`), and `tlc harness handoff` for the handoff state |
|
|
116
|
+
| `wiring-tamper` | a shell redirect, in-place edit, or delete into a registered provider's wiring target — the document its own editor reads to register the harness's hooks — and a direct `Edit`/`Write`/`MultiEdit` tool call against the same path — overwriting it silences every hook the harness has for that host | reading the same path with a proven reader or a read tool |
|
|
116
117
|
|
|
117
118
|
<!-- /generated -->
|
|
118
119
|
|
|
@@ -167,6 +168,7 @@ trade-off when you ran the init wizard. `configPath` is the key in `.tlc/harness
|
|
|
167
168
|
| **Plan gate (declared scope vs diff)**<br>`planGate.enabled` · off | Checks the files the turn changed against the scope it declared, and against any stated deviation. | `response.after`<br>`stop` | `block-stop` | tlc harness handoff — plan_paths, plan_at and plan_deviations |
|
|
168
169
|
| **Observation mode (measure a rail with its rule off)**<br>`observe.enabled` · off | Runs a rail's checker while that rail is not enforcing, and records the reading without acting on it. | `stop`<br>`session.end` | `record` | tlc harness obs report — the observation readings, held apart from the refusal counters so those stay honest |
|
|
169
170
|
| **Operator rules (your trigger, your proof)**<br>`rules.enabled` · off | Reads the rules you declare in markdown and enforces them: on this trigger, this must have happened. | `tool.before`<br>`stop` | `deny` | tlc harness doctor — each active rule, its tier, and any proof kind never observed here |
|
|
171
|
+
| **Secret redaction (tool/shell output)**<br>`secrets.redactOutput` · **on** | Scans a tool's or shell command's output for a secret-shaped signature or high-entropy span and masks it. | `tool.after` | `context` | the masked placeholder itself, in the tool output — `[REDACTED:<kind>:<hash>]` |
|
|
170
172
|
|
|
171
173
|
<!-- /generated -->
|
|
172
174
|
|
package/bin/tlc-cli.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
} from "../src/platform/paths.ts";
|
|
31
31
|
import { type Row, render, type Screen, type Section } from "../src/platform/screen.ts";
|
|
32
32
|
import { createStyle, PLAIN, type Style } from "../src/platform/style.ts";
|
|
33
|
+
import { scaffold } from "../tools/new-provider.ts";
|
|
33
34
|
|
|
34
35
|
export class UsageError extends Error {}
|
|
35
36
|
|
|
@@ -195,9 +196,10 @@ export function setPaused(root: string, on: boolean): string {
|
|
|
195
196
|
}
|
|
196
197
|
|
|
197
198
|
/**
|
|
198
|
-
* why: the operator's escape hatch for a stuck gate — `blockers
|
|
199
|
-
*
|
|
200
|
-
* clean stop clears it or this runs
|
|
199
|
+
* why: the operator's escape hatch for a stuck gate — `blockers`/`previous_gaps` are per session
|
|
200
|
+
* ([/decisions/ad-122.md](/decisions/ad-122.md)), and a subagent inheriting its parent's session key inherits
|
|
201
|
+
* a stuck signal the same way, until either a clean stop clears it or this runs across every session on
|
|
202
|
+
* record. Denied from inside a session by `policy-surface-write`.
|
|
201
203
|
*/
|
|
202
204
|
export async function resetStuckState(root: string): Promise<string> {
|
|
203
205
|
const cleared = await coreFacade.handoff.clearStuckSignals(root);
|
|
@@ -244,22 +246,23 @@ export function setMode(root: string, raw: string): string {
|
|
|
244
246
|
|
|
245
247
|
export type HandoffReport = {
|
|
246
248
|
root: string;
|
|
247
|
-
providers: Record<string, ReturnType<typeof coreFacade.handoff.
|
|
249
|
+
providers: Record<string, ReturnType<typeof coreFacade.handoff.readLatestSlice>>;
|
|
248
250
|
};
|
|
249
251
|
|
|
250
252
|
/**
|
|
251
253
|
* The sanctioned way to read handoff state.
|
|
252
254
|
*
|
|
253
|
-
* why: the
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
* to be one it grants ([/decisions/ad-047.md](/decisions/ad-047.md)).
|
|
255
|
+
* why: the route the floor grants instead of the raw file path it guards ([/decisions/ad-047.md](/decisions/ad-047.md)).
|
|
256
|
+
* One row per provider, the most recent session, not liveness-gated — a diagnostic summary, not a turn's own
|
|
257
|
+
* decision ([/decisions/ad-122.md](/decisions/ad-122.md)).
|
|
257
258
|
*/
|
|
258
259
|
export function handoffJson(root: string): HandoffReport {
|
|
259
|
-
const
|
|
260
|
+
const providerNames = new Set(
|
|
261
|
+
coreFacade.handoff.listHandoffSessionFiles(root).map((file) => file.owner.provider),
|
|
262
|
+
);
|
|
260
263
|
const providers: HandoffReport["providers"] = {};
|
|
261
|
-
for (const provider of
|
|
262
|
-
providers[provider] = coreFacade.handoff.
|
|
264
|
+
for (const provider of providerNames) {
|
|
265
|
+
providers[provider] = coreFacade.handoff.readLatestSlice(root, provider);
|
|
263
266
|
}
|
|
264
267
|
return { root, providers };
|
|
265
268
|
}
|
|
@@ -859,6 +862,43 @@ export function setGateCommand(root: string, field: GateField, argv: string[], i
|
|
|
859
862
|
return `grind.${field}Command = ${JSON.stringify(argv)}`;
|
|
860
863
|
}
|
|
861
864
|
|
|
865
|
+
const REGISTRY_TYPE_IMPORT = 'import type { ProviderPort } from "./provider.port.ts";\n';
|
|
866
|
+
const REGISTRY_ARRAY = /export const providers: ProviderPort\[\] = \[([^\]]*)\];/;
|
|
867
|
+
|
|
868
|
+
// invariant: an append, never a reorder — the new entry always lands last in `providers`, so every existing provider's detection-order position is unchanged.
|
|
869
|
+
export function appendProviderToRegistry(text: string, name: string): string {
|
|
870
|
+
if (!text.includes(REGISTRY_TYPE_IMPORT)) {
|
|
871
|
+
throw new Error("provider.registry.ts: could not find the ProviderPort type-import anchor line");
|
|
872
|
+
}
|
|
873
|
+
const withImport = text.replace(
|
|
874
|
+
REGISTRY_TYPE_IMPORT,
|
|
875
|
+
`import { ${name}Provider } from "./${name}/index.ts";\n${REGISTRY_TYPE_IMPORT}`,
|
|
876
|
+
);
|
|
877
|
+
|
|
878
|
+
const match = REGISTRY_ARRAY.exec(withImport);
|
|
879
|
+
if (!match) {
|
|
880
|
+
throw new Error("provider.registry.ts: could not find the providers array literal");
|
|
881
|
+
}
|
|
882
|
+
const existing = (match[1] ?? "").trim();
|
|
883
|
+
const appended = existing.length > 0 ? `${existing}, ${name}Provider` : `${name}Provider`;
|
|
884
|
+
return withImport.replace(REGISTRY_ARRAY, `export const providers: ProviderPort[] = [${appended}];`);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* The full `tlc harness new-provider` flow: T5's scaffold, then a real import appended to
|
|
889
|
+
* provider.registry.ts — in that order, so a refused scaffold never touches the registry.
|
|
890
|
+
*/
|
|
891
|
+
export function runNewProvider(name: string, root: string): { ok: true } | { ok: false; reason: string } {
|
|
892
|
+
const scaffolded = scaffold(name, root);
|
|
893
|
+
if (!scaffolded.ok) {
|
|
894
|
+
return scaffolded;
|
|
895
|
+
}
|
|
896
|
+
const registryPath = join(root, "src", "providers", "provider.registry.ts");
|
|
897
|
+
const current = readFileSync(registryPath, "utf8");
|
|
898
|
+
writeFileSync(registryPath, appendProviderToRegistry(current, name), "utf8");
|
|
899
|
+
return { ok: true };
|
|
900
|
+
}
|
|
901
|
+
|
|
862
902
|
export function helpScreen(): Screen {
|
|
863
903
|
return {
|
|
864
904
|
title: "tlc harness",
|
|
@@ -1165,6 +1205,7 @@ export type Action =
|
|
|
1165
1205
|
| { kind: "prices-lookup"; modelId: string; provider: string }
|
|
1166
1206
|
| { kind: "entry"; entry: string; args: string[] }
|
|
1167
1207
|
| { kind: "install"; args: string[] }
|
|
1208
|
+
| { kind: "new-provider"; name: string }
|
|
1168
1209
|
| { kind: "unknown"; cmd: string };
|
|
1169
1210
|
|
|
1170
1211
|
export function route(args: string[]): Action {
|
|
@@ -1293,6 +1334,13 @@ export function route(args: string[]): Action {
|
|
|
1293
1334
|
return { kind: "entry", entry: "init-project", args: args.slice(1) };
|
|
1294
1335
|
case "install":
|
|
1295
1336
|
return { kind: "install", args: args.slice(1) };
|
|
1337
|
+
case "new-provider": {
|
|
1338
|
+
const name = args[1];
|
|
1339
|
+
if (!name) {
|
|
1340
|
+
throw new UsageError("usage: tlc harness new-provider <name>");
|
|
1341
|
+
}
|
|
1342
|
+
return { kind: "new-provider", name };
|
|
1343
|
+
}
|
|
1296
1344
|
// why: the exit has to be as easy to find as the entrance. An operator who cannot get the harness off their
|
|
1297
1345
|
// machine without hand-editing settings.json will not try it on a second one
|
|
1298
1346
|
// ([/decisions/ad-066.md](/decisions/ad-066.md)).
|
|
@@ -1330,7 +1378,8 @@ export const TEST_ENV_IMPORT = ["--import", "./tools/test-env.mjs"];
|
|
|
1330
1378
|
* commit is one somebody looks at. Lowering it is the point; raising it needs an argument in the diff
|
|
1331
1379
|
* ([/decisions/ad-102.md](/decisions/ad-102.md)).
|
|
1332
1380
|
*/
|
|
1333
|
-
|
|
1381
|
+
// why: raised from 76 to 80 — knip cannot trace render-provider-docs.ts's dynamic import() of each provider's inbound module, so EVENT_KIND_BY_HOOK (both adapters) and Claude's PRE_TOOL_USE_FAN_OUT/POST_TOOL_USE_FAN_OUT report as unused though the drift gate depends on them.
|
|
1382
|
+
export const KNIP_EXPORTS_CEILING = 80;
|
|
1334
1383
|
|
|
1335
1384
|
export function buildTestSteps(): TestStep[] {
|
|
1336
1385
|
return [
|
|
@@ -1376,6 +1425,7 @@ export function buildTestSteps(): TestStep[] {
|
|
|
1376
1425
|
// for an unrelated reason ([/decisions/ad-081.md](/decisions/ad-081.md)).
|
|
1377
1426
|
{ label: "check-manifest", bin: "node", args: ["tools/dev/check-manifest.ts"] },
|
|
1378
1427
|
{ label: "capabilities in sync", bin: "node", args: ["tools/dev/render-capabilities.ts", "--check"] },
|
|
1428
|
+
{ label: "provider docs in sync", bin: "node", args: ["tools/dev/render-provider-docs.ts", "--check"] },
|
|
1379
1429
|
{ label: "changelog in sync", bin: "node", args: ["tools/dev/render-changelog.ts", "--check"] },
|
|
1380
1430
|
// why: the OKF bundle's log is a reserved file that cannot be retired, and hand-maintaining it drifted to 19
|
|
1381
1431
|
// of 66 records before anyone noticed ([/decisions/ad-067.md](/decisions/ad-067.md)).
|
|
@@ -1813,6 +1863,17 @@ async function main(argv: string[]): Promise<void> {
|
|
|
1813
1863
|
case "install":
|
|
1814
1864
|
runInstall(action.args, root);
|
|
1815
1865
|
break;
|
|
1866
|
+
case "new-provider": {
|
|
1867
|
+
const result = runNewProvider(action.name, process.cwd());
|
|
1868
|
+
if (!result.ok) {
|
|
1869
|
+
console.error(`new-provider: refusing — ${result.reason}`);
|
|
1870
|
+
process.exit(1);
|
|
1871
|
+
}
|
|
1872
|
+
console.log(
|
|
1873
|
+
`new-provider: scaffolded src/providers/${action.name}/, docs/providers/${action.name}.md, and appended ${action.name}Provider to provider.registry.ts`,
|
|
1874
|
+
);
|
|
1875
|
+
break;
|
|
1876
|
+
}
|
|
1816
1877
|
case "entry":
|
|
1817
1878
|
runEntry(action.entry, json ? [...action.args, JSON_FLAG] : action.args, root);
|
|
1818
1879
|
break;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"catalogVersion":
|
|
2
|
+
"catalogVersion": 15,
|
|
3
3
|
"capabilities": [
|
|
4
4
|
{
|
|
5
5
|
"id": "grind",
|
|
@@ -430,6 +430,23 @@
|
|
|
430
430
|
"verdict": "deny",
|
|
431
431
|
"inspect": "tlc harness doctor \u2014 each active rule, its tier, and any proof kind never observed here",
|
|
432
432
|
"asks": []
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
"id": "secretRedaction",
|
|
436
|
+
"configPath": "secrets.redactOutput",
|
|
437
|
+
"title": "Secret redaction (tool/shell output)",
|
|
438
|
+
"summary": "Scans a tool's or shell command's output for a secret-shaped signature or high-entropy span and masks it.",
|
|
439
|
+
"benefit": "Masks an AWS access key, GitHub/Slack/Stripe token, PEM private-key block, JWT, or an unlabelled high-entropy string with a deterministic placeholder \u2014 the same value always yields the same placeholder within a session \u2014 instead of blocking the command that produced it.",
|
|
440
|
+
"tradeOff": "A synchronous regex plus Shannon-entropy pass on every tool/shell output; a high-diversity non-secret string can be masked as a false positive, and an unlabelled low-entropy secret can be missed. On a host with no rewrite mechanism for the firing event, the mask degrades to a context warning instead \u2014 the model is told a secret was masked but the raw value already reached it.",
|
|
441
|
+
"defaultOn": true,
|
|
442
|
+
"sinceCatalogVersion": 15,
|
|
443
|
+
"fires": [
|
|
444
|
+
"tool.after"
|
|
445
|
+
],
|
|
446
|
+
"verdict": "context",
|
|
447
|
+
"inspect": "the masked placeholder itself, in the tool output \u2014 `[REDACTED:<kind>:<hash>]`",
|
|
448
|
+
"asks": [],
|
|
449
|
+
"recommend": "on"
|
|
433
450
|
}
|
|
434
451
|
]
|
|
435
452
|
}
|