@yegor256/dogent 0.4.0 → 0.5.1

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/README.md CHANGED
@@ -14,16 +14,22 @@ Vague, bloated, or ambiguous instructions make agents behave unpredictably.
14
14
  so every line earns its place.
15
15
 
16
16
  We respect [agent-sh/agnix](https://github.com/agent-sh/agnix)
17
- as a prototype of this idea.
18
- `dogent` goes further: it is stricter, more opinionated,
19
- and aims for extreme quality with no compromise.
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.
20
26
 
21
27
  ## Usage
22
28
 
23
29
  Run it on any manifesto file, no installation required:
24
30
 
25
31
  ```bash
26
- npx @yegor256/dogent@0.3.0 CLAUDE.md
32
+ npx @yegor256/dogent@0.5.0 CLAUDE.md
27
33
  ```
28
34
 
29
35
  Lint several files at once:
@@ -33,7 +39,10 @@ npx @yegor256/dogent SKILL.md CLAUDE.md AGENTS.md
33
39
  ```
34
40
 
35
41
  Point it at a directory to lint the default manifestos it holds
36
- (`AGENTS.md`, `CLAUDE.md`, `SKILL.md`, `SKILLS.md`):
42
+ (`AGENTS.md`, `CLAUDE.md`, `SKILL.md`, `SKILLS.md`).
43
+ The directory is scanned recursively through every subfolder
44
+ (skipping `node_modules` and `.git`),
45
+ and each scanned file is announced on the standard error stream:
37
46
 
38
47
  ```bash
39
48
  npx @yegor256/dogent .
@@ -74,12 +83,16 @@ The command exits with a non-zero status when problems are found,
74
83
 
75
84
  `dogent` works standalone by default,
76
85
  using fast deterministic checks with no network access.
77
- When `OPENAI_API_KEY` or `CLAUDE_TOKEN` is present in the environment,
78
- it additionally uses AI to verify the text for ambiguity,
79
- weak phrasing, and instructions that only pretend to be commands:
86
+ When `OPENAI_API_KEY` is present in the environment,
87
+ and only after the standalone rules find nothing,
88
+ `dogent` asks OpenAI for a second, deeper opinion.
89
+ It sends the manifesto together with one instruction per rule,
90
+ then prints any violation the model reports for ambiguity,
91
+ weak phrasing, and instructions that only pretend to be commands.
92
+ The model defaults to `gpt-4o-mini`; override it with `OPENAI_MODEL`.
80
93
 
81
94
  ```bash
82
- export CLAUDE_TOKEN=...
95
+ export OPENAI_API_KEY=...
83
96
  npx @yegor256/dogent CLAUDE.md
84
97
  ```
85
98
 
@@ -109,7 +122,7 @@ To enable AI verification in CI, expose a token as a secret:
109
122
  ```yaml
110
123
  - run: npx @yegor256/dogent CLAUDE.md
111
124
  env:
112
- CLAUDE_TOKEN: ${{ secrets.CLAUDE_TOKEN }}
125
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
113
126
  ```
114
127
 
115
128
  ## Pre-commit hook
@@ -128,7 +141,7 @@ Reference `dogent` as a remote hook in `.pre-commit-config.yaml`:
128
141
  ```yaml
129
142
  repos:
130
143
  - repo: https://github.com/yegor256/dogent
131
- rev: 0.3.0
144
+ rev: 0.5.0
132
145
  hooks:
133
146
  - id: dogent
134
147
  ```
package/package.json CHANGED
@@ -40,5 +40,5 @@
40
40
  "lint": "eslint .",
41
41
  "test": "mocha 'test/**/*.js' --timeout 60000"
42
42
  },
43
- "version": "0.4.0"
43
+ "version": "0.5.1"
44
44
  }
