@yegor256/dogent 0.10.0 → 0.12.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 (75) hide show
  1. package/README.md +135 -15
  2. package/package.json +1 -1
  3. package/src/args.js +24 -4
  4. package/src/defaults.js +47 -0
  5. package/src/dogent.js +65 -18
  6. package/src/openai.js +8 -5
  7. package/src/prompt.js +0 -4
  8. package/src/report.js +26 -3
  9. package/src/rules/ambiguous-or.js +61 -0
  10. package/src/rules/atomic.js +3 -0
  11. package/src/rules/budget.js +3 -0
  12. package/src/rules/command.js +3 -0
  13. package/src/rules/concise.js +3 -0
  14. package/src/rules/conditional.js +58 -0
  15. package/src/rules/consistent.js +4 -1
  16. package/src/rules/counter-example.js +3 -0
  17. package/src/rules/crowded.js +3 -0
  18. package/src/rules/dead-import.js +3 -0
  19. package/src/rules/default.js +63 -0
  20. package/src/rules/description-length.js +67 -0
  21. package/src/rules/description-triggers.js +3 -0
  22. package/src/rules/description-voice.js +70 -0
  23. package/src/rules/done.js +3 -0
  24. package/src/rules/duplicate-section.js +68 -0
  25. package/src/rules/emoji.js +63 -0
  26. package/src/rules/emphasis.js +3 -0
  27. package/src/rules/empty.js +3 -0
  28. package/src/rules/example-format.js +35 -0
  29. package/src/rules/example.js +3 -0
  30. package/src/rules/external-link.js +60 -0
  31. package/src/rules/fence-language.js +58 -0
  32. package/src/rules/format.js +3 -0
  33. package/src/rules/frontmatter.js +3 -0
  34. package/src/rules/grouped.js +3 -0
  35. package/src/rules/hedging.js +3 -0
  36. package/src/rules/hidden-char.js +64 -0
  37. package/src/rules/homoglyph.js +85 -0
  38. package/src/rules/index.js +40 -0
  39. package/src/rules/inline-code.js +82 -0
  40. package/src/rules/jargon.js +17 -4
  41. package/src/rules/line-length.js +3 -0
  42. package/src/rules/meta-reference.js +60 -0
  43. package/src/rules/name-format.js +3 -0
  44. package/src/rules/name-matches-dir.js +3 -0
  45. package/src/rules/no-articles.js +3 -0
  46. package/src/rules/ordered.js +3 -0
  47. package/src/rules/passive.js +3 -0
  48. package/src/rules/persona.js +3 -0
  49. package/src/rules/placement.js +65 -0
  50. package/src/rules/polite.js +3 -0
  51. package/src/rules/positive.js +3 -0
  52. package/src/rules/pseudo-heading.js +3 -0
  53. package/src/rules/punctuation.js +3 -0
  54. package/src/rules/quantifier.js +66 -0
  55. package/src/rules/rationale.js +3 -0
  56. package/src/rules/redundant.js +3 -0
  57. package/src/rules/referential.js +3 -0
  58. package/src/rules/scope.js +34 -0
  59. package/src/rules/section-level.js +3 -0
  60. package/src/rules/self-contained.js +3 -0
  61. package/src/rules/short-sections.js +3 -0
  62. package/src/rules/simple.js +3 -0
  63. package/src/rules/stale.js +3 -0
  64. package/src/rules/terms.js +3 -0
  65. package/src/rules/token-count.js +3 -0
  66. package/src/rules/tool-clarity.js +3 -0
  67. package/src/rules/transition.js +62 -0
  68. package/src/rules/unfinished.js +3 -0
  69. package/src/rules/unique.js +3 -0
  70. package/src/rules/units.js +84 -0
  71. package/src/rules/untrusted.js +3 -0
  72. package/src/rules/vague.js +3 -0
  73. package/src/rules/weak-verb.js +65 -0
  74. package/src/sources.js +3 -0
  75. package/src/version.js +2 -2
package/README.md CHANGED
@@ -13,23 +13,47 @@ Vague, bloated, or ambiguous instructions make agents behave unpredictably.
13
13
  `dogent` enforces a clear, command-style discipline
14
14
  so every line earns its place.
15
15
 
