@tech-leads-club/harness-toolkit 0.10.1 → 0.10.3
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/dist/compact-before.mjs +67 -67
- package/dist/doctor.mjs +68 -68
- package/dist/init-project.mjs +70 -70
- package/dist/install-runtime.mjs +67 -67
- package/dist/lessons-cli.mjs +67 -67
- package/dist/obs-cli.mjs +68 -68
- package/dist/prompt-submit.mjs +67 -67
- package/dist/refresh-model-prices.mjs +67 -67
- package/dist/response-after.mjs +67 -67
- package/dist/run.mjs +67 -67
- package/dist/session-end.mjs +69 -69
- package/dist/session-start.mjs +71 -71
- package/dist/shim.mjs +66 -66
- package/dist/ship-gate.mjs +67 -67
- package/dist/stop.mjs +75 -75
- package/dist/subagent-start.mjs +67 -67
- package/dist/subagent-stop.mjs +68 -68
- package/dist/support.mjs +68 -68
- package/dist/tlc-cli.mjs +83 -83
- package/dist/tool-after.mjs +67 -67
- package/dist/tool-before.mjs +69 -69
- package/dist/tool-failure.mjs +67 -67
- package/docs/concepts.md +45 -0
- package/docs/log.md +2 -0
- package/package.json +1 -1
- package/skills/harness-init/SKILL.md +13 -3
- package/src/core/rules/rules.proof.ts +30 -4
- package/src/core/rules/rules.trigger.ts +26 -10
package/docs/concepts.md
CHANGED
|
@@ -336,6 +336,45 @@ subcommands are refused from inside a session.
|
|
|
336
336
|
`since HEAD` compares the sha the observation was made against with the current one, so a review followed by
|
|
337
337
|
another commit is stale. A project with no git checkout cannot satisfy `since HEAD` at all.
|
|
338
338
|
|
|
339
|
+
A denial says which of two things happened: nothing of that kind and value was ever observed here (a flat
|
|
340
|
+
`missing …`), or it was, just not inside the window (`missing … (ran, but at a different commit)` /
|
|
341
|
+
`(ran, but in a different session)` / `(ran, but this project is not a git checkout, so since HEAD can never
|
|
342
|
+
be satisfied)`). The first of those three is common with more than one branch checked out of the same working
|
|
343
|
+
directory in turn — the proof is real, it is just stamped against whichever commit was checked out when it
|
|
344
|
+
ran, not the one the current command has in mind.
|
|
345
|
+
|
|
346
|
+
Each proof kind matches differently — picking the wrong one for what you actually want to check is the
|
|
347
|
+
most common way a new rule reads as protection and is not:
|
|
348
|
+
|
|
349
|
+
| proof | what it names | how it matches |
|
|
350
|
+
|---|---|---|
|
|
351
|
+
| `subagent(<type>)` | the *declared type* of a spawn that finished, not the name the spawning agent gave it | exact string |
|
|
352
|
+
| `command(<pattern>)` | a shell command that ran and did not fail | the pattern's words, in order, as a contiguous run inside the command — `command(gh pr create)` matches `gh pr create --fill`, not `gh api pulls`|
|
|
353
|
+
| `gate(<name>)` | one of this harness's own gates *passing* — never just running | exact string; the only names a gate ever reports are `lint`, `test`, `docs` |
|
|
354
|
+
| `file(<pattern>)` | a file edited by a write tool | exact path, **or** `*<suffix>` (a leading `*`, matched against the path's end), **or** `<dir>/` (a trailing `/`, matched as a prefix) — three shapes, not a glob engine |
|
|
355
|
+
|
|
356
|
+
One example per kind (shown separately — `require:` is a conjunction; listing more than one demands
|
|
357
|
+
all of them):
|
|
358
|
+
|
|
359
|
+
```
|
|
360
|
+
require:
|
|
361
|
+
- subagent(<type>) since HEAD # a spawn declaring exactly <type> finished
|
|
362
|
+
- command(<pattern>) since session # a shell command containing <pattern>, in order, ran this session
|
|
363
|
+
- gate(<name>) since HEAD # this harness's own <name> gate passed at the current HEAD
|
|
364
|
+
- file(<pattern>) since HEAD # a file matching <pattern> was edited since HEAD
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
**A `subagent(<type>)` proof needs the host to let the agent declare an arbitrary type when it spawns
|
|
368
|
+
one.** Some hosts only offer a closed set of built-in spawn types and cannot declare a custom name at
|
|
369
|
+
all — a rule naming one there never has a producer and denies forever, not because the work never ran
|
|
370
|
+
but because the host has no way to say which custom kind ran (Cursor's `Task` tool is one example: its
|
|
371
|
+
type field is a fixed enum, not a free string, so a custom skill name can never appear there no matter
|
|
372
|
+
what the skill itself is called). A rule meant to hold on every host the operator actually uses should
|
|
373
|
+
prove itself with something every host can produce identically: `command(<the work's own command>)` —
|
|
374
|
+
the exact command the work already runs to do its job — is the portable choice, because running a
|
|
375
|
+
command is not a closed set the way declaring a spawn's type can be. `subagent(<type>)` is the right
|
|
376
|
+
proof only when every host the rule has to survive on can actually declare that type.
|
|
377
|
+
|
|
339
378
|
The four verdicts land differently at the two moments a rule can fire:
|
|
340
379
|
|
|
341
380
|
| verdict | on an action | at the stop |
|
|
@@ -356,6 +395,12 @@ A pattern trigger is policy rather than containment. A script written to disk an
|
|
|
356
395
|
built at runtime, `gh api` instead of `gh pr create`, or a pull request opened in a browser all escape it — the
|
|
357
396
|
rule covers the agent's shell path ([/decisions/ad-100.md](/decisions/ad-100.md)).
|
|
358
397
|
|
|
398
|
+
`pr-open` does not fire on opening a **draft** pull request — only on a non-draft create and on converting a
|
|
399
|
+
draft to ready. This matters when a `require:` proof itself depends on the pull request already existing (a
|
|
400
|
+
review step that reads the diff through the PR, for instance): open the draft first, satisfy the proof against
|
|
401
|
+
it, then convert it to ready, which is still gated the same way
|
|
402
|
+
([/decisions/ad-118.md](/decisions/ad-118.md)).
|
|
403
|
+
|
|
359
404
|
## plan gate
|
|
360
405
|
|
|
361
406
|
`planGate.enabled` (off by default), with `planGate.windowMinutes` (default 120). The turn declares the paths
|
package/docs/log.md
CHANGED
|
@@ -19,6 +19,8 @@ newest first. For what landed in which npm release, see `CHANGELOG.md` at the re
|
|
|
19
19
|
- **AD-115** — A comment violation refuses commit, push and gh pr create, not only the stop ([/decisions/ad-115.md](/decisions/ad-115.md))
|
|
20
20
|
- **AD-116** — push and gh pr create run the stop-time battery before shipping, identically on every provider ([/decisions/ad-116.md](/decisions/ad-116.md))
|
|
21
21
|
- **AD-117** — turn_base_sha resolves sha from the event's own working directory, not the project root ([/decisions/ad-117.md](/decisions/ad-117.md))
|
|
22
|
+
- **AD-118** — pr-open does not fire on a draft, so a proof needing the pull request to exist has a way to run ([/decisions/ad-118.md](/decisions/ad-118.md))
|
|
23
|
+
- **AD-119** — A rule's denial says whether the proof never ran or ran outside the window ([/decisions/ad-119.md](/decisions/ad-119.md))
|
|
22
24
|
|
|
23
25
|
## 2026-08-26
|
|
24
26
|
|
package/package.json
CHANGED
|
@@ -164,22 +164,32 @@ The vocabulary is closed, so read it out rather than inventing terms:
|
|
|
164
164
|
|
|
165
165
|
```
|
|
166
166
|
on: pr-open | commit | push | stop | tool(<name>) | command(<pattern>)
|
|
167
|
-
require: subagent(<type>) | command(<pattern>) | gate(<name>) | file(<
|
|
167
|
+
require: subagent(<type>) | command(<pattern>) | gate(<name>) | file(<pattern>)
|
|
168
168
|
each since HEAD (default) or since session
|
|
169
169
|
otherwise: deny | ask | follow-up | warn
|
|
170
170
|
```
|
|
171
171
|
|
|
172
172
|
A rule is one file in `<repo>/.tlc/harness/rules/<name>.md` for the team, or in the runtime home's `rules/` for
|
|
173
173
|
every repository on this machine. The frontmatter is enforced; the body is their own text, injected verbatim when
|
|
174
|
-
the rule fires. Full grammar and the verdict matrix:
|
|
174
|
+
the rule fires. Full grammar, exact matching semantics per proof kind, and the verdict matrix:
|
|
175
|
+
`tlc harness help rules`.
|
|
175
176
|
|
|
176
|
-
|
|
177
|
+
Four things to say out loud, because all four surprise people later:
|
|
177
178
|
|
|
178
179
|
- **The proof has to be something the harness observed** — a subagent that ran, a command that completed, a gate
|
|
179
180
|
that passed, a file that changed. It cannot judge whether the review was any good, and the agent cannot create
|
|
180
181
|
a proof: that store is under the project state directory, which the floor refuses it.
|
|
181
182
|
- **`since HEAD` means a new commit makes an old proof stale**, and a project with no git checkout cannot satisfy
|
|
182
183
|
`since HEAD` at all.
|
|
184
|
+
- **`subagent(<type>)` only holds on a host that lets the agent declare an arbitrary custom type.** A host with a
|
|
185
|
+
closed set of built-in spawn types (Cursor's `Task` tool, for one) can never produce a match for a custom name,
|
|
186
|
+
no matter what the actual work is called — the rule denies forever, not because nothing ran but because the
|
|
187
|
+
host cannot say which kind ran. If the rule has to hold on more than one host, ask what command the work itself
|
|
188
|
+
runs to do its job and require that instead: `command(<pattern>)` is producible identically everywhere.
|
|
189
|
+
- **`on: pr-open` does not fire on opening a draft pull request** — only on a non-draft create and on
|
|
190
|
+
converting a draft to ready. If the requirement itself needs the pull request to already exist (a proof that
|
|
191
|
+
reads the diff through the PR, for one), have them open the draft, satisfy the proof against it, then convert
|
|
192
|
+
it to ready — still gated the same way.
|
|
183
193
|
|
|
184
194
|
If they accept the capability but have no requirement in mind, write `rules.enabled: false` and say why — a switch
|
|
185
195
|
with no file is the shape that looks like protection and is not.
|
|
@@ -84,17 +84,43 @@ export function proofSatisfied(
|
|
|
84
84
|
);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* why this exists at all: "missing" reads the same whether the thing never ran or it ran and the window rejected
|
|
89
|
+
* it, and only one of those is a config-side fact worth stating rather than a guess. The fact is cheap and always
|
|
90
|
+
* true — an observation of this kind and value exists — so it is stated whenever it holds
|
|
91
|
+
* ([/decisions/ad-060.md](/decisions/ad-060.md)'s own distinction between a fact and a diagnosis).
|
|
92
|
+
*/
|
|
93
|
+
function staleReason(proof: RuleProof, context: ProofContext): string {
|
|
94
|
+
if (proof.since === "session") {
|
|
95
|
+
return "ran, but in a different session";
|
|
96
|
+
}
|
|
97
|
+
return context.sha === null
|
|
98
|
+
? "ran, but this project is not a git checkout, so since HEAD can never be satisfied"
|
|
99
|
+
: "ran, but at a different commit";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type MissingProof = { proof: RuleProof; reason: string | null };
|
|
103
|
+
|
|
87
104
|
/** invariant: every proof must hold. The list is a conjunction, which is why there is no boolean algebra. */
|
|
88
105
|
export function missingProofs(
|
|
89
106
|
rule: Rule,
|
|
90
107
|
observations: readonly Observation[],
|
|
91
108
|
context: ProofContext,
|
|
92
|
-
):
|
|
93
|
-
return rule.require
|
|
109
|
+
): MissingProof[] {
|
|
110
|
+
return rule.require
|
|
111
|
+
.filter((proof) => !proofSatisfied(proof, observations, context))
|
|
112
|
+
.map((proof) => ({
|
|
113
|
+
proof,
|
|
114
|
+
reason: observations.some((observation) => valueMatches(proof, observation))
|
|
115
|
+
? staleReason(proof, context)
|
|
116
|
+
: null,
|
|
117
|
+
}));
|
|
94
118
|
}
|
|
95
119
|
|
|
96
|
-
export function proofLabel(
|
|
97
|
-
|
|
120
|
+
export function proofLabel(missing: MissingProof): string {
|
|
121
|
+
const { proof, reason } = missing;
|
|
122
|
+
const base = `${proof.kind}(${proof.value}) since ${proof.since === "head" ? "HEAD" : "session"}`;
|
|
123
|
+
return reason === null ? base : `${base} (${reason})`;
|
|
98
124
|
}
|
|
99
125
|
|
|
100
126
|
/**
|
|
@@ -27,13 +27,24 @@ export type TriggerContext = {
|
|
|
27
27
|
* every repository, or a rule copied between them silently stops firing. An operator who wants their own shape
|
|
28
28
|
* writes `command(<pattern>)`.
|
|
29
29
|
*/
|
|
30
|
-
|
|
30
|
+
type ShellShape = {
|
|
31
|
+
readonly prefix: readonly string[];
|
|
32
|
+
/**
|
|
33
|
+
* why this exists only on `gh pr create`: a draft is not yet open for review, so a rule gating `pr-open`
|
|
34
|
+
* has no work to demand proof of. It is also the only way a proof that itself depends on the pull request
|
|
35
|
+
* existing — `gh pr view`, for one — can ever run: open the draft, produce the proof against it, then
|
|
36
|
+
* `gh pr ready`, which keeps its own gate ([/decisions/ad-118.md](/decisions/ad-118.md)).
|
|
37
|
+
*/
|
|
38
|
+
readonly excludeIfAny?: readonly string[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const SHELL_SHAPES: Record<"pr-open" | "commit" | "push", readonly ShellShape[]> = {
|
|
31
42
|
"pr-open": [
|
|
32
|
-
["gh", "pr", "create"],
|
|
33
|
-
["gh", "pr", "ready"],
|
|
43
|
+
{ prefix: ["gh", "pr", "create"], excludeIfAny: ["--draft", "-d"] },
|
|
44
|
+
{ prefix: ["gh", "pr", "ready"] },
|
|
34
45
|
],
|
|
35
|
-
commit: [["git", "commit"]],
|
|
36
|
-
push: [["git", "push"]],
|
|
46
|
+
commit: [{ prefix: ["git", "commit"] }],
|
|
47
|
+
push: [{ prefix: ["git", "push"] }],
|
|
37
48
|
};
|
|
38
49
|
|
|
39
50
|
/**
|
|
@@ -53,8 +64,15 @@ function subCommands(command: string): string[][] {
|
|
|
53
64
|
}
|
|
54
65
|
|
|
55
66
|
/** why prefix rather than equality: `gh pr create --fill --base main` is the same act as `gh pr create`. */
|
|
56
|
-
function startsWithShape(words: readonly string[],
|
|
57
|
-
return
|
|
67
|
+
function startsWithShape(words: readonly string[], prefix: readonly string[]): boolean {
|
|
68
|
+
return prefix.every((token, index) => words[index] === token);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function matchesShape(words: readonly string[], shape: ShellShape): boolean {
|
|
72
|
+
if (!startsWithShape(words, shape.prefix)) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
return !shape.excludeIfAny?.some((flag) => words.includes(flag));
|
|
58
76
|
}
|
|
59
77
|
|
|
60
78
|
/**
|
|
@@ -82,9 +100,7 @@ export function triggerMatches(trigger: RuleTrigger, context: TriggerContext): b
|
|
|
82
100
|
return false;
|
|
83
101
|
}
|
|
84
102
|
const shapes = SHELL_SHAPES[trigger.kind];
|
|
85
|
-
return subCommands(context.command).some((words) =>
|
|
86
|
-
shapes.some((shape) => startsWithShape(words, shape)),
|
|
87
|
-
);
|
|
103
|
+
return subCommands(context.command).some((words) => shapes.some((shape) => matchesShape(words, shape)));
|
|
88
104
|
}
|
|
89
105
|
default: {
|
|
90
106
|
if (context.command === undefined) {
|