@onedotmint/charterleaf 0.4.1 → 0.4.3
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 +15 -4
- package/README.md +69 -27
- package/extensions/charterleaf.js +45 -14
- package/lib/charterleaf.js +24 -12
- package/lib/project.js +63 -17
- package/package.json +2 -1
- package/skills/charterleaf/SKILL.md +10 -2
package/AGENTS_SNIPPET.md
CHANGED
|
@@ -11,10 +11,21 @@ owner per fact and link to it rather than duplicating it.
|
|
|
11
11
|
|
|
12
12
|
Charterleaf automation is explicit opt-in, not implied by this entry or
|
|
13
13
|
by an existing `specs/` directory. In Pi, the user chooses Auto, Confirm,
|
|
14
|
-
Reference or Not Enable through `/charterleaf
|
|
15
|
-
`
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
Reference or Not Enable through `/charterleaf`. In Git, the saved mode lives in
|
|
15
|
+
`charterleaf.json` under `git rev-parse --path-format=absolute --git-common-dir`
|
|
16
|
+
(usually `.git/charterleaf.json`), shared by all branches and linked worktrees,
|
|
17
|
+
not independent clones. Outside Git, it remains `specs/.charterleaf.json`.
|
|
18
|
+
The local config is authoritative, including saved `off`; a branch's legacy config
|
|
19
|
+
cannot override it. Only when local config is absent may first load import a valid
|
|
20
|
+
saved mode from current-branch legacy `specs/.charterleaf.json`, without overwriting local
|
|
21
|
+
config or changing the legacy file/worktree. Invalid config means an error and
|
|
22
|
+
disabled automation; never recover lost authorization from Git history or sessions.
|
|
23
|
+
No saved choice means off, not a global Auto default: no automatic scan, adoption
|
|
24
|
+
prompt or document creation. Enabling Git projects creates no `specs/` directory or
|
|
25
|
+
documents; a branch without specs remains usable for normal reads/edits and later
|
|
26
|
+
justified spec creation under the selected mode. Specs route from the current
|
|
27
|
+
worktree, not the shared config directory. Do not change activation config on the
|
|
28
|
+
user's behalf.
|
|
18
29
|
|
|
19
30
|
For manual use on a concrete repository path, run from the project root:
|
|
20
31
|
|
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ pi install npm:@onedotmint/charterleaf
|
|
|
39
39
|
For the standalone CLI, use `npm install -g @onedotmint/charterleaf`.
|
|
40
40
|
For local development, use `pi install /absolute/path/to/charterleaf` (a package
|
|
41
41
|
directory, not a `.tgz` file). To test the CLI from a local package, run `npm pack`
|
|
42
|
-
then `npm install -g ./onedotmint-charterleaf-0.4.
|
|
42
|
+
then `npm install -g ./onedotmint-charterleaf-0.4.3.tgz`.
|
|
43
43
|
|
|
44
44
|
Restart Pi or run `/reload` after installation, then use `/charterleaf` in each
|
|
45
45
|
project you want to enable. To stop automation, choose **Not Enable** in that
|
|
@@ -51,14 +51,17 @@ directly and supplies the bundled CLI entry point for optional manual checks.
|
|
|
51
51
|
|
|
52
52
|
The **Core/CLI** runs standalone on Node.js **20+**, has no dependencies,
|
|
53
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`:
|
|
54
|
+
`@earendil-works/pi-coding-agent` **1.1.0 and 1.4.0** and uses `agent_before_settle`:
|
|
55
55
|
it requires a host with that compatible Extension API, not just any Pi
|
|
56
56
|
version. That Pi host requires Node.js **22.19.0+**; the Core's lower
|
|
57
57
|
minimum does not make older Node versions suitable for the host.
|
|
58
58
|
|
|
59
59
|
## Native Pi modes
|
|
60
60
|
|
|
61
|
-
Run `/charterleaf` in Pi for the native project mode menu
|
|
61
|
+
Run `/charterleaf` in Pi for the native project mode menu. It distinguishes
|
|
62
|
+
**not configured** from explicitly **disabled**, explains that existing specs do
|
|
63
|
+
not enable automation, and describes the current mode. Saving confirms what the
|
|
64
|
+
selected mode allows, not just where its config was written:
|
|
62
65
|
|
|
63
66
|
| Choice | Behavior |
|
|
64
67
|
| --- | --- |
|
|
@@ -67,20 +70,35 @@ Run `/charterleaf` in Pi for the native project mode menu:
|
|
|
67
70
|
| **Reference** | Read and check; report proposals or conflicts, never modify specs. |
|
|
68
71
|
| **Not Enable** | Turn automation off; keep existing documents. |
|
|
69
72
|
|
|
70
|
-
The choice is saved immediately in `
|
|
73
|
+
The choice is saved immediately in `charterleaf.json` under the absolute directory
|
|
74
|
+
returned by `git rev-parse --path-format=absolute --git-common-dir` (usually
|
|
75
|
+
`.git/charterleaf.json`). All branches and linked worktrees of that repository
|
|
76
|
+
share the mode; independent clones do not. Outside Git, the path remains
|
|
77
|
+
`specs/.charterleaf.json`. The config format is unchanged:
|
|
71
78
|
|
|
72
79
|
```json
|
|
73
80
|
{ "version": 1, "mode": "off" }
|
|
74
81
|
```
|
|
75
82
|
|
|
76
|
-
Valid modes are `auto`, `confirm`, `reference`, and `off`.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
83
|
+
Valid modes are `auto`, `confirm`, `reference`, and `off`. The new repository-local
|
|
84
|
+
config is authoritative whenever it exists, including saved `off`; a branch's
|
|
85
|
+
legacy `specs/.charterleaf.json` cannot override it. If no local config exists,
|
|
86
|
+
the first load imports the saved mode from a valid legacy config on the current branch,
|
|
87
|
+
without overwriting a local config or changing/removing the legacy file or tracked
|
|
88
|
+
worktree. Invalid config reports an error and disables automation, not a fallback
|
|
89
|
+
to another source. Lost authorization is never recovered from Git history or sessions.
|
|
90
|
+
|
|
91
|
+
With no saved choice, the mode stays **off**; existing `specs/` alone never enables
|
|
92
|
+
automation. This is repository-local opt-in, **not a global Auto default**.
|
|
93
|
+
Activation is an explicit user choice through `/charterleaf`, not an Agent decision.
|
|
94
|
+
Enabling a Git project creates only the local config, never a `specs/` directory or
|
|
95
|
+
spec documents. A branch without `specs/` is valid: normal code reads/edits and later
|
|
96
|
+
evidence-based spec creation still work under the selected mode. Spec content and
|
|
97
|
+
routing always belong to the current worktree, not the shared Git directory.
|
|
98
|
+
Outside Git, saving may create the `specs/` directory for the config only.
|
|
99
|
+
Disabling preserves documents; cancel changes nothing. Disabled/unconfigured
|
|
100
|
+
projects get no automatic adoption prompt, task context, scan, or document bootstrap.
|
|
101
|
+
The two manual CLI commands remain unchanged and independent of this config.
|
|
84
102
|
|
|
85
103
|
Without UI, saved Auto and Reference modes still read/check constraints;
|
|
86
104
|
Confirm denies spec mutations because approval is unavailable. A refused identical
|
|
@@ -109,9 +127,11 @@ request**, not when a parallel sibling tool finishes.
|
|
|
109
127
|
Automatic spec bodies have a **24,000-byte budget per task** and an
|
|
110
128
|
**8,000-byte per-file limit**. Larger files or exhausted budgets require
|
|
111
129
|
an explicit complete `read`; a partial/truncated read is not full
|
|
112
|
-
knowledge. Native Pi
|
|
113
|
-
|
|
114
|
-
|
|
130
|
+
knowledge. Native Pi raw pages and AFT numbered pages (offset/limit or
|
|
131
|
+
startLine/endLine) are credited only when their exact content covers the same
|
|
132
|
+
spec version with no gaps; AFT line labels must be sequential and correct.
|
|
133
|
+
Display newlines and CRLF line endings do not prevent complete-read credit;
|
|
134
|
+
ordinary prose about truncation does not count as a tool truncation notice. On a scoped miss, the Extension supplies metadata only for
|
|
115
135
|
available living specs, capped at 12 entries and 2,000 characters;
|
|
116
136
|
`charterleaf related <path>` provides the full routing list. A miss never
|
|
117
137
|
proves that no constraints apply.
|
|
@@ -130,8 +150,9 @@ again. This is task-local bookkeeping, not a watcher or persistent index.
|
|
|
130
150
|
|
|
131
151
|
Knowledge capture happens **during normal work**, not just after code changes:
|
|
132
152
|
development, debugging, substantive code/test/architecture investigation and
|
|
133
|
-
accepted technical decisions can establish or uncover durable knowledge.
|
|
134
|
-
with
|
|
153
|
+
accepted technical decisions can establish or uncover durable knowledge. An enabled
|
|
154
|
+
project with no spec documents (or no `specs/` directory in Git) is a valid initial
|
|
155
|
+
state. When evidence warrants
|
|
135
156
|
it, the Agent creates a concise scoped spec using ordinary authorized `write`/`edit`;
|
|
136
157
|
Core immediately routes its `id`/`applies_to` on later related code reads or edits.
|
|
137
158
|
There is no initialization scan, document quota or adoption wizard.
|
|
@@ -185,8 +206,10 @@ workflow or another development cycle.
|
|
|
185
206
|
|
|
186
207
|
## CLI
|
|
187
208
|
|
|
188
|
-
Charterleaf exposes exactly two deterministic commands. Run them from
|
|
189
|
-
|
|
209
|
+
Charterleaf exposes exactly two deterministic commands. Run them from the project
|
|
210
|
+
root or a subdirectory: the nearest ancestor containing `specs/` is used, without
|
|
211
|
+
crossing a Git repository or worktree boundary. The target path remains
|
|
212
|
+
repo-relative, not relative to the shell's current directory.
|
|
190
213
|
|
|
191
214
|
```bash
|
|
192
215
|
charterleaf related <path>
|
|
@@ -363,7 +386,8 @@ Important limits:
|
|
|
363
386
|
path that was checked; spec authorization also preserves the original path's
|
|
364
387
|
protected identity when a spec symlink points elsewhere inside the project.
|
|
365
388
|
Foreign UNC paths are not interpreted as local paths; symlink escapes are
|
|
366
|
-
rejected for project spec writes. Native Windows
|
|
389
|
+
rejected for project spec writes. Native Windows CLI, helper/Extension tests and
|
|
390
|
+
RPC mode persistence are covered by hosted CI; platform-specific permissions still apply.
|
|
367
391
|
- `related` has no filesystem awareness of the supplied target path and
|
|
368
392
|
no proof of routing coverage. Task-local hashes are not a continuous
|
|
369
393
|
monitor of external changes. A single spec line larger than Pi's read byte
|
|
@@ -386,6 +410,7 @@ Verification commands:
|
|
|
386
410
|
|
|
387
411
|
```bash
|
|
388
412
|
npm test
|
|
413
|
+
npm run test:package # real tarball install and tests against installed code
|
|
389
414
|
npm run test:pi # isolated real RPC/UI smoke; no model calls
|
|
390
415
|
CHARTERLEAF_MODEL=deepseek/deepseek-flash npm run test:organic # paid isolated A–G acceptance
|
|
391
416
|
CHARTERLEAF_MODEL=deepseek/deepseek-flash CHARTERLEAF_BASELINE=5d470db npm run test:context # paid comparison against v0.4.0
|
|
@@ -396,10 +421,20 @@ node bin/charterleaf.js related extensions/charterleaf.js
|
|
|
396
421
|
node bin/charterleaf.js lint
|
|
397
422
|
```
|
|
398
423
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
424
|
+
GitHub Actions runs the installed-package suite on Ubuntu and Windows with Node
|
|
425
|
+
20.0.0, 22.19.0 and 24.x, plus Pi 1.1.0/1.4.0 smoke checks on host-compatible Node.
|
|
426
|
+
CI makes no model calls and retains packaging/test logs. `PI_CLI` can select the
|
|
427
|
+
Pi Node entry point for smoke checks without relying on platform-specific shims.
|
|
428
|
+
|
|
429
|
+
For v0.4.2, the [passing eight-job release-candidate matrix](https://github.com/onedotmint/Charterleaf/actions/runs/40078816360)
|
|
430
|
+
checks the actual npm tarball after isolated installation, including byte equality,
|
|
431
|
+
CLI commands and Core/Extension/recovery tests. Each Ubuntu job passes all 227 tests;
|
|
432
|
+
each Windows job passes 220 with zero failures and seven platform-specific skips
|
|
433
|
+
(one Linux/WSL path check, two POSIX permission checks and four SIGKILL checks).
|
|
434
|
+
Windows symlink tests run, rather than being skipped. Host-compatible jobs also
|
|
435
|
+
pass real Pi RPC loading/native selection, cancellation, mode changes, legacy
|
|
436
|
+
migration and cross-branch/process persistence. RPC checks use isolated temporary
|
|
437
|
+
projects/settings and make no model calls.
|
|
403
438
|
|
|
404
439
|
The 0.4.1 organic-capture acceptance used real `deepseek/deepseek-flash` sessions
|
|
405
440
|
in isolated Git projects. Actual tool results, approval records, specs, routing,
|
|
@@ -407,11 +442,18 @@ file snapshots/diffs, original tests and an independent exact-money oracle were
|
|
|
407
442
|
checked, not just assistant claims. Empty-spec development and investigation,
|
|
408
443
|
ordinary-query non-triggers, authoritative-document references, bug-versus-contract
|
|
409
444
|
handling, cross-session reuse and mode/read-only/no-UI permissions all have passing
|
|
410
|
-
evidence. **The strict matrix was not consistently all-green:**
|
|
445
|
+
evidence. **The earlier strict matrix was not consistently all-green:** a focused
|
|
411
446
|
A→F1→F2 run passed A and F1, while F2 omitted its knowledge-completion comment and
|
|
412
447
|
received one redundant fallback review. Its actual routing, code behavior and
|
|
413
|
-
unchanged-spec checks passed.
|
|
414
|
-
|
|
448
|
+
unchanged-spec checks passed. A later complete pre-0.4.2 run on the same model
|
|
449
|
+
passed all 15 scenarios and 231 assertions, including F1/F2 without redundant reviews.
|
|
450
|
+
The final Windows Git-root path fix was subsequently validated by cross-platform
|
|
451
|
+
CI, not another paid model run. These bounded examples do not prove model reliability
|
|
452
|
+
on every task or provider, or guarantee zero redundant reviews. A subsequent
|
|
453
|
+
case-A rerun on `deepseek/deepseek-flash` failed knowledge-capture assertions on
|
|
454
|
+
both the v0.4.2 baseline and the patch candidate: accounting tests passed, but
|
|
455
|
+
the model concluded `covered` without writing a routed spec. Automatic knowledge
|
|
456
|
+
capture remains model-dependent; passing structural tests does not prove it.
|
|
415
457
|
|
|
416
458
|
The controlled organic-capture comparison (`scripts/context-smoke.js`) keeps cwd,
|
|
417
459
|
package paths, prompt and native tools identical. On `deepseek/deepseek-flash`:
|
|
@@ -4,7 +4,7 @@ const fs = require('node:fs');
|
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const { pathToFileURL } = require('node:url');
|
|
6
6
|
const { resolveRelated, lint } = require('../lib/charterleaf.js');
|
|
7
|
-
const {
|
|
7
|
+
const { projectRoot, projectPath, toolPath, modeConfigPath, isModeConfig, readMode, saveMode, fingerprint, gitSnapshot, changedSince } = require('../lib/project.js');
|
|
8
8
|
|
|
9
9
|
const OPTIONS = [
|
|
10
10
|
'Auto (recommended) — read constraints and maintain only durable knowledge',
|
|
@@ -13,6 +13,12 @@ const OPTIONS = [
|
|
|
13
13
|
'Not Enable — disable; keep existing documents',
|
|
14
14
|
];
|
|
15
15
|
const MODES = ['auto', 'confirm', 'reference', 'off'];
|
|
16
|
+
const MODE_EFFECTS = {
|
|
17
|
+
auto: 'Automatic constraint loading and code edit checks; maintain only justified durable specs.',
|
|
18
|
+
confirm: 'Automatic constraint loading and code edit checks; approve each spec modification.',
|
|
19
|
+
reference: 'Automatic constraint loading and code edit checks; never modify specs.',
|
|
20
|
+
off: 'Automation off: no automatic constraint loading or code edit checks; existing documents preserved.',
|
|
21
|
+
};
|
|
16
22
|
const isSpec = (file) => file === 'specs' || file.startsWith('specs/');
|
|
17
23
|
const message = (content) => ({ type: 'custom_message', customType: 'charterleaf', content, display: false });
|
|
18
24
|
|
|
@@ -32,8 +38,14 @@ module.exports = function charterleaf(pi) {
|
|
|
32
38
|
return state;
|
|
33
39
|
}
|
|
34
40
|
|
|
41
|
+
function related(file, root) {
|
|
42
|
+
// Mode survives branch switches even when the new branch has no specs yet.
|
|
43
|
+
if (!fs.existsSync(path.join(root, 'specs'))) return { global: [], scoped: [], changes: [], available: [] };
|
|
44
|
+
return resolveRelated(file, root);
|
|
45
|
+
}
|
|
46
|
+
|
|
35
47
|
function route(s, file) {
|
|
36
|
-
const result =
|
|
48
|
+
const result = related(file, s.root);
|
|
37
49
|
const notes = [];
|
|
38
50
|
let required = false;
|
|
39
51
|
for (const spec of [...result.global, ...result.scoped, ...result.changes]) {
|
|
@@ -53,7 +65,8 @@ module.exports = function charterleaf(pi) {
|
|
|
53
65
|
s.budget -= size;
|
|
54
66
|
s.staged.set(spec, hash);
|
|
55
67
|
} else {
|
|
56
|
-
|
|
68
|
+
const pages = Math.ceil(size / 8000);
|
|
69
|
+
notes.push(`Read the complete ${spec} (${size} bytes, ~${pages} page(s) via offset/limit) before modifying ${file}; it exceeds the ${size > 8000 ? '8 KB per-file limit' : 'per-task context budget'}.`);
|
|
57
70
|
}
|
|
58
71
|
}
|
|
59
72
|
if (!result.scoped.length && !s.miss) {
|
|
@@ -68,12 +81,23 @@ module.exports = function charterleaf(pi) {
|
|
|
68
81
|
|
|
69
82
|
function completePages(s, file, hash, event) {
|
|
70
83
|
const text = event.content.filter((part) => part.type === 'text').map((part) => part.text).join('\n');
|
|
71
|
-
const footer = /\n\n
|
|
84
|
+
const footer = /\n\n(?:\[(?:Showing lines \d+-\d+ of \d+[^\n]*|\d+ more lines in file\.[^\n]*)Use offset=\d+ to continue\.\]|\(File is large; use startLine\/endLine or offset\/limit to read a section\.\))$/;
|
|
72
85
|
const body = text.replace(footer, '');
|
|
73
86
|
const lines = fs.readFileSync(path.join(s.root, file), 'utf8').split('\n');
|
|
74
87
|
const start = (event.input.startLine || event.input.offset || 1) - 1;
|
|
75
|
-
|
|
76
|
-
if (start < 0
|
|
88
|
+
let end = start + body.split('\n').length;
|
|
89
|
+
if (start < 0) return false;
|
|
90
|
+
if (end > lines.length || lines.slice(start, end).join('\n') !== body) {
|
|
91
|
+
const numbered = body.split('\n');
|
|
92
|
+
// AFT adds sequential line labels and a final display newline, not file content.
|
|
93
|
+
if (numbered.at(-1) === '') numbered.pop();
|
|
94
|
+
if (!numbered.length || !numbered.every((line, i) => {
|
|
95
|
+
const match = line.match(/^ *(\d+): (.*)$/);
|
|
96
|
+
return match && Number(match[1]) === start + i + 1 && match[2] === lines[start + i]?.replace(/\r$/, '');
|
|
97
|
+
})) return false;
|
|
98
|
+
end = start + numbered.length;
|
|
99
|
+
if (end === lines.length - 1 && lines.at(-1) === '') end++;
|
|
100
|
+
}
|
|
77
101
|
let pages = s.pages.get(file);
|
|
78
102
|
if (!pages || pages.hash !== hash) pages = { hash, ranges: [] };
|
|
79
103
|
pages.ranges.push([start, end]);
|
|
@@ -107,13 +131,19 @@ module.exports = function charterleaf(pi) {
|
|
|
107
131
|
const root = projectRoot(ctx.cwd);
|
|
108
132
|
let mode;
|
|
109
133
|
try { mode = readMode(root); } catch { mode = 'invalid config (disabled)'; }
|
|
110
|
-
const
|
|
134
|
+
const config = modeConfigPath(root);
|
|
135
|
+
const status = mode === 'off'
|
|
136
|
+
? (fs.existsSync(config) ? 'off (disabled)' : 'off (not configured)') : mode;
|
|
137
|
+
const effect = MODE_EFFECTS[mode] || MODE_EFFECTS.off;
|
|
138
|
+
const adoption = !MODES.includes(mode) || mode === 'off'
|
|
139
|
+
? '\nExisting specs do not enable automation; choose a mode to enable it.' : '';
|
|
140
|
+
const choice = await ctx.ui.select(`Charterleaf — ${status}\n${root}\n${effect}${adoption}`, OPTIONS);
|
|
111
141
|
const index = OPTIONS.indexOf(choice);
|
|
112
142
|
if (index < 0) return;
|
|
113
143
|
if (MODES[index] === 'off' && mode === 'off') return;
|
|
114
144
|
saveMode(root, MODES[index]);
|
|
115
145
|
state = undefined;
|
|
116
|
-
ctx.ui.notify(`Charterleaf: ${MODES[index]} (saved to ${
|
|
146
|
+
ctx.ui.notify(`Charterleaf: ${MODES[index]} — ${MODE_EFFECTS[MODES[index]]} (saved to ${config})`, 'info');
|
|
117
147
|
} catch (error) { report(ctx, `Charterleaf: ${error.message}`); }
|
|
118
148
|
},
|
|
119
149
|
});
|
|
@@ -180,6 +210,9 @@ module.exports = function charterleaf(pi) {
|
|
|
180
210
|
if (!['read', 'write', 'edit'].includes(event.toolName)) return;
|
|
181
211
|
const mutation = event.toolName !== 'read';
|
|
182
212
|
try {
|
|
213
|
+
if (mutation && isModeConfig(s.root, ctx.cwd, event.input.path)) {
|
|
214
|
+
return { block: true, reason: 'Only the user may change Charterleaf mode via /charterleaf.' };
|
|
215
|
+
}
|
|
183
216
|
const file = projectPath(s.root, ctx.cwd, event.input.path);
|
|
184
217
|
const lexical = typeof event.input.path === 'string'
|
|
185
218
|
? path.relative(s.root, path.resolve(ctx.cwd, toolPath(event.input.path))).replaceAll('\\', '/') : null;
|
|
@@ -192,9 +225,6 @@ module.exports = function charterleaf(pi) {
|
|
|
192
225
|
// Execute the exact normalized path checked here, including Pi's @/file:/space aliases.
|
|
193
226
|
const target = path.join(s.root, file);
|
|
194
227
|
event.input.path = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/.test(target) ? pathToFileURL(target).href : target;
|
|
195
|
-
if (mutation && (file === CONFIG || lexical === CONFIG || file === projectPath(s.root, s.root, CONFIG))) {
|
|
196
|
-
return { block: true, reason: 'Only the user may change Charterleaf mode via /charterleaf.' };
|
|
197
|
-
}
|
|
198
228
|
if (mutation && (isSpec(file) || (lexical && isSpec(lexical)))) {
|
|
199
229
|
if (s.mode === 'reference') return { block: true, reason: 'Charterleaf Reference mode: report a proposal; do not modify specs.' };
|
|
200
230
|
if (s.mode === 'confirm') {
|
|
@@ -272,7 +302,8 @@ module.exports = function charterleaf(pi) {
|
|
|
272
302
|
}
|
|
273
303
|
} else if (!call.mutation) {
|
|
274
304
|
if (isSpec(call.file)) {
|
|
275
|
-
const fullRead =
|
|
305
|
+
const fullRead = !/^ *\d+: /.test(visible) &&
|
|
306
|
+
event.input.offset === undefined && event.input.startLine === undefined &&
|
|
276
307
|
event.input.limit === undefined && event.input.endLine === undefined &&
|
|
277
308
|
event.details?.truncation?.truncated !== true &&
|
|
278
309
|
!event.content.some((part) => part.type === 'text' &&
|
|
@@ -317,13 +348,13 @@ module.exports = function charterleaf(pi) {
|
|
|
317
348
|
const observed = changedSince(s.before, after);
|
|
318
349
|
const owned = [...s.owned].filter(([file, hash]) => fingerprint(s.root, file) === hash && !isSpec(file)).map(([file]) => file);
|
|
319
350
|
const unattributed = observed.filter((file) => !s.owned.has(file) || fingerprint(s.root, file) !== s.owned.get(file));
|
|
320
|
-
const emptySpecs = s.knowledge === 'no-value' &&
|
|
351
|
+
const emptySpecs = s.knowledge === 'no-value' && related('', s.root).available.length === 0;
|
|
321
352
|
if (s.knowledge === 'no-value' && !(emptySpecs && owned.length > 0)) return;
|
|
322
353
|
const candidate = s.knowledge === 'review';
|
|
323
354
|
if (!candidate && !owned.length && !unattributed.length && !(s.opaque && !comparable)) return;
|
|
324
355
|
const relevant = new Set();
|
|
325
356
|
for (const file of [...owned, ...unattributed].slice(0, 50)) {
|
|
326
|
-
const result =
|
|
357
|
+
const result = related(file, s.root);
|
|
327
358
|
for (const spec of [...result.global, ...result.scoped, ...result.changes]) relevant.add(spec);
|
|
328
359
|
}
|
|
329
360
|
const content = `Charterleaf final review — once only; mode=${s.mode}.\n` +
|
package/lib/charterleaf.js
CHANGED
|
@@ -122,7 +122,8 @@ function globMatches(pattern, targetPath) {
|
|
|
122
122
|
});
|
|
123
123
|
|
|
124
124
|
source += '$';
|
|
125
|
-
|
|
125
|
+
// Windows filesystems are case-insensitive, so a path's casing is not meaningful there.
|
|
126
|
+
return new RegExp(source, process.platform === 'win32' ? 'i' : '').test(normalizedPath);
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
function walkMarkdown(root) {
|
|
@@ -142,6 +143,15 @@ function walkMarkdown(root) {
|
|
|
142
143
|
});
|
|
143
144
|
}
|
|
144
145
|
|
|
146
|
+
// CLI discovery must not inherit another repository's constraints.
|
|
147
|
+
function resolveSpecRoot(cwd) {
|
|
148
|
+
for (let current = path.resolve(cwd); ; current = path.dirname(current)) {
|
|
149
|
+
const candidate = path.join(current, 'specs');
|
|
150
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) return current;
|
|
151
|
+
if (fs.existsSync(path.join(current, '.git')) || path.dirname(current) === current) return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
145
155
|
function relativeDisplay(filePath, cwd) {
|
|
146
156
|
const relative = path.relative(cwd, filePath);
|
|
147
157
|
if (!relative.startsWith('..') && !path.isAbsolute(relative)) return relative.replaceAll('\\', '/');
|
|
@@ -153,7 +163,8 @@ function write(stream, line) {
|
|
|
153
163
|
}
|
|
154
164
|
|
|
155
165
|
function resolveRelated(repoPath, cwd = process.cwd()) {
|
|
156
|
-
const
|
|
166
|
+
const root = path.resolve(cwd);
|
|
167
|
+
const specRoot = path.join(root, 'specs');
|
|
157
168
|
if (!fs.existsSync(specRoot) || !fs.statSync(specRoot).isDirectory()) {
|
|
158
169
|
throw new Error('ERROR specs/: not found');
|
|
159
170
|
}
|
|
@@ -163,7 +174,7 @@ function resolveRelated(repoPath, cwd = process.cwd()) {
|
|
|
163
174
|
const errors = [];
|
|
164
175
|
|
|
165
176
|
for (const filePath of walkMarkdown(specRoot)) {
|
|
166
|
-
const display = relativeDisplay(filePath,
|
|
177
|
+
const display = relativeDisplay(filePath, root);
|
|
167
178
|
let parsed;
|
|
168
179
|
try {
|
|
169
180
|
parsed = parseFrontmatter(fs.readFileSync(filePath, 'utf8'));
|
|
@@ -317,7 +328,8 @@ function allRequirementIds(text) {
|
|
|
317
328
|
function lint(cwd = process.cwd(), streams = {}) {
|
|
318
329
|
const stdout = streams.stdout || process.stdout;
|
|
319
330
|
const stderr = streams.stderr || process.stderr;
|
|
320
|
-
const
|
|
331
|
+
const root = path.resolve(cwd);
|
|
332
|
+
const specRoot = path.join(root, 'specs');
|
|
321
333
|
if (!fs.existsSync(specRoot) || !fs.statSync(specRoot).isDirectory()) {
|
|
322
334
|
write(stderr, 'ERROR specs/: not found');
|
|
323
335
|
return 2;
|
|
@@ -332,15 +344,15 @@ function lint(cwd = process.cwd(), streams = {}) {
|
|
|
332
344
|
const paths = walkMarkdown(specRoot);
|
|
333
345
|
|
|
334
346
|
for (const filePath of paths) {
|
|
335
|
-
const display = relativeDisplay(filePath,
|
|
347
|
+
const display = relativeDisplay(filePath, root);
|
|
336
348
|
let result;
|
|
337
349
|
try {
|
|
338
350
|
result = parseFrontmatter(fs.readFileSync(filePath, 'utf8'));
|
|
339
|
-
parsed.set(filePath, result);
|
|
340
351
|
} catch (error) {
|
|
341
352
|
errors.push([display, `invalid frontmatter: ${error.message}`]);
|
|
342
353
|
continue;
|
|
343
354
|
}
|
|
355
|
+
parsed.set(filePath, result);
|
|
344
356
|
|
|
345
357
|
const frontmatter = result.frontmatter || {};
|
|
346
358
|
const living = isLivingSpec(filePath, specRoot);
|
|
@@ -353,7 +365,7 @@ function lint(cwd = process.cwd(), streams = {}) {
|
|
|
353
365
|
} else {
|
|
354
366
|
const specId = rawSpecId.trim();
|
|
355
367
|
if (ids.has(specId)) {
|
|
356
|
-
errors.push([display, `duplicate spec id ${specId} (also in ${relativeDisplay(ids.get(specId),
|
|
368
|
+
errors.push([display, `duplicate spec id ${specId} (also in ${relativeDisplay(ids.get(specId), root)})`]);
|
|
357
369
|
} else {
|
|
358
370
|
ids.set(specId, filePath);
|
|
359
371
|
}
|
|
@@ -411,8 +423,8 @@ function lint(cwd = process.cwd(), streams = {}) {
|
|
|
411
423
|
const first = reqPaths[0];
|
|
412
424
|
for (const filePath of reqPaths.slice(1)) {
|
|
413
425
|
errors.push([
|
|
414
|
-
relativeDisplay(filePath,
|
|
415
|
-
`duplicate requirement id ${reqId} (also in ${relativeDisplay(first,
|
|
426
|
+
relativeDisplay(filePath, root),
|
|
427
|
+
`duplicate requirement id ${reqId} (also in ${relativeDisplay(first, root)})`,
|
|
416
428
|
]);
|
|
417
429
|
}
|
|
418
430
|
}
|
|
@@ -425,7 +437,7 @@ function lint(cwd = process.cwd(), streams = {}) {
|
|
|
425
437
|
const allExistingRequirements = new Set(globalRequirements.keys());
|
|
426
438
|
for (const filePath of paths) {
|
|
427
439
|
if (path.dirname(filePath) !== path.join(specRoot, 'changes') || !parsed.has(filePath)) continue;
|
|
428
|
-
const display = relativeDisplay(filePath,
|
|
440
|
+
const display = relativeDisplay(filePath, root);
|
|
429
441
|
const { frontmatter: rawFrontmatter, body } = parsed.get(filePath);
|
|
430
442
|
const frontmatter = rawFrontmatter || {};
|
|
431
443
|
const sections = changeSections(stripHtmlComments(body));
|
|
@@ -534,7 +546,7 @@ function main(argv = process.argv.slice(2), cwd = process.cwd(), streams = {}) {
|
|
|
534
546
|
return 0;
|
|
535
547
|
}
|
|
536
548
|
if (rest.length !== 1) return usageError('related requires exactly one path', streams);
|
|
537
|
-
return related(rest[0], path.resolve(cwd), streams);
|
|
549
|
+
return related(rest[0], resolveSpecRoot(cwd) || path.resolve(cwd), streams);
|
|
538
550
|
}
|
|
539
551
|
if (command === 'lint') {
|
|
540
552
|
if (rest.length === 1 && (rest[0] === '--help' || rest[0] === '-h')) {
|
|
@@ -542,7 +554,7 @@ function main(argv = process.argv.slice(2), cwd = process.cwd(), streams = {}) {
|
|
|
542
554
|
return 0;
|
|
543
555
|
}
|
|
544
556
|
if (rest.length) return usageError('lint takes no arguments', streams);
|
|
545
|
-
return lint(path.resolve(cwd), streams);
|
|
557
|
+
return lint(resolveSpecRoot(cwd) || path.resolve(cwd), streams);
|
|
546
558
|
}
|
|
547
559
|
return usageError(`unknown command ${command}`, streams);
|
|
548
560
|
}
|
package/lib/project.js
CHANGED
|
@@ -12,9 +12,11 @@ const CONFIG = 'specs/.charterleaf.json';
|
|
|
12
12
|
|
|
13
13
|
function projectRoot(cwd) {
|
|
14
14
|
const start = fs.realpathSync(cwd);
|
|
15
|
+
let legacyRoot;
|
|
15
16
|
for (let current = start; ; current = path.dirname(current)) {
|
|
16
|
-
if (fs.existsSync(path.join(current, '.git'))
|
|
17
|
-
if (path.
|
|
17
|
+
if (fs.existsSync(path.join(current, '.git'))) return current;
|
|
18
|
+
if (!legacyRoot && fs.existsSync(path.join(current, CONFIG))) legacyRoot = current;
|
|
19
|
+
if (path.dirname(current) === current) return legacyRoot || start;
|
|
18
20
|
}
|
|
19
21
|
}
|
|
20
22
|
|
|
@@ -52,7 +54,7 @@ function toolPath(input) {
|
|
|
52
54
|
return value;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
function
|
|
57
|
+
function absoluteToolPath(cwd, input) {
|
|
56
58
|
if (typeof input !== 'string' || !input || input.includes('\0')) return null;
|
|
57
59
|
let value = toolPath(input);
|
|
58
60
|
if (process.platform !== 'win32' && /^[A-Za-z]:\//.test(value)) {
|
|
@@ -61,35 +63,77 @@ function projectPath(root, cwd, input) {
|
|
|
61
63
|
// A foreign UNC host cannot safely be interpreted as a local Linux path.
|
|
62
64
|
return null;
|
|
63
65
|
}
|
|
64
|
-
|
|
66
|
+
return path.resolve(cwd, value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function projectPath(root, cwd, input) {
|
|
70
|
+
const lexical = absoluteToolPath(cwd, input);
|
|
71
|
+
if (!lexical) return null;
|
|
65
72
|
const resolved = canonical(lexical);
|
|
66
73
|
if (!inside(root, lexical) || !inside(root, resolved)) return null;
|
|
67
74
|
return path.relative(root, resolved).replaceAll('\\', '/') || '.';
|
|
68
75
|
}
|
|
69
76
|
|
|
70
|
-
function
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (
|
|
77
|
+
function modeConfigPath(root) {
|
|
78
|
+
let directory = root;
|
|
79
|
+
let file = path.join(root, CONFIG);
|
|
80
|
+
if (fs.existsSync(path.join(root, '.git'))) {
|
|
81
|
+
const env = { ...process.env };
|
|
82
|
+
// Git hooks and wrappers may export another repository's location; it is not this project's authority.
|
|
83
|
+
for (const key of ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_WORK_TREE']) delete env[key];
|
|
84
|
+
directory = fs.realpathSync(execFileSync('git', ['-C', root, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
|
85
|
+
encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'pipe'], env,
|
|
86
|
+
}).trim());
|
|
87
|
+
file = path.join(directory, 'charterleaf.json');
|
|
88
|
+
}
|
|
89
|
+
if (!inside(directory, canonical(file))) throw new Error('Charterleaf config escapes project storage');
|
|
90
|
+
return file;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readConfig(file) {
|
|
74
94
|
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
75
|
-
if (config
|
|
95
|
+
if (config?.version !== 1 || !MODES.includes(config.mode)) throw new Error('Invalid Charterleaf config (expected version: 1 and a valid mode)');
|
|
76
96
|
return config.mode;
|
|
77
97
|
}
|
|
78
98
|
|
|
79
|
-
function
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
99
|
+
function readMode(root) {
|
|
100
|
+
const file = modeConfigPath(root);
|
|
101
|
+
if (fs.existsSync(file)) return readConfig(file);
|
|
102
|
+
const legacy = path.join(root, CONFIG);
|
|
103
|
+
if (file === legacy || !fs.existsSync(legacy)) return 'off';
|
|
104
|
+
if (!inside(root, canonical(legacy))) throw new Error('Charterleaf config escapes project');
|
|
105
|
+
// Preserve a prior explicit choice without changing tracked files or replacing a concurrent choice.
|
|
106
|
+
persistMode(file, readConfig(legacy), false);
|
|
107
|
+
return readConfig(file);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function persistMode(file, mode, replace) {
|
|
83
111
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
84
112
|
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
85
113
|
try {
|
|
86
|
-
fs.writeFileSync(temporary, `${JSON.stringify({ version: 1, mode }, null, 2)}\n`, { flag: 'wx' });
|
|
87
|
-
fs.renameSync(temporary, file);
|
|
114
|
+
fs.writeFileSync(temporary, `${JSON.stringify({ version: 1, mode }, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
115
|
+
if (replace) fs.renameSync(temporary, file);
|
|
116
|
+
else {
|
|
117
|
+
try { fs.linkSync(temporary, file); }
|
|
118
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
119
|
+
}
|
|
88
120
|
} finally {
|
|
89
121
|
if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
|
|
90
122
|
}
|
|
91
123
|
}
|
|
92
124
|
|
|
125
|
+
function saveMode(root, mode) {
|
|
126
|
+
if (!MODES.includes(mode)) throw new Error('Invalid Charterleaf mode');
|
|
127
|
+
persistMode(modeConfigPath(root), mode, true);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isModeConfig(root, cwd, input) {
|
|
131
|
+
const lexical = absoluteToolPath(cwd, input);
|
|
132
|
+
if (!lexical) return false;
|
|
133
|
+
const target = canonical(lexical);
|
|
134
|
+
return [path.join(root, CONFIG), modeConfigPath(root)].some((file) => target === canonical(file));
|
|
135
|
+
}
|
|
136
|
+
|
|
93
137
|
function fingerprint(root, relative) {
|
|
94
138
|
const file = path.join(root, relative);
|
|
95
139
|
if (!inside(root, canonical(file))) return 'outside';
|
|
@@ -110,7 +154,9 @@ function gitSnapshot(root) {
|
|
|
110
154
|
encoding: 'utf8', timeout: 3000, maxBuffer: 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
|
|
111
155
|
});
|
|
112
156
|
try {
|
|
113
|
-
|
|
157
|
+
// Windows Git may expand an 8.3 temp-directory alias to its long path.
|
|
158
|
+
root = fs.realpathSync.native(root);
|
|
159
|
+
if (fs.realpathSync.native(git(['rev-parse', '--show-toplevel']).trim()) !== root) return null;
|
|
114
160
|
// Compare index and worktree separately; this also works before the first commit.
|
|
115
161
|
const names = [git(['diff', '--no-renames', '--name-only', '-z']), git(['diff', '--cached', '--no-renames', '--name-only', '-z']),
|
|
116
162
|
git(['ls-files', '--others', '--exclude-standard', '-z'])].join('\0').split('\0').filter(Boolean);
|
|
@@ -139,4 +185,4 @@ function changedSince(before, after) {
|
|
|
139
185
|
before.index?.get(name) !== after.index?.get(name)).sort();
|
|
140
186
|
}
|
|
141
187
|
|
|
142
|
-
module.exports = { CONFIG, MODES, projectRoot, projectPath, toolPath, readMode, saveMode, fingerprint, gitSnapshot, changedSince };
|
|
188
|
+
module.exports = { CONFIG, MODES, projectRoot, projectPath, toolPath, modeConfigPath, isModeConfig, readMode, saveMode, fingerprint, gitSnapshot, changedSince };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onedotmint/charterleaf",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
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": {
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
],
|
|
39
39
|
"scripts": {
|
|
40
40
|
"test": "node --test",
|
|
41
|
+
"test:package": "node scripts/package-smoke.js",
|
|
41
42
|
"test:pi": "node scripts/pi-smoke.js",
|
|
42
43
|
"test:organic": "node scripts/organic-smoke.js",
|
|
43
44
|
"test:context": "node scripts/context-smoke.js"
|
|
@@ -10,11 +10,17 @@ The essential rules live in [runtime.md](runtime.md); read it only if not alread
|
|
|
10
10
|
|
|
11
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.
|
|
12
12
|
|
|
13
|
+
## Mode scope
|
|
14
|
+
|
|
15
|
+
The user chooses the mode only through `/charterleaf`. In Git, it is saved in `charterleaf.json` under `git rev-parse --path-format=absolute --git-common-dir` (usually `.git/charterleaf.json`), shared across the repository's branches and linked worktrees, not independent clones. Outside Git, keep using `specs/.charterleaf.json`. No saved choice means off, never a global Auto default.
|
|
16
|
+
|
|
17
|
+
An existing local config is authoritative, including saved `off`; branch-local legacy config cannot override it. Only if local config is absent, first load imports the saved mode from a valid current-branch legacy `specs/.charterleaf.json` without overwriting local config or changing/removing the legacy file or tracked worktree. Invalid config must report an error and disable automation, not fall back. Never recover lost authorization from Git history or sessions. Enabling a Git project creates neither `specs/` nor spec documents; spec content and routing remain in the current worktree. The two CLI commands are unchanged and independent of mode storage.
|
|
18
|
+
|
|
13
19
|
## Ownership and durable writing
|
|
14
20
|
|
|
15
21
|
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.
|
|
16
22
|
|
|
17
|
-
Capture during ordinary development, debugging, code/test/architecture investigation or technical decisions; do not wait for settlement or require existing specs. An enabled project
|
|
23
|
+
Capture during ordinary development, debugging, code/test/architecture investigation or technical decisions; do not wait for settlement or require existing specs. An enabled project with no spec documents (or no `specs/` directory in Git) is a valid starting state, not a bootstrap request; normal code reads/edits and later justified spec creation remain allowed under the selected mode.
|
|
18
24
|
|
|
19
25
|
Distinguish **newly established knowledge** (an accepted new contract/decision) from **newly discovered knowledge** (an existing but ineffectively documented constraint). Either can qualify without code changes; neither obliges every task to produce a document. Apply all three checks:
|
|
20
26
|
|
|
@@ -55,7 +61,9 @@ Use `specs/changes/<name>.md` only when an accepted future contract must coexist
|
|
|
55
61
|
|
|
56
62
|
## CLI details
|
|
57
63
|
|
|
58
|
-
Run from the project root
|
|
64
|
+
Run from the project root or a subdirectory. CLI discovery stops at the nearest
|
|
65
|
+
`specs/` directory or Git repository/worktree boundary; target paths stay
|
|
66
|
+
repo-relative:
|
|
59
67
|
|
|
60
68
|
- `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.
|
|
61
69
|
- `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.
|