16
- We respect [agent-sh/agnix](https://github.com/agent-sh/agnix)
17
- as a prototype of this idea, but the two lint different layers.
18
- `agnix` validates the harness around a prompt frontmatter schema,
19
- hook JSON, MCP config, tool wiring — asking whether the configuration
20
- is well-formed and correctly wired.
21
- `dogent` lints the prose of the instructions themselves,
22
- asking whether every line is a tight, unambiguous command.
23
- In short: `agnix` lints the harness, `dogent` lints the prompt.
24
- `dogent` is the stricter, more opinionated of the two,
25
- aiming for extreme quality with no compromise.
16
+ ## Alternatives
17
+
18
+ Several tools sit near this problem, yet none lint manifesto prose
19
+ the way we do.
20
+ Most rewrite prompts for you or score a file, while we enforce
21
+ line-level discipline and fail the build when one line breaks it.
22
+
23
+ - [agent-sh/agnix](https://github.com/agent-sh/agnix)
24
+ validates the harness — frontmatter schema, hook JSON, MCP config, tools —
25
+ while we lint the prose, asking if each line is a tight command.
26
+
27
+ - [AgentLinter](https://agentlinter.com)
28
+ lints the same files but scores them 0–100 across eight dimensions,
29
+ while we run a strict pass/fail gate, not a scorer.
30
+
31
+ - [microsoft/SkillOpt](https://github.com/microsoft/SkillOpt)
32
+ rewrites `skill.md` against benchmarks to raise an agent's task score,
33
+ while we never rewrite, need no benchmarks, and judge discipline.
34
+
35
+ - [linshenkx/prompt-optimizer](https://github.com/linshenkx/prompt-optimizer)
36
+ rewrites a prompt through an LLM in the browser, round by round,
37
+ while we lint manifesto files offline in CI.
38
+
39
+ - [DSPy](https://github.com/stanfordnlp/dspy)
40
+ tunes prompts as programs to maximize a metric on a dataset,
41
+ while we lint hand-written manifestos, demanding clarity over a score.
42
+
43
+ - Prompt platforms like [LangSmith](https://www.langchain.com/langsmith),
44
+ [PromptLayer](https://www.promptlayer.com), and
45
+ [Braintrust](https://www.braintrust.dev)
46
+ version, trace, and evaluate prompts from runtime data,
47
+ while we lint statically and offline, needing no traces or account.
26
48
 
27
49
  ## Usage
28
50
 
51
+ [Watch the demo](https://github.com/user-attachments/assets/0f05c975-2bc8-4a95-9c3a-76a78fe5d655)
52
+
29
53
  Run it on any manifesto file, no installation required:
30
54
 
31
55
  ```bash
32
- npx @yegor256/dogent@0.7.2 SKILL.md
56
+ npx @yegor256/dogent@0.11.0 SKILL.md
33
57
  ```
34
58
 
35
59
  Point it at a directory to lint the default manifestos it holds
@@ -51,9 +75,14 @@ CLAUDE.md
51
75
  24: article "the" detected, remove noise
52
76
  31: section name too long, use 1-3 words
53
77
 
54
- 4 problems found, exit code 1
78
+ Locally: 4 problems found, exit code 1
79
+ Spotted a false positive? dogent is in beta, please report it at https://github.com/yegor256/dogent/issues
55
80
  ```
56
81
 
82
+ The standalone checks report under their own `Locally:` summary line.
83
+ When a token enables AI verification, a second `OpenAI:` summary line
84
+ follows, reporting the problems the model found on top.
85
+
57
86
  The command exits with a non-zero status when problems are found,
58
87
  so it plugs directly into CI and pre-commit hooks.
59
88
 
@@ -100,12 +129,19 @@ The command exits with a non-zero status when problems are found,
100
129
  `dogent` works standalone by default,
101
130
  using fast deterministic checks with no network access.
102
131
  When `OPENAI_API_KEY` is present in the environment,
103
- and only after the standalone rules find nothing,
104
- `dogent` asks OpenAI for a second, deeper opinion.
132
+ `dogent` first reports the problems found locally,
133
+ then asks OpenAI for a second, deeper opinion and reports those apart.
105
134
  It sends the manifesto together with one instruction per rule,
106
135
  then prints any violation the model reports for ambiguity,
107
136
  weak phrasing, and instructions that only pretend to be commands.
137
+ Each step closes with its own summary line, `Locally:` then `OpenAI:`,
138
+ so both counts stay visible even when neither step finds a problem.
108
139
  The model defaults to `gpt-4o-mini`; override it with `OPENAI_MODEL`.
140
+ Requests go to `https://api.openai.com/v1` by default;
141
+ set `OPENAI_BASE_URL` to any OpenAI-compatible endpoint instead,
142
+ such as a local vLLM, Ollama, or LocalAI server, or a private gateway.
143
+ For a keyless local server, set `OPENAI_API_KEY` to any placeholder,
144
+ since the server ignores it yet `dogent` calls the LLM only when it is set.
109
145
  After the report, `dogent` prints a one-line usage summary to standard error,
110
146
  naming the model, the tokens sent and received, and an estimated cost,
111
147
  for example `OpenAI: gpt-4o-mini, 1234 sent, 567 received, ~$0.0005`.
@@ -115,6 +151,15 @@ export OPENAI_API_KEY=...
115
151
  npx @yegor256/dogent CLAUDE.md
116
152
  ```
117
153
 
154
+ Point it at a local model the same way:
155
+
156
+ ```bash
157
+ export OPENAI_BASE_URL=http://localhost:11434/v1
158
+ export OPENAI_MODEL=llama3
159
+ export OPENAI_API_KEY=dummy
160
+ npx @yegor256/dogent CLAUDE.md
161
+ ```
162
+
118
163
  Pass `--offline` to keep `dogent` away from the LLM,
119
164
  even when `OPENAI_API_KEY` is present in the environment:
120
165
 
@@ -124,6 +169,14 @@ npx @yegor256/dogent --offline CLAUDE.md
124
169
 
125
170
  Pass `--sarif` to print the report as SARIF instead of plain text.
126
171
 
172
+ Pass `--hints` to append, for every rule that reported a violation, one
173
+ English paragraph explaining how to fix it. This helps you or your agent
174
+ repair the manifesto faster:
175
+
176
+ ```bash
177
+ npx @yegor256/dogent --hints CLAUDE.md
178
+ ```
179
+
127
180
  Pass `--suppress` to silence a rule by its id. Repeat the option or
128
181
  join several ids with commas to silence many at once:
129
182
 
@@ -131,6 +184,43 @@ Pass `--suppress` to silence a rule by its id. Repeat the option or
131
184
  npx @yegor256/dogent --suppress=name-matches-dir,line-length CLAUDE.md
132
185
  ```
133
186
 
187
+ Pass `--openai-http-header` to add one `Name: Value` header to every OpenAI
188
+ call, handy for a gateway that wants its own token beside the API key.
189
+ Repeat the option to add several headers:
190
+
191
+ ```bash
192
+ npx @yegor256/dogent \
193
+ --openai-http-header='X-Api-Key: secret' \
194
+ --openai-http-header='X-Tenant: acme' \
195
+ CLAUDE.md
196
+ ```
197
+
198
+ A header value often holds a secret, so keep it out of your shell history
199
+ by listing the option in a `.dogent` file (see below) instead.
200
+
201
+ ## Defaults file
202
+
203
+ Tired of repeating the same flags on every run?
204
+ Drop a `.dogent` file beside your project, and `dogent` reads its options
205
+ as defaults before it touches the command line.
206
+ The file holds one option per line, written exactly as you would type it,
207
+ with the value after a space (for example `--suppress name-matches-dir`).
208
+ A blank line is ignored, and a line that starts with `#` is a comment:
209
+
210
+ ```text
211
+ # project defaults for dogent
212
+ --offline
213
+ --suppress name-matches-dir
214
+ --suppress line-length
215
+ ```
216
+
217
+ `dogent` looks first in the current directory, then in your home directory,
218
+ and reads the first `.dogent` it finds.
219
+ The file only supplies defaults, so any option you type on the command line
220
+ overrides the same option in the file.
221
+ Thus `npx @yegor256/dogent --sarif .` still prints SARIF even when the file
222
+ says nothing about it, and a hand-typed flag always wins.
223
+
134
224
  ## GitHub Actions
135
225
 
136
226
  Because `dogent` runs through `npx`, no extra action is needed.
@@ -176,7 +266,7 @@ Reference `dogent` as a remote hook in `.pre-commit-config.yaml`:
176
266
  ```yaml
177
267
  repos:
178
268
  - repo: https://github.com/yegor256/dogent
179
- rev: 0.7.2
269
+ rev: 0.11.0
180
270
  hooks:
181
271
  - id: dogent
182
272
  ```
@@ -196,3 +286,33 @@ repos:
196
286
  ```
197
287
 
198
288
  Either way, the commit is rejected until every flagged line is fixed.
289
+
290
+ ## Architecture
291
+
292
+ The component diagram below shows how the pieces fit together
293
+ ([source](components.svg)):
294
+
295
+ ![dogent component diagram](components.svg)
296
+
297
+ A user, or an AI agent, runs `dogent` from the console against a manifesto
298
+ such as `SKILL.md`.
299
+ `dogent` parses the file into a document, applies every deterministic rule,
300
+ and renders the violations as text or SARIF.
301
+ Only after the rules find nothing, and only when a token is present,
302
+ it asks an OpenAI-compatible LLM for a second, deeper opinion.
303
+
304
+ ## How to Contribute
305
+
306
+ First, make sure you can build it locally:
307
+
308
+ ```bash
309
+ npm test
310
+ ```
311
+
312
+ The build has to be clean.
313
+ If it's not, [submit an issue](https://github.com/yegor256/dogent/issues).
314
+
315
+ Then, make your changes, make sure the build is still clean,
316
+ and [submit a pull request][guidelines].
317
+
318
+ [guidelines]: https://www.yegor256.com/2014/04/15/github-guidelines.html
package/package.json CHANGED
@@ -40,7 +40,7 @@
40
40
  "lint": "eslint .",
41
41
  "test": "mocha 'test/**/*.js' --timeout 60000"
42
42
  },
43
- "version": "0.10.0",
43
+ "version": "0.12.0",
44
44
  "dependencies": {
45
45
  "minimist": "^1.2.8",
46
46
  "pretty-ms": "^7.0.1"
package/src/args.js CHANGED
@@ -16,14 +16,18 @@ const minimist = require('minimist');
16
16
  * any talk to the LLM even when a token sits in the environment. The `--help`
17
17
  * flag, also spelled `-h`, asks for the usage banner. The `--version` flag
18
18
  * asks for the release number. The `--suppress` option names a rule to
19
- * silence; repeat it or join names with commas to silence many at once.
20
- * Everything after a `--` separator counts as a path, never as an option.
19
+ * silence; repeat it or join names with commas to silence many at once. The
20
+ * `--hints` flag appends, for every rule that reported a violation, one
21
+ * English paragraph telling the agent how to fix it. The
22
+ * `--openai-http-header` option adds one `Name: Value` header to every OpenAI
23
+ * call; repeat it to add many. Everything after a `--` separator counts as a
24
+ * path, never as an option.
21
25
  */
22
26
  class Args {
23
27
  constructor(
24
28
  argv,
25
- flags = ['sarif', 'offline', 'help', 'version'],
26
- options = ['suppress']
29
+ flags = ['sarif', 'offline', 'help', 'version', 'hints'],
30
+ options = ['suppress', 'openai-http-header']
27
31
  ) {
28
32
  this.flags = flags;
29
33
  this.options = options;
@@ -44,12 +48,28 @@ class Args {
44
48
  version() {
45
49
  return this.parsed.version === true;
46
50
  }
51
+ hints() {
52
+ return this.parsed.hints === true;
53
+ }
47
54
  suppress() {
48
55
  return [].concat(this.parsed.suppress || [])
49
56
  .flatMap((item) => String(item).split(','))
50
57
  .map((name) => name.trim())
51
58
  .filter((name) => name !== '');
52
59
  }
60
+ headers() {
61
+ return [].concat(this.parsed['openai-http-header'] || [])
62
+ .map(String)
63
+ .map((line) => line.trim())
64
+ .filter((line) => line !== '')
65
+ .reduce((acc, line) => {
66
+ const at = line.indexOf(':');
67
+ if (at < 0) {
68
+ throw new Error(`Malformed header "${line}", expected "Name: Value"`);
69
+ }
70
+ return {...acc, [line.slice(0, at).trim()]: line.slice(at + 1).trim()};
71
+ }, {});
72
+ }
53
73
  paths() {
54
74
  return this.parsed._.concat(this.parsed['--']).map(String);
55
75
  }
@@ -0,0 +1,47 @@
1
+ /*
2
+ * SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+
12
+ /**
13
+ * Defaults.
14
+ *
15
+ * The default command-line options pulled from a `.dogent` file. Dogent looks
16
+ * first in the current directory, then in the user home, and reads the first
17
+ * file it finds. Each line names one option, written exactly as typed on the
18
+ * command line, with its value after a space. A blank line vanishes, and a
19
+ * line that opens with `#` reads as a comment. These options sit ahead of the
20
+ * real argv, so any flag typed by hand overrides the file.
21
+ */
22
+ class Defaults {
23
+ constructor(
24
+ dirs = [process.cwd(), os.homedir()],
25
+ exists = (file) => fs.existsSync(file),
26
+ reader = (file) => fs.readFileSync(file, 'utf8')
27
+ ) {
28
+ this.dirs = dirs;
29
+ this.exists = exists;
30
+ this.reader = reader;
31
+ }
32
+ argv() {
33
+ const file = this.dirs
34
+ .map((dir) => path.join(dir, '.dogent'))
35
+ .find((candidate) => this.exists(candidate));
36
+ if (!file) {
37
+ return [];
38
+ }
39
+ return this.reader(file)
40
+ .split('\n')
41
+ .map((line) => line.trim())
42
+ .filter((line) => line !== '' && !line.startsWith('#'))
43
+ .flatMap((line) => line.split(/\s+/u));
44
+ }
45
+ }
46
+
47
+ module.exports = Defaults;
package/src/dogent.js CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  const fs = require('fs');
10
10
  const Args = require('./args');
11
+ const Defaults = require('./defaults');
11
12
  const Markdown = require('./markdown');
12
13
  const Report = require('./report');
13
14
  const Sources = require('./sources');
@@ -18,9 +19,9 @@ const prettyMs = require('pretty-ms');
18
19
  const version = require('./version');
19
20
  const rules = require('./rules');
20
21
 
21
- const args = new Args(process.argv.slice(2));
22
+ const args = new Args(new Defaults().argv().concat(process.argv.slice(2)));
22
23
  const sarif = args.sarif();
23
- const banner = 'Usage: dogent [--sarif] [--offline] [--suppress=RULE,...] <file.md|dir>...';
24
+ const banner = 'Usage: dogent [--sarif] [--offline] [--hints] [--suppress=RULE,...] <file.md|dir>...';
24
25
  if (args.version()) {
25
26
  process.stdout.write(`${version}\n`);
26
27
  process.exit(0);
@@ -33,8 +34,13 @@ if (args.help()) {
33
34
  ' --sarif render the report as SARIF JSON\n' +
34
35
  ' --offline never call the LLM, even when a token exists\n' +
35
36
  ' --suppress silence a rule by id; repeat or comma-join to silence many\n' +
37
+ ' --hints append a fixing hint for every rule that reported a violation\n' +
38
+ ' --openai-http-header add a "Name: Value" header to OpenAI calls\n' +
36
39
  ' --version show the version and exit\n' +
37
- ' --help show this help and exit\n'
40
+ ' --help show this help and exit\n\n' +
41
+ 'Defaults:\n' +
42
+ ' A .dogent file in the current directory or home holds default\n' +
43
+ ' options, one per line; options typed by hand override it.\n'
38
44
  );
39
45
  process.exit(0);
40
46
  }
@@ -44,12 +50,29 @@ if (unknown.length > 0) {
44
50
  process.stderr.write(`${banner}\n`);
45
51
  process.exit(2);
46
52
  }
53
+ let headers = {};
54
+ try {
55
+ headers = args.headers();
56
+ } catch (error) {
57
+ process.stderr.write(`${error.message}\n`);
58
+ process.exit(2);
59
+ }
47
60
  const paths = args.paths();
48
61
  if (paths.length === 0) {
49
62
  process.stderr.write(`${banner}\n`);
50
63
  process.exit(2);
51
64
  }
52
- const scanned = new Sources(paths).files();
65
+ const scan = () => {
66
+ try {
67
+ return new Sources(paths).files();
68
+ } catch (error) {
69
+ process.stderr.write(`${error.message}\n`);
70
+ process.stderr.write(`${banner}\n`);
71
+ process.exit(2);
72
+ }
73
+ return [];
74
+ };
75
+ const scanned = scan();
53
76
  scanned.forEach((file) => process.stderr.write(`Scanning ${file}\n`));
54
77
  const checks = rules();
55
78
  process.stderr.write(`${scanned.length} files scanned, ${checks.length} rules applied\n`);
@@ -65,6 +88,7 @@ documents.forEach((document) => {
65
88
  rule.violations(document).filter(allowed).forEach((violation) => found.push(violation));
66
89
  });
67
90
  });
91
+ const localMillis = Date.now() - started;
68
92
  const key = process.env.OPENAI_API_KEY;
69
93
  const audit = async (docs) => {
70
94
  const oracle = new Oracle(
@@ -72,7 +96,11 @@ const audit = async (docs) => {
72
96
  new Openai(
73
97
  key,
74
98
  process.env.OPENAI_MODEL || 'gpt-4o-mini',
75
- (url, options) => globalThis.fetch(url, options)
99
+ process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1',
100
+ (url, options) => globalThis.fetch(
101
+ url,
102
+ {...options, headers: {...options.headers, ...headers}}
103
+ )
76
104
  )
77
105
  );
78
106
  const replies = await Promise.all(docs.map((doc) => oracle.violations(doc)));
@@ -84,23 +112,42 @@ const audit = async (docs) => {
84
112
  {extra: [], usage: new Usage('', 0, 0)}
85
113
  );
86
114
  };
87
- const finish = (usage, aiMillis) => {
88
- const report = new Report('dogent', found, Date.now() - started);
89
- process.stdout.write(`${sarif ? JSON.stringify(report.sarif(), null, 2) : report.text()}\n`);
90
- if (usage !== null) {
91
- process.stderr.write(`${usage.text()}, analysed in ${prettyMs(aiMillis)}\n`);
92
- }
93
- process.exit(report.count() > 0 ? 1 : 0);
94
- };
95
115
  const verify = async () => {
96
116
  const clock = Date.now();
97
117
  const result = await audit(documents);
98
- result.extra.filter(allowed).forEach((violation) => found.push(violation));
99
- return {usage: result.usage, aiMillis: Date.now() - clock};
118
+ return {
119
+ extra: result.extra.filter(allowed),
120
+ usage: result.usage,
121
+ aiMillis: Date.now() - clock
122
+ };
123
+ };
124
+ const consult = Boolean(key) && !args.offline();
125
+ const human = (outcome) => {
126
+ const all = found.concat(outcome.extra);
127
+ process.stdout.write(`${new Report('dogent', found, localMillis, 'Locally').text()}\n`);
128
+ if (consult) {
129
+ process.stdout.write(`${new Report('dogent', outcome.extra, outcome.aiMillis, 'OpenAI').text()}\n`);
130
+ }
131
+ if (args.hints() && all.length > 0) {
132
+ process.stdout.write(`\n${new Report('dogent', all).hints(checks)}\n`);
133
+ }
134
+ };
135
+ const render = (outcome) => {
136
+ const all = found.concat(outcome.extra);
137
+ if (sarif) {
138
+ const report = new Report('dogent', all, localMillis + outcome.aiMillis);
139
+ process.stdout.write(`${JSON.stringify(report.sarif(), null, 2)}\n`);
140
+ } else {
141
+ human(outcome);
142
+ }
143
+ if (outcome.usage !== null) {
144
+ process.stderr.write(`${outcome.usage.text()}, analysed in ${prettyMs(outcome.aiMillis)}\n`);
145
+ }
146
+ process.exit(all.length > 0 ? 1 : 0);
100
147
  };
101
148
  (async () => {
102
- let outcome = {aiMillis: 0, usage: null};
103
- if (found.length === 0 && key && !args.offline()) {
149
+ let outcome = {extra: [], aiMillis: 0, usage: null};
150
+ if (consult) {
104
151
  try {
105
152
  outcome = await verify();
106
153
  } catch (error) {
@@ -108,5 +155,5 @@ const verify = async () => {
108
155
  process.exit(2);
109
156
  }
110
157
  }
111
- finish(outcome.usage, outcome.aiMillis);
158
+ render(outcome);
112
159
  })();
package/src/openai.js CHANGED
@@ -10,20 +10,23 @@ const Usage = require('./usage');
10
10
  /**
11
11
  * Openai.
12
12
  *
13
- * A thin adapter over the OpenAI chat-completions endpoint. Sends one
14
- * prompt, demands a JSON object back, and returns the assistant text
15
- * paired with a Usage tally of the model and the tokens it consumed.
13
+ * A thin adapter over an OpenAI-compatible chat-completions endpoint.
14
+ * Sends one prompt, demands a JSON object back, and returns the assistant
15
+ * text paired with a Usage tally of the model and the tokens it consumed.
16
+ * The base URL is configurable, so the same adapter reaches OpenAI itself
17
+ * or any compatible server, such as vLLM, Ollama, or a private gateway.
16
18
  * The transport is injected so the class runs in tests without a socket.
17
19
  */
18
20
  class Openai {
19
- constructor(key, model, transport) {
21
+ constructor(key, model, base, transport) {
20
22
  this.key = key;
21
23
  this.model = model;
24
+ this.base = base;
22
25
  this.transport = transport;
23
26
  }
24
27
  async answer(prompt) {
25
28
  const response = await this.transport(
26
- 'https://api.openai.com/v1/chat/completions',
29
+ `${this.base.replace(/\/+$/u, '')}/chat/completions`,
27
30
  {
28
31
  method: 'POST',
29
32
  headers: {
package/src/prompt.js CHANGED
@@ -41,10 +41,6 @@ class Prompt {
41
41
  'report nothing and return {"results": []}. Never invent a',
42
42
  'violation to seem useful, and never flag a line merely for',
43
43
  'sitting in a plain, on-topic section.',
44
- 'One clash is never a false alarm: when two lines give the same',
45
- 'order twice, or demand opposite things, report each offending',
46
- 'line with high confidence, even while every other check stays',
47
- 'conservative. A clean file has no such clash and stays silent.',
48
44
  'Report a violation only when you are certain it breaks a check.',
49
45
  'Give every result a "confidence" number from 0 to 1, your own',
50
46
  'probability that the violation is real, and omit any result you',
package/src/report.js CHANGED
@@ -14,21 +14,44 @@ const prettyMs = require('pretty-ms');
14
14
  * violation it gathered. Renders itself for humans or as a SARIF log.
15
15
  * When handed the analysis duration in milliseconds, the human text
16
16
  * closes with a friendly "in 340ms" rendered through pretty-ms.
17
+ * A label tags the summary line, telling local checks from AI ones.
18
+ * Given the rules that ran, it can also render one fixing hint per rule
19
+ * that reported a violation, in first-appearance order.
17
20
  */
18
21
  class Report {
19
- constructor(tool, violations, millis = null) {
22
+ constructor(tool, violations, millis = null, label = '') {
20
23
  this.tool = tool;
21
24
  this.bag = violations;
22
25
  this.millis = millis;
26
+ this.label = label;
23
27
  }
24
28
  count() {
25
29
  return this.bag.length;
26
30
  }
27
31
  text() {
28
32
  const suffix = this.millis === null ? '' : ` in ${prettyMs(this.millis)}`;
29
- return this.bag
33
+ const prefix = this.label === '' ? '' : `${this.label}: `;
34
+ const lines = this.bag
30
35
  .map((violation) => violation.text())
31
- .concat(`${this.bag.length} problems found${suffix}`)
36
+ .concat(`${prefix}${this.bag.length} problems found${suffix}`);
37
+ if (this.bag.length > 0) {
38
+ lines.push(
39
+ 'Spotted a false positive? dogent is in beta, please report it at ' +
40
+ 'https://github.com/yegor256/dogent/issues'
41
+ );
42
+ }
43
+ return lines.join('\n');
44
+ }
45
+ hints(rules) {
46
+ const byId = new Map(rules.map((rule) => [rule.id, rule]));
47
+ const seen = [];
48
+ this.bag.forEach((violation) => {
49
+ if (!seen.includes(violation.rule) && byId.has(violation.rule)) {
50
+ seen.push(violation.rule);
51
+ }
52
+ });
53
+ return seen
54
+ .map((id) => `[${id}]: ${byId.get(id).hint()}`)
32
55
  .join('\n');
33
56
  }
34
57
  sarif() {
@@ -0,0 +1,61 @@
1
+ /*
2
+ * SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ const Violation = require('../violation');
9
+ const Region = require('../region');
10
+ const mask = require('../mask');
11
+
12
+ /**
13
+ * AmbiguousOr.
14
+ *
15
+ * An "either-or" choice left to the reader is an instruction the agent
16
+ * cannot follow without guessing. This flags the literal "and/or" and a
17
+ * two-term slashed alternative like "tabs/spaces", each demanding the
18
+ * author pick one branch and state exactly which option applies. Inline
19
+ * code is masked first, so a backticked path such as `src/app` never
20
+ * matches. Subtler ambiguities stay with the oracle; prompt() defers
21
+ * the open-ended either-or judgement to it.
22
+ */
23
+ class AmbiguousOr {
24
+ constructor() {
25
+ this.id = 'ambiguous-or';
26
+ }
27
+ hint() {
28
+ return 'Replace every either-or choice such as and/or or a slashed alternative with one explicit branch, naming exactly which option the agent must take so no guessing remains.';
29
+ }
30
+ prompt() {
31
+ return `${this.id}: flag either-or ambiguity beyond "and/or" and slashed alternatives, and state exactly which option applies`;
32
+ }
33
+ violations(document) {
34
+ const uri = document.uri();
35
+ return document.walk({
36
+ header: () => [],
37
+ prose: (text, line) => this.scan(text, line, uri),
38
+ snippet: () => [],
39
+ bullets: () => [],
40
+ frontmatter: () => []
41
+ });
42
+ }
43
+ scan(text, line, uri) {
44
+ const masked = mask(text);
45
+ const pattern = /\band\/or\b|(?<![\w./])[A-Za-z]{2,}\/[A-Za-z]{2,}(?![\w/]|\.\w)/giu;
46
+ const found = [];
47
+ let hit = pattern.exec(masked);
48
+ while (hit !== null) {
49
+ found.push(new Violation(
50
+ this.id,
51
+ 'warning',
52
+ `ambiguous "${hit[0]}", state exactly which option applies`,
53
+ new Region(uri, line, hit.index + 1)
54
+ ));
55
+ hit = pattern.exec(masked);
56
+ }
57
+ return found;
58
+ }
59
+ }
60
+
61
+ module.exports = AmbiguousOr;
@@ -24,6 +24,9 @@ class Atomic {
24
24
  constructor() {
25
25
  this.id = 'atomic';
26
26
  }
27
+ hint() {
28
+ return 'Split a line that bundles several instructions into one line per instruction, since the agent reads each line as a single command and welded clauses get half-followed.';
29
+ }
27
30
  prompt() {
28
31
  return `${this.id}: flag any line that carries more than one instruction, counting distinct clauses whether or not a semicolon, "and", or "then" welds them, yet never count a coordinated object or noun phrase trailing "and" or "then" as a second instruction`;
29
32
  }
@@ -24,6 +24,9 @@ class Budget {
24
24
  this.id = 'budget';
25
25
  this.cap = cap;
26
26
  }
27
+ hint() {
28
+ return 'Trim or split the manifesto so it holds fewer instructions than the budget, moving secondary guidance into separate referenced files to keep the core small.';
29
+ }
27
30
  prompt() {
28
31
  return '';
29
32
  }
@@ -20,6 +20,9 @@ class Command {
20
20
  constructor() {
21
21
  this.id = 'command';
22
22
  }
23
+ hint() {
24
+ return 'Rewrite the line as a direct imperative that opens with a base-form verb such as Write, Strip, or Keep, dropping any pronoun, question, or plain statement.';
25
+ }
23
26
  prompt() {
24
27
  return `${this.id}: flag any line that reads as a description, a question, or a plain statement rather than a direct order; a line opening with a base-form imperative verb, such as "Write", "Strip", "Drop", or "Keep", is itself a direct order and must never be flagged`;
25
28
  }
@@ -25,6 +25,9 @@ class Concise {
25
25
  this.id = 'concise';
26
26
  this.max = max;
27
27
  }
28
+ hint() {
29
+ return 'Shorten the file or split its detail into referenced files so its middle instructions survive, since models attend to the start and end and skim the middle.';
30
+ }
28
31
  prompt() {
29
32
  return `${this.id}: flag a manifesto so long its middle instructions risk being lost, and recommend splitting detail into referenced files`;
30
33
  }