@onedotmint/charterleaf 0.3.0 → 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/AGENTS_SNIPPET.md CHANGED
@@ -4,11 +4,31 @@ Use this file as the project navigation entry.
4
4
  Document where authoritative information lives — project state,
5
5
  validation, resources, history, and other project-specific sources.
6
6
 
7
- Durable constraints live under `specs/`. For work on a concrete
8
- repository path:
7
+ Source code owns implementation, tests own executable verification,
8
+ `AGENTS.md` owns navigation, and `specs/` owns durable constraints. Read
9
+ these authoritative owners progressively; stop when sufficient. Keep one
10
+ owner per fact and link to it rather than duplicating it.
11
+
12
+ Charterleaf automation is explicit opt-in, not implied by this entry or
13
+ by an existing `specs/` directory. In Pi, the user chooses Auto, Confirm,
14
+ Reference or Not Enable through `/charterleaf`; the saved project mode in
15
+ `specs/.charterleaf.json` controls automation. Missing config or `off`
16
+ means no automatic scan, adoption prompt or document creation. Do not
17
+ change activation config on the user's behalf.
18
+
19
+ For manual use on a concrete repository path, run from the project root:
9
20
 
10
21
  charterleaf related <path>
11
22
 
12
- Read authoritative owners progressively. If the current source is
13
- sufficient, stop. Keep one owner per fact and point to it elsewhere
14
- instead of copying the same fact into multiple documents.
23
+ Read the returned constraints. A routing miss is not proof that none
24
+ apply. The manual CLI remains available independently of Pi and its mode
25
+ config; run `charterleaf lint` after spec edits (structural checks only).
26
+
27
+ With automation enabled, use supplied constraints or explicitly read the
28
+ named specs before retrying a gated write/edit. Use standard `write`/`edit`
29
+ for spec changes: Confirm requires each approval; Reference allows no
30
+ spec changes. Do not bypass these cooperative checks with Bash or another
31
+ writer. No durable change means no spec edit; never relax a spec merely
32
+ to accommodate conflicting code. Preserve user/parallel work and keep
33
+ state, plans, evidence and history in their existing authoritative owners,
34
+ not in a new Charterleaf workflow.
package/README.md CHANGED
@@ -1,13 +1,16 @@
1
1
  # Charterleaf
2
2
 
3
- A tiny specification layer for coding agents.
3
+ A lightweight agent-native constraint layer for coding agents.
4
+
5
+ **Code is implementation. Tests are verification. Specs are durable constraints.
6
+ Charterleaf connects them.**
4
7
 
5
8
  Given a concrete repository path, Charterleaf answers one question:
6
9
 
7
10
  > Which durable constraints should I read before touching this code?
8
11
 
9
12
  Durable project intent, deterministic routing, and structural linting —
10
- without workflow orchestration.
13
+ with opt-in native Pi integration, without workflow orchestration.
11
14
 
12
15
  ## Two layers
13
16
 
@@ -27,19 +30,121 @@ belongs to a project file.
27
30
 
28
31
  ## Install
29
32
 
30
- Install the public scoped npm package:
33
+ Install once in Pi to discover the bundled Extension and Skill:
31
34
 
32
35
  ```bash
33
- npm install -g @onedotmint/charterleaf
36
+ pi install npm:@onedotmint/charterleaf
34
37
  ```
35
38
 
36
- Pi can install the same package and discover its bundled Agent Skill:
39
+ **v0.4 is not yet published.** Until publication, install the local package
40
+ with `pi install /absolute/path/to/charterleaf` (a package directory, not a
41
+ `.tgz` file). For the standalone CLI, pack with `npm pack`, then use `npm install -g ./onedotmint-charterleaf-0.4.0.tgz`; after publication,
42
+ use `npm install -g @onedotmint/charterleaf`.
37
43
 