package/src/answer.js ADDED
@@ -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 Violation = require('./violation');
9
+ const Region = require('./region');
10
+
11
+ /**
12
+ * Answer.
13
+ *
14
+ * The oracle's raw reply, treated as untrusted input. Parses the JSON
15
+ * object it carries and turns every well-formed SARIF result back into a
16
+ * native violation, ignoring any result it cannot read.
17
+ */
18
+ class Answer {
19
+ constructor(raw) {
20
+ this.raw = raw;
21
+ }
22
+ violations() {
23
+ return this.results().flatMap((result) => {
24
+ const spot = result?.locations?.[0]?.physicalLocation;
25
+ const line = spot?.region?.startLine;
26
+ const text = result?.message?.text;
27
+ if (typeof line !== 'number' || typeof text !== 'string' || !spot.artifactLocation) {
28
+ return [];
29
+ }
30
+ return [new Violation(
31
+ result.ruleId || 'oracle',
32
+ result.level || 'warning',
33
+ text,
34
+ new Region(spot.artifactLocation.uri, line, spot.region.startColumn || 1)
35
+ )];
36
+ });
37
+ }
38
+ results() {
39
+ try {
40
+ return JSON.parse(this.raw).results || [];
41
+ } catch (error) {
42
+ throw new Error(`oracle returned malformed JSON: ${error.message}`, {cause: error});
43
+ }
44
+ }
45
+ }
46
+
47
+ module.exports = Answer;
package/src/document.js CHANGED
@@ -9,12 +9,14 @@
9
9
  * Document.
10
10
  *
11
11
  * An entire manifesto already parsed into an ordered collection of
12
- * fragments, ready to be walked by a rule that hunts for violations.
12
+ * fragments, ready to be walked by a rule that hunts for violations. It
13
+ * also keeps the raw text, which the AI oracle reads verbatim.
13
14
  */
