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

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
@@ -353,7 +353,7 @@ most common way a new rule reads as protection and is not:
353
353
  | proof | what it names | how it matches |
354
354
  |---|---|---|
355
355
  | `subagent(<type>)` | the *declared type* of a spawn that finished, not the name the spawning agent gave it | exact string |
356
- | `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`|
356
+ | `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`. A word with no `/` of its own also matches a path ending in it: `command(build.sh)` matches `./scripts/build.sh` and `/opt/tools/build.sh` alike, never a different file that merely ends the same way |
357
357
  | `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` |
358
358
  | `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 |
359
359
 
package/docs/log.md CHANGED
@@ -16,6 +16,7 @@ newest first. For what landed in which npm release, see `CHANGELOG.md` at the re
16
16
  ## 2026-08-30
17
17
 
18
18
  - **AD-120** — A rule denial names the directory and sha it actually checked ([/decisions/ad-120.md](/decisions/ad-120.md))
19
+ - **AD-121** — command(<pattern>) matches a bare word against a path ending in it, not only an exact word ([/decisions/ad-121.md](/decisions/ad-121.md))
19
20
 
20
21
  ## 2026-08-29
21
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tech-leads-club/harness-toolkit",
3
- "version": "0.10.4",
3
+ "version": "0.10.5",
4
4
  "type": "module",
5
5
  "description": "Multi-provider agent steering: gates, follow-up, handoff, policy",
6
6
  "keywords": [
@@ -75,6 +75,24 @@ function matchesShape(words: readonly string[], shape: ShellShape): boolean {
75
75
  return !shape.excludeIfAny?.some((flag) => words.includes(flag));
76
76
  }
77
77
 
78
+ /**
79
+ * why a basename fallback: a token with no `/` of its own names an act, not a location — `build.sh` is the
80
+ * same script whether it runs as `build.sh`, `./scripts/build.sh` or `/home/user/tools/scripts/build.sh`.
81
+ * Confirmed live: a real `command(<script>)` proof stayed unsatisfied forever because every recorded
82
+ * invocation ran the script by its full installed path, and a whole word is never `===` one of its own path
83
+ * segments ([/decisions/ad-121.md](/decisions/ad-121.md)).
84
+ *
85
+ * why the `/` guard and not a bare suffix check: a token that already contains a `/` is the operator naming a
86
+ * location, and a location is exact or it is the wrong one — treating `not_review.py` as a match for
87
+ * `review.py` would turn a word boundary into a substring scan.
88
+ */
89
+ function tokenMatches(word: string | undefined, token: string): boolean {
90
+ if (word === undefined) {
91
+ return false;
92
+ }
93
+ return word === token || (!token.includes("/") && word.endsWith(`/${token}`));
94
+ }
95
+
78
96
  /**
79
97
  * why a phrase and not a word: an operator writes `command(gh pr review)`, meaning those words in that order.
80
98
  * Matching the raw string against the whole command would let a heredoc or an unrelated argument satisfy it.
@@ -84,7 +102,7 @@ export function matchesPhrase(words: readonly string[], pattern: string): boolea
84
102
  if (phrase.length === 0) {
85
103
  return false;
86
104
  }
87
- return words.some((_, start) => phrase.every((token, index) => words[start + index] === token));
105
+ return words.some((_, start) => phrase.every((token, index) => tokenMatches(words[start + index], token)));
88
106
  }
89
107
 
90
108
  export function triggerMatches(trigger: RuleTrigger, context: TriggerContext): boolean {