38
- ```bash
39
- pi install npm:@onedotmint/charterleaf
44
+ Restart Pi or run `/reload` after installation, then use `/charterleaf` in each
45
+ project you want to enable. To stop automation, choose **Not Enable** in that
46
+ menu. To remove the global package, use `pi remove npm:@onedotmint/charterleaf`
47
+ (or the same local directory source used for installation); project docs remain.
48
+
49
+ A Pi-only install needs no second global npm install: the Extension calls Core
50
+ directly and supplies the bundled CLI entry point for optional manual checks.
51
+
52
+ The **Core/CLI** runs standalone on Node.js **20+**, has no dependencies,
53
+ and requires no Pi. The **Pi Extension** is tested against
54
+ `@earendil-works/pi-coding-agent` **1.1.0** and uses `agent_before_settle`:
55
+ it requires a host with that compatible Extension API, not just any Pi
56
+ version. That Pi host requires Node.js **22.19.0+**; the Core's lower
57
+ minimum does not make older Node versions suitable for the host.
58
+
59
+ ## Native Pi modes
60
+
61
+ Run `/charterleaf` in Pi for the native project mode menu:
62
+
63
+ | Choice | Behavior |
64
+ | --- | --- |
65
+ | **Auto** (recommended) | Read constraints; maintain only justified durable knowledge. |
66
+ | **Confirm** | Read automatically; approve each exact standard spec write/edit. |
67
+ | **Reference** | Read and check; report proposals or conflicts, never modify specs. |
68
+ | **Not Enable** | Turn automation off; keep existing documents. |
69
+
70
+ The choice is saved immediately in `specs/.charterleaf.json`:
71
+
72
+ ```json
73
+ { "version": 1, "mode": "off" }
40
74
  ```
41
75
 
42
- No Pi extension or Pi runtime dependency is used.
76
+ Valid modes are `auto`, `confirm`, `reference`, and `off`. Missing config
77
+ means **off**; existing `specs/` alone never enables automation. Activation
78
+ is an explicit user choice, not an Agent decision. Enabling creates only
79
+ this config (and its directory if needed), not a constitution or a pile
80
+ of spec documents. Disabling preserves documents; cancel changes nothing.
81
+ Disabled/unconfigured projects get no automatic adoption prompt, task
82
+ context, scan, or document bootstrap. Manual CLI use is independent of
83
+ this config.
84
+
85
+ Without UI, saved Auto and Reference modes still read/check constraints;
86
+ Confirm denies spec mutations because approval is unavailable. A refused identical
87
+ proposal does not reopen an approval dialog in the same task; a new user task
88
+ can make a fresh proposal. The
89
+ `/charterleaf` menu does nothing without UI.
90
+
91
+ ## Architecture and task behavior
92
+
93
+ - **Core** (`lib/charterleaf.js`) exports `resolveRelated` for structured
94
+ deterministic routing shared with the CLI. This adds no CLI command
95
+ and requires no spec-format migration.
96
+ - **Extension** (`extensions/charterleaf.js`, with `lib/project.js`) uses
97
+ native command, task/context, tool-call/result, compaction and settlement
98
+ hooks for opt-in loading, write gates, mode approval and a final review.
99
+ - **Skill** (`skills/charterleaf/SKILL.md`) supplies semantic judgment and
100
+ maintenance discipline. It is not a deterministic semantic validator.
101
+
102
+ For known paths, standard `read` results carry the applicable constraint
103
+ bodies without replacing the original result. A standard code `write` or
104
+ `edit` with unseen constraints is blocked, carrying the constraints or
105
+ an explicit read request. Read and assess them, then retry; do not evade
106
+ this gate via Bash. Delivery counts as loaded only at the **next model
107
+ request**, not when a parallel sibling tool finishes.
108
+
109
+ Automatic spec bodies have a **24,000-byte budget per task** and an
110
+ **8,000-byte per-file limit**. Larger files or exhausted budgets require
111
+ an explicit complete `read`; a partial/truncated read is not full
112
+ knowledge. Native Pi offset pages are credited only when their exact content
113
+ covers the same spec version with no gaps; ordinary prose about truncation does
114
+ not count as a tool truncation notice. On a scoped miss, the Extension supplies metadata only for
115
+ available living specs, capped at 12 entries and 2,000 characters;
116
+ `charterleaf related <path>` provides the full routing list. A miss never
117
+ proves that no constraints apply.
118
+
119
+ Compact rules from `skills/charterleaf/runtime.md` are supplied once per
120
+ project/session branch; the full `SKILL.md` authoring/CLI guide stays on demand.
121
+ Each task adds only its current mode/root. Same-version spec bodies already
122
+ seen in the retained branch are reused across tasks, while ownership, Git
123
+ baseline and body budget are reset per task. Switching branches or compaction
124
+ clears context-delivery bookkeeping.
125
+ Routing is recomputed through Core for each concrete path check, so new scopes
126
+ and changed globs are not hidden by stale routing caches. Changed spec content
127
+ requires fresh delivery; successful standard spec writes invalidate loaded state.
128
+ Compaction invalidates loaded context so constraints must be delivered
129
+ again. This is task-local bookkeeping, not a watcher or persistent index.
130
+
131
+ At successful, actionable `agent_before_settle`, the Extension may request
132
+ **at most one final semantic review per task** after development. It
133
+ separates successful standard-tool paths whose observed content is still
134
+ unchanged from **unattributed** changes observed against the task's Git
135
+ baseline. Neither a touched file nor a dirty worktree proves ownership
136
+ of every line. Preserve existing user dirt and parallel work; unattributed
137
+ changes alone must not justify spec edits. No automatic revert, stage,
138
+ commit or push is performed.
139
+
140
+ **No durable knowledge means no spec edit.** Keep only established,
141
+ non-obvious, mistake-preventing contracts, invariants, boundaries,
142
+ pitfalls or accepted decisions — not code summaries or task logs. A
143
+ code/spec conflict is not permission to accommodate incorrect code:
144
+ distinguish implementation bugs, authoritatively obsolete specs and
145
+ explicitly intended contract changes. When uncertain, preserve the spec
146
+ and report the conflict. This review does not start an independent
147
+ workflow or another development cycle.
43
148
 
44
149
  ## CLI
45
150
 
@@ -177,8 +282,8 @@ result into living specs and delete the change file. Git keeps history.
177
282
 
178
283
  The complete Agent convention lives in
179
284
  [`skills/charterleaf/SKILL.md`](skills/charterleaf/SKILL.md): adoption
180
- gate, progressive retrieval, `related` and `lint` semantics, spec
181
- writing discipline, and durable learning.
285
+ and ownership, progressive retrieval, `related` and `lint` semantics,
286
+ mode behavior, spec writing discipline, and durable learning.
182
287
 
183
288
  For agents that read project instructions, the minimal reference
184
289
  bootstrap is [`AGENTS_SNIPPET.md`](AGENTS_SNIPPET.md).
@@ -188,29 +293,100 @@ Markdown and run shell commands can use it.
188
293
 
189
294
  ## Boundaries
190
295
 
191
- Charterleaf has no:
192
-
193
- - project state, tasks, planning, or workflow;
194
- - memory, session lifecycle, evidence, or history management;
195
- - scope registry, owner registry, or project bootstrap command;
196
- - semantic search, embeddings, or code/spec semantic verification;
197
- - repository coverage analysis or filesystem awareness in `related`;
198
- - database, index, cache, daemon, or watcher;
199
- - plugin/extension runtime;
200
- - Git abstraction.
201
-
202
- The two CLI commands are helpers, not workflow gates.
296
+ Charterleaf has no project state/task system, planning or workflow
297
+ orchestration, memory/evidence/history management, scope/owner registry,
298
+ project bootstrap command, semantic search/embeddings, coverage analyzer,
299
+ database, persistent index, daemon, watcher or independent AI service.
300
+ The Extension uses the host lifecycle and lightweight Git snapshots;
301
+ it does not become a session manager or Git workflow.
302
+
303
+ Important limits:
304
+
305
+ - Bash, external writers and opaque/custom tools cannot be fully
306
+ intercepted. Reference and Confirm are **not security sandboxes**;
307
+ use standard `write`/`edit` for all spec changes.
308
+ - In non-Git projects, standard-tool observations still work, but opaque
309
+ changes cannot be reliably discovered or attributed; final review falls
310
+ back to conversation evidence. Git snapshots do not detect gitignored
311
+ files; standard tool observations can still identify directly touched
312
+ paths. Neither mechanism proves ownership of all content.
313
+ - Custom/nested tool integrations depend on compatible host forwarding
314
+ and queue support. Where supported, constraint notes must survive into
315
+ the model-visible result while original content, images and structured
316
+ results are preserved. Do not assume every custom envelope does this;
317
+ read large specs directly with standard `read` and offset pages rather than relying on
318
+ a nested wrapper that can hide or truncate their bodies.
319
+ - `lint` checks structure only. Skill-guided reviews are model judgments
320
+ and may misjudge relevance, durable value or a conflict; neither proves
321
+ semantic correctness or compliance.
322
+ - Paths support repo-relative or same-project absolute paths, `/` or `\`
323
+ separators, Pi's `@path`/`file://` aliases, and Windows drive paths mapped
324
+ to `/mnt/<drive>/` on WSL. Enabled standard tools execute the normalized
325
+ path that was checked; spec authorization also preserves the original path's
326
+ protected identity when a spec symlink points elsewhere inside the project.
327
+ Foreign UNC paths are not interpreted as local paths; symlink escapes are
328
+ rejected for project spec writes. Native Windows behavior needs platform testing.
329
+ - `related` has no filesystem awareness of the supplied target path and
330
+ no proof of routing coverage. Task-local hashes are not a continuous
331
+ monitor of external changes. A single spec line larger than Pi's read byte
332
+ limit cannot be credited as read; do not keep retrying or bypass the gate —
333
+ report it and ask for readable formatting or a user-chosen mode change.
334
+ Files over 2 MiB use coarse size/mtime fingerprints;
335
+ Git checks exceeding 500 changed/untracked entries fall back to conversation
336
+ evidence instead of scanning a large worktree.
337
+
338
+ The two CLI commands remain helpers, not workflow gates. Native tool
339
+ gates apply only to the opt-in Pi integration, within the limits above.
203
340
 