14
15
  class Document {
15
- constructor(uri, fragments) {
16
+ constructor(uri, fragments, content) {
16
17
  this.address = uri;
17
18
  this.pieces = fragments;
19
+ this.body = content;
18
20
  }
19
21
  uri() {
20
22
  return this.address;
@@ -22,6 +24,9 @@ class Document {
22
24
  fragments() {
23
25
  return this.pieces;
24
26
  }
27
+ text() {
28
+ return this.body;
29
+ }
25
30
  walk(visitor) {
26
31
  return this.pieces.reduce((all, piece) => all.concat(piece.accept(visitor)), []);
27
32
  }
package/src/dogent.js CHANGED
@@ -10,6 +10,8 @@ const fs = require('fs');
10
10
  const Markdown = require('./markdown');
11
11
  const Report = require('./report');
12
12
  const Sources = require('./sources');
13
+ const Openai = require('./openai');
14
+ const Oracle = require('./oracle');
13
15
  const rules = require('./rules');
14
16
 
15
17
  const argv = process.argv.slice(2);
@@ -19,13 +21,40 @@ if (paths.length === 0) {
19
21
  process.stderr.write('Usage: dogent [--sarif] <file.md|dir>...\n');
20
22
  process.exit(2);
21
23
  }
24
+ const scanned = new Sources(paths).files();
25
+ scanned.forEach((file) => process.stderr.write(`Scanning ${file}\n`));
26
+ process.stderr.write(`${scanned.length} files scanned\n`);
27
+ const documents = scanned.map(
28
+ (file) => new Markdown(file, fs.readFileSync(file, 'utf8')).document()
29
+ );
22
30
  const found = [];
23
- new Sources(paths).files().forEach((file) => {
24
- const document = new Markdown(file, fs.readFileSync(file, 'utf8')).document();
31
+ documents.forEach((document) => {
25
32
  rules().forEach((rule) => {
26
33
  rule.violations(document).forEach((violation) => found.push(violation));
27
34
  });
28
35
  });
29
- const report = new Report('dogent', found);
30
- process.stdout.write(`${sarif ? JSON.stringify(report.sarif(), null, 2) : report.text()}\n`);
31
- process.exit(report.count() > 0 ? 1 : 0);
36
+ const key = process.env.OPENAI_API_KEY;
37
+ (async () => {
38
+ if (found.length === 0 && key) {
39
+ try {
40
+ const oracle = new Oracle(
41
+ rules(),
42
+ new Openai(
43
+ key,
44
+ process.env.OPENAI_MODEL || 'gpt-4o-mini',
45
+ (url, options) => globalThis.fetch(url, options)
46
+ )
47
+ );
48
+ const extra = await Promise.all(
49
+ documents.map((document) => oracle.violations(document))
50
+ );
51
+ extra.forEach((bag) => bag.forEach((violation) => found.push(violation)));
52
+ } catch (error) {
53
+ process.stderr.write(`AI verification failed: ${error.message}\n`);
54
+ process.exit(2);
55
+ }
56
+ }
57
+ const report = new Report('dogent', found);
58
+ process.stdout.write(`${sarif ? JSON.stringify(report.sarif(), null, 2) : report.text()}\n`);
59
+ process.exit(report.count() > 0 ? 1 : 0);
60
+ })();
package/src/markdown.js CHANGED
@@ -98,7 +98,7 @@ class Markdown {
98
98
  if (fence !== '') {
99
99
  pieces.push(new Snippet(block.join('\n'), opened));
100
100
  }
101
- return new Document(this.address, pieces);
101
+ return new Document(this.address, pieces, this.content);
102
102
  }
103
103
  }
104
104
 
package/src/openai.js ADDED
@@ -0,0 +1,45 @@
1
+ /*
2
+ * SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ /**
9
+ * Openai.
10
+ *
11
+ * A thin adapter over the OpenAI chat-completions endpoint. Sends one
12
+ * prompt, demands a JSON object back, and returns the assistant text.
13
+ * The transport is injected so the class runs in tests without a socket.
14
+ */
15
+ class Openai {
16
+ constructor(key, model, transport) {
17
+ this.key = key;
18
+ this.model = model;
19
+ this.transport = transport;
20
+ }
21
+ async answer(prompt) {
22
+ const response = await this.transport(
23
+ 'https://api.openai.com/v1/chat/completions',
24
+ {
25
+ method: 'POST',
26
+ headers: {
27
+ 'Content-Type': 'application/json',
28
+ Authorization: `Bearer ${this.key}`
29
+ },
30
+ body: JSON.stringify({
31
+ model: this.model,
32
+ temperature: 0,
33
+ response_format: {type: 'json_object'},
34
+ messages: [{role: 'user', content: prompt}]
35
+ })
36
+ }
37
+ );
38
+ if (!response.ok) {
39
+ throw new Error(`OpenAI request rejected with status ${response.status}`);
40
+ }
41
+ return (await response.json()).choices[0].message.content;
42
+ }
43
+ }
44
+
45
+ module.exports = Openai;
package/src/oracle.js ADDED
@@ -0,0 +1,30 @@
1
+ /*
2
+ * SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ const Prompt = require('./prompt');
9
+ const Answer = require('./answer');
10
+
11
+ /**
12
+ * Oracle.
13
+ *
14
+ * The AI second opinion. Wraps the rules and a chat endpoint, builds one
15
+ * prompt from a document, asks the endpoint, and parses the reply into
16
+ * violations. Mirrors a rule, but consults a model instead of guessing.
17
+ */
18
+ class Oracle {
19
+ constructor(rules, chat) {
20
+ this.rules = rules;
21
+ this.chat = chat;
22
+ }
23
+ async violations(document) {
24
+ return new Answer(
25
+ await this.chat.answer(new Prompt(this.rules, document).text())
26
+ ).violations();
27
+ }
28
+ }
29
+
30
+ module.exports = Oracle;
package/src/prompt.js ADDED
@@ -0,0 +1,54 @@
1
+ /*
2
+ * SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ /**
9
+ * Prompt.
10
+ *
11
+ * The full request handed to the AI oracle: a header fixing the task and
12
+ * the reply shape, one fragment per rule, and the manifesto itself with
13
+ * every line numbered so the oracle can cite an exact row.
14
+ */
15
+ class Prompt {
16
+ constructor(rules, document) {
17
+ this.rules = rules;
18
+ this.doc = document;
19
+ }
20
+ text() {
21
+ return [this.header(), this.fragments(), this.body()].join('\n\n');
22
+ }
23
+ header() {
24
+ const uri = this.doc.uri();
25
+ return [
26
+ 'You are a strict linter for an AI-agent manifesto.',
27
+ `The file under review is "${uri}".`,
28
+ 'Apply only the checks listed below this header.',
29
+ 'Report a violation only when it is clear and certain.',
30
+ 'When in doubt, stay silent and report nothing.',
31
+ 'Reply with one JSON object and nothing else, shaped as',
32
+ '{"results":[ ... ]}, where each item is a SARIF result with',
33
+ 'keys ruleId, level "warning", message.text, and locations.',
34
+ 'Set ruleId to the rule name and startLine to the printed',
35
+ 'line number; locations[0].physicalLocation must carry',
36
+ `artifactLocation.uri "${uri}" and region.startColumn 1.`
37
+ ].join('\n');
38
+ }
39
+ fragments() {
40
+ return this.rules
41
+ .map((rule) => rule.prompt())
42
+ .filter((fragment) => fragment !== '')
43
+ .join('\n');
44
+ }
45
+ body() {
46
+ return this.doc
47
+ .text()
48
+ .split('\n')
49
+ .map((line, index) => `${index + 1}: ${line}`)
50
+ .join('\n');
51
+ }
52
+ }
53
+
54
+ module.exports = Prompt;
@@ -13,15 +13,16 @@ const Region = require('../region');
13
13
  *
14
14
  * Demands that every instruction sound like a command. A standalone
15
15
  * checker can only guess: it flags lines that open with a pronoun or
16
- * end with a question mark, both signs of description, not order.
17
- *
18
- * @todo #1:90min Replace this heuristic with a real imperative-mood check
19
- * driven by an AI oracle when a token is present in the environment.
16
+ * end with a question mark, both signs of description, not order. Its
17
+ * prompt hands the subtler imperative-mood judgement to the AI oracle.
20
18
  */
21
19
  class Command {
22
20
  constructor() {
23
21
  this.id = 'command';
24
22
  }
23
+ prompt() {
24
+ return `${this.id}: flag any line that reads as a description, a question, or a plain statement rather than a direct order`;
25
+ }
25
26
  violations(document) {
26
27
  const uri = document.uri();
27
28
  return document.walk({
@@ -0,0 +1,54 @@
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
+
11
+ /**
12
+ * Empty.
13
+ *
14
+ * Flags any heading that declares a section but carries no body.
15
+ * A heading is empty when it is immediately followed by another
16
+ * heading or by end-of-file — no prose, bullets, or snippet sits
17
+ * between them.
18
+ */
19
+ class Empty {
20
+ constructor() {
21
+ this.id = 'empty';
22
+ }
23
+ prompt() {
24
+ return `${this.id}: flag any section heading that carries no instructions beneath it`;
25
+ }
26
+ violations(document) {
27
+ const uri = document.uri();
28
+ const marks = document.walk({
29
+ header: (text, line) => [{header: true, line}],
30
+ prose: (text, line) => [{header: false, line}],
31
+ bullets: (row) => [{header: false, line: row}],
32
+ snippet: (text, line) => [{header: false, line}],
33
+ frontmatter: () => []
34
+ });
35
+ const result = [];
36
+ marks.forEach((mark, index) => {
37
+ if (!mark.header) {
38
+ return;
39
+ }
40
+ const next = marks[index + 1];
41
+ if (!next || next.header) {
42
+ result.push(new Violation(
43
+ this.id,
44
+ 'error',
45
+ 'hollow section, no instructions found',
46
+ new Region(uri, mark.line, 1)
47
+ ));
48
+ }
49
+ });
50
+ return result;
51
+ }
52
+ }
53
+
54
+ module.exports = Empty;
@@ -22,6 +22,9 @@ class Frontmatter {
22
22
  this.required = required;
23
23
  this.allowed = allowed;
24
24
  }
25
+ prompt() {
26
+ return `${this.id}: in a ${this.name} file, flag any required key whose value is empty, vague, or a leftover placeholder`;
27
+ }
25
28
  violations(document) {
26
29
  const uri = document.uri();
27
30
  if (uri.replace(/^.*\//u, '') !== this.name) {
@@ -18,6 +18,9 @@ class Grouped {
18
18
  constructor() {
19
19
  this.id = 'grouped';
20
20
  }
21
+ prompt() {
22
+ return `${this.id}: flag any instruction that sits under a section where it does not belong`;
23
+ }
21
24
  violations(document) {
22
25
  const uri = document.uri();
23
26
  const marks = document.walk({
@@ -9,6 +9,7 @@ const LineLength = require('./line-length');
9
9
  const TokenCount = require('./token-count');
10
10
  const ShortSections = require('./short-sections');
11
11
  const Grouped = require('./grouped');
12
+ const Empty = require('./empty');
12
13
  const NoArticles = require('./no-articles');
13
14
  const Command = require('./command');
14
15
  const Punctuation = require('./punctuation');
@@ -16,6 +17,7 @@ const Frontmatter = require('./frontmatter');
16
17
 
17
18
  module.exports = () => [
18
19
  new Grouped(),
20
+ new Empty(),
19
21
  new ShortSections(),
20
22
  new LineLength(80),
21
23
  new TokenCount(4000),
@@ -19,6 +19,9 @@ class LineLength {
19
19
  this.id = 'line-length';
20
20
  this.max = max;
21
21
  }
22
+ prompt() {
23
+ return `${this.id}: flag any instruction too wordy to grasp in a single read`;
24
+ }
22
25
  violations(document) {
23
26
  const uri = document.uri();
24
27
  return document.walk({
@@ -18,6 +18,9 @@ class NoArticles {
18
18
  constructor() {
19
19
  this.id = 'no-articles';
20
20
  }
21
+ prompt() {
22
+ return `${this.id}: flag filler or noise words that add nothing to an instruction`;
23
+ }
21
24
  violations(document) {
22
25
  const uri = document.uri();
23
26
  return document.walk({
@@ -19,6 +19,9 @@ class Punctuation {
19
19
  constructor() {
20
20
  this.id = 'punctuation';
21
21
  }
22
+ prompt() {
23
+ return `${this.id}: flag any instruction that is not one complete, grammatical sentence`;
24
+ }
22
25
  violations(document) {
23
26
  const uri = document.uri();
24
27
  return document.walk({
@@ -18,6 +18,9 @@ class ShortSections {
18
18
  constructor() {
19
19
  this.id = 'short-sections';
20
20
  }
21
+ prompt() {
22
+ return `${this.id}: flag any section heading that is not a short, noun-style label`;
23
+ }
21
24
  violations(document) {
22
25
  const uri = document.uri();
23
26
  return document.walk({
@@ -21,6 +21,9 @@ class TokenCount {
21
21
  this.id = 'token-count';
22
22
  this.cap = cap;
23
23
  }
24
+ prompt() {
25
+ return `${this.id}: flag bloated wording that wastes the context budget`;
26
+ }
24
27
  violations(document) {
25
28
  const count = (document.walk({
26
29
  header: (text) => [text],
package/src/sources.js CHANGED
@@ -8,13 +8,15 @@
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
10
 
11
+ const PRUNED = ['node_modules', '.git'];
12
+
11
13
  /**
12
14
  * Sources.
13
15
  *
14
16
  * The paths passed on the command line. Each path names either one
15
17
  * manifesto file or one directory. A directory expands into the default
16
- * manifesto files it actually contains, so `dogent .` lints every known
17
- * manifesto in the current folder.
18
+ * manifesto files it holds, scanned recursively through every subfolder,
19
+ * so `dogent .` lints every known manifesto in the whole tree.
18
20
  */
19
21
  class Sources {
20
22
  constructor(paths, defaults = ['AGENTS.md', 'CLAUDE.md', 'SKILL.md', 'SKILLS.md']) {
@@ -25,18 +27,26 @@ class Sources {
25
27
  const found = [];
26
28
  this.paths.forEach((entry) => {
27
29
  if (fs.statSync(entry).isDirectory()) {
28
- this.defaults.forEach((name) => {
29
- const file = path.join(entry, name);
30
- if (fs.existsSync(file)) {
31
- found.push(file);
32
- }
33
- });
30
+ this.scan(entry, found);
34
31
  } else {
35
32
  found.push(entry);
36
33
  }
37
34
  });
38
35
  return found;
39
36
  }
37
+ scan(dir, found) {
38
+ this.defaults.forEach((name) => {
39
+ const file = path.join(dir, name);
40
+ if (fs.existsSync(file) && fs.statSync(file).isFile()) {
41
+ found.push(file);
42
+ }
43
+ });
44
+ fs.readdirSync(dir, {withFileTypes: true}).forEach((entry) => {
45
+ if (entry.isDirectory() && !PRUNED.includes(entry.name)) {
46
+ this.scan(path.join(dir, entry.name), found);
47
+ }
48
+ });
49
+ }
40
50
  }
41
51
 
42
52
  module.exports = Sources;