@yegor256/dogent 0.6.1 → 0.7.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.
package/README.md CHANGED
@@ -76,8 +76,19 @@ The command exits with a non-zero status when problems are found,
76
76
  - Every sentence must start with a capital and end with a period.
77
77
  - No articles, no noise, no bloated text.
78
78
  - Simple grammar, no ambiguity.
79
+ - No tangled, multi-clause instructions.
80
+ - A `SKILL.md` `name` must equal its parent directory.
81
+ - No courtesy or scaffolding words.
82
+ - No leftover markers or unfilled placeholders.
83
+ - A section must hold at most ten instructions.
84
+ - A `SKILL.md` `description` must say when to use the skill.
85
+ - Every line must carry exactly one instruction.
86
+ - No hedging or soft wording.
87
+ - No passive voice; use the active imperative.
88
+ - No instruction may repeat another.
79
89
  - `SKILL.md` must open with valid frontmatter.
80
90
  - Frontmatter must declare only allowed keys.
91
+ - A `SKILL.md` `name` must be kebab-case.
81
92
 
82
93
  ## AI verification
83
94
 
@@ -90,6 +101,9 @@ It sends the manifesto together with one instruction per rule,
90
101
  then prints any violation the model reports for ambiguity,
91
102
  weak phrasing, and instructions that only pretend to be commands.
92
103
  The model defaults to `gpt-4o-mini`; override it with `OPENAI_MODEL`.
104
+ After the report, `dogent` prints a one-line usage summary to standard error,
105
+ naming the model, the tokens sent and received, and an estimated cost,
106
+ for example `OpenAI: gpt-4o-mini, 1234 sent, 567 received, ~$0.0005`.
93
107
 