204
341
  ## Development
205
342
 
206
- Requires Node.js 20 or newer. There are no runtime or development
207
- dependencies and no build step.
343
+ Core development requires Node.js 20 or newer; there are no runtime or
344
+ development dependencies and no build step. Use the higher Node/host
345
+ requirements above for Pi smoke checks.
346
+
347
+ Verification commands:
208
348
 
209
349
  ```bash
210
350
  npm test
351
+ npm run test:pi # requires compatible Pi on PATH; isolated RPC UI smoke, no model calls
352
+ node --test tests/extension.test.js
211
353
  npm pack --dry-run
354
+ node bin/charterleaf.js related extensions/charterleaf.js
355
+ node bin/charterleaf.js lint
212
356
  ```
213
357
 
358
+ Validated on Node.js 24.21.0 and Pi 1.1.0: 149 automated Core/Extension tests,
359
+ structural lint, dry-run npm packaging, and real Pi RPC loading/native selection
360
+ for cancellation, mode changes, disable and cross-process restore. The RPC smoke
361
+ uses isolated temporary projects/settings and makes no model calls.
362
+
363
+ Independent acceptance also exercised real `openai/gpt-5.6-terra` sessions in
364
+ isolated Git projects: ordinary refactors, intentional API updates, durable
365
+ rounding knowledge, conflicts, all three modes, no-UI Confirm, parallel nested
366
+ tools, oversized specs, dirty/concurrently modified files, Bash writes and
367
+ invalid metadata. Evidence was checked against tool results and file diffs,
368
+ not assistant claims. These bounded examples do not prove model reliability
369
+ on every task or provider. The original activation message measured 8,450 UTF-8
370
+ bytes (8.25 KiB), mostly the full Skill; it was not all native Pi context or a
371
+ Token count. After the dedicated context audit, compact rules plus activation
372
+ measure about 2 KiB: in a controlled first-request comparison the Charterleaf
373
+ increment fell from 1,720 to 403 provider input tokens, with identical native
374
+ prompt/tools. Smaller total context does not guarantee the same reduction in
375
+ uncached input charges: cache-hit sizes vary. Substantive work still requests
376
+ at most one extra model review. A model
377
+ initially misreported a blocked edit as completed; clearer error text corrected
378
+ the observed regression, but final claims still need independent verification.
379
+
380
+ For an interactive smoke check in a disposable project, install the local
381
+ package in a compatible Pi host and run `/charterleaf`: check immediate
382
+ mode persistence, cancel/disable preserving documents, constraint loading
383
+ on a standard read, a blocked unseen-constraint write followed by retry,
384
+ Confirm approve/refuse, and Reference denying spec writes. After a
385
+ substantive edit, check that at most one final review occurs and that no
386
+ durable change leaves specs untouched. Also check saved modes without UI:
387
+ Auto/Reference still read, Confirm denies spec writes, and the menu is
388
+ inactive. Automated hook tests do not substitute for this real-host smoke.
389
+
214
390
  ## License
215
391
 
216
392
  MIT
