@onedotmint/charterleaf 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/project.js ADDED
@@ -0,0 +1,142 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+ const { fileURLToPath } = require('node:url');
7
+ const { createHash, randomUUID } = require('node:crypto');
8
+ const { execFileSync } = require('node:child_process');
9
+
10
+ const MODES = ['auto', 'confirm', 'reference', 'off'];
11
+ const CONFIG = 'specs/.charterleaf.json';
12
+
13
+ function projectRoot(cwd) {
14
+ const start = fs.realpathSync(cwd);
15
+ for (let current = start; ; current = path.dirname(current)) {
16
+ if (fs.existsSync(path.join(current, '.git')) || fs.existsSync(path.join(current, CONFIG))) return current;
17
+ if (path.dirname(current) === current) return start;
18
+ }
19
+ }
20
+
21
+ function inside(root, file) {
22
+ const relative = path.relative(root, file);
23
+ return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
24
+ }
25
+
26
+ // Resolve existing ancestors too: new files below a symlink must not escape the project.
27
+ function canonical(file, depth = 0) {
28
+ if (depth > 40) throw new Error('Too many symlinks');
29
+ try {
30
+ const stat = fs.lstatSync(file);
31
+ if (stat.isSymbolicLink()) return canonical(path.resolve(path.dirname(file), fs.readlinkSync(file)), depth + 1);
32
+ return fs.realpathSync(file);
33
+ } catch (error) {
34
+ if (error.code !== 'ENOENT') throw error;
35
+ const parent = path.dirname(file);
36
+ if (parent === file) return file;
37
+ return path.join(canonical(parent, depth + 1), path.basename(file));
38
+ }
39
+ }
40
+
41
+ function toolPath(input) {
42
+ let value = input.replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, ' ');
43
+ if (value.startsWith('@')) value = value.slice(1);
44
+ if (value.startsWith('file://')) value = fileURLToPath(value);
45
+ value = value.replaceAll('\\', '/');
46
+ if (value === '~') value = os.homedir();
47
+ if (value.startsWith('~/')) value = path.join(os.homedir(), value.slice(2));
48
+ if (process.platform === 'win32') {
49
+ const drive = value.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
50
+ if (drive) value = `${drive[1].toUpperCase()}:/${drive[2] || ''}`;
51
+ }
52
+ return value;
53
+ }
54
+
55
+ function projectPath(root, cwd, input) {
56
+ if (typeof input !== 'string' || !input || input.includes('\0')) return null;
57
+ let value = toolPath(input);
58
+ if (process.platform !== 'win32' && /^[A-Za-z]:\//.test(value)) {
59
+ value = `/mnt/${value[0].toLowerCase()}/${value.slice(3)}`;
60
+ } else if (process.platform !== 'win32' && value.startsWith('//')) {
61
+ // A foreign UNC host cannot safely be interpreted as a local Linux path.
62
+ return null;
63
+ }
64
+ const lexical = path.resolve(cwd, value);
65
+ const resolved = canonical(lexical);
66
+ if (!inside(root, lexical) || !inside(root, resolved)) return null;
67
+ return path.relative(root, resolved).replaceAll('\\', '/') || '.';
68
+ }
69
+
70
+ function readMode(root) {
71
+ const file = path.join(root, CONFIG);
72
+ if (!fs.existsSync(file)) return 'off';
73
+ if (!inside(root, canonical(file))) throw new Error('Charterleaf config escapes project');
74
+ const config = JSON.parse(fs.readFileSync(file, 'utf8'));
75
+ if (config.version !== 1 || !MODES.includes(config.mode)) throw new Error('Invalid Charterleaf config (expected version: 1 and a valid mode)');
76
+ return config.mode;
77
+ }
78
+
79
+ function saveMode(root, mode) {
80
+ if (!MODES.includes(mode)) throw new Error('Invalid Charterleaf mode');
81
+ const file = path.join(root, CONFIG);
82
+ if (!inside(root, canonical(file))) throw new Error('Charterleaf config escapes project');
83
+ fs.mkdirSync(path.dirname(file), { recursive: true });
84
+ const temporary = `${file}.${randomUUID()}.tmp`;
85
+ try {
86
+ fs.writeFileSync(temporary, `${JSON.stringify({ version: 1, mode }, null, 2)}\n`, { flag: 'wx' });
87
+ fs.renameSync(temporary, file);
88
+ } finally {
89
+ if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
90
+ }
91
+ }
92
+
93
+ function fingerprint(root, relative) {
94
+ const file = path.join(root, relative);
95
+ if (!inside(root, canonical(file))) return 'outside';
96
+ try {
97
+ const stat = fs.statSync(file);
98
+ if (!stat.isFile()) return 'not-file';
99
+ // shortcut: over 2 MiB use size/mtime, use streaming hashes if large-file attribution is needed.
100
+ if (stat.size > 2 * 1024 * 1024) return `large:${stat.size}:${stat.mtimeMs}`;
101
+ return createHash('sha256').update(fs.readFileSync(file)).digest('hex');
102
+ } catch (error) {
103
+ if (error.code === 'ENOENT') return 'missing';
104
+ throw error;
105
+ }
106
+ }
107
+
108
+ function gitSnapshot(root) {
109
+ const git = (args) => execFileSync('git', ['-C', root, ...args], {
110
+ encoding: 'utf8', timeout: 3000, maxBuffer: 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
111
+ });
112
+ try {
113
+ if (fs.realpathSync(git(['rev-parse', '--show-toplevel']).trim()) !== root) return null;
114
+ // Compare index and worktree separately; this also works before the first commit.
115
+ const names = [git(['diff', '--no-renames', '--name-only', '-z']), git(['diff', '--cached', '--no-renames', '--name-only', '-z']),
116
+ git(['ls-files', '--others', '--exclude-standard', '-z'])].join('\0').split('\0').filter(Boolean);
117
+ if (names.length > 500) return null; // Bounded task checks, not a repository scan.
118
+ const snapshot = new Map([...new Set(names)].map((name) => {
119
+ const file = path.join(root, name);
120
+ // Git tracks a symlink's target string, not the target file's contents.
121
+ let stat;
122
+ try { stat = fs.lstatSync(file); } catch (error) { if (error.code !== 'ENOENT') throw error; }
123
+ const hash = stat?.isSymbolicLink() ? `symlink:${fs.readlinkSync(file)}` : fingerprint(root, name);
124
+ return [name, hash];
125
+ }));
126
+ const raw = git(['diff', '--cached', '--raw', '--no-abbrev', '--no-renames', '-z']).split('\0');
127
+ snapshot.index = new Map();
128
+ for (let i = 0; i + 1 < raw.length; i += 2) snapshot.index.set(raw[i + 1], raw[i]);
129
+ return snapshot;
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
135
+ function changedSince(before, after) {
136
+ if (!before || !after) return [];
137
+ return [...new Set([...before.keys(), ...after.keys()])].filter((name) =>
138
+ (before.get(name) ?? 'clean') !== (after.get(name) ?? 'clean') ||
139
+ before.index?.get(name) !== after.index?.get(name)).sort();
140
+ }
141
+
142
+ module.exports = { CONFIG, MODES, projectRoot, projectPath, toolPath, readMode, saveMode, fingerprint, gitSnapshot, changedSince };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@onedotmint/charterleaf",
3
- "version": "0.2.1",
4
- "description": "A tiny specification layer for coding agents.",
3
+ "version": "0.4.0",
4
+ "description": "Lightweight Agent-Native Constraint Layer: opt-in spec discovery and maintenance for Pi, with a standalone zero-dependency CLI.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -20,6 +20,7 @@
20
20
  "files": [
21
21
  "bin/",
22
22
  "lib/",
23
+ "extensions/",
23
24
  "skills/",
24
25
  "templates/",
25
26
  "AGENTS_SNIPPET.md",
@@ -32,12 +33,17 @@
32
33
  "coding-agent",
33
34
  "specification",
34
35
  "agent-skills",
35
- "pi"
36
+ "pi",
37
+ "pi-package"
36
38
  ],
37
39
  "scripts": {
38
- "test": "node --test"
40
+ "test": "node --test",
41
+ "test:pi": "node scripts/pi-smoke.js"
39
42
  },
40
43
  "pi": {
44
+ "extensions": [
45
+ "./extensions/charterleaf.js"
46
+ ],
41
47
  "skills": [
42
48
  "./skills"
43
49
  ]
@@ -1,58 +1,37 @@
1
1
  ---
2
2
  name: charterleaf
3
- description: A tiny specification layer for durable project intent and constraints.
3
+ description: Discover and maintain durable project constraints when Charterleaf is explicitly enabled or requested.
4
+ disable-model-invocation: true
4
5
  ---
5
6
 
6
- # Charterleaf
7
+ # Charterleaf authoring guide
7
8
 
8
- Treat Charterleaf as a small knowledge layer, not a workflow system.
9
+ The essential rules live in [runtime.md](runtime.md); read it only if not already supplied. The Pi Extension supplies those rules automatically only when the project is enabled via `/charterleaf`; it does not preload this guide. Outside Pi, use the CLI only when explicitly requested. Existing `specs/` alone does not enable automation; do not initialize documents or recommend adoption automatically.
9
10
 
10
- When a concrete code path is known, run:
11
+ Read this guide only for unfamiliar spec formats, staged changes or manual CLI use. Ordinary code edits need no Skill read or CLI call when applicable constraints have already been supplied.
11
12
 
12
- ```bash
13
- charterleaf related <path>
14
- ```
13
+ ## Ownership and durable writing
15
14
 
16
- Read only the returned living specs and active changes.
15
+ Source owns implementation; tests own executable verification; `AGENTS.md` owns project entry/navigation; specs own durable constraints. Never copy signatures, inventories, current state or existing documentation into specs. Link the authoritative owner instead. Charterleaf is not task management, planning, memory, evidence, history or a project workflow.
17
16
 
18
- When the relevant code path is not yet known, run:
17
+ Place only established, non-obvious knowledge whose absence could cause a future mistake:
19
18
 
20
- ```bash
21
- charterleaf map
22
- ```
19
+ - `specs/constitution.md`: rare project-wide boundaries;
20
+ - `specs/capabilities/`: observable contracts, invariants and non-goals;
21
+ - `specs/engineering/`: long-lived implementation boundaries, recurring pitfalls and verification constraints;
22
+ - `specs/decisions/`: accepted rationale and revisit conditions.
23
23
 
24
- Use the map only to discover available specification scopes. Do not read every listed specification by default. Use normal code navigation to locate the smallest relevant area. Once a concrete path is known, run `charterleaf related <path>`.
24
+ Living capability/engineering specs need a unique `id` and non-empty `applies_to` list. Keep scope narrow, preserve existing formats and requirement IDs, and verify explicit local references normally. `anchors` and `verified_by` are not required. Do not create speculative standards or code summaries to satisfy a final review.
25
25
 
26
- For broad project-level questions, start with normal project entry documents. Do not expand into every spec or project-state document merely because no path was provided. `map` is not semantic search.
26
+ ## Optional staged changes
27
27
 
28
- Use authority in this order:
28
+ Use `specs/changes/<name>.md` only when an accepted future contract must coexist with current truth: review, another session or an ongoing migration. Supply valid `affects` spec IDs and at least one non-empty `ADD`, `MODIFY` or `REMOVE` section; `PRESERVE` alone is not a change. Once implemented and verified, merge durable truth into living specs and remove obsolete staging. Git keeps history; never commit automatically.
29
29
 
30
- ```text
31
- Constitution > Active Change > Living Spec > Current Code
32
- ```
30
+ ## CLI details
33
31
 
34
- 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.
32
+ Run from the project root:
35
33
 
36
- Do not touch specs for renames, lint cleanup, routine refactors, temporary debugging, Todo/status changes, or trivial fixes with no durable implication.
34
+ - `charterleaf related <path>` returns constitution as Global, capability/engineering specs matching `applies_to`, and active changes affecting returned spec IDs. It returns paths/metadata, not bodies. Decisions and explicit references are followed manually when relevant. A scoped miss is not proof that no constraints apply.
35
+ - `charterleaf lint` checks frontmatter, IDs/globs, duplicate IDs, change references/deltas and conflicting active changes. `0 structural errors` does not prove semantic correctness, coverage or code/spec agreement. The Extension also lints standard spec writes.
37
36
 
38
- After non-trivial implementation or debugging, consider whether durable project knowledge was discovered. If so, record only the durable result:
39
-
40
- - observable behavior, contract, invariant, or non-goal → capability spec;
41
- - long-lived implementation constraint, boundary, recurring pitfall, or required verification practice → engineering spec;
42
- - important architectural choice, rationale, or revisit condition → decision.
43
-
44
- If nothing durable was learned, do not modify specs. Do not record current task status, implementation progress, temporary debugging notes, one-off commands, session history, Todo items, or transient observations. Specs accumulate durable project knowledge, not development activity.
45
-
46
- 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.
47
-
48
- After editing specs, run:
49
-
50
- ```bash
51
- charterleaf lint
52
- ```
53
-
54
- `lint` checks structural conventions and deterministic active-change conflicts only. It does not judge writing, architecture quality, or semantic code/spec drift.
55
-
56
- After an accepted change is implemented, merge the durable result into living specs and delete the change file. Git keeps history.
57
-
58
- Do not plan, manage tasks, invoke agents, run implementation/review workflows, manage memory, or replace the host agent's code-navigation tools.
37
+ If the CLI is not on PATH, use `node <skill-directory>/../../bin/charterleaf.js related <path>` or `lint`; the Extension supplies the absolute entry point. Report failed writing or validation accurately, and never relax a contract merely to remove an error.
@@ -0,0 +1,11 @@
1
+ # Charterleaf runtime
2
+
3
+ Use constraints supplied with standard read results; do not load all specs or reread unchanged supplied bodies. A blocked write/edit requires inspecting the constraints or reading the named complete spec before retrying; never bypass it through another writer. Routing is a hint, not proof of coverage.
4
+
5
+ Modes govern specs, not code: Auto maintains only justified durable knowledge; Confirm edits code normally and submits exact spec write/edit to the Extension's approval dialog (refusal/no UI: preserve spec, do not retry); Reference reports proposals/conflicts, never writes specs. Use standard write/edit only. Activation config is user-owned via /charterleaf. These guards are not a filesystem sandbox.
6
+
7
+ A code/spec conflict is not permission to relax the spec. Fix implementation bugs; change contracts only with authoritative evidence or explicit accepted intent. Internal/refactor requests do not authorize changing return or ownership contracts. If a requested method violates an invariant, preserve spec/tests and ask for an explicit contract decision before coding. When uncertain, preserve the spec and report the conflict. Preserve user/parallel work; a touched file or dirty worktree does not prove line ownership. Never automatically revert, commit or push.
8
+
9
+ ## One final semantic review
10
+
11
+ Only established, non-obvious, mistake-preventing contracts, invariants, boundaries, pitfalls or accepted decisions belong in specs. No durable change means no spec edit: exclude implementation summaries, tasks, progress, debugging and plans. At most one final review may follow development; do not start another cycle. Report task results/tests, not routine review bookkeeping. Spec writes receive structural lint, not semantic verification.
@@ -5,6 +5,16 @@ affects:
5
5
 
6
6
  # <Change name>
7
7
 
8
+ <!-- Optional staging. Use only when an accepted future contract must
9
+ coexist with current living reality — implementation in another session or
10
+ by another agent, human review first, or a multi-step migration.
11
+
12
+ A change must contain at least one of ADD, MODIFY, or REMOVE.
13
+ PRESERVE alone is not a change.
14
+
15
+ After implementation, merge the durable result into living specs and
16
+ delete this file. Git keeps history. -->
17
+
8
18
  ## Intent
9
19
 
10
20
  <What intentional behavior/contract change is being made, and why?>