94
108
  ```bash
95
109
  export OPENAI_API_KEY=...
package/package.json CHANGED
@@ -40,5 +40,8 @@
40
40
  "lint": "eslint .",
41
41
  "test": "mocha 'test/**/*.js' --timeout 60000"
42
42
  },
43
- "version": "0.6.1"
43
+ "version": "0.7.0",
44
+ "dependencies": {
45
+ "minimist": "^1.2.8"
46
+ }
44
47
  }
package/src/answer.js CHANGED
@@ -8,16 +8,22 @@
8
8
  const Violation = require('./violation');
9
9
  const Region = require('./region');
10
10
 
11
+ const FLOOR = 0.6;
12
+
11
13
  /**
12
14
  * Answer.
13
15
  *
14
16
  * The oracle's raw reply, treated as untrusted input. Parses the JSON
15
17
  * object it carries and turns every well-formed SARIF result back into a
16
- * native violation, ignoring any result it cannot read.
18
+ * native violation, ignoring any result it cannot read. Drops a result
19
+ * whose self-reported confidence sits below the floor, so the model's
20
+ * own doubt filters out its guesses; a result without a confidence is
21
+ * trusted and kept.
17
22
  */
18
23
  class Answer {
19
- constructor(raw) {
24
+ constructor(raw, floor = FLOOR) {
20
25
  this.raw = raw;
26
+ this.floor = floor;
21
27
  }
22
28
  violations() {
23
29
  return this.results().flatMap((result) => {
@@ -27,6 +33,9 @@ class Answer {
27
33
  if (typeof line !== 'number' || typeof text !== 'string' || !spot.artifactLocation) {
28
34
  return [];
29
35
  }
36
+ if (typeof result.confidence === 'number' && result.confidence < this.floor) {
37
+ return [];
38
+ }
30
39
  return [new Violation(
31
40
  result.ruleId || 'oracle',
32
41
  result.level || 'warning',
package/src/args.js CHANGED
@@ -5,32 +5,35 @@
5
5
 
6
6
  'use strict';
7
7
 
8
+ const minimist = require('minimist');
9
+
8
10
  /**
9
11
  * Args.
10
12
  *
11
- * The command-line arguments handed to dogent. It splits the raw argv into
12
- * recognized options and the manifesto paths that remain. The `--sarif`
13
- * flag switches the report to SARIF, while `--offline` forbids any talk to
14
- * the LLM even when a token sits in the environment.
13
+ * The command-line arguments handed to dogent. It leans on minimist to split
14
+ * the raw argv into recognized options and the manifesto paths that remain.
15
+ * The `--sarif` flag switches the report to SARIF, while `--offline` forbids
16
+ * any talk to the LLM even when a token sits in the environment. Everything
17
+ * after a `--` separator counts as a path, never as an option.
15
18
  */
16
19
  class Args {
17
- constructor(argv, flags = ['--sarif', '--offline']) {
18
- this.argv = argv;
20
+ constructor(argv, flags = ['sarif', 'offline']) {
19
21
  this.flags = flags;
22
+ this.parsed = minimist(argv, {boolean: flags, '--': true});
20
23
  }
21
24
  sarif() {
22
- return this.argv.includes('--sarif');
25
+ return this.parsed.sarif === true;
23
26
  }
24
27
  offline() {
25
- return this.argv.includes('--offline');
28
+ return this.parsed.offline === true;
26
29
  }
27
30
  paths() {
28
- return this.argv.filter((arg) => !arg.startsWith('-'));
31
+ return this.parsed._.concat(this.parsed['--']).map(String);
29
32
  }
30
33
  unknown() {
31
- return this.argv.filter(
32
- (arg) => arg.startsWith('-') && !this.flags.includes(arg)
33
- );
34
+ return Object.keys(this.parsed)
35
+ .filter((key) => key !== '_' && key !== '--' && !this.flags.includes(key))
36
+ .map((key) => `${key.length === 1 ? '-' : '--'}${key}`);
34
37
  }
35
38
  }
36
39
 
package/src/dogent.js CHANGED
@@ -13,6 +13,7 @@ const Report = require('./report');
13
13
  const Sources = require('./sources');
14
14
  const Openai = require('./openai');
15
15
  const Oracle = require('./oracle');
16
+ const Usage = require('./usage');
16
17
  const rules = require('./rules');
17
18
 
18
19
  const args = new Args(process.argv.slice(2));
@@ -41,27 +42,43 @@ documents.forEach((document) => {
41
42
  });
42
43
  });
43
44
  const key = process.env.OPENAI_API_KEY;
45
+ const audit = async (docs) => {
46
+ const oracle = new Oracle(
47
+ rules(),
48
+ new Openai(
49
+ key,
50
+ process.env.OPENAI_MODEL || 'gpt-4o-mini',
51
+ (url, options) => globalThis.fetch(url, options)
52
+ )
53
+ );
54
+ const replies = await Promise.all(docs.map((doc) => oracle.violations(doc)));
55
+ return replies.reduce(
56
+ (acc, reply) => ({
57
+ extra: acc.extra.concat(reply.found),
58
+ usage: acc.usage.plus(reply.usage)
59
+ }),
60
+ {extra: [], usage: new Usage('', 0, 0)}
61
+ );
62
+ };
63
+ const finish = (usage) => {
64
+ const report = new Report('dogent', found);
65
+ process.stdout.write(`${sarif ? JSON.stringify(report.sarif(), null, 2) : report.text()}\n`);
66
+ if (usage !== null) {
67
+ process.stderr.write(`${usage.text()}\n`);
68
+ }
69
+ process.exit(report.count() > 0 ? 1 : 0);
70
+ };
44
71
  (async () => {
72
+ let usage = null;
45
73
  if (found.length === 0 && key && !args.offline()) {
46
74
  try {
47
- const oracle = new Oracle(
48
- rules(),
49
- new Openai(
50
- key,
51
- process.env.OPENAI_MODEL || 'gpt-4o-mini',
52
- (url, options) => globalThis.fetch(url, options)
53
- )
54
- );
55
- const extra = await Promise.all(
56
- documents.map((document) => oracle.violations(document))
57
- );
58
- extra.forEach((bag) => bag.forEach((violation) => found.push(violation)));
75
+ const result = await audit(documents);
76
+ result.extra.forEach((violation) => found.push(violation));
77
+ ({usage} = result);
59
78
  } catch (error) {
60
79
  process.stderr.write(`AI verification failed: ${error.message}\n`);
61
80
  process.exit(2);
62
81
  }
63
82
  }
64
- const report = new Report('dogent', found);
65
- process.stdout.write(`${sarif ? JSON.stringify(report.sarif(), null, 2) : report.text()}\n`);
66
- process.exit(report.count() > 0 ? 1 : 0);
83
+ finish(usage);
67
84
  })();
package/src/markdown.js CHANGED
@@ -42,7 +42,8 @@ class Markdown {
42
42
  items = [];
43
43
  }
44
44
  };
45
- const lines = this.content.split('\n');
45
+ const body = this.content.replace(/\r\n?/gu, '\n');
46
+ const lines = body.split('\n');
46
47
  let skip = 0;
47
48
  if (lines[0].trim() === '---') {
48
49
  const rest = lines.slice(1);
@@ -98,7 +99,7 @@ class Markdown {
98
99
  if (fence !== '') {
99
100
  pieces.push(new Snippet(block.join('\n'), opened));
100
101
  }
101
- return new Document(this.address, pieces, this.content);
102
+ return new Document(this.address, pieces, body);
102
103
  }
103
104
  }
104
105
 
package/src/openai.js CHANGED
@@ -5,11 +5,14 @@
5
5
 
6
6
  'use strict';
7
7
 
8
+ const Usage = require('./usage');
9
+
8
10
  /**
9
11
  * Openai.
10
12
  *
11
13
  * A thin adapter over the OpenAI chat-completions endpoint. Sends one
12
- * prompt, demands a JSON object back, and returns the assistant text.
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
16
  * The transport is injected so the class runs in tests without a socket.
14
17
  */
15
18
  class Openai {
@@ -38,7 +41,16 @@ class Openai {
38
41
  if (!response.ok) {
39
42
  throw new Error(`OpenAI request rejected with status ${response.status}`);
40
43
  }
41
- return (await response.json()).choices[0].message.content;
44
+ const body = await response.json();
45
+ const usage = body.usage || {};
46
+ return {
47
+ content: body.choices[0].message.content,
48
+ usage: new Usage(
49
+ this.model,
50
+ usage.prompt_tokens || 0,
51
+ usage.completion_tokens || 0
52
+ )
53
+ };
42
54
  }
43
55
  }
44
56
 
package/src/oracle.js CHANGED
@@ -13,7 +13,8 @@ const Answer = require('./answer');
13
13
  *
14
14
  * The AI second opinion. Wraps the rules and a chat endpoint, builds one
15
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.
16
+ * violations paired with the token usage the model reported. Mirrors a
17
+ * rule, but consults a model instead of guessing.
17
18
  */
18
19
  class Oracle {
19
20
  constructor(rules, chat) {
@@ -21,9 +22,11 @@ class Oracle {
21
22
  this.chat = chat;
22
23
  }
23
24
  async violations(document) {
24
- return new Answer(
25
- await this.chat.answer(new Prompt(this.rules, document).text())
26
- ).violations();
25
+ const reply = await this.chat.answer(new Prompt(this.rules, document).text());
26
+ return {
27
+ found: new Answer(reply.content).violations(),
28
+ usage: reply.usage
29
+ };
27
30
  }
28
31
  }
29
32
 
package/src/prompt.js CHANGED
@@ -25,14 +25,35 @@ class Prompt {
25
25
  return [
26
26
  'You are a strict linter for an AI-agent manifesto.',
27
27
  `The file under review is "${uri}".`,
28
+ 'The manifesto is written in a terse house style: each line is',
29
+ 'one compressed imperative command, with articles and filler',
30
+ 'words deliberately stripped. Read the first word of every line',
31
+ 'as an imperative verb, so "Tag release before shipping" is the',
32
+ 'order "tag the release", not a noun phrase about a tag. Never',
33
+ 'flag a line for being short, for dropping articles, or for',
34
+ 'omitting a subject; that compression is the required style.',
35
+ 'A heading opens a section, and every line beneath it, until the',
36
+ 'next heading, belongs to that section. Treat a deeper heading as',
37
+ 'a subsection of the one above, never as a misplaced instruction.',
28
38
  '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.',
39
+ 'Most manifestos are clean. An empty result list is the normal,',
40
+ 'expected reply: when no line clearly breaks a listed check,',
41
+ 'report nothing and return {"results": []}. Never invent a',
42
+ 'violation to seem useful, and never flag a line merely for',
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
+ 'Report a violation only when you are certain it breaks a check.',
49
+ 'Give every result a "confidence" number from 0 to 1, your own',
50
+ 'probability that the violation is real, and omit any result you',
51
+ 'score below 0.6. When unsure, lower the confidence, never guess.',
31
52
  'Reply with one JSON object and nothing else, shaped as',
32
53
  '{"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',
54
+ 'keys ruleId, level "warning", message.text, confidence, and',
55
+ 'locations. Set ruleId to the rule name and startLine to the',
56
+ 'printed line number; locations[0].physicalLocation must carry',
36
57
  `artifactLocation.uri "${uri}" and region.startColumn 1.`
37
58
  ].join('\n');
38
59
  }
@@ -43,9 +64,11 @@ class Prompt {
43
64
  .join('\n');
44
65
  }
45
66
  body() {
46
- return this.doc
47
- .text()
48
- .split('\n')
67
+ const lines = this.doc.text().split('\n');
68
+ if (lines.length > 0 && lines[lines.length - 1] === '') {
69
+ lines.pop();
70
+ }
71
+ return lines
49
72
  .map((line, index) => `${index + 1}: ${line}`)
50
73
  .join('\n');
51
74
  }
@@ -0,0 +1,57 @@
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
+ * Atomic.
13
+ *
14
+ * Demands that every line carry exactly one instruction. A standalone
15
+ * checker only spots the loud signs: a sentence terminator sitting
16
+ * mid-line with more text after it, or two verb phrases welded together
17
+ * with a semicolon, an " and ", or a " then ". The prompt hands the
18
+ * subtler clause-counting to the AI oracle.
19
+ *
20
+ * @todo #21:45min Upgrade to a real clause-count check through an AI
21
+ * oracle so that subtle multi-instruction lines, which the conservative
22
+ * heuristic cannot see today, are reliably caught, as requested in
23
+ * issue #21.
24
+ */
25
+ class Atomic {
26
+ constructor() {
27
+ this.id = 'atomic';
28
+ }
29
+ prompt() {
30
+ return `${this.id}: flag any line that carries more than one instruction`;
31
+ }
32
+ violations(document) {
33
+ const uri = document.uri();
34
+ return document.walk({
35
+ header: () => [],
36
+ prose: (text, line) => this.judge(text, line, uri),
37
+ snippet: () => [],
38
+ bullets: () => [],
39
+ frontmatter: () => []
40
+ });
41
+ }
42
+ judge(text, line, uri) {
43
+ const clean = text.replace(/^\s*(?:[-*+]|\d+\.)\s+/u, '').trimEnd();
44
+ const welded = /;|\s(?:and|then)\s+[a-z]+\s+\S/u;
45
+ if (!/[.!?]\s+\S/u.test(clean) && !welded.test(clean)) {
46
+ return [];
47
+ }
48
+ return [new Violation(
49
+ this.id,
50
+ 'warning',
51
+ 'line carries more than one instruction',
52
+ new Region(uri, line, 1)
53
+ )];
54
+ }
55
+ }
56
+
57
+ module.exports = Atomic;
@@ -21,7 +21,7 @@ class Command {
21
21
  this.id = 'command';
22
22
  }
23
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`;
24
+ 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
25
  }
