gcf-aif-ext 0.5.0 → 0.5.2

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
@@ -50,6 +50,17 @@ Updating preserves `gcf/`, `cnlp.config.json` and the pre-commit hook. Run `ai-f
50
50
 
51
51
  On Windows, run `extension add` and `extension update` from PowerShell or cmd, not Git Bash: the installer unpacks with `tar`, and Git Bash's GNU tar reads `C:\…` as a remote host (`Cannot connect to C: resolve failed`). The Windows `tar.exe` handles it.
52
52
 
53
+ ### 0.5.2
54
+
55
+ - Bundles `cnlp-kit` 3.9, so `gcf design check` and the commit gate accept a bullet written in Cyrillic alone.
56
+ - With `cnlp-kit` 3.9 or later, the `AGENTS.md` block between `<!-- gcf:start -->` and `<!-- gcf:end -->` is a marked region that the project's guide profile does not shape: the `custom_sections` entries 0.5.1 asked for are no longer needed. Update the `cnlp-kit` your project runs `npx cnlp check` with.
57
+
58
+ ### 0.5.1
59
+
60
+ - A provenance record `assumed: q-NNN` must name a record still in `gcf/open-questions.md` (M9). A D2 record lives until its `until:` fires; once the human confirms the value, cite `answer: q-NNN` instead and remove it. After updating, run `ai-factory gcf design check`: every citation of a removed question blocks the commit gate until it is rewritten.
61
+ - An unplayed item's body is frozen, so a word added to the glossary or a removed question is reported there, not blocked.
62
+ - The `AGENTS.md` block has no `##` heading any more: a CNL-P guide is flat. A project that checks `AGENTS.md` with `cnlp check` adds `gcf_context`, `gcf_work`, `gcf_forbidden` and `gcf_skills` to `custom_sections` of its guide profile.
63
+
53
64
  ### 0.5.0
54
65
 
55
66
  The run layer of the methodology. Update both extensions together — `gcf-harness-aif-ext` 0.6.0 requires this core — and run `ai-factory gcf init` to refresh the agent block in `AGENTS.md`. New commands:
package/extension.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/lee-to/ai-factory/2.x/schemas/extension.schema.json",
3
3
  "name": "gcf-aif-ext",
4
- "version": "0.5.0",
4
+ "version": "0.5.2",
5
5
  "description": "Game Context Framework for AI Factory",
