@tech-leads-club/harness-toolkit 0.10.5 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +6 -4
  2. package/bin/tlc-cli.ts +73 -12
  3. package/capabilities/catalog.json +18 -1
  4. package/dist/compact-before.mjs +80 -79
  5. package/dist/doctor.mjs +83 -82
  6. package/dist/init-project.mjs +86 -85
  7. package/dist/install-runtime.mjs +80 -79
  8. package/dist/lessons-cli.mjs +82 -81
  9. package/dist/new-provider.mjs +126 -0
  10. package/dist/obs-cli.mjs +86 -85
  11. package/dist/price-lookup.mjs +1 -1
  12. package/dist/prompt-submit.mjs +80 -79
  13. package/dist/refresh-model-prices.mjs +80 -79
  14. package/dist/response-after.mjs +80 -79
  15. package/dist/run.mjs +80 -79
  16. package/dist/session-end.mjs +85 -84
  17. package/dist/session-start.mjs +87 -86
  18. package/dist/shim.mjs +81 -80
  19. package/dist/ship-gate.mjs +80 -79
  20. package/dist/stop.mjs +86 -85
  21. package/dist/subagent-start.mjs +80 -79
  22. package/dist/subagent-stop.mjs +81 -80
  23. package/dist/support.mjs +84 -83
  24. package/dist/tlc-cli.mjs +224 -96
  25. package/dist/tool-after.mjs +80 -79
  26. package/dist/tool-before.mjs +81 -80
  27. package/dist/tool-failure.mjs +80 -79
  28. package/dist/uninstall-runtime.mjs +4 -4
  29. package/docs/architecture.md +16 -3
  30. package/docs/concepts.md +11 -0
  31. package/docs/log.md +8 -0
  32. package/docs/providers/claude-code.md +42 -21
  33. package/docs/providers/cursor.md +23 -7
  34. package/docs/troubleshooting.md +1 -0
  35. package/package.json +1 -1
  36. package/schema.json +12 -0
  37. package/skills/harness-init/references/capabilities.md +1 -0
  38. package/src/contracts/capabilities.ts +8 -1
  39. package/src/contracts/decision.ts +2 -1
  40. package/src/core/core.facade.ts +14 -0
  41. package/src/core/floor/floor.catalog.ts +5 -0
  42. package/src/core/floor/floor.paths.ts +17 -1
  43. package/src/core/floor/floor.policy-surface.ts +26 -9
  44. package/src/core/floor/floor.service.ts +37 -5
  45. package/src/core/gate/gate.lock.ts +8 -32
  46. package/src/core/handoff/handoff.service.ts +110 -27
  47. package/src/core/handoff/handoff.session-store.ts +200 -0
  48. package/src/core/handoff/handoff.store.ts +5 -20
  49. package/src/core/handoff/handoff.types.ts +42 -9
  50. package/src/core/policy/policy.defaults.ts +3 -0
  51. package/src/core/policy/policy.loader.ts +1 -0
  52. package/src/core/policy/policy.types.ts +4 -0
  53. package/src/core/presence/presence.service.ts +59 -13
  54. package/src/core/secret-scan/secret-scan.service.ts +96 -0
  55. package/src/core/secret-scan/secret-scan.store.ts +56 -0
  56. package/src/entrypoints/prompt-submit.ts +1 -1
  57. package/src/entrypoints/response-after.ts +4 -4
  58. package/src/entrypoints/run.ts +3 -1
  59. package/src/entrypoints/session-end.ts +1 -1
  60. package/src/entrypoints/session-start.ts +5 -4
  61. package/src/entrypoints/ship-gate.ts +2 -2
  62. package/src/entrypoints/stop.ts +18 -18
  63. package/src/entrypoints/subagent-stop.ts +4 -3
  64. package/src/entrypoints/support.ts +5 -3
  65. package/src/entrypoints/tool-after.ts +21 -1
  66. package/src/entrypoints/tool-before.ts +2 -1
  67. package/src/platform/paths.ts +4 -0
  68. package/src/platform/process.ts +36 -0
  69. package/src/providers/claude/claude.capabilities.ts +3 -1
  70. package/src/providers/claude/claude.inbound.ts +37 -22
  71. package/src/providers/claude/claude.outbound.ts +4 -0
  72. package/src/providers/claude/claude.wiring.ts +4 -0
  73. package/src/providers/claude/index.ts +2 -1
  74. package/src/providers/cursor/cursor.capabilities.ts +3 -1
  75. package/src/providers/cursor/cursor.inbound.ts +1 -1
  76. package/src/providers/cursor/cursor.outbound.ts +9 -0
  77. package/src/providers/cursor/cursor.wiring.ts +9 -1
  78. package/src/providers/cursor/index.ts +2 -1
  79. package/src/providers/provider.degrade.ts +19 -0
  80. package/src/providers/provider.port.ts +1 -0
  81. package/tools/new-provider.ts +251 -0
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>7 floor rules</b> no configuration can reach, <b>3 always-on checks</b>, and <b>24 rails</b> you choose.
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) | 7 rules | Never | Before any policy is loaded, on every tool call, shell command and read |
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) | 24 capabilities | Each one, individually | Where the table says |
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-four rows below, it is not the
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` and `previous_gaps` are project-wide,
199
- * so a grind-cap or stagnation signal from one session can block every later subagent until either a
200
- * clean stop clears it or this runs. Denied from inside a session by `policy-surface-write`.
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.readHandoff>>;
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 bootstrap used to tell the agent to read `.tlc/harness/state/handoff.json`, a path the floor guards. So
254
- * the instruction and the permission disagreed, and the obvious command `test -f && head -c 2000 …` was
255
- * refused with advice about writing policy. An instruction is not an affordance; the route the harness asks for has
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 file = coreFacade.handoff.readHandoffFile(root);
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 Object.keys(file.by_provider)) {
262
- providers[provider] = coreFacade.handoff.readHandoff(root, provider);
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
- export const KNIP_EXPORTS_CEILING = 76;
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": 14,
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
  }