26
26
  violations(document) {
27
27
  const uri = document.uri();
@@ -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
+ /**
9
+ * Consistent.
10
+ *
11
+ * Demands that the manifesto never say one thing twice nor say two
12
+ * things that fight each other. A duplicate instruction wastes the
13
+ * context budget; a contradictory pair leaves the agent guessing which
14
+ * order wins. Neither is visible line by line, so this check is pure
15
+ * judgement: prompt() hands the whole comparison to the AI oracle and
16
+ * violations() finds nothing on its own.
17
+ */
18
+ class Consistent {
19
+ constructor() {
20
+ this.id = 'consistent';
21
+ }
22
+ prompt() {
23
+ return `${this.id}: flag an instruction that repeats another instruction word for word, or that directly contradicts another instruction in the same file`;
24
+ }
25
+ violations() {
26
+ return [];
27
+ }
28
+ }
29
+
30
+ module.exports = Consistent;
@@ -0,0 +1,67 @@
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
+ * Crowded.
13
+ *
14
+ * Demands that every section stay small, holding no more instructions
15
+ * than the limit allows. A section runs from one heading to the next
16
+ * heading or to end-of-file; its instructions are the prose lines that
17
+ * sit between them. Prose before the first heading is loose and belongs
18
+ * to the grouped rule, so this rule ignores it.
19
+ *
20
+ * The check is standalone and deterministic, so prompt() returns an
21
+ * empty string and the AI oracle never re-checks this rule.
22
+ */
23
+ class Crowded {
24
+ constructor(limit) {
25
+ this.id = 'crowded';
26
+ this.limit = limit;
27
+ }
28
+ prompt() {
29
+ return '';
30
+ }
31
+ violations(document) {
32
+ const uri = document.uri();
33
+ const marks = document.walk({
34
+ header: (text, line) => [{header: true, line}],
35
+ prose: (text, line) => [{header: false, line}],
36
+ snippet: () => [],
37
+ bullets: () => [],
38
+ frontmatter: () => []
39
+ });
40
+ const result = [];
41
+ let open = null;
42
+ let count = 0;
43
+ marks.forEach((mark) => {
44
+ if (mark.header) {
45
+ this.flush(open, count, uri, result);
46
+ open = mark;
47
+ count = 0;
48
+ } else if (open) {
49
+ count += 1;
50
+ }
51
+ });
52
+ this.flush(open, count, uri, result);
53
+ return result;
54
+ }
55
+ flush(open, count, uri, result) {
56
+ if (open && count > this.limit) {
57
+ result.push(new Violation(
58
+ this.id,
59
+ 'error',
60
+ `section holds ${count} instructions, limit ${this.limit}`,
61
+ new Region(uri, open.line, 1)
62
+ ));
63
+ }
64
+ }
65
+ }
66
+
67
+ module.exports = Crowded;
@@ -0,0 +1,64 @@
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
+ * DescriptionTriggers.
13
+ *
14
+ * Demands that a SKILL.md description say when to use the skill. A
15
+ * standalone checker can only approximate: it flags a value that is too
16
+ * short or that never names a trigger with the word "when". Its prompt
17
+ * hands the deeper judgement to the AI oracle.
18
+ *
19
+ * @todo #19:30min Upgrade the trigger check to an AI oracle that judges
20
+ * whether the description truly names the situations and user phrases
21
+ * that should activate the skill, as requested in issue #19.
22
+ */
23
+ class DescriptionTriggers {
24
+ constructor() {
25
+ this.id = 'description-triggers';
26
+ this.minimum = 20;
27
+ }
28
+ prompt() {
29
+ return `${this.id}: in a SKILL.md, flag a description that is too short or never says when to use the skill`;
30
+ }
31
+ violations(document) {
32
+ const uri = document.uri();
33
+ if (uri.replace(/^.*\//u, '') !== 'SKILL.md') {
34
+ return [];
35
+ }
36
+ const pairs = document.walk({
37
+ header: () => [],
38
+ prose: () => [],
39
+ snippet: () => [],
40
+ bullets: () => [],
41
+ frontmatter: (keys) => keys
42
+ });
43
+ const found = pairs.filter((pair) => pair.key === 'description');
44
+ if (found.length === 0) {
45
+ return [];
46
+ }
47
+ return this.judge(found[0], uri);
48
+ }
49
+ judge(pair, uri) {
50
+ const value = pair.value.trim();
51
+ if (value.length < this.minimum) {
52
+ return [this.flag('description too short', pair.row, uri)];
53
+ }
54
+ if (!/\bwhen\b/iu.test(value)) {
55
+ return [this.flag('description must say when to use the skill', pair.row, uri)];
56
+ }
57
+ return [];
58
+ }
59
+ flag(message, row, uri) {
60
+ return new Violation(this.id, 'warning', message, new Region(uri, row, 1));
61
+ }
62
+ }
63
+
64
+ module.exports = DescriptionTriggers;
@@ -13,13 +13,16 @@ const Region = require('../region');
13
13
  *
14
14
  * Demands that every instruction live under a section. Any prose that
15
15
  * appears before the first heading is loose and therefore a violation.
16
+ *
17
+ * The check is standalone and deterministic, so prompt() returns an
18
+ * empty string and the AI oracle never re-checks this rule.
16
19
  */
17
20
  class Grouped {
18
21
  constructor() {
19
22
  this.id = 'grouped';
20
23
  }
21
24
  prompt() {
22
- return `${this.id}: flag any instruction that sits under a section where it does not belong`;
25
+ return '';
23
26
  }
24
27
  violations(document) {
25
28
  const uri = document.uri();
@@ -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
+
11
+ /**
12
+ * Hedging.
13
+ *
14
+ * Flags soft, non-committal, or hedging wording that weakens an order.
15
+ * Catches words like "should", "just", "usually", and phrases like
16
+ * "try to" or "if possible", each a sign of timid instruction.
17
+ *
18
+ * @todo #22:30min Upgrade to an AI oracle that catches subtler hedging,
19
+ * such as conditional escape hatches and vague scope, which the fixed
20
+ * blacklist of hedge words cannot detect today, as requested in
21
+ * issue #22.
22
+ */
23
+ class Hedging {
24
+ constructor() {
25
+ this.id = 'hedging';
26
+ }
27
+ prompt() {
28
+ return `${this.id}: flag soft, non-committal, or hedging wording`;
29
+ }
30
+ violations(document) {
31
+ const uri = document.uri();
32
+ return document.walk({
33
+ header: () => [],
34
+ prose: (text, line) => this.scan(text, line, uri),
35
+ snippet: () => [],
36
+ bullets: () => [],
37
+ frontmatter: () => []
38
+ });
39
+ }
40
+ scan(text, line, uri) {
41
+ const found = [];
42
+ const regex = new RegExp(
43
+ '\\b(?:should|try to|if possible|as appropriate|as needed|' +
44
+ 'when necessary|usually|generally|etc|just|simply|very)\\b',
45
+ 'giu'
46
+ );
47
+ let hit = regex.exec(text);
48
+ while (hit !== null) {
49
+ found.push(new Violation(
50
+ this.id,
51
+ 'warning',
52
+ `hedge word "${hit[0]}" must be removed`,
53
+ new Region(uri, line, hit.index + 1)
54
+ ));
55
+ hit = regex.exec(text);
56
+ }
57
+ return found;
58
+ }
59
+ }
60
+
61
+ module.exports = Hedging;
@@ -14,8 +14,20 @@ const NoArticles = require('./no-articles');
14
14
  const Command = require('./command');
15
15
  const Punctuation = require('./punctuation');
16
16
  const Frontmatter = require('./frontmatter');
17
+ const NameFormat = require('./name-format');
17
18
  const DeadImport = require('./dead-import');
18
19
  const Redundant = require('./redundant');
20
+ const NameMatchesDir = require('./name-matches-dir');
21
+ const Polite = require('./polite');
22
+ const Unfinished = require('./unfinished');
23
+ const Crowded = require('./crowded');
24
+ const DescriptionTriggers = require('./description-triggers');
25
+ const Atomic = require('./atomic');
26
+ const Hedging = require('./hedging');
27
+ const Passive = require('./passive');
28
+ const Unique = require('./unique');
29
+ const Consistent = require('./consistent');
30
+ const Simple = require('./simple');
19
31
 
20
32
  module.exports = () => [
21
33
  new Grouped(),
@@ -28,9 +40,21 @@ module.exports = () => [
28
40
  new Punctuation(),
29
41
  new DeadImport(),
30
42
  new Redundant(),
43
+ new Consistent(),
44
+ new Simple(),
45
+ new NameMatchesDir(),
46
+ new Polite(),
47
+ new Unfinished(),
48
+ new Crowded(10),
49
+ new DescriptionTriggers(),
50
+ new Atomic(),
51
+ new Hedging(),
52
+ new Passive(),
53
+ new Unique(),
31
54
  new Frontmatter(
32
55
  'SKILL.md',
33
56
  ['name', 'description'],
34
57
  ['name', 'description', 'license', 'allowed-tools']
35
- )
58
+ ),
59
+ new NameFormat()
36
60
  ];
@@ -13,6 +13,9 @@ const Region = require('../region');
13
13
  *
14
14
  * Demands that every instruction and every heading stay within a
15
15
  * maximum width. Code snippets are exempt, since code is not prose.
16
+ *
17
+ * The check is standalone and deterministic, so prompt() returns an
18
+ * empty string and the AI oracle never re-checks this rule.
16
19
  */
17
20
  class LineLength {
18
21
  constructor(max) {
@@ -20,7 +23,7 @@ class LineLength {
20
23
  this.max = max;
21
24
  }
22
25
  prompt() {
23
- return `${this.id}: flag any instruction too wordy to grasp in a single read`;
26
+ return '';
24
27
  }
25
28
  violations(document) {
26
29
  const uri = document.uri();
@@ -0,0 +1,59 @@
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
+ * NameFormat.
13
+ *
14
+ * Demands that a SKILL.md frontmatter "name" read as kebab-case: lower
15
+ * letters and digits joined by single hyphens, no leading or trailing
16
+ * hyphen. Ignores every other file and leaves a missing name to the
17
+ * frontmatter rule.
18
+ *
19
+ * The check is standalone and deterministic, so prompt() returns an
20
+ * empty string and the AI oracle never re-checks this rule.
21
+ */
22
+ class NameFormat {
23
+ constructor() {
24
+ this.id = 'name-format';
25
+ }
26
+ prompt() {
27
+ return '';
28
+ }
29
+ violations(document) {
30
+ const uri = document.uri();
31
+ if (uri.replace(/^.*\//u, '') !== 'SKILL.md') {
32
+ return [];
33
+ }
34
+ const blocks = document.walk({
35
+ header: () => [],
36
+ prose: () => [],
37
+ snippet: () => [],
38
+ bullets: () => [],
39
+ frontmatter: (pairs) => [pairs]
40
+ });
41
+ if (blocks.length === 0) {
42
+ return [];
43
+ }
44
+ return this.check(blocks[0].find((pair) => pair.key === 'name'), uri);
45
+ }
46
+ check(name, uri) {
47
+ if (!name || /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(name.value)) {
48
+ return [];
49
+ }
50
+ return [new Violation(
51
+ this.id,
52
+ 'error',
53
+ `name "${name.value}" must be kebab-case`,
54
+ new Region(uri, name.row, 1)
55
+ )];
56
+ }
57
+ }
58
+
59
+ module.exports = NameFormat;
@@ -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 path = require('path');
9
+
10
+ const Violation = require('../violation');
11
+ const Region = require('../region');
12
+
13
+ /**
14
+ * NameMatchesDir.
15
+ *
16
+ * Demands that a SKILL.md frontmatter "name" equal the name of the
17
+ * directory that holds the file. The check applies only to SKILL.md
18
+ * and stays silent when the file carries no "name" key.
19
+ *
20
+ * The check is standalone and deterministic, so prompt() returns an
21
+ * empty string and the AI oracle never re-checks this rule.
22
+ */
23
+ class NameMatchesDir {
24
+ constructor() {
25
+ this.id = 'name-matches-dir';
26
+ }
27
+ prompt() {
28
+ return '';
29
+ }
30
+ violations(document) {
31
+ const uri = document.uri();
32
+ if (uri.replace(/^.*\//u, '') !== 'SKILL.md') {
33
+ return [];
34
+ }
35
+ const blocks = document.walk({
36
+ header: () => [],
37
+ prose: () => [],
38
+ snippet: () => [],
39
+ bullets: () => [],
40
+ frontmatter: (pairs) => [pairs]
41
+ });
42
+ const name = blocks.length === 0
43
+ ? null
44
+ : blocks[0].find((pair) => pair.key === 'name');
45
+ return this.mismatch(uri, name);
46
+ }
47
+ mismatch(uri, name) {
48
+ const parent = path.basename(path.dirname(uri));
49
+ if (!name || name.value === parent) {
50
+ return [];
51
+ }
52
+ return [new Violation(
53
+ this.id,
54
+ 'error',
55
+ `name "${name.value}" must match directory "${parent}"`,
56
+ new Region(uri, name.row, 1)
57
+ )];
58
+ }
59
+ }
60
+
61
+ module.exports = NameMatchesDir;
@@ -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
+ * Passive.
13
+ *
14
+ * Demands active imperative voice. A standalone checker can only guess:
15
+ * it flags a "be" verb followed, perhaps through an adverb, by a past
16
+ * participle, the surest mark of passive voice.
17
+ *
18
+ * @todo #24:45min Upgrade to an AI oracle for accurate passive-voice
19
+ * detection, since the regular-expression heuristic both misses many
20
+ * irregular participles and cannot judge true grammatical voice, as
21
+ * requested in issue #24.
22
+ */
23
+ class Passive {
24
+ constructor() {
25
+ this.id = 'passive';
26
+ }
27
+ prompt() {
28
+ return `${this.id}: flag any instruction written in passive voice`;
29
+ }
30
+ violations(document) {
31
+ const uri = document.uri();
32
+ return document.walk({
33
+ header: () => [],
34
+ prose: (text, line) => this.scan(text, line, uri),
35
+ snippet: () => [],
36
+ bullets: () => [],
37
+ frontmatter: () => []
38
+ });
39
+ }
40
+ scan(text, line, uri) {
41
+ const regex = /\b(?:is|are|was|were|be|been|being)\b\s+(?:\w+ly\s+)?(?:\w+ed|written|done|made|built|kept|sent|shown|seen|taken|given|held|found|run|read|set)\b/iu;
42
+ if (!regex.test(text)) {
43
+ return [];
44
+ }
45
+ return [new Violation(
46
+ this.id,
47
+ 'warning',
48
+ 'line uses passive voice',
49
+ new Region(uri, line, 1)
50
+ )];
51
+ }
52
+ }
53
+
54
+ module.exports = Passive;
@@ -0,0 +1,56 @@
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
+ * Polite.
13
+ *
14
+ * Flags courtesy and scaffolding phrases that soften an instruction
15
+ * without adding meaning: "please", "kindly", "feel free to", and the
16
+ * like. Each phrase wastes tokens and weakens a command, so every hit
17
+ * earns its own violation.
18
+ *
19
+ * The check is standalone and deterministic, so prompt() returns an
20
+ * empty string and the AI oracle never re-checks this rule.
21
+ */
22
+ class Polite {
23
+ constructor() {
24
+ this.id = 'polite';
25
+ }
26
+ prompt() {
27
+ return '';
28
+ }
29
+ violations(document) {
30
+ const uri = document.uri();
31
+ return document.walk({
32
+ header: () => [],
33
+ prose: (text, line) => this.scan(text, line, uri),
34
+ snippet: () => [],
35
+ bullets: () => [],
36
+ frontmatter: () => []
37
+ });
38
+ }
39
+ scan(text, line, uri) {
40
+ const found = [];
41
+ const regex = /\b(?:please|kindly|feel free to|make sure to|be sure to|don't forget to|remember to|note that|it is important to)\b/giu;
42
+ let hit = regex.exec(text);
43
+ while (hit !== null) {
44
+ found.push(new Violation(
45
+ this.id,
46
+ 'error',
47
+ `courtesy phrase "${hit[0]}" must be removed`,
48
+ new Region(uri, line, hit.index + 1)
49
+ ));
50
+ hit = regex.exec(text);
51
+ }
52
+ return found;
53
+ }
54
+ }
55
+
56
+ module.exports = Polite;
@@ -13,13 +13,16 @@ const Region = require('../region');
13
13
  *
14
14
  * Demands that every section name be a short label of one to three
15
15
  * words, so the manifesto reads as a map and not as prose.
16
+ *
17
+ * The check is standalone and deterministic, so prompt() returns an
18
+ * empty string and the AI oracle never re-checks this rule.
16
19
  */
17
20
  class ShortSections {
18
21
  constructor() {
19
22
  this.id = 'short-sections';
20
23
  }
21
24
  prompt() {
22
- return `${this.id}: flag any section heading that is not a short, noun-style label`;
25
+ return '';
23
26
  }
24
27
  violations(document) {
25
28
  const uri = document.uri();
@@ -0,0 +1,57 @@
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
+ * Simple.
13
+ *
14
+ * Demands simple grammar over ambiguity. A standalone checker can only
15
+ * guess: it counts commas and conjunctions to flag lines that pile up
16
+ * clauses. Its prompt hands the subtler tangle judgement to the oracle.
17
+ *
18
+ * @todo #29:45min Upgrade to true clause-depth analysis through an AI
19
+ * oracle so that subtle tangled instructions, which the comma and
20
+ * conjunction heuristic cannot measure today, are reliably caught, as
21
+ * requested in issue #29.
22
+ */
23
+ class Simple {
24
+ constructor() {
25
+ this.id = 'simple';
26
+ }
27
+ prompt() {
28
+ return `${this.id}: flag any grammatically tangled, multi-clause instruction`;
29
+ }
30
+ violations(document) {
31
+ const uri = document.uri();
32
+ return document.walk({
33
+ header: () => [],
34
+ prose: (text, line) => this.judge(text, line, uri),
35
+ snippet: () => [],
36
+ bullets: () => [],
37
+ frontmatter: () => []
38
+ });
39
+ }
40
+ judge(text, line, uri) {
41
+ const commas = text.match(/,/gu);
42
+ const commaCount = commas === null ? 0 : commas.length;
43
+ const hasConjunction = /\b(?:if|when|unless|because|although|while)\b/iu.test(text);
44
+ const tangled = hasConjunction && commaCount >= 2 || commaCount >= 3;
45
+ if (!tangled) {
46
+ return [];
47
+ }
48
+ return [new Violation(
49
+ this.id,
50
+ 'warning',
51
+ 'line is grammatically tangled, split into simpler lines',
52
+ new Region(uri, line, 1)
53
+ )];
54
+ }
55
+ }
56
+
57
+ module.exports = Simple;
@@ -0,0 +1,59 @@
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
+ * Unfinished.
13
+ *
14
+ * Flags any prose line that still carries a leftover marker, the
15
+ * fingerprint of work left half done. It catches the uppercase tokens
16
+ * TODO, TBD, FIXME, XXX, and WIP, the placeholder phrase "lorem ipsum",
17
+ * a trailing bare ellipsis, and an unfilled angle-bracket placeholder
18
+ * such as <placeholder>.
19
+ *
20
+ * The check is standalone and deterministic, so prompt() returns an
21
+ * empty string and the AI oracle never re-checks this rule.
22
+ */
23
+ class Unfinished {
24
+ constructor() {
25
+ this.id = 'unfinished';
26
+ }
27
+ prompt() {
28
+ return '';
29
+ }
30
+ violations(document) {
31
+ const uri = document.uri();
32
+ return document.walk({
33
+ header: () => [],
34
+ prose: (text, line) => this.scan(text, line, uri),
35
+ snippet: () => [],
36
+ bullets: () => [],
37
+ frontmatter: () => []
38
+ });
39
+ }
40
+ scan(text, line, uri) {
41
+ const markers = [
42
+ /\b(?:TODO|TBD|FIXME|XXX|WIP)\b/u,
43
+ /lorem ipsum/iu,
44
+ /\.\.\.\s*$/u,
45
+ /<[^>\n]*>/u
46
+ ];
47
+ if (!markers.some((marker) => marker.test(text))) {
48
+ return [];
49
+ }
50
+ return [new Violation(
51
+ this.id,
52
+ 'error',
53
+ 'leftover marker found, file looks unfinished',
54
+ new Region(uri, line, 1)
55
+ )];
56
+ }
57
+ }
58
+
59
+ module.exports = Unfinished;
@@ -0,0 +1,73 @@
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
+ const normalize = (text) => {
12
+ const clean = text
13
+ .toLowerCase()
14
+ .replace(/[^a-z0-9\s]/gu, '')
15
+ .replace(/\b(?:a|an|the)\b/gu, '')
16
+ .replace(/\s+/gu, ' ')
17
+ .trim();
18
+ return clean.split(' ').sort().join(' ');
19
+ };
20
+
21
+ /**
22
+ * Unique.
23
+ *
24
+ * Flags any instruction that repeats another instruction in the file.
25
+ * It normalizes each prose line, then remembers the first line where
26
+ * each normal form appeared, so a later twin earns one violation.
27
+ *
28
+ * @todo #25:45min Upgrade to semantic near-duplicate detection through
29
+ * embeddings or an AI oracle, to catch same-meaning different-words
30
+ * pairs the normalizer misses, as requested in issue #25.
31
+ */
32
+ class Unique {
33
+ constructor() {
34
+ this.id = 'unique';
35
+ }
36
+ prompt() {
37
+ return `${this.id}: flag any instruction that repeats another instruction in the file`;
38
+ }
39
+ violations(document) {
40
+ const uri = document.uri();
41
+ const lines = document.walk({
42
+ header: () => [],
43
+ prose: (text, line) => [{text, line}],
44
+ snippet: () => [],
45
+ bullets: () => [],
46
+ frontmatter: () => []
47
+ });
48
+ return this.repeats(uri, lines);
49
+ }
50
+ repeats(uri, lines) {
51
+ const seen = new Map();
52
+ const found = [];
53
+ lines.forEach((item) => {
54
+ const norm = normalize(item.text);
55
+ if (norm === '') {
56
+ return;
57
+ }
58
+ if (seen.has(norm)) {
59
+ found.push(new Violation(
60
+ this.id,
61
+ 'warning',
62
+ `instruction repeats line ${seen.get(norm)}`,
63
+ new Region(uri, item.line, 1)
64
+ ));
65
+ } else {
66
+ seen.set(norm, item.line);
67
+ }
68
+ });
69
+ return found;
70
+ }
71
+ }
72
+
73
+ module.exports = Unique;
package/src/usage.js ADDED
@@ -0,0 +1,52 @@
1
+ /*
2
+ * SPDX-FileCopyrightText: Copyright (c) 2026 Yegor Bugayenko
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ /**
9
+ * Price table.
10
+ *
11
+ * United States dollars per one million tokens, sent and received, for
12
+ * every model dogent knows. An unknown model falls back to zero, so the
13
+ * summary still prints token counts without inventing a price.
14
+ */
15
+ const PRICES = {
16
+ 'gpt-4o-mini': {input: 0.15, output: 0.6},
17
+ 'gpt-4o': {input: 2.5, output: 10},
18
+ 'gpt-4.1-nano': {input: 0.1, output: 0.4},
19
+ 'gpt-4.1-mini': {input: 0.4, output: 1.6},
20
+ 'gpt-4.1': {input: 2, output: 8}
21
+ };
22
+
23
+ /**
24
+ * Usage.
25
+ *
26
+ * One immutable tally of an OpenAI exchange: the model, the tokens sent,
27
+ * and the tokens received. Sums itself with another tally and renders a
28
+ * single human summary line, complete with an estimated cost in cents.
29
+ */
30
+ class Usage {
31
+ constructor(model, sent, received) {
32
+ this.model = model;
33
+ this.sent = sent;
34
+ this.received = received;
35
+ }
36
+ plus(other) {
37
+ return new Usage(
38
+ this.model || other.model,
39
+ this.sent + other.sent,
40
+ this.received + other.received
41
+ );
42
+ }
43
+ cost() {
44
+ const price = PRICES[this.model] || {input: 0, output: 0};
45
+ return this.sent / 1e6 * price.input + this.received / 1e6 * price.output;
46
+ }
47
+ text() {
48
+ return `OpenAI: ${this.model}, ${this.sent} sent, ${this.received} received, ~${(this.cost() * 100).toFixed(2)}¢`;
49
+ }
50
+ }
51
+
52
+ module.exports = Usage;