6
6
  "skills": [
7
7
  "skills/gcf-init",
@@ -121,12 +121,14 @@ function itemsIn(text, key) {
121
121
  export const recordsIn = (text, key) =>
122
122
  readRecords(itemsIn(text, key)).records.map((r) => r.values);
123
123
 
124
- /** `blocks:` of open-questions.md: every reference, and the D1 ones per blocked id. */
124
+ /** open-questions.md: every record id, every `blocks:` reference, the D1 ones per blocked id. */
125
125
  export function questionBlocks(text) {
126
126
  /** @type {Map<string, string[]>} */
127
127
  const d1 = new Map();
128
128
  const refs = [];
129
- for (const rec of String(text ?? '').split(/\n(?=- id:)/)) {
129
+ const src = String(text ?? '');
130
+ const qids = new Set([...src.matchAll(/^\s*- id:\s*(\S+)/gm)].map((m) => m[1]));
131
+ for (const rec of src.split(/\n(?=- id:)/)) {
130
132
  const qid = rec.match(/^- id:\s*(\S+)/)?.[1];
131
133
  const ids = listOf(rec.match(/^\s+blocks:\s*(.+)$/m)?.[1] ?? '');
132
134
  if (!qid || ids === 'TBD' || !ids.length) continue;
@@ -135,7 +137,7 @@ export function questionBlocks(text) {
135
137
  for (const id of ids) d1.set(id, [...(d1.get(id) ?? []), qid]);
136
138
  }
137
139
 
138
- return { d1, refs };
140
+ return { d1, refs, qids };
139
141
  }
140
142
 
141
143
  /** Everything outside a unit that its references resolve against. */
@@ -296,7 +298,15 @@ function recordProblem(r, ctx) {
296
298
  }
297
299
 
298
300
  if (r.answer) return /^(q-\d+|manual)$/.test(r.answer) ? null : 'answer is q-NNN or manual';
299
- if (r.assumed) return /^q-\d+$/.test(r.assumed) ? null : 'assumed is q-NNN';
301
+ if (r.assumed) {
302
+ if (!/^q-\d+$/.test(r.assumed)) return 'assumed is q-NNN';
303
+
304
+ // The D2 record is what the readset and the human's skim list see: it outlives its citation.
305
+ return ctx.questions.qids.has(r.assumed)
306
+ ? null
307
+ : `assumed: ${r.assumed} is not in open-questions.md - a D2 record stays until its ` +
308
+ `until: fires; once the human confirms the value, cite answer: ${r.assumed} instead`;
309
+ }
300
310
 
301
311
  return 'a record carries src + quote, answer, or assumed';
302
312
  }
@@ -326,6 +336,9 @@ function uncovered(values, records) {
326
336
  export const isDrift = (problem) =>
327
337
  /^(quote is not verbatim|GDD#\S+ is not an anchor)/.test(problem ?? '');
328
338
 
339
+ /** The D2 record an `assumed:` cites left open-questions.md. */
340
+ const isGone = (problem) => /^assumed: q-\d+ is not in open-questions/.test(problem ?? '');
341
+
329
342
  /** M9: provenance records are well formed, quotes verbatim, every GDD-sourced slot backed. */
330
343
  function provenanceIssues(u, values, ctx) {
331
344
  const records = provenanceOf(u.body);
@@ -446,11 +459,16 @@ async function checkItem(item, ctx, ledger) {
446
459
  const values = slotValues(item.body, profile).filter((v) => !OPERATOR_BLOCKS.includes(v.block));
447
460
  for (const v of values.filter((x) => isTbd(x.value)))
448
461
  add('M1', v.line, `a unit in work carries no TBD: ${v.block}`);
449
- for (const [c, line, m] of valueIssues(values, ctx)) add(c, line, m);
462
+ // An unplayed body is frozen (§5.4): a later glossary word or unit, or a gone question, cannot
463
+ // be fixed there, only reported until a tune or a kill takes the item back.
464
+ const frozen = item.data.status === 'unplayed' ? 'report' : 'block';
465
+ for (const [c, line, m] of valueIssues(values, ctx)) add(c, line, m, frozen);
450
466
  for (const r of provenanceOf(item.body)) {
451
467
  const problem = recordProblem(r, ctx);
452
468
  // An item's quotes go stale when the GDD moves on: that is a D1 for the human, not a gate.
453
- if (problem) add('M9', r.line, problem, isDrift(problem) ? 'report' : 'block');
469
+ // A gone question is outside the frozen body too; a malformed record was malformed at close.
470
+ const sev = isDrift(problem) ? 'report' : isGone(problem) ? frozen : 'block';
471
+ if (problem) add('M9', r.line, problem, sev);
454
472
  }
455
473
 
456
474
  return issues;
@@ -9,11 +9,15 @@ import { scalarIn } from './design.js';
9
9
 
10
10
  const NONE_LINE = '- none: no item of this subsystem has collapsed yet';
11
11
 
12
+ /**
13
+ * The raw lines of a `key:` block. `prose`, not `lines`: cnlp-kit 3.9 blanks HTML comments in
14
+ * `lines`, and upsertBlock rewrites the block from these — a `gcf:allow` pragma must survive.
15
+ */
12
16
  export function blockLines(body, key) {
13
- const { lines, sections } = parseBlocks(body);
17
+ const { prose, sections } = parseBlocks(body);
14
18
  const at = sections.find((s) => s.key === key);
15
19
 
16
- return at ? itemsOf(lines, at.line).map((i) => i.text) : [];
20
+ return at ? itemsOf(prose, at.line).map((i) => i.text) : [];
17
21
  }
18
22
 
19
23
  /**
@@ -4,6 +4,213 @@ The format has its own version from here on. It used to be stamped with the rele
4
4
  an ADR extension, which made a breaking change to the format invisible inside that project's
5
5
  patch release.
6
6
 
7
+ ## 3.9.1
8
+
9
+ ### Added
10
+
11
+ - **`cnlp --version`** writes the version of the installed package, alone on 1 line, and exits 0.
12
+ Before it the only answer was `npm ls cnlp-kit`, and `--version` printed the usage and exited 1.
13
+
14
+ ## 3.9.0
15
+
16
+ An agent file that several tools write into could not pass the check. Each tool keeps its own block
17
+ between `<!-- <name>:start -->` and `<!-- <name>:end -->`, and `cnlp check` read the marker as an
18
+ item of the block above it and the tool's keys as unknown blocks of the file. Neither the tool nor
19
+ the project could fix that honestly: moving a marker moves the issue to another block, and naming a
20
+ tool's keys in `custom_sections:` breaks §7, which keeps that block for content that is not a rule.
21
+
22
+ ### Changed
23
+
24
+ - **§3: an HTML comment is markup, not structure.** A line that is 1 whole comment, and every line
25
+ of a comment that runs over several, is no item of the block above it and no section key. A line
26
+ carrying text beside a comment is still read, a comment that never closes hides nothing, and the
27
+ §4 and §5 line rules still read comment text, because an agent reads the raw file.
28
+ - **§3: a marked region belongs to the tool that wrote it.** The lines between
29
+ `<!-- <name>:start -->` and `<!-- <name>:end -->` are not shaped by the file's profile: no key in
30
+ them is unknown, repeated or out of order, no heading in them is unknown, and nothing in them
31
+ supplies a block the profile requires. §4 and §5 still hold on every line. An end closes the
32
+ latest start of its own name; a start with no end marks nothing, so it cannot exempt the rest of
33
+ a file.
34
+ - **`cnlp check` lists every region it did not shape**, by file, line range and name, the way it
35
+ lists the files the lexicon did not reach: a region is an exemption any author can write.
36
+ - **A page reference inside a region is not resolved**, and does not reach a page. It belongs to
37
+ the tool's own corpus, not to this one.
38
+ - **`parseBlocks` changed what it returns.** `lines`, which `itemsOf` and every structural check
39
+ read, now blanks comments and regions as well as fences. `prose` is new: it blanks fences only,
40
+ and the lexicon reads it. `regions` is new: `{ name, line, end }` for each marked region.
41
+
42
+ ### Fixed
43
+
44
+ - **A reported line is a line of the file.** `parseBlocks` stripped the frontmatter, so every line
45
+ number in a document carrying one counted from the end of it: an issue on line 13 of a skill read
46
+ as line 10. The frontmatter is now blanked in place, so `<file>:<line>` opens the line it names.
47
+
48
+ ## 3.8.1
49
+
50
+ 2 things a consumer of a produced set ran into. A registry that keeps a pre-release as
51
+ `2.0.0-[rc]` could not check it by path, and `identity:` `version:` had no stated shape, so the
52
+ kit's own skill wrote `0.9.2, read at commit a741e91` where a tool comparing versions expects `0.9.2`.
53
+
54
+ ### Fixed
55
+
56
+ - **`cnlp check <dir>` reads a set whose directory names carry `[`, `*` or `?`.** `configForPath`
57
+ put the directory names into the corpus glob as they were, so `2.0.0-[rc]` read as a character
58
+ class that matches no directory, and the set was reported as holding no document. Each name now
59
+ goes in as a glob matching only itself. A `{` still opens a brace set: `fs.glob` offers no escape
60
+ for it, and a set directory named with braces needs a config.
61
+
62
+ ### Changed
63
+
64
+ - **`profiles/docset.md`: `version:` opens with the version alone.** The `identity:` note says so,
65
+ and why: a tool comparing the version reads it up to the first comma. Anything after that comma
66
+ stays a note for a reader, which is the shape `examples/dragonecs-unity` already has.
67
+ - **`skills/cnlp-docset/SKILL.md`, step 4:** it records the version alone in `version:`, then a comma
68
+ and the revision, instead of leaving the 2 to be written in any order. An installed copy of the
69
+ skill reports as stale until `cnlp init` runs again.
70
+
71
+ ## 3.8.0
72
+
73
+ A set that crosses into a companion could not be checked on its own. `configForPath(dir)` built a
74
+ corpus of the 1 set it was pointed at, so `cnlp check examples/dragonecs-unity` reported 14
75
+ references that resolve the moment both sets are read together, and a registry keeping sets as
76
+ `<set>/<version>/` had no root holding both at all.
77
+
78
+ ### Added
79
+
80
+ - **`configForPath(dir, { name, companions })`.** `name` names the checked set when its directory
81
+ does not; `companions` is a list of `{ name, dir }`, each a set whose pages resolve references and
82
+ whose files are never checked. The returned config always carries `companions` (absolute dirs,
83
+ `[]` when none) and `rename`, so a caller detects a kit that ignores the option by the field's
84
+ absence. A name that is empty or carries `:`, `/` or `\`, a companion with no `index.md`, a
85
+ companion named like the checked set or named twice, a companion that is the checked set's own
86
+ directory, and either option on a directory that is not 1 set each fail with the fix. The checked
87
+ directory is read by its spelling on disk, so a path typed in another case does not read the set
88
+ as its own sibling.
89
+ - **`cnlp check <dir> --name <set> --companion <set>=<dir>`.** `--companion` repeats and splits at
90
+ its first `=`. Each companion prints `resolving against <name> at <dir>`: a caller passing
91
+ companions detects an executable older than 3.8.0 by that line's absence, so its wording is kept
92
+ stable. Both flags fail with a config run, which already reads every set under its root, and in
93
+ the `--flag=value` form, which would otherwise be ignored and print no `resolving` line. A value
94
+ flag followed directly by another flag now reads as having no value, for every value flag.
95
+ - **`linkIssues(docs, docsets, { context, rename })` and `setSizes(docs, docsets, rename)`.** A
96
+ companion's pages resolve a `page:` or `by:` value, its name resolves a `docset:` value, and its
97
+ references count toward reach; its own references and its unreached pages are never reported,
98
+ and it is in no count. A call with 2
99
+ arguments behaves as before.
100
+
101
+ ### Changed
102
+
103
+ - **A set checked by path resolves against the sets beside it.** When `<dir>` holds an `index.md`
104
+ and its name is the directory's name, every sibling directory holding an `index.md` joins as a
105
+ companion. `cnlp check <set>` and `configForPath(<set>)` report fewer issues where a sibling answers
106
+ a reference; `cnlp check examples/dragonecs-unity` and `examples/dragonecs` both conform now. An
107
+ explicit companion of the same name wins over a sibling.
108
+ - **`cnlp check <dir>` prints its `reading` line before the check runs**, with the `resolving` lines
109
+ after it, so a check that throws still says what it was reading.
110
+ - **`cnlp-format.md` §9** says a directory checked by path reaches the reference rules with no
111
+ config, and that a set checked on its own resolves against its companions.
112
+ - **`skills/cnlp-docset/SKILL.md`**: a new step 16 names the sets a crossing reaches that do not sit
113
+ beside the output directory, and step 17 passes each as `--companion`. The old steps 16 to 19 are
114
+ 17 to 20. An installed copy of the skill reports as stale until `cnlp init` runs again.
115
+
116
+ ## 3.7.0
117
+
118
+ The upgrade path reached the skills and nothing else. A project holds 2 more copies of what this
119
+ package declares — a local profile, and the block `cnlp init` prints for an agent file — and both
120
+ are files the project owns, so neither can be overwritten on an upgrade. They are reported instead.
121
+
122
+ ### Added
123
+
124
+ - **`cnlp check` names a local profile the packaged one has outgrown.** `profileDrift` in
125
+ `src/config.js` reads the copy and the packaged profile of the same name and reports what the
126
+ package declares and the copy does not: a section, its form, a record key, a required block, a
127
+ heading, a frontmatter field, the mood. What a copy adds is the vocabulary it was copied for and
128
+ is never reported, so a filled `custom_sections:` stays silent. Until now `profileFor` preferred
129
+ the copy and said nothing about its age: a profile copied at 3.1 held the corpus to a declaration
130
+ 3 versions old, and the run reported it conformant.
131
+ - **`cnlp check` names an agent file carrying an earlier copy of the pasted block.** The block is 1
132
+ value now — `AGENT_BLOCK` in `src/config.js` — read by the `init` that prints it and by the check
133
+ that watches it. `agentBlockDrift` reads `AGENTS.md` and `CLAUDE.md` at the project root and finds
134
+ the copy by its own opening bullet, the one naming `npx cnlp print format`. A file that never took
135
+ the block stays silent. It answers half of `docs/BACKLOG.md` item 9: the copy is watched, writing
136
+ it is still open.
137
+
138
+ Both are reports and not issues, like the stale-skill line: a copy the project owns is not a defect
139
+ in a document the corpus names, and neither decides the exit code.
140
+
141
+ ## 3.6.0
142
+
143
+ A bug report from a project writing its corpus in Russian. The item rule read an ASCII letter, so
144
+ every bullet of every non-Latin script reported as an item carrying no word, and the fix is 1
145
+ character class. What the report turned up behind it is the release: the checker was answering
146
+ `conform` over a corpus half of §5 never touched.
147
+
148
+ ### Fixed
149
+
150
+ - **An item states its claim in a letter of any script.** `§5`'s "the item carries no word" rule read
151
+ `/[a-z]/i`, which reports every bullet written in Cyrillic, Greek, Hebrew or a CJK script and
152
+ passes the moment the line names a Latin identifier. It reads `/\p{L}/u` now. What the rule was
153
+ written for — `- 4`, `- —`, `- ...`, the residue of a mechanical split — is rejected unchanged,
154
+ because a digit and a punctuation mark are letters in no script.
155
+
156
+ ### Added
157
+
158
+ - **`cnlp check` names the files the §5 word rules did not reach.** Every word list in §5 — the deny
159
+ list, the spelled thresholds, the open limits, the comparatives, the light verbs, the passive — is
160
+ English. Against a body in another script they raise no false issue; they silently match nothing,
161
+ which made a run report a conformant corpus over rules that never ran. `lexiconApplies` in
162
+ `src/cnlp.js` decides it from the prose — the line with its section key, its heading marker and its
163
+ quoted spans taken off, so this format's own English vocabulary and a Latin identifier do not
164
+ count as English prose — and `bin/cnlp.js` prints the count and the paths. It is a report and not
165
+ an issue: nothing in such a file is wrong, and the exit code does not change.
166
+
167
+ ### Changed
168
+
169
+ - **`§5` states that its word rules are English**, which rules still hold for a document in another
170
+ script — §3 block forms, §4 line rules, quoted text — and that the checker reports the coverage it
171
+ lost rather than staying silent about it.
172
+
173
+ ## 3.5.0
174
+
175
+ The first release shaped by a measurement rather than by a reading. `docs/PROBE-behaviour.md` holds
176
+ the run: 4 arms, 14 tasks, 5 repetitions, against a falsifier committed before the first cell. A
177
+ document set halved the wrong-call rate against the library's own documentation, 7% against 16%, and
178
+ beat an arm carrying all 19 documentation pages and the whole declaration surface in context — 3.3
179
+ times the tokens for more wrong calls, not fewer.
180
+
181
+ Nothing here changes the standard or a checker rule. The run produced 1 loss and 1 cost, and each
182
+ gets the smallest answer that fits it.
183
+
184
+ The cost: a set is dearer per question than the documentation it replaces, 22172 tokens a call
185
+ against 19236, and the difference is the index. A reader loads the index to answer any question and
186
+ a page to answer 1, so those are different kinds of cost, and nothing in the kit made the split
187
+ visible to whoever was growing one of them.
188
+
189
+ The loss: on 1 task of 14 the set scored worse than the prose. The task wanted the `Scene` value a
190
+ load returned; the set documents the manager's lookup members in full, because `catalog:` is closed
191
+ and they are part of the library, and the agent went looking a scene up instead. A task page's
192
+ preference lost to an API page's completeness, and nothing ranks them. That is `docs/BACKLOG.md`
193
+ item 10, left open on purpose: 1 task in 1 set does not buy a vocabulary.
194
+
195
+ ### Added
196
+
197
+ - **`cnlp check` reports each set's index bytes against its page bytes**, as
198
+ `<set>: index N B fixed, M page(s) K B on demand`. `checkProject` computes it from the documents
199
+ it has already read, so the report costs no second read, and `bin/cnlp.js` prints it. It is a
200
+ report and not a rule: the numbers say nothing about what a set may weigh.
201
+ - **A step in `skills/cnlp-docset/SKILL.md`** for the 1 rule the run turned up that no check reaches:
202
+ a member a task page steers a reader away from carries that in its own `pitfalls:`. Step 18; the
203
+ old step 18 is 19.
204
+
205
+ ### Changed
206
+
207
+ - **`profiles/docset.md` `read_protocol:` states what the index costs.** The index is the cost of
208
+ every question and a page the cost of 1. It is a value rather than a rule, so it sits in the
209
+ profile, and §6 keeps it in exactly 1 note.
210
+ - **`docs/DOCSETS.md` carries what the probe measured about behaviour**, including the result the
211
+ totals hide: both reading arms averaged 4.4 turns, and on the task whose contract no documentation
212
+ page carries, the documentation arm read *more* than anywhere else and still failed every run.
213
+
7
214
  ## 3.4.1
8
215
 
9
216
  A record-list of multi-key records read as a wall. The only boundary between 2 records was a line
@@ -46,6 +46,9 @@ Nothing else about your config moves on that second run.
46
46
  config records, and installs for every agent when there is no config yet.
47
47
  - `cnlp check` reports skills left behind by an upgrade, and its exit code stays the corpus's own
48
48
  answer: a stale copy is not a defect in a document.
49
+ - **An upgrade reaches the skills and nothing else, because nothing else is the package's to
50
+ rewrite.** A local profile and the pasted agent-file block are files you own, so `cnlp check`
51
+ reports what each one is missing against the installed version and leaves both where they are.
49
52
 
50
53
  On the first run `cnlp init` also looks at what the repository already has — `AGENTS.md`,
51
54
  `CLAUDE.md`, `skills/*/SKILL.md`, `docs/adr/*.md`, `docs/references/*.md`, `docs/docsets/*/…` — and
@@ -66,7 +69,11 @@ you say otherwise. Edit the entries it wrote, or add your own:
66
69
 
67
70
  `profiles` is optional and names a directory of your own profiles. A profile there resolves
68
71
  **before** the packaged one of the same name, which is how a project adds its own vocabulary:
69
- copy `profiles/skill.md` into it and fill `custom_sections:`.
72
+ copy `profiles/skill.md` into it and fill `custom_sections:`. The copy is yours and no command
73
+ here rewrites it, so `cnlp check` names what the packaged profile declares and the copy does not
74
+ — a section, a form, a record key, a required block — which is how an upgrade reaches a file it
75
+ must not overwrite. What the copy adds is the vocabulary it was copied for, and is never
76
+ reported.
70
77
 
71
78
  `cnlp init` also prints the block to paste into `AGENTS.md`, `CLAUDE.md` or whatever file your
72
79
  agent reads. Three lines, and they are the rules no skill announces: an installed skill states
@@ -94,9 +101,27 @@ set is a folder of markdown and nothing else — **no config file, no dependency
94
101
  it** — and `cnlp check <dir>` reads it by path:
95
102
 
96
103
  ```sh
97
- cnlp check ./nanoid-docset # 12 file(s) conform
104
+ cnlp check ./nanoid-docset
105
+ # 12 file(s) conform
106
+ # nanoid-docset: index 5603 B fixed, 11 page(s) 25778 B on demand
98
107
  ```
99
108
 
109
+ The 2 sizes are reported apart because they are different costs. A reader loads the index to answer
110
+ any question and a page to answer 1, so an index that grows is paid on every question.
111
+
112
+ **Checking 1 set of a pair.** A set that extends another names it in `companions:` or `overrides:`,
113
+ and its references cross into it. Checked by path, a set resolves those against the sets beside it,
114
+ so `cnlp check examples/dragonecs-unity` conforms on its own. A companion kept elsewhere is named,
115
+ and a set whose directory is a version rather than its name is named too:
116
+
117
+ ```sh
118
+ cnlp check docsets/dragonecs-unity/0.9.2 --name dragonecs-unity --companion dragonecs=docsets/dragonecs/1.1.17
119
+ # reading 4 entr(ies) under …/docsets
120
+ # resolving against dragonecs at …/docsets/dragonecs/1.1.17
121
+ ```
122
+
123
+ A companion is read to resolve a reference and never checked: its own defects are not this run's.
124
+
100
125
  That is the producing side. **Adopting** a set is the other half and a separate act: a repository
101
126
  that will hold one among its own documents runs `cnlp init`, adds the `docsets` key and the four
102
127
  corpus entries shown under [Document sets](#document-sets), and checks it with everything else from
@@ -111,6 +136,10 @@ that states it. What the set rests on lands in `identity:` `derived_from:`, so a
111
136
  page written from a signature from one written from prose, and the skill will not quietly write a
112
137
  prose claim as though it came from a declaration.
113
138
 
139
+ `identity:` `version:` opens with the version alone, and the revision the pages were read at follows
140
+ a comma — `version: 0.9.2, read at commit a741e91`. A tool that compares the set against the version
141
+ a project resolves, such as a registry or a version-guard test, reads up to that first comma.
142
+
114
143
  It builds in the order that keeps a half-finished set honest: the closed catalog of every public
115
144
  capability first, every record at `status: source`, so the set itself says what has no page yet; then
116
145
  the type names of one running example; then `canonical_use:`, one whole working program in the index;
@@ -122,11 +151,12 @@ other page reaches. `cnlp docs` is the single command you point your agent at.
122
151
  | | |
123
152
  |---|---|
124
153
  | `cnlp init [<dir>] [--agents <a,b>]` | install or update: config, profiles directory, skills for the agents you pick |
125
- | `cnlp check [<dir>] [--config <path>]` | hold every document to its profile: a directory read by path, or every corpus entry of the config. Exit non-zero on any issue |
154
+ | `cnlp check [<dir>] [--config <path>] [--name <set>] [--companion <set>=<dir>]` | hold every document to its profile: a directory read by path, or every corpus entry of the config. A set read by path resolves its references against the sets beside it and the companions you name. Reports each set's index bytes against its page bytes, names any file the §5 word rules did not reach and any marked region its profile did not shape, and names a local profile or a pasted agent-file block left behind by an upgrade. Exit non-zero on any issue |
126
155
  | `cnlp docs [<set>]` | write every document set's index, in reading order |
127
156
  | `cnlp print [<doc>] [--path]` | write the standard, or a profile, to stdout |
128
157
  | `cnlp scaffold <profile> <data.json>` | write a document from data, in the blocks and forms the profile declares |
129
158
  | `cnlp diff <profile> <doc> <data.json>` | report what moved between a document and a fresh read of its sources |
159
+ | `cnlp --version` | write the version of the installed package, alone on 1 line |
130
160
 
131
161
  ## The ownership rule
132
162
 
@@ -167,6 +197,13 @@ and the inventory step of `cnlp-migrate` is where it comes from. `adr` declares
167
197
  block list and no custom section, because an ADR block outside that list is prose looking
168
198
  for a home.
169
199
 
200
+ The §5 word rules are English — the deny list, the spelled thresholds, the open limits, the
201
+ comparatives, the light verbs, the passive are lists of English words. A document written in
202
+ another script matches none of them, so it is held to the rules that read shape — block forms,
203
+ line rules, quoted text — and `cnlp check` names it rather than reporting a corpus as conformant
204
+ against rules that never ran. The check reads script and not language, so a Latin-script document
205
+ in another language still reads as English to it.
206
+
170
207
  Severity is the caller's. The checker returns issues; `cnlp check` treats every issue as an
171
208
  error. A project that gates on a lifecycle status — a warning while a decision is a draft, an
172
209
  error once it is accepted — applies that policy in its own validator, calling `bodyIssues`
@@ -9,16 +9,19 @@ import path from 'node:path';
9
9
  import readline from 'node:readline';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { PACKAGE_ROOT, recordDrift, renderBody, resolveDoc } from '../src/cnlp.js';
12
- import { AGENTS, CONFIG_NAME, STAMP_NAME, checkProject, configForPath, docsetsOf, findConfig, profileFor, proposeCorpus, readConfig, readingOrder, skillDrift, skillsDirFor } from '../src/config.js';
12
+ import { AGENTS, AGENT_BLOCK, CONFIG_NAME, STAMP_NAME, agentBlockDrift, checkProject, configForPath, docsetsOf, findConfig, profileDrift, profileFor, proposeCorpus, readConfig, readingOrder, skillDrift, skillsDirFor } from '../src/config.js';
13
13
 
14
14
  const USAGE = `cnlp — the CNL-P checker
15
15
 
16
16
  cnlp init [<dir>] [--agents <a,b>] install or update this kit in a project
17
17
  cnlp check [<dir>] [--config <path>] check a directory of documents, or every corpus entry
18
+ [--name <set>] [--companion <set>=<dir>]...
19
+ name the set <dir> holds, and the sets it crosses into
18
20
  cnlp docs [<set>] write the index of every document set, or of one
19
21
  cnlp print [<doc>] [--path] write a shipped document to stdout, or its path
20
22
  cnlp scaffold <profile> <data> write a document from a JSON file, in the profile's shape
21
23
  cnlp diff <profile> <doc> <data> report what moved between a document and a fresh read
24
+ cnlp --version write the version of this package
22
25
 
23
26
  <doc> is "format" for the standard, or a profile name such as "skill".
24
27
  <a,b> is a comma list of ${AGENTS.map((a) => a.value).join(', ')}.
@@ -36,12 +39,18 @@ const STARTER = {
36
39
  const VERSION = JSON.parse(await readFile(fileURLToPath(new URL('package.json', PACKAGE_ROOT)), 'utf8')).version;
37
40
 
38
41
  const argv = process.argv.slice(2);
39
- const VALUE_FLAGS = ['--config', '--agents', '--out'];
42
+ const VALUE_FLAGS = ['--config', '--agents', '--out', '--name', '--companion'];
40
43
  const flag = (name) => argv.includes(name);
41
44
  const value = (name) => {
42
45
  const at = argv.indexOf(name);
43
- return at >= 0 ? argv[at + 1] : undefined;
46
+ return at >= 0 ? valueAt(at + 1) : undefined;
44
47
  };
48
+ // A flag right after a flag is no value: `--name --companion x=y` would name the set `--companion`.
49
+ function valueAt(i) {
50
+ return argv[i]?.startsWith('--') ? undefined : argv[i];
51
+ }
52
+ // A flag that repeats: `value` returns the first occurrence only.
53
+ const values = (name) => argv.flatMap((a, i) => (a === name ? [valueAt(i + 1)] : []));
45
54
  const positional = argv.filter((a, i) => !a.startsWith('--') && !VALUE_FLAGS.includes(argv[i - 1]));
46
55
 
47
56
  const fail = (message) => {
@@ -94,6 +103,12 @@ function multiSelect(message, choices, checked) {
94
103
 
95
104
  const [command] = positional;
96
105
 
106
+ // The version alone on stdout, so a script compares it without parsing a sentence.
107
+ if (flag('--version')) {
108
+ console.log(VERSION);
109
+ process.exit(0);
110
+ }
111
+
97
112
  if (!command || flag('--help') || flag('-h')) {
98
113
  console.log(USAGE);
99
114
  // An asked-for usage is a success; a missing command is not.
@@ -108,16 +123,39 @@ if (command === 'check') {
108
123
  if (!target && !configPath) {
109
124
  fail(`no ${CONFIG_NAME} found here or above ${process.cwd()} — name a directory to check, or run \`cnlp init\` to write a config`);
110
125
  }
126
+ const name = value('--name');
127
+ const companionUsage = 'usage: cnlp check <dir> [--name <set>] [--companion <set>=<dir>]...';
128
+ // `--companion=a=b` read as no flag at all would print no `resolving` line, which a caller reads
129
+ // as an executable older than 3.8.0: refuse the form instead of ignoring it.
130
+ if ((flag('--name') && !name) || argv.some((a) => /^--(name|companion)=/.test(a))) fail(companionUsage);
131
+ // The first `=` splits the value, so a dir may carry `=` and a Windows drive letter.
132
+ const companions = values('--companion').map((v) => {
133
+ const cut = v?.indexOf('=') ?? -1;
134
+ if (cut <= 0 || cut === v.length - 1) fail(`--companion ${v ?? ''}: expected <set>=<dir>
135
+ ${companionUsage}`);
136
+ return { name: v.slice(0, cut), dir: v.slice(cut + 1) };
137
+ });
138
+ if (!target && (flag('--name') || companions.length)) {
139
+ fail('--name and --companion apply to a <dir>: a config run already reads every set under its root');
140
+ }
111
141
  let result;
112
142
  let config;
113
143
  try {
114
- config = target ? await configForPath(target) : await readConfig(configPath);
115
- result = await checkProject(config);
144
+ config = target ? await configForPath(target, { name, companions }) : await readConfig(configPath);
116
145
  } catch (e) {
117
146
  fail(e.message);
118
147
  }
119
- // Paths below are relative to a root the caller never named, so name it once.
148
+ // Paths below are relative to a root the caller never named, so name it once. Printed before the
149
+ // check runs, so a caller seeing no `resolving` line concludes an old executable, not a crash.
120
150
  if (target) console.log(`reading ${config.corpus.length} entr(ies) under ${config.root}`);
151
+ // The wording is a contract: a caller passing companions detects an executable older than 3.8.0
152
+ // by this line's absence. Keep it stable.
153
+ for (const c of config.companions ?? []) console.log(`resolving against ${c.name} at ${c.dir}`);
154
+ try {
155
+ result = await checkProject(config);
156
+ } catch (e) {
157
+ fail(e.message);
158
+ }
121
159
  for (const { file, line, message } of result.issues) {
122
160
  console.error(`${file}:${line}: ${message}`);
123
161
  }
@@ -128,10 +166,35 @@ if (command === 'check') {
128
166
  const from = held ? `are from ${held}` : 'carry no version';
129
167
  console.error(`${path.relative(config.root, dir) || dir}: skills ${from}, this package is ${VERSION} — run \`cnlp init\``);
130
168
  }
169
+ // A copied profile and a pasted agent block are the 2 artifacts an upgrade cannot reach: the
170
+ // project owns both, so neither is overwritten and both are reported. Like a stale skill, a copy
171
+ // left behind is not a defect in a document the corpus names, and it decides no exit code.
172
+ for (const { file, profile, missing } of await profileDrift(config)) {
173
+ console.error(`${file}: the packaged ${profile} profile declares ${missing.join(', ')}, which this copy does not — compare them with \`npx cnlp print ${profile} --path\``);
174
+ }
175
+ for (const name of await agentBlockDrift(config.root)) {
176
+ console.error(`${name}: carries an earlier copy of the block \`cnlp init\` prints — paste the block that run ends with`);
177
+ }
178
+ // Every word list in §5 is English, so a body in another script matches none of them. Reporting
179
+ // which files the lexicon never reached is the difference between a partial check and a check
180
+ // that claims to be whole.
181
+ if (result.foreign.length) {
182
+ console.log(`${result.foreign.length} file(s) carry no English prose — the §5 word rules (deny list, thresholds, limits, comparatives, nominalizations, passive) did not apply to them`);
183
+ for (const rel of result.foreign) console.log(` ${rel}`);
184
+ }
185
+ if (result.regions.length) {
186
+ console.log(`${result.regions.length} marked region(s) the file's profile did not shape — the §4 and §5 line rules read them`);
187
+ for (const r of result.regions) console.log(` ${r.file}:${r.line}-${r.end}: ${r.name}`);
188
+ }
131
189
  if (result.issues.length) {
132
190
  fail(`\n${result.issues.length} issue(s) in ${result.checked.length} file(s)`);
133
191
  }
134
192
  console.log(`${result.checked.length} file(s) conform`);
193
+ // A reader loads the index to answer any question and a page to answer 1, so an author sees
194
+ // which of the 2 they are growing. `profiles/docset.md` states why they are different costs.
195
+ for (const set of result.sets) {
196
+ console.log(`${set.name}: index ${set.indexBytes} B fixed, ${set.pages} page(s) ${set.pageBytes} B on demand`);
197
+ }
135
198
  process.exit(0);
136
199
  }
137
200
 
@@ -324,10 +387,8 @@ if (command === 'init') {
324
387
  // decision about how that project's agent works. The operator pastes it where their agent reads.
325
388
  // 3 lines and not 5: an installed skill announces its own task through its description, so the
326
389
  // block states only what a skill cannot — a standing rule answers to no task description.
327
- console.log(`\nPaste this into AGENTS.md, CLAUDE.md, or whatever file your agent reads:\n
328
- - the CNL-P standard is the authority on document form: read it with \`npx cnlp print format\`
329
- - run \`npx cnlp check\` before a commit that touches a document the corpus names
330
- - load a library's documentation with \`npx cnlp docs\`, which writes every set's index in reading order`);
390
+ console.log(`\nPaste this into AGENTS.md, CLAUDE.md, or whatever file your agent reads:\n`);
391
+ console.log(AGENT_BLOCK.join('\n'));
331
392
  process.exit(0);
332
393
  }
333
394
 
@@ -124,6 +124,22 @@ never carries sub-bullets to hold a second thought — that is two bullets.
124
124
  document, not CNL-P sections. Anything reading this format skips fenced regions before
125
125
  looking for section keys.
126
126
 
127
+ **An HTML comment is markup, not structure.** A line that is 1 whole `<!-- … -->` comment, and
128
+ every line of a comment opening a line and running over several, is not an item of the block above
129
+ it and not a section key. A line carrying text beside a comment carries that text, and is read. A
130
+ comment that never closes hides nothing, because it would otherwise hide the rest of the file. §4
131
+ and §5 still read comment text: the rendered file hides it, and an agent reads the raw file.
132
+
133
+ **A marked region belongs to the tool that wrote it.** A file several tools write into gives each
134
+ tool its own lines between `<!-- <name>:start -->` and `<!-- <name>:end -->`, so the tool finds and
135
+ rewrites its own block and leaves the rest alone. `<name>` is ASCII letters, digits, `_`, `.` and
136
+ `-`. The file's profile does not shape a region: its keys and headings are not the file's blocks, so
137
+ none of them is unknown, repeated or out of order, and nothing in it supplies a block the profile
138
+ requires. §4 and §5 still hold on every line of it — a region is exempt from the file's shape, not
139
+ from its language. An end closes the latest start of its own name. A start with no end marks
140
+ nothing, because it would otherwise exempt the rest of the file. The checker lists every region it
141
+ did not shape, because a region is an exemption any author can write.
142
+
127
143
  **An empty block.** A required block with nothing in it carries `- none` and the reason:
128
144
  `- none: no alternative was viable at this scale`. That single line is the whole content and
129
145
  replaces the form the block would otherwise take, so a record-list or a keyed-block says it
@@ -166,6 +182,14 @@ A block whose content is a fenced region is not empty — the fence is opaque, n
166
182
  - A short concrete verb over an abstract one.
167
183
  - Define a specialized term once, then reuse it verbatim.
168
184
 
185
+ **The word rules are English, and a check says when they did not run.** The 7 bullets above hold
186
+ in any language. Every table and every list below is a list of English words — the deny-list, the
187
+ spelled thresholds, the open limits, the comparatives, the light verbs, the passive — so a body
188
+ written in another script matches none of them and is held only to the rules that read shape:
189
+ §3 block forms, §4 line rules, and the quoted-text rule below. The checker reports every such file
190
+ by name, because a corpus reported as conformant against rules that never ran is worse than a
191
+ corpus nobody checked.
192
+
169
193
  **`check the corpus` and `run the check` are 2 terms.** One term per concept fixes which word names a
170
194
  thing; one part of speech fixes which job that word does — here the action, and the program that
171
195
  performs the action. Keep the part of speech the term already carries and rename the other use:
@@ -443,9 +467,18 @@ The rules a checker holds:
443
467
  - every document except a set's `index` is the target of at least 1 reference. A page nothing
444
468
  reaches is a page nobody reads, and it goes stale unobserved.
445
469
 
446
- **The checker reads this only when a project says where its sets live**, with a `docsets` key in
447
- `cnlp.config.json` naming the directory whose immediate subdirectories are one set each. A project
448
- with no such key has no document set, and none of this applies to it.
470
+ **The checker reads this only where it knows a set lives**: a project names the directory whose
471
+ immediate subdirectories are one set each with a `docsets` key in `cnlp.config.json`, and a
472
+ directory checked by path is a set, or the root of several, by the `index.md` it holds. A project
473
+ with no such key and no such directory has no document set, and none of this applies to it.
474
+
475
+ **A set checked on its own resolves its crossings against its companions**: the sets the caller
476
+ names, and the sets beside it. A reference into `toolkit` from a set that extends it resolves
477
+ when `toolkit` is a companion. A companion is read to resolve a reference and is never checked: a
478
+ reference inside it is not reported, and a page of it nothing reaches is not reported, but a
479
+ reference from it into the checked set counts as reaching that page. The sets beside a set join
480
+ only when its name is the name of its directory; a set named apart from its directory sits in a
481
+ layout whose neighbours are not sets, and the caller names every companion.
449
482
 
450
483
  The rules a review holds. They are the same kind of rule as the four above and no checker reaches
451
484
  them, the way §5 splits the enforced half of a limit from the reviewed half:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cnlp-kit",
3
- "version": "3.4.1",
3
+ "version": "3.9.1",
4
4
  "description": "CNL-P — a format for documents an agent has to read precisely. The standard, its profiles, and the checker that holds them.",
5
5
  "type": "module",
6
6
  "license": "MIT",