@nurix/etna 0.20.39 → 0.20.41
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/data/harness-index.json +1 -1
- package/data/harnesses/code/agents/brainstorm.md +4 -2
- package/data/harnesses/code/agents/doc-reviewer.md +31 -0
- package/data/harnesses/code/codex/agents/brainstorm.toml +4 -2
- package/data/harnesses/code/codex/agents/doc-reviewer.toml +28 -0
- package/data/harnesses/code/harness.json +2 -2
- package/data/harnesses/code/skills/brainstorm/references/harvest.md +1 -1
- package/data/harnesses/code/skills/documentation/SKILL.md +13 -10
- package/data/harnesses/code/skills/documentation/references/document-sets.md +93 -0
- package/data/harnesses/code/skills/documentation/references/style-guide.md +38 -6
- package/data/harnesses/code/skills/documentation/scripts/doc-lint.mjs +128 -1
- package/data/harnesses/code/skills/documentation/templates/doctrine.md +20 -15
- package/data/harnesses/delivery/agents/brainstorm.md +4 -2
- package/data/harnesses/delivery/codex/agents/brainstorm.toml +4 -2
- package/data/harnesses/delivery/harness.json +2 -2
- package/data/harnesses/delivery/skills/brainstorm/references/harvest.md +1 -1
- package/data/harnesses/delivery/skills/documentation/SKILL.md +13 -10
- package/data/harnesses/delivery/skills/documentation/references/document-sets.md +93 -0
- package/data/harnesses/delivery/skills/documentation/references/style-guide.md +38 -6
- package/data/harnesses/delivery/skills/documentation/scripts/doc-lint.mjs +128 -1
- package/data/harnesses/delivery/skills/documentation/templates/doctrine.md +20 -15
- package/data/harnesses/etna/agents/brainstorm.md +4 -2
- package/data/harnesses/etna/agents/doc-reviewer.md +31 -0
- package/data/harnesses/etna/codex/agents/brainstorm.toml +4 -2
- package/data/harnesses/etna/codex/agents/doc-reviewer.toml +28 -0
- package/data/harnesses/etna/harness.json +2 -2
- package/data/harnesses/etna/skills/brainstorm/references/harvest.md +1 -1
- package/data/harnesses/etna/skills/documentation/SKILL.md +13 -10
- package/data/harnesses/etna/skills/documentation/references/document-sets.md +93 -0
- package/data/harnesses/etna/skills/documentation/references/style-guide.md +38 -6
- package/data/harnesses/etna/skills/documentation/scripts/doc-lint.mjs +128 -1
- package/data/harnesses/etna/skills/documentation/templates/doctrine.md +20 -15
- package/package.json +2 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// skills/documentation/scripts/doc-lint.mjs - mechanical checks over markdown docs (tense and history narration, dates, redirect stubs, frontmatter, index shape, owning-document body)
|
|
1
|
+
// skills/documentation/scripts/doc-lint.mjs - mechanical checks over markdown docs (tense and history narration, dates, redirect stubs, frontmatter, index shape, owning-document body, doctrine sets)
|
|
2
2
|
// Usage: node doc-lint.mjs <file.md ...> | node doc-lint.mjs --changed (git-changed + untracked .md)
|
|
3
3
|
// Exit 1 when any block-severity finding survives. Escape hatch: <!-- doc-lint-disable: id[, id] --> alone on its
|
|
4
4
|
// own line (file-wide, with a reason comment beside it) or at the end of the offending line (that line only).
|
|
@@ -392,6 +392,13 @@ function lintFile(path, findings) {
|
|
|
392
392
|
if (!label || /^For the agent$/i.test(label)) continue;
|
|
393
393
|
if (CLAUSE.test(label.replace(/`[^`]*`/g, 'X'))) findings.push({ path, n, severity: 'warn', id: 'clause-label', message: 'a heading or bold label written as a clause — a label is a noun phrase with no verb, tense or person ("Decisions", not "What was decided"; "Organization member:", not "You already belong to an organization:")', excerpt: label.slice(0, 90) });
|
|
394
394
|
}
|
|
395
|
+
// "X exists so that…" restates the subject its heading already named; the purpose belongs in the rule it implies.
|
|
396
|
+
if (!disabled.has('purpose-opener')) {
|
|
397
|
+
for (const { n, line } of lines) {
|
|
398
|
+
const bare = line.replace(/`[^`]*`/g, 'X').replace(/^\s*[-*]\s+(\*\*[^*]+\*\*:?\s*)?/, '');
|
|
399
|
+
if (/^(the |an? )?[\w' -]{1,40}? exists (so that|to |because|in order)/i.test(bare)) findings.push({ path, n, severity: 'warn', id: 'purpose-opener', message: '"X exists so that…" — state the rule the purpose implies ("An unvetted organization signs in and reads, and creates nothing"), not that the thing has a purpose', excerpt: line.trim().slice(0, 90) });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
395
402
|
// The sentences after a label describe the product in the third person; quoted interface copy keeps its own "you".
|
|
396
403
|
if (!disabled.has('second-person')) {
|
|
397
404
|
for (const { n, line } of lines) {
|
|
@@ -400,6 +407,126 @@ function lintFile(path, findings) {
|
|
|
400
407
|
}
|
|
401
408
|
}
|
|
402
409
|
}
|
|
410
|
+
|
|
411
|
+
if (scope === 'truth' && !isFolderIndex && (declared === 'doctrine' || DOCTRINE_LOCATION.test(path))) lintDoctrine(path, text, lines, disabled, findings);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// A doctrine folder is read as a set: its documents cite each other's principles by number and must not restate each
|
|
415
|
+
// other (templates/doctrine.md, references/document-sets.md), so these checks read the siblings beside the linted file.
|
|
416
|
+
const DOCTRINE_LOCATION = /(^|\/)docs\/architecture\/common\/[^/]+\.md$/;
|
|
417
|
+
// The specification sentence every doctrine document carries verbatim is the template's, not a duplicated rule.
|
|
418
|
+
const SPEC_SENTENCE = /specification of its concern|has a gap to plan and build|never settled silently in code/i;
|
|
419
|
+
const STOPWORDS = new Set('the and for that this with from are its their them they into onto than then when where which while who whom whose what each every any all one its not nor but was were has have had been being does did can may must will would should could only also more most other such same own per via out off over under about after before between both either neither those these there here because so as at by in of on or to an a is be it no'.split(' '));
|
|
420
|
+
const siblingCache = new Map();
|
|
421
|
+
|
|
422
|
+
// The doctrine documents in a folder, each with its title, objects, principle numbers, headings and sentences.
|
|
423
|
+
function doctrineSiblings(dir) {
|
|
424
|
+
if (siblingCache.has(dir)) return siblingCache.get(dir);
|
|
425
|
+
const docs = [];
|
|
426
|
+
for (const name of existsSync(dir) ? readdirSync(dir) : []) {
|
|
427
|
+
if (!name.endsWith('.md') || /^readme\.md$/i.test(name)) continue;
|
|
428
|
+
const path = join(dir, name);
|
|
429
|
+
const text = readFileSync(path, 'utf8');
|
|
430
|
+
const lines = proseLines(text);
|
|
431
|
+
const title = (lines.find(({ line }) => /^#\s+/.test(line))?.line || '').replace(/^#\s+/, '').trim().toLowerCase();
|
|
432
|
+
docs.push({ path, name, title, text, lines, objects: listValue((frontmatter(text) || new Map()).get('objects')).map((o) => o.toLowerCase()), principles: principleNumbers(lines), headings: new Set(lines.map(({ line }) => /^#{2,4}\s+(.+?)\s*$/.exec(line)?.[1].toLowerCase()).filter(Boolean)), sentences: sentencesOf(lines) });
|
|
433
|
+
}
|
|
434
|
+
siblingCache.set(dir, docs);
|
|
435
|
+
return docs;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// The numbers of a document's principles: the numbered items under its Principles heading.
|
|
439
|
+
function principleNumbers(lines) {
|
|
440
|
+
const out = new Set();
|
|
441
|
+
let level = 0;
|
|
442
|
+
for (const { line } of lines) {
|
|
443
|
+
const heading = /^(#{2,4})\s+(.+?)\s*$/.exec(line);
|
|
444
|
+
if (heading) { level = /^principles$/i.test(heading[2]) ? heading[1].length : level && heading[1].length <= level ? 0 : level; continue; }
|
|
445
|
+
const item = level && /^(\d+)\.\s/.exec(line);
|
|
446
|
+
if (item) out.add(Number(item[1]));
|
|
447
|
+
}
|
|
448
|
+
return out;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// The body's sentences long enough to state a rule, as content-word sets; headings, tables and the footer drop out.
|
|
452
|
+
function sentencesOf(lines) {
|
|
453
|
+
const out = [];
|
|
454
|
+
for (const { n, line } of lines) {
|
|
455
|
+
if (/^##\s+For the agent\b/.test(line)) break;
|
|
456
|
+
if (/^\s*(#|\|)/.test(line) || !line.trim()) continue;
|
|
457
|
+
const plain = line.replace(/^\s*([-*]|\d+\.)\s+/, '').replace(/\*\*[^*]+\*\*:?/g, '').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/`/g, '');
|
|
458
|
+
for (const sentence of plain.split(/(?<=[.;])\s+(?=[A-Z(])/)) {
|
|
459
|
+
if (SPEC_SENTENCE.test(sentence)) continue;
|
|
460
|
+
const words = new Set((sentence.toLowerCase().match(/[a-z][a-z'-]+/g) || []).filter((w) => w.length > 2 && !STOPWORDS.has(w)));
|
|
461
|
+
if (words.size >= 9) out.push({ n, text: sentence.trim(), words });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return out;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const slugOf = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
468
|
+
|
|
469
|
+
function lintDoctrine(path, text, lines, disabled, findings) {
|
|
470
|
+
const siblings = doctrineSiblings(dirname(path));
|
|
471
|
+
const self = siblings.find((d) => d.name === path.split('/').pop()) || { path, objects: [], principles: principleNumbers(lines), headings: new Set(), sentences: sentencesOf(lines) };
|
|
472
|
+
const byName = (name) => siblings.find((d) => d.title === name.toLowerCase() || d.name === `${slugOf(name)}.md`);
|
|
473
|
+
const footerAt = lines.findIndex(({ line }) => /^##\s+For the agent\b/.test(line));
|
|
474
|
+
const body = footerAt === -1 ? lines : lines.slice(0, footerAt);
|
|
475
|
+
|
|
476
|
+
// A citation names a principle by number or a section by heading, in parentheses: "(principle 4)", "(auth, principle 3)", "(API protocol § Idempotency)".
|
|
477
|
+
if (!disabled.has('citation-unresolved')) {
|
|
478
|
+
for (const { n, line } of lines) {
|
|
479
|
+
for (const group of line.replace(/`[^`]*`/g, '').matchAll(/\(([^()]+)\)/g)) {
|
|
480
|
+
for (const part of group[1].split(';').map((p) => p.trim())) {
|
|
481
|
+
const principle = /^(?:(.+?),\s*)?principles?\s+(\d+(?:\s*(?:,|and)\s*\d+)*)$/i.exec(part);
|
|
482
|
+
const section = /^(.+?)\s+§\s+(.+)$/.exec(part);
|
|
483
|
+
if (!principle && !section) continue;
|
|
484
|
+
const name = principle ? principle[1] : section[1];
|
|
485
|
+
const target = name ? byName(name) : self;
|
|
486
|
+
if (!target) { findings.push({ path, n, severity: 'warn', id: 'citation-unresolved', message: `"${name}" names no doctrine document in this folder — cite a sibling by its title`, excerpt: part }); continue; }
|
|
487
|
+
const where = target === self ? 'this document' : target.name;
|
|
488
|
+
if (principle) for (const num of principle[2].split(/\s*(?:,|and)\s*/).map(Number))
|
|
489
|
+
if (!target.principles.has(num)) findings.push({ path, n, severity: 'block', id: 'citation-unresolved', message: `principle ${num} does not exist in ${where} (it numbers ${[...target.principles].join(', ') || 'none'})`, excerpt: part });
|
|
490
|
+
if (section && !target.headings.has(section[2].trim().toLowerCase())) findings.push({ path, n, severity: 'block', id: 'citation-unresolved', message: `${where} has no heading "${section[2].trim()}"`, excerpt: part });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
// One concept has one home: an object two doctrine documents both govern is a boundary the set has not drawn.
|
|
496
|
+
if (!disabled.has('objects-shared')) {
|
|
497
|
+
for (const object of self.objects) {
|
|
498
|
+
const other = siblings.find((d) => d !== self && d.objects.includes(object));
|
|
499
|
+
if (other) findings.push({ path, n: 1, severity: 'warn', id: 'objects-shared', message: `\`${object}\` is also in ${other.name}'s objects — one concept, one governing document; the other cites it` });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
// The body names its neighbours in plain words; links live in "For the agent" (references/style-guide.md § Self-contained documents).
|
|
503
|
+
if (!disabled.has('doctrine-body-link')) {
|
|
504
|
+
for (const { n, line } of body)
|
|
505
|
+
if (/\]\([^)]+\)/.test(line.replace(/`[^`]*`/g, '')) && !line.includes('doc-lint-disable: doctrine-body-link')) findings.push({ path, n, severity: 'warn', id: 'doctrine-body-link', message: 'a link in a doctrine body — name the neighbour in plain words; its link belongs in "For the agent"', excerpt: line.trim().slice(0, 90) });
|
|
506
|
+
}
|
|
507
|
+
// A doctrine document specifies; what one product has not built is that product's plan or backlog, never a marker here.
|
|
508
|
+
if (!disabled.has('doctrine-unbuilt')) {
|
|
509
|
+
const at = text.split('\n').findIndex((l) => /<!--\s*UNBUILT/.test(l));
|
|
510
|
+
if (at !== -1) findings.push({ path, n: at + 1, severity: 'warn', id: 'doctrine-unbuilt', message: 'an UNBUILT marker in a doctrine document — doctrine is the specification; a product that lacks a part has a gap to plan and build in its own repo' });
|
|
511
|
+
}
|
|
512
|
+
// A rule stated twice drifts; the second statement is a citation. Sentences sharing most of their content words are flagged once per pair.
|
|
513
|
+
if (!disabled.has('sibling-duplicate')) {
|
|
514
|
+
for (const s of self.sentences) {
|
|
515
|
+
for (const d of siblings) {
|
|
516
|
+
const match = d.sentences.find((t) => (d !== self || t.n !== s.n) && overlap(s.words, t.words) >= 0.7 && (d !== self || t.n > s.n));
|
|
517
|
+
if (!match) continue;
|
|
518
|
+
findings.push({ path, n: s.n, severity: 'warn', id: 'sibling-duplicate', message: `restates ${d === self ? 'this document' : d.name}:${match.n} — keep the rule in one home and cite it there`, excerpt: s.text.slice(0, 90) });
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Shared content words over the smaller sentence's, so a sentence quoted inside a longer one still counts.
|
|
526
|
+
function overlap(a, b) {
|
|
527
|
+
let shared = 0;
|
|
528
|
+
for (const w of a) if (b.has(w)) shared++;
|
|
529
|
+
return shared / Math.min(a.size, b.size);
|
|
403
530
|
}
|
|
404
531
|
|
|
405
532
|
// A changed plan whose committed version is already frozen must not be edited.
|
|
@@ -4,20 +4,23 @@ Truth document. Use for **one concern the platform handles everywhere**: authent
|
|
|
4
4
|
|
|
5
5
|
**A doctrine document states the design and nothing beneath it** (`references/style-guide.md` § Shape — the model first, the detail after). It is the most stable document in the tree, because it says why the platform is shaped the way it is, and that changes far less often than the code. It explains no mechanism: no function, file, column, cookie attribute, header value or per-route error code, since each of those fails the stability test. The code graph answers what the code does. This document answers why, and which principles any new code in the concern must keep.
|
|
6
6
|
|
|
7
|
+
**A doctrine document is a specification, not a description.** It states how every product handles the concern, so a product whose code differs has a gap to plan and build, and the document never records which product has built what: no `UNBUILT` marker, no "today the product…", no product's name, setting value or code root. When one product's posture against the doctrine matters, it is that product's own security overview, plan or backlog.
|
|
8
|
+
|
|
9
|
+
**Earn the document before writing it.** List the decisions only this document would own, each checked against the documents that exist: a decision another document already states is a citation, not an owned decision. Fewer than about six owned decisions means the concern is a section of a neighbour, not a document (`references/document-sets.md` § Usefulness test).
|
|
10
|
+
|
|
7
11
|
**Required:**
|
|
8
12
|
|
|
9
13
|
- the frontmatter
|
|
10
|
-
- the opening (the question, the boundary, and the
|
|
14
|
+
- the opening (the question, the boundary, and the specification sentence)
|
|
11
15
|
- **The model**, ending in numbered principles, each with its reason
|
|
12
|
-
- **The parts**: one table or diagram placing every part in the model, then their relations
|
|
13
|
-
- **one section per part**
|
|
14
16
|
- **For the agent**
|
|
15
17
|
|
|
16
|
-
**Optional:**
|
|
18
|
+
**Optional, each only with something the principles do not already say:**
|
|
17
19
|
|
|
18
|
-
-
|
|
20
|
+
- **The parts**: one table or diagram placing the parts in the model, where there are enough parts that a reader needs them placed, then **Relations** where two parts meet in a way no principle states
|
|
21
|
+
- **The parts in depth**: one section per part that carries decisions of its own
|
|
19
22
|
|
|
20
|
-
Nothing else: the neighbouring documents live in **For the agent**, the one closing link surface (`references/style-guide.md` § Self-contained documents and the link surface). Expect
|
|
23
|
+
Nothing else: the neighbouring documents live in **For the agent**, the one closing link surface, and the body names them in plain words with no link (`references/style-guide.md` § Self-contained documents and the link surface). Expect eighty to two hundred and fifty lines; past that, mechanism or restatement has usually crept in.
|
|
21
24
|
|
|
22
25
|
```yaml
|
|
23
26
|
---
|
|
@@ -33,7 +36,7 @@ objects: [principal, credential, device grant] # required — the concepts thi
|
|
|
33
36
|
|
|
34
37
|
{The question this concern answers for every request, row or run, in one sentence, bolded — then what it does not answer and which document does. One paragraph.}
|
|
35
38
|
|
|
36
|
-
{The
|
|
39
|
+
{The specification sentence, verbatim, with the scope named: "This document is the specification of its concern for every product on {the scope, e.g. the enterprise plane}. A product whose code differs from it has a gap to plan and build; a disagreement with a principle or its reason is raised against this document, never settled silently in code."}
|
|
37
40
|
|
|
38
41
|
## The model
|
|
39
42
|
|
|
@@ -51,26 +54,26 @@ objects: [principal, credential, device grant] # required — the concepts thi
|
|
|
51
54
|
|
|
52
55
|
### Principles
|
|
53
56
|
|
|
54
|
-
{Numbered invariants that hold across every part, each a bold noun-phrase label, then the rule and its reason in a sentence or two. Later sections cite them by number.}
|
|
57
|
+
{Numbered invariants that hold across every part, each a bold noun-phrase label, then the rule and its reason in a sentence or two. Later sections cite them by number. A rule another doctrine document owns is cited — "(auth service, principle 5)" — never restated as a principle here.}
|
|
55
58
|
|
|
56
|
-
## {The parts, as a noun phrase — e.g. "Credential families"}
|
|
59
|
+
## {The parts, as a noun phrase — e.g. "Credential families"} (optional)
|
|
57
60
|
|
|
58
|
-
{One table: each part placed on the model's axes.
|
|
61
|
+
{One table: each part placed on the model's axes. A row whose only content is "see principle N" is not placing anything; drop the table when every row would be one. Then "Relations": only where two parts meet in a way no principle states — what they share, what keeps them apart, the order in which they are tried. A role-level diagram (mermaid) where the relationships are a flow.}
|
|
59
62
|
|
|
60
|
-
## {The parts in depth, as a noun phrase — e.g. "Families in depth"}
|
|
63
|
+
## {The parts in depth, as a noun phrase — e.g. "Families in depth"} (optional)
|
|
61
64
|
|
|
62
65
|
### {One part, named for who meets it — e.g. "Browser person", "Trusted system"}
|
|
63
66
|
|
|
64
|
-
{
|
|
67
|
+
{The decisions this part rests on that no principle states, as bullets — a bold noun-phrase label, then the rule and its reason — citing principles by number. No opener saying the part exists or what it is for; the first bullet is its first decision. A part with nothing beyond its principles gets no section. A sequence diagram at role level where the part is a flow.}
|
|
65
68
|
|
|
66
69
|
## For the agent
|
|
67
70
|
|
|
68
|
-
{Pointers, not an inventory: the objects with one line each;
|
|
71
|
+
{Pointers, not an inventory: the objects with one line each; any cross-cutting seam by name; the neighbouring doctrine with a purpose each, linked. No code roots: the doctrine binds every product, and each product's code is found through the documents that name it.}
|
|
69
72
|
```
|
|
70
73
|
|
|
71
|
-
###
|
|
74
|
+
### Citations
|
|
72
75
|
|
|
73
|
-
|
|
76
|
+
Principles are cited by number, sections by heading, always in parentheses: "(principle 4)" in the document itself, "(auth, principle 3)" or "(API protocol § Rate limits)" for a sibling, by its title. `doc-lint` resolves each against the folder and blocks a number or heading that does not exist, so a renumbering is caught at the next lint, not by a reader.
|
|
74
77
|
|
|
75
78
|
### What a doctrine document is not
|
|
76
79
|
|
|
@@ -80,3 +83,5 @@ Code cannot reveal what it does not yet do, so decided-but-unbuilt intent is one
|
|
|
80
83
|
| The catalog of routes, error codes or variables | An API reference or environment registry |
|
|
81
84
|
| The one feature that happens to use the concern most | That feature's own document, which names this doctrine in its Scope |
|
|
82
85
|
| The story of how the concern got this way | The subject's architecture decision (`decisions/`), whose Decision Changelog tail carries how it moved, and the changelog for the when |
|
|
86
|
+
| A record of which product has built which part | That product's plan, backlog, or security overview |
|
|
87
|
+
| A restatement of a sibling's rule for completeness | A citation of the sibling's principle, with the one clause of context the sentence needs |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nurix/etna",
|
|
3
|
-
"version": "0.20.
|
|
3
|
+
"version": "0.20.41",
|
|
4
4
|
"description": "Bootstrap a project with a Nurix harness — writes the committed contract (CLAUDE.md, AGENTS.md, .claude/harness.json, settings.json, the mount hook) and mounts the harness itself, gitignored, from a bundled payload; sessions keep it current from the CDN.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -46,5 +46,5 @@
|
|
|
46
46
|
"agent",
|
|
47
47
|
"plugin"
|
|
48
48
|
],
|
|
49
|
-
"etnaFingerprint": "
|
|
49
|
+
"etnaFingerprint": "f22341cd281e"
|
|
50
50
|
}
|