@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/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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tech-leads-club/harness-toolkit",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
4
4
  "type": "module",
5
5
  "description": "Multi-provider agent steering: gates, follow-up, handoff, policy",
6
6
  "keywords": [
@@ -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(<glob>)
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: `tlc harness help rules`.
174
+ the rule fires. Full grammar, exact matching semantics per proof kind, and the verdict matrix:
175
+ `tlc harness help rules`.
175
176
 
176
- Two things to say out loud, because both surprise people later:
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
- ): RuleProof[] {
93
- return rule.require.filter((proof) => !proofSatisfied(proof, observations, context));
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(proof: RuleProof): string {
97
- return `${proof.kind}(${proof.value}) since ${proof.since === "head" ? "HEAD" : "session"}`;
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
- const SHELL_SHAPES: Record<"pr-open" | "commit" | "push", readonly string[][]> = {
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[], shape: readonly string[]): boolean {
57
- return shape.every((token, index) => words[index] === token);
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) {