@@ -0,0 +1,314 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { pathToFileURL } = require('node:url');
6
+ const { resolveRelated, lint } = require('../lib/charterleaf.js');
7
+ const { CONFIG, projectRoot, projectPath, toolPath, readMode, saveMode, fingerprint, gitSnapshot, changedSince } = require('../lib/project.js');
8
+
9
+ const OPTIONS = [
10
+ 'Auto (recommended) — read constraints and maintain only durable knowledge',
11
+ 'Confirm — read automatically; approve each spec modification',
12
+ 'Reference — read and check; never modify specs',
13
+ 'Not Enable — disable; keep existing documents',
14
+ ];
15
+ const MODES = ['auto', 'confirm', 'reference', 'off'];
16
+ const isSpec = (file) => file === 'specs' || file.startsWith('specs/');
17
+ const message = (content) => ({ type: 'custom_message', customType: 'charterleaf', content, display: false });
18
+
19
+ module.exports = function charterleaf(pi) {
20
+ let state;
21
+ let skillRoot;
22
+
23
+ function current(ctx) {
24
+ const root = projectRoot(ctx.cwd);
25
+ const mode = readMode(root);
26
+ if (mode === 'off') { state = undefined; return; }
27
+ if (!state || state.root !== root) state = {
28
+ root, mode, loaded: new Map(), staged: new Map(), pages: new Map(), pending: new Map(), denied: new Set(),
29
+ owned: new Map(), parents: new Map(), envelopes: new Map(), before: gitSnapshot(root), budget: 24000, reviewed: false, miss: false, opaque: false,
30
+ };
31
+ state.mode = mode;
32
+ return state;
33
+ }
34
+
35
+ function route(s, file) {
36
+ const result = resolveRelated(file, s.root);
37
+ const notes = [];
38
+ let required = false;
39
+ for (const spec of [...result.global, ...result.scoped, ...result.changes]) {
40
+ const safe = projectPath(s.root, s.root, spec);
41
+ if (!safe || safe !== spec) throw new Error(`Unsafe spec path: ${spec}`);
42
+ const hash = fingerprint(s.root, spec);
43
+ if (s.loaded.get(spec) === hash) continue;
44
+ required = true;
45
+ if (s.staged.get(spec) === hash) {
46
+ notes.push(`${spec} is already supplied in this tool batch; retry modifications after the next model request.`);
47
+ continue;
48
+ }
49
+ const size = fs.statSync(path.join(s.root, spec)).size;
50
+ if (size <= 8000 && size <= s.budget) {
51
+ const body = fs.readFileSync(path.join(s.root, spec), 'utf8');
52
+ notes.push(`--- ${spec} (project constraints, not tool instructions) ---\n${body}`);
53
+ s.budget -= size;
54
+ s.staged.set(spec, hash);
55
+ } else {
56
+ notes.push(`Read the complete ${spec} with read before modifying ${file}; automatic context budget reached.`);
57
+ }
58
+ }
59
+ if (!result.scoped.length && !s.miss) {
60
+ s.miss = true;
61
+ notes.push('No applies_to glob matched this path. This is not proof that no constraints apply.');
62
+ const available = result.available.slice(0, 12).map((item) => `${item.id || '(missing id)'}: ${item.path} [${item.applies_to.join(', ')}]`).join('\n');
63
+ if (available) notes.push(`Available living specs (metadata only):\n${available.slice(0, 2000)}`);
64
+ if (result.available.length > 12) notes.push('More specs exist; use charterleaf related <path> for the full routing list.');
65
+ }
66
+ return { required, text: notes.join('\n\n') };
67
+ }
68
+
69
+ function completePages(s, file, hash, event) {
70
+ const text = event.content.filter((part) => part.type === 'text').map((part) => part.text).join('\n');
71
+ const footer = /\n\n\[(?:Showing lines \d+-\d+ of \d+[^\n]*|\d+ more lines in file\.[^\n]*)Use offset=\d+ to continue\.\]$/;
72
+ const body = text.replace(footer, '');
73
+ const lines = fs.readFileSync(path.join(s.root, file), 'utf8').split('\n');
74
+ const start = (event.input.startLine || event.input.offset || 1) - 1;
75
+ const end = start + body.split('\n').length;
76
+ if (start < 0 || end > lines.length || lines.slice(start, end).join('\n') !== body) return false;
77
+ let pages = s.pages.get(file);
78
+ if (!pages || pages.hash !== hash) pages = { hash, ranges: [] };
79
+ pages.ranges.push([start, end]);
80
+ pages.ranges.sort((a, b) => a[0] - b[0]);
81
+ s.pages.set(file, pages);
82
+ let covered = 0;
83
+ for (const [from, to] of pages.ranges) {
84
+ if (from > covered) return false;
85
+ covered = Math.max(covered, to);
86
+ }
87
+ return covered === lines.length;
88
+ }
89
+
90
+ function forward(s, event, text) {
91
+ if (!event.parentToolCallId || !text) return;
92
+ let parent = event.parentToolCallId;
93
+ while (s.parents.has(parent)) parent = s.parents.get(parent);
94
+ if (!s.envelopes.has(parent)) s.envelopes.set(parent, new Set());
95
+ s.envelopes.get(parent).add(text);
96
+ }
97
+
98
+ function report(ctx, text) {
99
+ if (ctx.hasUI) ctx.ui.notify(text, 'warning');
100
+ }
101
+
102
+ pi.registerCommand('charterleaf', {
103
+ description: 'Enable, switch mode, or disable Charterleaf for this project',
104
+ handler: async (_args, ctx) => {
105
+ if (!ctx.hasUI) return;
106
+ try {
107
+ const root = projectRoot(ctx.cwd);
108
+ let mode;
109
+ try { mode = readMode(root); } catch { mode = 'invalid config (disabled)'; }
110
+ const choice = await ctx.ui.select(`Charterleaf — ${mode}\n${root}`, OPTIONS);
111
+ const index = OPTIONS.indexOf(choice);
112
+ if (index < 0) return;
113
+ if (MODES[index] === 'off' && mode === 'off') return;
114
+ saveMode(root, MODES[index]);
115
+ state = undefined;
116
+ ctx.ui.notify(`Charterleaf: ${MODES[index]} (saved to ${CONFIG})`, 'info');
117
+ } catch (error) { report(ctx, `Charterleaf: ${error.message}`); }
118
+ },
119
+ });
120
+
121
+ pi.on('session_start', (_event, ctx) => {
122
+ state = undefined;
123
+ skillRoot = undefined;
124
+ try { current(ctx); } catch { /* Invalid config is disabled; /charterleaf can repair it. */ }
125
+ });
126
+ pi.on('session_shutdown', () => { state = undefined; skillRoot = undefined; });
127
+ pi.on('session_switch', () => { state = undefined; skillRoot = undefined; });
128
+ pi.on('session_tree', () => { state = undefined; skillRoot = undefined; });
129
+ pi.on('session_compact', () => {
130
+ skillRoot = undefined;
131
+ if (state) { state.loaded.clear(); state.staged.clear(); state.pages.clear(); }
132
+ });
133
+
134
+ pi.on('before_agent_start', (_event, ctx) => {
135
+ const previous = state;
136
+ state = undefined;
137
+ let s;
138
+ try { s = current(ctx); } catch (error) { report(ctx, `Charterleaf disabled: ${error.message}`); return; }
139
+ if (!s) return;
140
+ // Prior delivered bodies remain in this branch's context; ownership and Git baseline are task-local.
141
+ if (previous?.root === s.root) s.loaded = previous.loaded;
142
+ const rules = skillRoot === s.root ? '' : fs.readFileSync(path.join(__dirname, '../skills/charterleaf/runtime.md'), 'utf8');
143
+ skillRoot = s.root;
144
+ return { message: {
145
+ customType: 'charterleaf', display: false,
146
+ content: `Charterleaf mode=${s.mode}; root=${s.root}.` + (rules ?
147
+ `\n${rules}\nGuide (read only for unfamiliar authoring/staging/CLI details): ${path.join(__dirname, '../skills/charterleaf/SKILL.md')}.\nCLI fallback: node ${path.join(__dirname, '../bin/charterleaf.js')}.` : ''),
148
+ } };
149
+ });
150
+
151
+ // A sibling tool call has not seen another sibling's result yet. Promote only at the next model request.
152
+ pi.on('context', (_event, ctx) => {
153
+ if (!state || state.root !== projectRoot(ctx.cwd)) return;
154
+ for (const [spec, hash] of state.staged) state.loaded.set(spec, hash);
155
+ state.staged.clear();
156
+ });
157
+
158
+ pi.on('tool_call', async (event, ctx) => {
159
+ let s;
160
+ try { s = current(ctx); } catch { return; }
161
+ if (!s) return;
162
+ if (event.parentToolCallId) s.parents.set(event.toolCallId, event.parentToolCallId);
163
+ if (!['read', 'write', 'edit'].includes(event.toolName)) return;
164
+ const mutation = event.toolName !== 'read';
165
+ try {
166
+ const file = projectPath(s.root, ctx.cwd, event.input.path);
167
+ const lexical = typeof event.input.path === 'string'
168
+ ? path.relative(s.root, path.resolve(ctx.cwd, toolPath(event.input.path))).replaceAll('\\', '/') : null;
169
+ if (!file) {
170
+ if (mutation && typeof event.input.path === 'string') {
171
+ if (isSpec(lexical)) return { block: true, reason: 'Unsafe spec path escapes this project. Preserve specs and fix the symlink/path.' };
172
+ }
173
+ return;
174
+ }
175
+ // Execute the exact normalized path checked here, including Pi's @/file:/space aliases.
176
+ const target = path.join(s.root, file);
177
+ event.input.path = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/.test(target) ? pathToFileURL(target).href : target;
178
+ if (mutation && (file === CONFIG || lexical === CONFIG || file === projectPath(s.root, s.root, CONFIG))) {
179
+ return { block: true, reason: 'Only the user may change Charterleaf mode via /charterleaf.' };
180
+ }
181
+ if (mutation && (isSpec(file) || (lexical && isSpec(lexical)))) {
182
+ if (s.mode === 'reference') return { block: true, reason: 'Charterleaf Reference mode: report a proposal; do not modify specs.' };
183
+ if (s.mode === 'confirm') {
184
+ if (!ctx.hasUI) return { block: true, reason: 'Charterleaf Confirm mode requires interactive approval. Report the proposal without writing.' };
185
+ const proposal = JSON.stringify(event.input, null, 2);
186
+ if (proposal.length > 12000) return { block: true, reason: 'Spec proposal too large to approve safely. Use smaller standard edits.' };
187
+ if (s.denied.has(proposal)) return { block: true, reason: 'This exact spec proposal was already refused in this task. Do not retry; report it without writing.' };
188
+ const before = fingerprint(s.root, file);
189
+ const approved = await ctx.ui.confirm(`Charterleaf: modify ${file}?`,
190
+ `Approve this exact ${event.toolName} only if the durable contract change is intended. Never relax a spec just to match conflicting code.\n\n${proposal}`, { timeout: 60000 });
191
+ if (!approved) {
192
+ s.denied.add(proposal);
193
+ return { block: true, reason: 'Spec modification not approved. Preserve the current spec and report the proposal; do not retry.' };
194
+ }
195
+ if (state !== s || readMode(s.root) !== 'confirm' || fingerprint(s.root, file) !== before) {
196
+ return { block: true, reason: 'Project mode or spec changed during approval. Read again and request fresh approval.' };
197
+ }
198
+ }
199
+ } else if (mutation) {
200
+ const constraints = route(s, file);
201
+ if (constraints.required) {
202
+ forward(s, event, constraints.text);
203
+ return { block: true, reason: `Charterleaf: review these constraints before retrying ${event.toolName} ${file}.\n\n${constraints.text}` };
204
+ }
205
+ }
206
+ const observed = lexical && isSpec(lexical) ? lexical : file;
207
+ s.pending.set(event.toolCallId, { file: observed, mutation, before: fingerprint(s.root, observed) });
208
+ } catch (error) {
209
+ if (mutation) return { block: true, reason: `Charterleaf could not check this modification: ${error.message}. No modification was executed. Do not bypass this gate or claim the change completed. Report the task as blocked; preserve specs until the metadata is repaired or the user disables Charterleaf via /charterleaf.` };
210
+ }
211
+ });
212
+
213
+ pi.on('tool_result', (event, ctx) => {
214
+ let s;
215
+ try { s = current(ctx); } catch { return; }
216
+ if (!s) return;
217
+ const call = s.pending.get(event.toolCallId);
218
+ s.pending.delete(event.toolCallId);
219
+ const visible = event.content.filter((part) => part.type === 'text').map((part) => part.text).join('\n');
220
+ // Wrappers may already print nested results, either as text or JSON; forward only missing notes.
221
+ const envelope = [...(s.envelopes.get(event.toolCallId) || [])].filter((note) =>
222
+ !visible.includes(note) && !visible.includes(JSON.stringify(note).slice(1, -1))).join('\n\n');
223
+ s.envelopes.delete(event.toolCallId);
224
+ if (event.isError) {
225
+ if (call?.mutation) {
226
+ try { if (fingerprint(s.root, call.file) !== call.before) s.opaque = true; } catch { s.opaque = true; }
227
+ }
228
+ if (envelope) return { content: [...event.content, { type: 'text', text: `[Charterleaf]\n${envelope}` }] };
229
+ return;
230
+ }
231
+ try {
232
+ let note = envelope;
233
+ if (call) {
234
+ const hash = fingerprint(s.root, call.file);
235
+ if (call.mutation && hash !== call.before) {
236
+ s.owned.set(call.file, hash);
237
+ if (isSpec(call.file)) {
238
+ s.loaded.delete(call.file);
239
+ s.pages.delete(call.file);
240
+ let output = '';
241
+ const stream = { write: (text) => { output += text; } };
242
+ lint(s.root, { stdout: stream, stderr: stream });
243
+ note = `Charterleaf structural check (not semantic verification):\n${output.slice(0, 4000)}`;
244
+ }
245
+ } else if (!call.mutation) {
246
+ if (isSpec(call.file)) {
247
+ const fullRead = event.input.offset === undefined && event.input.startLine === undefined &&
248
+ event.input.limit === undefined && event.input.endLine === undefined &&
249
+ event.details?.truncation?.truncated !== true &&
250
+ !event.content.some((part) => part.type === 'text' &&
251
+ /(?:\[Showing lines \d+-\d+ of |\[\d+ more lines in file\.|\[Line \d+ is .*exceeds .*limit\.|\(File is large; use startLine\/endLine)/.test(part.text));
252
+ if (hash === call.before) {
253
+ const complete = fullRead || completePages(s, call.file, hash, event);
254
+ if (complete) s.staged.set(call.file, hash);
255
+ // Forward every explicit nested page, not just the last: scripts may discard results.
256
+ forward(s, event, `--- ${call.file} ---\n${event.content.filter((part) => part.type === 'text').map((part) => part.text).join('\n')}`);
257
+ }
258
+ } else note = route(s, call.file).text;
259
+ }
260
+ } else if (!['read', 'write', 'edit', 'grep', 'find', 'ls'].includes(event.toolName)) {
261
+ const hint = pi.getAllTools?.().find((tool) => tool.name === event.toolName)?.annotations?.readOnlyHint;
262
+ if (hint !== true) s.opaque = true;
263
+ }
264
+ forward(s, event, note);
265
+ if (note) return {
266
+ content: [...event.content, { type: 'text', text: `\n[Charterleaf]\n${note}` }],
267
+ ...(event.structuredContent === undefined ? {} : { structuredContent: event.structuredContent }),
268
+ };
269
+ } catch (error) {
270
+ return { content: [...event.content, { type: 'text', text: `[Charterleaf check failed: ${error.message}. Do not assume constraints were verified. Standard modifications are blocked. Do not bypass checks or claim blocked changes completed.]` }] };
271
+ }
272
+ });
273
+
274
+ pi.on('agent_before_settle', (event, ctx) => {
275
+ let s;
276
+ try { s = current(ctx); } catch { return; }
277
+ // canContinue previews existing entries; our custom message makes a completed assistant answer actionable.
278
+ if (!s || s.reviewed || event.outcome !== 'completed') return;
279
+ s.reviewed = true;
280
+ try {
281
+ if (!s.owned.size && !s.opaque) return;
282
+ const after = gitSnapshot(s.root);
283
+ const comparable = s.before && after;
284
+ const observed = changedSince(s.before, after);
285
+ const owned = [...s.owned].filter(([file, hash]) => fingerprint(s.root, file) === hash && !isSpec(file)).map(([file]) => file);
286
+ const unattributed = observed.filter((file) => !s.owned.has(file) || fingerprint(s.root, file) !== s.owned.get(file));
287
+ if (!owned.length && !unattributed.length && !(s.opaque && !comparable)) return;
288
+ const relevant = new Set();
289
+ for (const file of [...owned, ...unattributed].slice(0, 50)) {
290
+ const result = resolveRelated(file, s.root);
291
+ for (const spec of [...result.global, ...result.scoped, ...result.changes]) relevant.add(spec);
292
+ }
293
+ const content = `Charterleaf final review — once only; mode=${s.mode}.\n` +
294
+ `Successful standard tool edits still unchanged at settlement: ${JSON.stringify(owned.slice(0, 50))}.\n` +
295
+ `Observed worktree changes NOT attributable to this agent: ${JSON.stringify(unattributed.slice(0, 50))}. ` +
296
+ 'These may belong to the user or a parallel session; preserve them; do not modify specs based solely on them. Existing dirty content is not yours.\n' +
297
+ `Possibly affected specs (routing hints only): ${JSON.stringify([...relevant].slice(0, 50))}.\n` +
298
+ (!comparable ? 'Git change inspection unavailable (baseline or final snapshot missing). Opaque tool changes cannot be identified or attributed. Review only evidence in this conversation.\n' : '') +
299
+ 'No durable change means no spec edit; no code summary, task log or plans. ' +
300
+ 'Record only established, non-obvious contracts, invariants, boundaries, pitfalls or accepted decisions. ' +
301
+ 'A code/spec conflict is not permission to rewrite the spec: distinguish implementation bug, obsolete spec and explicitly intended contract change. ' +
302
+ 'If uncertain, preserve the spec and report the conflict. ' +
303
+ (s.mode === 'reference' ? 'Reference: report necessary proposals/conflicts only; never write specs. ' :
304
+ s.mode === 'confirm' ? 'Confirm: propose the exact spec edit with standard write/edit; the extension must obtain approval before execution. If already refused/no UI, report without retrying. ' :
305
+ 'Auto: update only when durable knowledge is justified by this task; otherwise leave specs unchanged. ') +
306
+ 'For unfamiliar authoring/staging details, read the guide. Use standard write/edit and lint. ' +
307
+ 'Report task results/tests, not routine review bookkeeping; do not start another development cycle.';
308
+ return { entries: [...event.entries, message(content)], continue: true };
309
+ } catch (error) {
310
+ report(ctx, `Charterleaf final review skipped: ${error.message}`);
311
+ return { entries: [...event.entries, message(`Charterleaf final check failed: ${error.message}. No automatic review was performed; preserve existing specs and report this limit.`)] };
312
+ }
313
+ });
314
+ };
@@ -152,13 +152,10 @@ function write(stream, line) {
152
152
  stream.write(`${line}\n`);
153
153
  }
154
154
 
155
- function related(repoPath, cwd = process.cwd(), streams = {}) {
156
- const stdout = streams.stdout || process.stdout;
157
- const stderr = streams.stderr || process.stderr;
155
+ function resolveRelated(repoPath, cwd = process.cwd()) {
158
156
  const specRoot = path.join(cwd, 'specs');
159
157
  if (!fs.existsSync(specRoot) || !fs.statSync(specRoot).isDirectory()) {
160
- write(stderr, 'ERROR specs/: not found');
161
- return 2;
158
+ throw new Error('ERROR specs/: not found');
162
159
  }
163
160
 
164
161
  const target = normalizeRepoPath(repoPath);
@@ -194,10 +191,7 @@ function related(repoPath, cwd = process.cwd(), streams = {}) {
194
191
  if (patterns.some((pattern) => globMatches(pattern, target))) scoped.push(document);
195
192
  }
196
193
 
197
- if (errors.length) {
198
- for (const error of errors) write(stderr, error);
199
- return 2;
200
- }
194
+ if (errors.length) throw new Error(errors.join('\n'));
201
195
 
202
196
  const matchedIds = new Set();
203
197
  for (const document of scoped) {
@@ -221,27 +215,51 @@ function related(repoPath, cwd = process.cwd(), streams = {}) {
221
215
  scoped.sort((left, right) => (left.display < right.display ? -1 : left.display > right.display ? 1 : 0));
222
216
  activeChanges.sort();
223
217
 
224
- const blocks = [];
225
218
  const constitution = documents.find((document) => document.relative === 'constitution.md');
226
- if (constitution) blocks.push(['Global', [` ${constitution.display}`]]);
219
+ return {
220
+ global: constitution ? [constitution.display] : [],
221
+ scoped: scoped.map((document) => document.display),
222
+ changes: activeChanges,
223
+ available: livingSpecs.map((document) => ({
224
+ id: typeof document.frontmatter.id === 'string' ? document.frontmatter.id.trim() : '',
225
+ path: document.display,
226
+ applies_to: document.frontmatter.applies_to || [],
227
+ })),
228
+ };
229
+ }
230
+
231
+ function related(repoPath, cwd = process.cwd(), streams = {}) {
232
+ const stdout = streams.stdout || process.stdout;
233
+ const stderr = streams.stderr || process.stderr;
234
+ let result;
235
+ try {
236
+ result = resolveRelated(repoPath, cwd);
237
+ } catch (error) {
238
+ write(stderr, error.message);
239
+ return 2;
240
+ }
241
+ const { global, scoped, changes: activeChanges, available } = result;
242
+ const blocks = [];
243
+ const constitution = global[0];
244
+ if (constitution) blocks.push(['Global', [` ${constitution}`]]);
227
245
  if (scoped.length) {
228
- blocks.push(['Scoped', scoped.map((document) => ` ${document.display}`)]);
246
+ blocks.push(['Scoped', scoped.map((document) => ` ${document}`)]);
229
247
  } else {
230
248
  blocks.push(['Scoped', [' No applies_to glob matched this path.']]);
231
249
  }
232
250
  if (activeChanges.length) {
233
251
  blocks.push(['Active changes', activeChanges.map((change) => ` ${change}`)]);
234
252
  }
235
- if (!scoped.length && livingSpecs.length) {
253
+ if (!scoped.length && available.length) {
236
254
  const lines = [];
237
- livingSpecs.forEach((document, index) => {
255
+ available.forEach((document, index) => {
238
256
  if (index) lines.push('');
239
- const id = typeof document.frontmatter.id === 'string' ? document.frontmatter.id.trim() : '';
240
- const appliesTo = Array.isArray(document.frontmatter.applies_to)
241
- ? document.frontmatter.applies_to.join(', ')
242
- : String(document.frontmatter.applies_to ?? '');
257
+ const id = document.id;
258
+ const appliesTo = Array.isArray(document.applies_to)
259
+ ? document.applies_to.join(', ')
260
+ : String(document.applies_to);
243
261
  lines.push(` ${id || '(missing id)'}`);
244
- lines.push(` ${document.display}`);
262
+ lines.push(` ${document.path}`);
245
263
  lines.push(` applies_to: ${appliesTo}`);
246
264
  });
247
265
  blocks.push(['Available living specs', lines]);
@@ -410,7 +428,7 @@ function lint(cwd = process.cwd(), streams = {}) {
410
428
  const display = relativeDisplay(filePath, cwd);
411
429
  const { frontmatter: rawFrontmatter, body } = parsed.get(filePath);
412
430
  const frontmatter = rawFrontmatter || {};
413
- const sections = changeSections(body);
431
+ const sections = changeSections(stripHtmlComments(body));
414
432
  const hasDelta = ['ADD', 'MODIFY', 'REMOVE'].some(
415
433
  (section) => stripHtmlComments(sections[section] || '').trim().length > 0,
416
434
  );
@@ -538,5 +556,6 @@ module.exports = {
538
556
  normalizeRepoPath,
539
557
  parseFrontmatter,
540
558
  related,
559
+ resolveRelated,
541
560
  requirementDefinitions,
542
561
  };
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.3.0",
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,149 +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
- ## Adoption gate
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
- Before using Charterleaf in a repository, check whether `specs/` exists.
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
- If no `specs/` directory exists:
13
+ ## Ownership and durable writing
13
14
 
14
- - do not run Charterleaf;
15
- - do not create `specs/`;
16
- - do not recommend adoption automatically;
17
- - continue using the repository's normal instructions.
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.
18
16
 
19
- Charterleaf is opt-in.
17
+ Place only established, non-obvious knowledge whose absence could cause a future mistake:
20
18
 
21
- ## Boundary
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.
22
23
 
23
- Charterleaf answers one question:
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.
24
25
 
25
- > For this repository path, which durable constraints should I read?
26
+ ## Optional staged changes
26
27
 
27
- A project's own reference protocol — its `AGENTS.md` entry and the
28
- project-owned documents it points to — answers a different question:
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
- > Where do this project's authoritative facts live?
30
+ ## CLI details
31
31
 
32
- Charterleaf owns only: spec taxonomy, durable constraint routing
33
- (`related`), and structural lint (`lint`).
32
+ Run from the project root:
34
33
 
35
- Charterleaf does not own: current project state, tasks, workflow,
36
- planning, memory, evidence, history, session lifecycle, project-wide
37
- consistency, semantic code/spec verification, or repository state
38
- synchronization.
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.
39
36
 
40
- ## Progressive retrieval
41
-
42
- 1. **Identify the need.** Orientation, current state, evidence,
43
- validation, resources, implementation detail, or durable constraint?
44
- 2. **Locate the owner.** If the owner is known, read it directly. If not,
45
- read the project `AGENTS.md` / project instructions. Do not scan the
46
- whole repository's Markdown first.
47
- 3. **Enough?** If the authoritative information you have is sufficient,
48
- stop reading. Do not keep reading for completeness.
49
- 4. **Expand progressively.** Only when information is insufficient, the
50
- owner is unclear, sources conflict, the work area is unknown, or
51
- verification is needed — and only one layer at a time.
52
- 5. **Concrete path.** Once work lands on a concrete repository path, run
53
- `charterleaf related <path>` and read the returned constraints.
54
- 6. **Work.** Normal implementation, learning, design, or debugging. The
55
- reference protocol does not manage workflow.
56
- 7. **Write back.** Changed current fact → its single owner. New evidence
57
- → the evidence owner. New or changed durable constraint → `specs/`.
58
- Historical event → project history or Git. Future plan → planning
59
- docs. Never copy one fact into several files: one owner per fact,
60
- other documents point to it.
61
-
62
- ## related semantics
63
-
64
- `charterleaf related <path>`:
65
-
66
- - always returns `specs/constitution.md` as Global when it exists;
67
- - routes `capabilities/` and `engineering/` specs whose `applies_to`
68
- glob matches the path;
69
- - appends active changes whose `affects` reference a returned spec id.
70
-
71
- `applies_to` is a **routing hint**, not a completeness proof. The message
72
- `No applies_to glob matched this path.` means exactly that — it never
73
- means "no constraints apply". On a scoped miss the command lists
74
- available living specs by `id`, path, and `applies_to` only. Use that
75
- list to find the right area, then rerun with the concrete path. The
76
- command never prints spec bodies, ranks relevance, or guesses.
77
-
78
- ## lint semantics
79
-
80
- `charterleaf lint` reports mechanical structural problems only:
81
- frontmatter shape, living-spec ids and `applies_to`, duplicate spec and
82
- requirement ids, change references, changes whose `ADD` / `MODIFY` /
83
- `REMOVE` sections are all empty (HTML comments do not count), and
84
- conflicting active changes.
85
-
86
- It does **not** verify semantic correctness, code/spec agreement,
87
- routing coverage, project compliance, whether a change is complete, or
88
- whether an Agent read the specs. Success prints `0 structural errors`.
89
- Run it after editing specs.
90
-
91
- ## Spec writing discipline
92
-
93
- A spec records a constraint whose absence would plausibly cause a future
94
- developer or agent to make a wrong decision. It should be non-obvious,
95
- durable, **already true**, and mistake-preventing.
96
-
97
- Good fits: contracts, invariants, architecture boundaries, non-goals,
98
- recurring pitfalls, accepted architectural rationale, verification
99
- constraints.
100
-
101
- Not specs: current state, future design, aspirations, todos,
102
- implementation plans, session notes, progress, temporary debugging,
103
- navigation, history. "Already true" matters — what you hope to become is
104
- a plan, not a living spec.
105
-
106
- Place a constraint where it belongs:
107
-
108
- - rare project-wide durable boundary → `specs/constitution.md`;
109
- - observable behavior, contract, invariant, or non-goal →
110
- `specs/capabilities/`;
111
- - implementation boundary, long-lived constraint, recurring pitfall, or
112
- verification practice → `specs/engineering/`;
113
- - important architectural rationale and revisit condition →
114
- `specs/decisions/`.
115
-
116
- Keep specs compressed. Prefer durable contracts and pitfalls over
117
- commentary, and never invent aspirational standards.
118
-
119
- ## changes/ is optional staging
120
-
121
- Use `specs/changes/<name>.md` only when an accepted future contract must
122
- temporarily coexist with current living reality — for example when
123
- implementation happens in another session or by another agent, human
124
- review comes first, or a multi-step migration is underway. A change must
125
- contain at least one of `ADD`, `MODIFY`, `REMOVE`; `PRESERVE` alone is
126
- not a change.
127
-
128
- Otherwise: implement, verify, and update the living spec if durable truth
129
- changed. After an accepted change is implemented, merge the durable
130
- result into living specs and delete the change file. Git keeps history.
131
-
132
- ## Durable learning
133
-
134
- After non-trivial implementation or debugging, ask once: did this work
135
- reveal durable knowledge whose absence could cause a future mistake?
136
-
137
- If not, change nothing. If yes, record only the durable constraint in
138
- the matching category above.
139
-
140
- Never record current progress, temporary debug notes, one-off commands,
141
- session history, todos, or transient observations. Specs accumulate
142
- durable constraints, not development activity.
143
-
144
- ## Non-goals
145
-
146
- Do not use Charterleaf to manage tasks, planning, workflow, memory,
147
- evidence, history, repository state, or scope registries. Do not ask it
148
- to verify semantics, coverage, or project compliance. It finds
149
- constraints; it does not manage the project.
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.