@onedotmint/charterleaf 0.1.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.
@@ -0,0 +1,21 @@
1
+ # Charterleaf instructions
2
+
3
+ Project specifications live under `specs/`. For non-trivial architectural, behavioral, or cross-cutting changes, find the smallest relevant set; never read the whole tree by default.
4
+
5
+ - `specs/capabilities/` — durable behavior, contracts, invariants, non-goals.
6
+ - `specs/engineering/` — durable implementation constraints and concrete recurring pitfalls.
7
+ - `specs/decisions/` — important architectural choices and revisit conditions.
8
+ - `specs/changes/` — lightweight active deltas.
9
+ - `specs/constitution.md` — project-wide guardrails only.
10
+
11
+ When a concrete code path is known, use `charterleaf related <path>` to locate matching living specs plus active changes that affect them. Otherwise use normal code/text search. Use `charterleaf lint` for structural validation and mechanical active-change conflicts. These commands are helpers, not workflow gates.
12
+
13
+ Authority is `Constitution > Active Change > Living Spec > Current Code`. Do not add authority scores or silently rewrite specs to match code.
14
+
15
+ Keep specs compressed and evidence-based. Prefer durable contracts and pitfalls over commentary. Do not invent aspirational standards that are not demonstrated by the repo or explicitly accepted.
16
+
17
+ Do not create specs for temporary implementation details, current tasks, debugging notes, routine refactors, or trivial fixes with no durable implication. For a durable bug fix, use `Current` / `Expected` when useful and explicitly state what must `PRESERVE`.
18
+
19
+ For meaningful durable behavior/contract changes, use one change file with only the needed `ADD`, `MODIFY`, `REMOVE`, and/or `PRESERVE` sections. After acceptance and implementation, merge durable results into living specs and delete the change file. Git is the history.
20
+
21
+ Charterleaf is a knowledge layer, not a workflow layer. It must not manage tasks, planning, agents, execution, review, memory, compression, semantic search, code/spec verification, release lifecycle, or Git history.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Charterleaf contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,114 @@
1
+ # Charterleaf
2
+
3
+ A tiny specification layer for coding agents.
4
+
5
+ Durable project intent, deterministic routing, and structural linting —
6
+ without workflow orchestration.
7
+
8
+ > **Keep project intent in the repo, without turning specs into a workflow.**
9
+
10
+ Charterleaf keeps durable project knowledge in Markdown and provides exactly two deterministic helpers:
11
+
12
+ ```bash
13
+ charterleaf related <path>
14
+ charterleaf lint
15
+ ```
16
+
17
+ It is a knowledge layer, not a workflow framework.
18
+
19
+ ## Install
20
+
21
+ Install the public scoped npm package:
22
+
23
+ ```bash
24
+ npm install -g @onedotmint/charterleaf
25
+ ```
26
+
27
+ Pi can install the same package and discover its bundled Agent Skill:
28
+
29
+ ```bash
30
+ pi install npm:@onedotmint/charterleaf
31
+ ```
32
+
33
+ No Pi extension or Pi runtime dependency is used.
34
+
35
+ ## CLI
36
+
37
+ Run from a repository root containing `specs/`:
38
+
39
+ ```bash
40
+ charterleaf related src/auth/session.ts
41
+ charterleaf lint
42
+ ```
43
+
44
+ `related <path>` performs deterministic path routing. It matches `applies_to` with `*` and `**`, normalizes repo-relative paths to POSIX form, then appends active changes whose `affects` reference a matched Spec ID. Output is stable; no match is success. It is not semantic search.
45
+
46
+ `lint` performs mechanical checks only: frontmatter shape, living Spec IDs, duplicate requirement IDs, change references, static `ADD` / `MODIFY` / `REMOVE` references, and conflicting active changes that touch the same requirement ID. It does not judge wording, architecture quality, or semantic code/spec drift.
47
+
48
+ The frontmatter convention intentionally supports only top-level scalar values and simple string lists. Runtime dependencies remain zero.
49
+
50
+ ## Project knowledge
51
+
52
+ ```text
53
+ specs/
54
+ ├── constitution.md
55
+ ├── capabilities/
56
+ ├── engineering/
57
+ ├── decisions/
58
+ └── changes/
59
+ ```
60
+
61
+ - `constitution.md` — rare project-wide guardrails.
62
+ - `capabilities/` — durable behavior, contracts, invariants, non-goals.
63
+ - `engineering/` — durable implementation constraints and, when useful, concrete pitfalls.
64
+ - `decisions/` — why an important architectural choice exists.
65
+ - `changes/` — a lightweight active delta for an intentional durable change.
66
+
67
+ Authority is structural, not scored metadata:
68
+
69
+ ```text
70
+ Constitution > Active Change > Living Spec > Current Code
71
+ ```
72
+
73
+ Keep specs compressed and evidence-based. Prefer contracts over commentary and record durable pitfalls that code alone does not reveal. Do not invent aspirational standards merely to make a spec look complete.
74
+
75
+ For bug fixes with durable behavioral implications, clarify `Current`, `Expected`, and `PRESERVE` when that reduces accidental scope. Do not create a spec for trivial fixes with no durable implication.
76
+
77
+ Load the smallest relevant set; do not read the whole tree by default. After an accepted change is implemented, merge the durable result into living specs and delete the change file. Git is the history.
78
+
79
+ ## Other agents
80
+
81
+ Charterleaf core does not depend on Pi. Any coding agent capable of reading Markdown and executing shell commands can use it.
82
+
83
+ Use `AGENTS_SNIPPET.md` with agents that support project instructions. Agent Skills-compatible harnesses can use the bundled `skills/charterleaf/SKILL.md`.
84
+
85
+ ## Boundaries
86
+
87
+ Charterleaf has no:
88
+
89
+ - planning
90
+ - task management
91
+ - agent orchestration
92
+ - implementation or review workflow
93
+ - session memory or context compression
94
+ - semantic search or embeddings
95
+ - code/spec semantic verification
96
+ - database, index, or cache
97
+ - daemon or background watcher
98
+ - plugin/extension runtime
99
+ - Git/history abstraction
100
+
101
+ The two CLI commands are helpers, not workflow gates.
102
+
103
+ ## Development
104
+
105
+ Requires Node.js 20 or newer. There are no runtime or development dependencies and no build step.
106
+
107
+ ```bash
108
+ npm test
109
+ npm pack --dry-run
110
+ ```
111
+
112
+ ## License
113
+
114
+ MIT
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { main } = require('../lib/charterleaf.js');
5
+
6
+ process.exitCode = main();
@@ -0,0 +1,495 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const REQUIREMENT_ID_SOURCE = String.raw`\b[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d{3,}\b`;
7
+ const REQUIREMENT_HEADING = /^#{2,6}\s+([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*-\d{3,})\b/;
8
+ const SECTION_HEADING = /^##\s+(ADD|MODIFY|REMOVE|PRESERVE)\s*$/i;
9
+
10
+ class FrontmatterError extends Error {}
11
+
12
+ function normalizeRepoPath(value) {
13
+ let result = String(value).trim().replaceAll('\\', '/');
14
+ while (result.startsWith('./')) result = result.slice(2);
15
+ result = result.replace(/\/+/g, '/');
16
+ while (result.length > 1 && result.endsWith('/')) result = result.slice(0, -1);
17
+ return result || '.';
18
+ }
19
+
20
+ function unquote(value) {
21
+ if (
22
+ value.length >= 2 &&
23
+ value[0] === value[value.length - 1] &&
24
+ (value[0] === '"' || value[0] === "'")
25
+ ) {
26
+ return value.slice(1, -1);
27
+ }
28
+ return value;
29
+ }
30
+
31
+ function inlineList(value) {
32
+ const trimmed = value.trim();
33
+ if (!(trimmed.startsWith('[') && trimmed.endsWith(']'))) return null;
34
+ const inner = trimmed.slice(1, -1).trim();
35
+ if (!inner) return [];
36
+ return inner.split(',').map((part) => {
37
+ const item = unquote(part.trim());
38
+ if (!item) throw new FrontmatterError('empty inline list item');
39
+ return item;
40
+ });
41
+ }
42
+
43
+ function parseFrontmatter(text) {
44
+ const lines = String(text).split(/\r?\n/);
45
+ if (!lines.length || lines[0].trim() !== '---') {
46
+ return { frontmatter: null, body: String(text) };
47
+ }
48
+
49
+ let end = -1;
50
+ for (let index = 1; index < lines.length; index += 1) {
51
+ if (lines[index].trim() === '---') {
52
+ end = index;
53
+ break;
54
+ }
55
+ }
56
+ if (end === -1) throw new FrontmatterError('unclosed frontmatter');
57
+
58
+ const data = {};
59
+ let currentList = null;
60
+ for (let index = 1; index < end; index += 1) {
61
+ const raw = lines[index];
62
+ const number = index + 1;
63
+ if (!raw.trim() || raw.trimStart().startsWith('#')) continue;
64
+
65
+ const stripped = raw.trimStart();
66
+ const indent = raw.length - stripped.length;
67
+ if (indent) {
68
+ if (currentList === null || !stripped.startsWith('- ')) {
69
+ throw new FrontmatterError(`line ${number}: unsupported indentation`);
70
+ }
71
+ const item = unquote(stripped.slice(2).trim());
72
+ if (!item) throw new FrontmatterError(`line ${number}: empty list item`);
73
+ data[currentList].push(item);
74
+ continue;
75
+ }
76
+
77
+ currentList = null;
78
+ const colon = raw.indexOf(':');
79
+ if (colon === -1) throw new FrontmatterError(`line ${number}: expected 'key: value'`);
80
+ const key = raw.slice(0, colon).trim();
81
+ const value = raw.slice(colon + 1).trim();
82
+ if (!key || !/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key)) {
83
+ throw new FrontmatterError(`line ${number}: invalid key`);
84
+ }
85
+ if (Object.hasOwn(data, key)) {
86
+ throw new FrontmatterError(`line ${number}: duplicate key ${key}`);
87
+ }
88
+
89
+ if (!value) {
90
+ data[key] = [];
91
+ currentList = key;
92
+ continue;
93
+ }
94
+
95
+ const parsedList = inlineList(value);
96
+ data[key] = parsedList === null ? unquote(value) : parsedList;
97
+ }
98
+
99
+ return { frontmatter: data, body: lines.slice(end + 1).join('\n') };
100
+ }
101
+
102
+ function escapeRegexChar(char) {
103
+ return /[\\^$.*+?()[\]{}|]/.test(char) ? `\\${char}` : char;
104
+ }
105
+
106
+ function globMatches(pattern, targetPath) {
107
+ const normalizedPattern = normalizeRepoPath(pattern);
108
+ const normalizedPath = normalizeRepoPath(targetPath);
109
+ const parts = normalizedPattern.split('/');
110
+ let source = '^';
111
+
112
+ parts.forEach((part, index) => {
113
+ if (part === '**') {
114
+ source += index === parts.length - 1 ? '.*' : '(?:[^/]+/)*';
115
+ return;
116
+ }
117
+
118
+ for (const char of part) {
119
+ source += char === '*' ? '[^/]*' : escapeRegexChar(char);
120
+ }
121
+ if (index !== parts.length - 1) source += '/';
122
+ });
123
+
124
+ source += '$';
125
+ return new RegExp(source).test(normalizedPath);
126
+ }
127
+
128
+ function walkMarkdown(root) {
129
+ const files = [];
130
+ function walk(directory) {
131
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
132
+ const full = path.join(directory, entry.name);
133
+ if (entry.isDirectory()) walk(full);
134
+ else if (entry.isFile() && entry.name.endsWith('.md')) files.push(full);
135
+ }
136
+ }
137
+ walk(root);
138
+ return files.sort((a, b) => {
139
+ const left = a.replaceAll('\\', '/');
140
+ const right = b.replaceAll('\\', '/');
141
+ return left < right ? -1 : left > right ? 1 : 0;
142
+ });
143
+ }
144
+
145
+ function relativeDisplay(filePath, cwd) {
146
+ const relative = path.relative(cwd, filePath);
147
+ if (!relative.startsWith('..') && !path.isAbsolute(relative)) return relative.replaceAll('\\', '/');
148
+ return filePath.replaceAll('\\', '/');
149
+ }
150
+
151
+ function write(stream, line) {
152
+ stream.write(`${line}\n`);
153
+ }
154
+
155
+ function related(repoPath, cwd = process.cwd(), streams = {}) {
156
+ const stdout = streams.stdout || process.stdout;
157
+ const stderr = streams.stderr || process.stderr;
158
+ const specRoot = path.join(cwd, 'specs');
159
+ if (!fs.existsSync(specRoot) || !fs.statSync(specRoot).isDirectory()) {
160
+ write(stderr, 'ERROR specs/: not found');
161
+ return 2;
162
+ }
163
+
164
+ const target = normalizeRepoPath(repoPath);
165
+ const documents = [];
166
+ const matches = [];
167
+ const matchedIds = new Set();
168
+ const errors = [];
169
+
170
+ for (const filePath of walkMarkdown(specRoot)) {
171
+ const display = relativeDisplay(filePath, cwd);
172
+ let parsed;
173
+ try {
174
+ parsed = parseFrontmatter(fs.readFileSync(filePath, 'utf8'));
175
+ } catch (error) {
176
+ errors.push(`ERROR ${display}: ${error.message}`);
177
+ continue;
178
+ }
179
+ documents.push({ filePath, display, frontmatter: parsed.frontmatter || {} });
180
+ }
181
+
182
+ for (const document of documents) {
183
+ const { display, frontmatter } = document;
184
+ if (!Object.hasOwn(frontmatter, 'applies_to')) continue;
185
+ const patterns = frontmatter.applies_to;
186
+ if (!Array.isArray(patterns) || !patterns.every((item) => typeof item === 'string')) {
187
+ errors.push(`ERROR ${display}: applies_to must be a list of paths`);
188
+ continue;
189
+ }
190
+ if (!patterns.some((pattern) => globMatches(pattern, target))) continue;
191
+
192
+ matches.push(display);
193
+ if (typeof frontmatter.id === 'string' && frontmatter.id.trim()) {
194
+ matchedIds.add(frontmatter.id.trim());
195
+ }
196
+ }
197
+
198
+ if (errors.length) {
199
+ for (const error of errors) write(stderr, error);
200
+ return 2;
201
+ }
202
+
203
+ const activeChanges = [];
204
+ if (matchedIds.size) {
205
+ const changeRoot = path.join(specRoot, 'changes');
206
+ for (const document of documents) {
207
+ if (path.dirname(document.filePath) !== changeRoot) continue;
208
+ const affects = document.frontmatter.affects;
209
+ if (!Array.isArray(affects)) continue;
210
+ if (affects.some((specId) => typeof specId === 'string' && matchedIds.has(specId.trim()))) {
211
+ activeChanges.push(document.display);
212
+ }
213
+ }
214
+ }
215
+
216
+ matches.sort();
217
+ activeChanges.sort();
218
+ const output = [...matches];
219
+ for (const change of activeChanges) {
220
+ if (!output.includes(change)) output.push(change);
221
+ }
222
+
223
+ for (const match of output) write(stdout, match);
224
+ if (!output.length) write(stdout, 'No related specs.');
225
+ return 0;
226
+ }
227
+
228
+ function isLivingSpec(filePath, specRoot) {
229
+ const relative = path.relative(specRoot, filePath).replaceAll('\\', '/').split('/');
230
+ return relative.length >= 2 && (relative[0] === 'capabilities' || relative[0] === 'engineering');
231
+ }
232
+
233
+ function requirementDefinitions(text) {
234
+ const found = [];
235
+ for (const line of text.split(/\r?\n/)) {
236
+ const match = line.trim().match(REQUIREMENT_HEADING);
237
+ if (match) found.push(match[1]);
238
+ }
239
+ return found;
240
+ }
241
+
242
+ function changeSections(body) {
243
+ const sections = new Map();
244
+ let current = null;
245
+ for (const line of body.split(/\r?\n/)) {
246
+ const heading = line.trim().match(SECTION_HEADING);
247
+ if (heading) {
248
+ current = heading[1].toUpperCase();
249
+ if (!sections.has(current)) sections.set(current, []);
250
+ continue;
251
+ }
252
+ if (line.startsWith('## ')) {
253
+ current = null;
254
+ continue;
255
+ }
256
+ if (current !== null) sections.get(current).push(line);
257
+ }
258
+ return Object.fromEntries([...sections].map(([name, lines]) => [name, lines.join('\n')]));
259
+ }
260
+
261
+ function allRequirementIds(text) {
262
+ return [...String(text).matchAll(new RegExp(REQUIREMENT_ID_SOURCE, 'g'))].map((match) => match[0]);
263
+ }
264
+
265
+ function lint(cwd = process.cwd(), streams = {}) {
266
+ const stdout = streams.stdout || process.stdout;
267
+ const stderr = streams.stderr || process.stderr;
268
+ const specRoot = path.join(cwd, 'specs');
269
+ if (!fs.existsSync(specRoot) || !fs.statSync(specRoot).isDirectory()) {
270
+ write(stderr, 'ERROR specs/: not found');
271
+ return 2;
272
+ }
273
+
274
+ const errors = [];
275
+ const parsed = new Map();
276
+ const ids = new Map();
277
+ const livingRequirements = new Map();
278
+ const requirementsBySpecId = new Map();
279
+ const changeTouches = new Map();
280
+ const paths = walkMarkdown(specRoot);
281
+
282
+ for (const filePath of paths) {
283
+ const display = relativeDisplay(filePath, cwd);
284
+ let result;
285
+ try {
286
+ result = parseFrontmatter(fs.readFileSync(filePath, 'utf8'));
287
+ parsed.set(filePath, result);
288
+ } catch (error) {
289
+ errors.push([display, `invalid frontmatter: ${error.message}`]);
290
+ continue;
291
+ }
292
+
293
+ const frontmatter = result.frontmatter || {};
294
+ const living = isLivingSpec(filePath, specRoot);
295
+ const change = path.dirname(filePath) === path.join(specRoot, 'changes');
296
+
297
+ if (living) {
298
+ const rawSpecId = frontmatter.id;
299
+ if (typeof rawSpecId !== 'string' || !rawSpecId.trim()) {
300
+ errors.push([display, 'missing spec id']);
301
+ } else {
302
+ const specId = rawSpecId.trim();
303
+ if (ids.has(specId)) {
304
+ errors.push([display, `duplicate spec id ${specId} (also in ${relativeDisplay(ids.get(specId), cwd)})`]);
305
+ } else {
306
+ ids.set(specId, filePath);
307
+ }
308
+ }
309
+ }
310
+
311
+ for (const field of ['applies_to', 'tags']) {
312
+ if (Object.hasOwn(frontmatter, field)) {
313
+ const value = frontmatter[field];
314
+ if (!Array.isArray(value) || !value.every((item) => typeof item === 'string' && item.length > 0)) {
315
+ errors.push([display, `${field} must be a list of strings`]);
316
+ }
317
+ }
318
+ }
319
+
320
+ if (Object.hasOwn(frontmatter, 'affects')) {
321
+ const value = frontmatter.affects;
322
+ if (!Array.isArray(value) || !value.every((item) => typeof item === 'string' && item.length > 0)) {
323
+ errors.push([display, 'affects must be a list of spec ids']);
324
+ }
325
+ }
326
+
327
+ if (change && !Object.hasOwn(frontmatter, 'affects')) {
328
+ errors.push([display, 'missing affects']);
329
+ } else if (change && Array.isArray(frontmatter.affects) && frontmatter.affects.length === 0) {
330
+ errors.push([display, 'affects must contain at least one spec id']);
331
+ }
332
+
333
+ if (living) {
334
+ const requirementIds = requirementDefinitions(result.body);
335
+ livingRequirements.set(filePath, requirementIds);
336
+ const counts = new Map();
337
+ for (const reqId of requirementIds) counts.set(reqId, (counts.get(reqId) || 0) + 1);
338
+ for (const [reqId, count] of [...counts].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
339
+ if (count > 1) errors.push([display, `duplicate requirement id ${reqId}`]);
340
+ }
341
+ }
342
+ }
343
+
344
+ const globalRequirements = new Map();
345
+ for (const [filePath, reqIds] of livingRequirements) {
346
+ for (const reqId of [...new Set(reqIds)].sort()) {
347
+ if (!globalRequirements.has(reqId)) globalRequirements.set(reqId, []);
348
+ globalRequirements.get(reqId).push(filePath);
349
+ }
350
+ }
351
+ for (const [reqId, reqPaths] of [...globalRequirements].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
352
+ if (reqPaths.length > 1) {
353
+ const first = reqPaths[0];
354
+ for (const filePath of reqPaths.slice(1)) {
355
+ errors.push([
356
+ relativeDisplay(filePath, cwd),
357
+ `duplicate requirement id ${reqId} (also in ${relativeDisplay(first, cwd)})`,
358
+ ]);
359
+ }
360
+ }
361
+ }
362
+
363
+ for (const [specId, filePath] of ids) {
364
+ requirementsBySpecId.set(specId, new Set(livingRequirements.get(filePath) || []));
365
+ }
366
+
367
+ const allExistingRequirements = new Set(globalRequirements.keys());
368
+ for (const filePath of paths) {
369
+ if (path.dirname(filePath) !== path.join(specRoot, 'changes') || !parsed.has(filePath)) continue;
370
+ const display = relativeDisplay(filePath, cwd);
371
+ const { frontmatter: rawFrontmatter, body } = parsed.get(filePath);
372
+ const frontmatter = rawFrontmatter || {};
373
+ const affects = frontmatter.affects;
374
+ if (!Array.isArray(affects) || !affects.every((item) => typeof item === 'string' && item.length > 0)) {
375
+ continue;
376
+ }
377
+
378
+ const affectedRequirements = new Set();
379
+ let anyKnown = false;
380
+ for (const specId of affects) {
381
+ if (!ids.has(specId)) {
382
+ errors.push([display, `unknown spec id ${specId}`]);
383
+ } else {
384
+ anyKnown = true;
385
+ for (const reqId of requirementsBySpecId.get(specId) || []) affectedRequirements.add(reqId);
386
+ }
387
+ }
388
+
389
+ const sections = changeSections(body);
390
+ for (const section of ['ADD', 'MODIFY', 'REMOVE']) {
391
+ for (const reqId of new Set(allRequirementIds(sections[section] || ''))) {
392
+ if (!changeTouches.has(reqId)) changeTouches.set(reqId, new Map());
393
+ const byFile = changeTouches.get(reqId);
394
+ if (!byFile.has(display)) byFile.set(display, new Set());
395
+ byFile.get(display).add(section);
396
+ }
397
+ }
398
+
399
+ if (anyKnown) {
400
+ for (const section of ['MODIFY', 'REMOVE']) {
401
+ for (const reqId of [...new Set(allRequirementIds(sections[section] || ''))].sort()) {
402
+ if (!affectedRequirements.has(reqId)) {
403
+ errors.push([display, `${section} unknown requirement id ${reqId}`]);
404
+ }
405
+ }
406
+ }
407
+ }
408
+ for (const reqId of [...new Set(allRequirementIds(sections.ADD || ''))].sort()) {
409
+ if (allExistingRequirements.has(reqId)) {
410
+ errors.push([display, `ADD existing requirement id ${reqId}`]);
411
+ }
412
+ }
413
+ }
414
+
415
+ for (const [reqId, byFile] of [...changeTouches].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
416
+ if (byFile.size < 2) continue;
417
+ const entries = [...byFile].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
418
+ const touches = entries.map(
419
+ ([display, sections]) => `${display}: ${[...sections].sort().join('/')}`,
420
+ );
421
+ errors.push([
422
+ entries[0][0],
423
+ `conflicting active changes for ${reqId}: ${touches.join('; ')}`,
424
+ ]);
425
+ }
426
+
427
+ errors.sort((a, b) => {
428
+ const left = a[0] === b[0] ? a[1] : a[0];
429
+ const right = a[0] === b[0] ? b[1] : b[0];
430
+ return left < right ? -1 : left > right ? 1 : 0;
431
+ });
432
+ for (const [display, message] of errors) write(stdout, `ERROR ${display}: ${message}`);
433
+ if (errors.length) {
434
+ write(stdout, `${errors.length} errors`);
435
+ return 1;
436
+ }
437
+ write(stdout, '0 errors');
438
+ return 0;
439
+ }
440
+
441
+ function helpText() {
442
+ return [
443
+ 'Usage: charterleaf <command> [args]',
444
+ '',
445
+ 'Commands:',
446
+ ' related <path> find specs matching a repo-relative code path',
447
+ ' lint check Charterleaf structural conventions',
448
+ ].join('\n');
449
+ }
450
+
451
+ function usageError(message, streams = {}) {
452
+ const stderr = streams.stderr || process.stderr;
453
+ if (message) write(stderr, `ERROR: ${message}`);
454
+ write(stderr, helpText());
455
+ return 2;
456
+ }
457
+
458
+ function main(argv = process.argv.slice(2), cwd = process.cwd(), streams = {}) {
459
+ if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) {
460
+ write(streams.stdout || process.stdout, helpText());
461
+ return 0;
462
+ }
463
+ if (!argv.length) return usageError('a command is required', streams);
464
+
465
+ const [command, ...rest] = argv;
466
+ if (command === 'related') {
467
+ if (rest.length === 1 && (rest[0] === '--help' || rest[0] === '-h')) {
468
+ write(streams.stdout || process.stdout, 'Usage: charterleaf related <path>');
469
+ return 0;
470
+ }
471
+ if (rest.length !== 1) return usageError('related requires exactly one path', streams);
472
+ return related(rest[0], path.resolve(cwd), streams);
473
+ }
474
+ if (command === 'lint') {
475
+ if (rest.length === 1 && (rest[0] === '--help' || rest[0] === '-h')) {
476
+ write(streams.stdout || process.stdout, 'Usage: charterleaf lint');
477
+ return 0;
478
+ }
479
+ if (rest.length) return usageError('lint takes no arguments', streams);
480
+ return lint(path.resolve(cwd), streams);
481
+ }
482
+ return usageError(`unknown command ${command}`, streams);
483
+ }
484
+
485
+ module.exports = {
486
+ FrontmatterError,
487
+ changeSections,
488
+ globMatches,
489
+ lint,
490
+ main,
491
+ normalizeRepoPath,
492
+ parseFrontmatter,
493
+ related,
494
+ requirementDefinitions,
495
+ };
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@onedotmint/charterleaf",
3
+ "version": "0.1.0",
4
+ "description": "A tiny specification layer for coding agents.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/onedotmint/charterleaf.git"
9
+ },
10
+ "homepage": "https://github.com/onedotmint/charterleaf#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/onedotmint/charterleaf/issues"
13
+ },
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "bin": {
18
+ "charterleaf": "./bin/charterleaf.js"
19
+ },
20
+ "files": [
21
+ "bin/",
22
+ "lib/",
23
+ "skills/",
24
+ "templates/",
25
+ "AGENTS_SNIPPET.md",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "keywords": [
30
+ "charterleaf",
31
+ "ai-agent",
32
+ "coding-agent",
33
+ "specification",
34
+ "agent-skills",
35
+ "pi"
36
+ ],
37
+ "scripts": {
38
+ "test": "node --test"
39
+ },
40
+ "pi": {
41
+ "skills": [
42
+ "./skills"
43
+ ]
44
+ }
45
+ }
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: charterleaf
3
+ description: A tiny specification layer for durable project intent and constraints.
4
+ ---
5
+
6
+ # Charterleaf
7
+
8
+ Treat Charterleaf as a small knowledge layer, not a workflow system.
9
+
10
+ When a concrete code path is known, run:
11
+
12
+ ```bash
13
+ charterleaf related <path>
14
+ ```
15
+
16
+ Read only the returned living specs and active changes. If the location is unknown, use the host agent's normal search tools; Charterleaf does not provide semantic search.
17
+
18
+ Use authority in this order:
19
+
20
+ ```text
21
+ Constitution > Active Change > Living Spec > Current Code
22
+ ```
23
+
24
+ Keep specs compressed and evidence-based. Prefer contracts over commentary and durable pitfalls over patterns obvious from code. Never invent aspirational standards; record only demonstrated or explicitly accepted intent.
25
+
26
+ Do not touch specs for renames, lint cleanup, routine refactors, temporary debugging, Todo/status changes, or trivial fixes with no durable implication.
27
+
28
+ For a durable bug fix, use `Current` and `Expected` when useful and state what must `PRESERVE`. For other durable behavior/contract changes, keep one `specs/changes/<name>.md` delta with only needed `ADD`, `MODIFY`, `REMOVE`, and/or `PRESERVE` sections.
29
+
30
+ After editing specs, run:
31
+
32
+ ```bash
33
+ charterleaf lint
34
+ ```
35
+
36
+ `lint` checks structural conventions and deterministic active-change conflicts only. It does not judge writing, architecture quality, or semantic code/spec drift.
37
+
38
+ After an accepted change is implemented, merge the durable result into living specs and delete the change file. Git keeps history.
39
+
40
+ Do not plan, manage tasks, invoke agents, run implementation/review workflows, manage memory, or replace the host agent's code-navigation tools.
@@ -0,0 +1,31 @@
1
+ ---
2
+ id: <domain.capability>
3
+ applies_to:
4
+ - <path/**>
5
+ ---
6
+
7
+ # <Capability name>
8
+
9
+ ## Purpose
10
+
11
+ <One short paragraph describing the durable capability.>
12
+
13
+ ## Requirements
14
+
15
+ ### <REQ-001> — <Requirement name>
16
+
17
+ The system MUST <observable or verifiable behavior>.
18
+
19
+ #### Scenarios
20
+
21
+ - GIVEN <precondition>
22
+ WHEN <event/action>
23
+ THEN <observable result>.
24
+
25
+ ## Invariants
26
+
27
+ - <What must always remain true.>
28
+
29
+ ## Non-goals
30
+
31
+ - <What this capability intentionally does not cover.>
@@ -0,0 +1,49 @@
1
+ ---
2
+ affects:
3
+ - <existing.spec.id>
4
+ ---
5
+
6
+ # <Change name>
7
+
8
+ ## Intent
9
+
10
+ <What intentional behavior/contract change is being made, and why?>
11
+
12
+ <!-- For a durable bug fix, optionally make the gap explicit:
13
+ ## Current
14
+ <Observed behavior.>
15
+
16
+ ## Expected
17
+ <Intended behavior.>
18
+
19
+ Always use PRESERVE for important unchanged behavior.
20
+ -->
21
+
22
+ <!-- Keep only the delta sections that are actually needed. -->
23
+
24
+ ## ADD
25
+
26
+ ### <REQ-ID> — <Requirement name>
27
+
28
+ <New durable requirement.>
29
+
30
+ ## MODIFY
31
+
32
+ ### <REQ-ID> — <Requirement name>
33
+
34
+ <New form of an existing requirement.>
35
+
36
+ Previously: <old behavior, only when useful for clarity>.
37
+
38
+ ## REMOVE
39
+
40
+ - <Durable requirement or behavior being removed.>
41
+
42
+ ## PRESERVE
43
+
44
+ - <Behavior, API shape, invariant, or boundary that must remain unchanged.>
45
+
46
+ ## Verification
47
+
48
+ - <Behavior that proves the intended change.>
49
+ - <Existing behavior that proves PRESERVE constraints did not drift.>
@@ -0,0 +1,17 @@
1
+ # ADR-<NNNN> — <Decision title>
2
+
3
+ ## Context
4
+
5
+ <What durable problem or constraint made a decision necessary?>
6
+
7
+ ## Decision
8
+
9
+ <What was deliberately chosen?>
10
+
11
+ ## Why
12
+
13
+ <Why is this preferable under the current project constraints?>
14
+
15
+ ## Revisit when
16
+
17
+ <Concrete condition that would justify reconsidering this decision.>
@@ -0,0 +1,25 @@
1
+ ---
2
+ id: <engineering.scope>
3
+ applies_to:
4
+ - <path/**>
5
+ ---
6
+
7
+ # <Engineering scope>
8
+
9
+ ## Boundaries
10
+
11
+ - <Durable dependency or architecture boundary.>
12
+
13
+ ## Constraints
14
+
15
+ - <Long-lived implementation constraint.>
16
+
17
+ <!-- Optional; include only for concrete, durable traps not obvious from code:
18
+ ## Pitfalls
19
+
20
+ - <What looks reasonable here but must not be done, and why.>
21
+ -->
22
+
23
+ ## Verification
24
+
25
+ - <How behavior changes in this scope should be verified.>